mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
fix: ignore published main commits during dev reconciliation
Issue: #155 User-Visible: no
This commit is contained in:
@@ -588,6 +588,13 @@ Performance зелёные на точном SHA; статусов issue не к
|
||||
считается от `merge-base` с `origin/dev`, а не от начала истории — иначе в него
|
||||
попали бы все нарушения, совершённые до появления гейта.
|
||||
|
||||
При возврате `main` в `dev` диапазон merge-коммита содержит второй родитель —
|
||||
уже опубликованные в `main` коммиты с закрытыми issue. Для destination `dev`
|
||||
общий скрипт pre-push/CI исключает только SHA, доказанно достижимые из
|
||||
`origin/main`; сам merge и новые post-merge коммиты остаются под всеми
|
||||
проверками. На `main`, beta/issue-ветки и обычный push в `dev` это исключение
|
||||
не распространяется (issue #155).
|
||||
|
||||
Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает
|
||||
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
|
||||
который не работает в самолёте, отключают целиком, а строгий проход всё равно
|
||||
|
||||
@@ -293,6 +293,24 @@ export function isStableTarget(targetRef) {
|
||||
return /^(?:refs\/heads\/)?main$/.test(targetRef ?? '');
|
||||
}
|
||||
|
||||
export function isDevTarget(targetRef) {
|
||||
return /^(?:refs\/heads\/)?dev$/.test(targetRef ?? '');
|
||||
}
|
||||
|
||||
// A main-only infrastructure commit is already published and already passed
|
||||
// the process gate. When main is merged back into dev, `old-dev..merge` walks
|
||||
// that second parent as if it were fresh issue-branch work. Requiring its
|
||||
// closed issue to become active again makes the mandatory main -> dev
|
||||
// reconciliation impossible. Exclude only commits proven reachable from the
|
||||
// remote main ref, and only while the destination itself is dev. The merge
|
||||
// commit and every genuinely new commit remain in the checked set.
|
||||
export function commitsNeedingTargetValidation(
|
||||
commits, { targetRef = '', isCommitOnMain = () => false } = {},
|
||||
) {
|
||||
if (!isDevTarget(targetRef)) return commits;
|
||||
return commits.filter((commit) => !isCommitOnMain(commit.sha));
|
||||
}
|
||||
|
||||
// При stable promotion диапазон main..candidate закономерно содержит коммиты,
|
||||
// уже выпущенные prerelease-тегом. Их issue к этому моменту обязаны быть закрыты
|
||||
// (§2.8), поэтому повторная online-проверка статуса дала бы ложный отказ. Новые
|
||||
@@ -424,8 +442,27 @@ function main(argv) {
|
||||
);
|
||||
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repo).trim();
|
||||
|
||||
const hasOriginMain = spawnSync(
|
||||
'git', ['-C', repo, 'rev-parse', '--verify', 'refs/remotes/origin/main'],
|
||||
{ encoding: 'utf8' },
|
||||
).status === 0;
|
||||
const mainCache = new Map();
|
||||
const isCommitOnMain = (sha) => {
|
||||
if (!hasOriginMain) return false;
|
||||
if (!mainCache.has(sha)) {
|
||||
mainCache.set(sha, spawnSync(
|
||||
'git', ['-C', repo, 'merge-base', '--is-ancestor', sha, 'refs/remotes/origin/main'],
|
||||
{ encoding: 'utf8' },
|
||||
).status === 0);
|
||||
}
|
||||
return mainCache.get(sha);
|
||||
};
|
||||
const checkedCommits = commitsNeedingTargetValidation(
|
||||
commits, { targetRef, isCommitOnMain },
|
||||
);
|
||||
|
||||
const findings = [];
|
||||
for (const c of commits) findings.push(...evaluateCommit(c));
|
||||
for (const c of checkedCommits) findings.push(...evaluateCommit(c));
|
||||
|
||||
// Проверке 2 отдаются только коммиты самой ветки: origin/dev..HEAD, а не
|
||||
// диапазон события. См. комментарий у checkBranchRule.
|
||||
@@ -433,12 +470,13 @@ function main(argv) {
|
||||
? (() => {
|
||||
const hasDev = spawnSync('git', ['-C', repo, 'rev-parse', '--verify', 'origin/dev'],
|
||||
{ encoding: 'utf8' }).status === 0;
|
||||
if (!hasDev) return commits;
|
||||
return parseRecords(
|
||||
if (!hasDev) return checkedCommits;
|
||||
const own = parseRecords(
|
||||
git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, 'origin/dev..HEAD'], repo),
|
||||
);
|
||||
return commitsNeedingTargetValidation(own, { targetRef, isCommitOnMain });
|
||||
})()
|
||||
: commits;
|
||||
: checkedCommits;
|
||||
findings.push(...checkBranchRule(branch, ownCommits));
|
||||
|
||||
// Метки читаются один раз и используются дважды: проверкой 8 и escalation
|
||||
@@ -458,7 +496,7 @@ function main(argv) {
|
||||
}
|
||||
return publishedCache.get(sha);
|
||||
};
|
||||
const statusCommits = commitsNeedingIssueStatus(commits, {
|
||||
const statusCommits = commitsNeedingIssueStatus(checkedCommits, {
|
||||
targetRef, isPublishedPrereleaseCommit,
|
||||
});
|
||||
const numbers = [...new Set(statusCommits.flatMap((c) => c.issues).map((t) => t.slice(1)))];
|
||||
@@ -482,7 +520,9 @@ function main(argv) {
|
||||
}
|
||||
|
||||
const specsDir = join(repo, 'docs', 'specs');
|
||||
findings.push(...checkSpecs(commits, existsSync(specsDir) ? readdirSync(specsDir) : null, labelsOf));
|
||||
findings.push(...checkSpecs(
|
||||
checkedCommits, existsSync(specsDir) ? readdirSync(specsDir) : null, labelsOf,
|
||||
));
|
||||
|
||||
const reviewDir = join(repo, 'docs', 'reviews');
|
||||
const reviewFiles = [
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
checkSpecs,
|
||||
classify,
|
||||
commitsNeedingIssueStatus,
|
||||
commitsNeedingTargetValidation,
|
||||
commitsUnderRuleOne,
|
||||
evaluateCommit,
|
||||
makeCommit,
|
||||
@@ -202,6 +203,55 @@ test('stable promotion skips status recheck only for commits already published i
|
||||
);
|
||||
});
|
||||
|
||||
test('dev reconciliation ignores published main commits but keeps new post-merge work', () => {
|
||||
const mainOnly = makeCommit({
|
||||
sha: 'a'.repeat(40), subject: 'Main-only workflow fix', body: 'Issue: #85',
|
||||
files: ['.github/workflows/mutation-gate.yml'],
|
||||
});
|
||||
const postMerge = makeCommit({
|
||||
sha: 'b'.repeat(40), subject: 'New gate fix', body: 'Issue: #155',
|
||||
files: ['scripts/process-gate.mjs'],
|
||||
});
|
||||
const commits = [mainOnly, postMerge];
|
||||
const isCommitOnMain = (sha) => sha === mainOnly.sha;
|
||||
|
||||
const dev = commitsNeedingTargetValidation(commits, {
|
||||
targetRef: 'refs/heads/dev', isCommitOnMain,
|
||||
});
|
||||
assert.deepEqual(dev.map((commit) => commit.sha), [postMerge.sha]);
|
||||
|
||||
const statusByIssue = (nn) => ({
|
||||
ok: true,
|
||||
json: nn === '85'
|
||||
? { state: 'CLOSED', labels: [] }
|
||||
: { state: 'OPEN', labels: [{ name: 'S6-in-progress' }] },
|
||||
});
|
||||
assert.deepEqual(
|
||||
rules(checkIssueStatuses(
|
||||
dev.flatMap((candidate) => candidate.issues).map((issue) => issue.slice(1)),
|
||||
statusByIssue,
|
||||
)),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(rules(checkIssueStatuses(['85', '155'], statusByIssue)), [8]);
|
||||
|
||||
// The exemption belongs only to a dev destination. Main promotion, issue
|
||||
// branches and an ordinary dev push with no main-reachable commits keep the
|
||||
// complete input set.
|
||||
for (const targetRef of ['refs/heads/main', 'refs/heads/issue/155-gate']) {
|
||||
assert.deepEqual(
|
||||
commitsNeedingTargetValidation(commits, { targetRef, isCommitOnMain }),
|
||||
commits,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
commitsNeedingTargetValidation(commits, {
|
||||
targetRef: 'refs/heads/dev', isCommitOnMain: () => false,
|
||||
}),
|
||||
commits,
|
||||
);
|
||||
});
|
||||
|
||||
test('issue status check is fail closed when the source of truth is unreachable', () => {
|
||||
// AC3: недоступный gh должен давать отказ, а не молчаливый пропуск.
|
||||
const broken = () => ({ ok: false, error: 'gh: could not resolve to a Repository' });
|
||||
|
||||
Reference in New Issue
Block a user