mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +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`, а не от начала истории — иначе в него
|
считается от `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`, поэтому при его отсутствии хук печатает
|
Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает
|
||||||
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
|
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
|
||||||
который не работает в самолёте, отключают целиком, а строгий проход всё равно
|
который не работает в самолёте, отключают целиком, а строгий проход всё равно
|
||||||
|
|||||||
@@ -293,6 +293,24 @@ export function isStableTarget(targetRef) {
|
|||||||
return /^(?:refs\/heads\/)?main$/.test(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 закономерно содержит коммиты,
|
// При stable promotion диапазон main..candidate закономерно содержит коммиты,
|
||||||
// уже выпущенные prerelease-тегом. Их issue к этому моменту обязаны быть закрыты
|
// уже выпущенные prerelease-тегом. Их issue к этому моменту обязаны быть закрыты
|
||||||
// (§2.8), поэтому повторная online-проверка статуса дала бы ложный отказ. Новые
|
// (§2.8), поэтому повторная online-проверка статуса дала бы ложный отказ. Новые
|
||||||
@@ -424,8 +442,27 @@ function main(argv) {
|
|||||||
);
|
);
|
||||||
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repo).trim();
|
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 = [];
|
const findings = [];
|
||||||
for (const c of commits) findings.push(...evaluateCommit(c));
|
for (const c of checkedCommits) findings.push(...evaluateCommit(c));
|
||||||
|
|
||||||
// Проверке 2 отдаются только коммиты самой ветки: origin/dev..HEAD, а не
|
// Проверке 2 отдаются только коммиты самой ветки: origin/dev..HEAD, а не
|
||||||
// диапазон события. См. комментарий у checkBranchRule.
|
// диапазон события. См. комментарий у checkBranchRule.
|
||||||
@@ -433,12 +470,13 @@ function main(argv) {
|
|||||||
? (() => {
|
? (() => {
|
||||||
const hasDev = spawnSync('git', ['-C', repo, 'rev-parse', '--verify', 'origin/dev'],
|
const hasDev = spawnSync('git', ['-C', repo, 'rev-parse', '--verify', 'origin/dev'],
|
||||||
{ encoding: 'utf8' }).status === 0;
|
{ encoding: 'utf8' }).status === 0;
|
||||||
if (!hasDev) return commits;
|
if (!hasDev) return checkedCommits;
|
||||||
return parseRecords(
|
const own = parseRecords(
|
||||||
git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, 'origin/dev..HEAD'], repo),
|
git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, 'origin/dev..HEAD'], repo),
|
||||||
);
|
);
|
||||||
|
return commitsNeedingTargetValidation(own, { targetRef, isCommitOnMain });
|
||||||
})()
|
})()
|
||||||
: commits;
|
: checkedCommits;
|
||||||
findings.push(...checkBranchRule(branch, ownCommits));
|
findings.push(...checkBranchRule(branch, ownCommits));
|
||||||
|
|
||||||
// Метки читаются один раз и используются дважды: проверкой 8 и escalation
|
// Метки читаются один раз и используются дважды: проверкой 8 и escalation
|
||||||
@@ -458,7 +496,7 @@ function main(argv) {
|
|||||||
}
|
}
|
||||||
return publishedCache.get(sha);
|
return publishedCache.get(sha);
|
||||||
};
|
};
|
||||||
const statusCommits = commitsNeedingIssueStatus(commits, {
|
const statusCommits = commitsNeedingIssueStatus(checkedCommits, {
|
||||||
targetRef, isPublishedPrereleaseCommit,
|
targetRef, isPublishedPrereleaseCommit,
|
||||||
});
|
});
|
||||||
const numbers = [...new Set(statusCommits.flatMap((c) => c.issues).map((t) => t.slice(1)))];
|
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');
|
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 reviewDir = join(repo, 'docs', 'reviews');
|
||||||
const reviewFiles = [
|
const reviewFiles = [
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
checkSpecs,
|
checkSpecs,
|
||||||
classify,
|
classify,
|
||||||
commitsNeedingIssueStatus,
|
commitsNeedingIssueStatus,
|
||||||
|
commitsNeedingTargetValidation,
|
||||||
commitsUnderRuleOne,
|
commitsUnderRuleOne,
|
||||||
evaluateCommit,
|
evaluateCommit,
|
||||||
makeCommit,
|
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', () => {
|
test('issue status check is fail closed when the source of truth is unreachable', () => {
|
||||||
// AC3: недоступный gh должен давать отказ, а не молчаливый пропуск.
|
// AC3: недоступный gh должен давать отказ, а не молчаливый пропуск.
|
||||||
const broken = () => ({ ok: false, error: 'gh: could not resolve to a Repository' });
|
const broken = () => ({ ok: false, error: 'gh: could not resolve to a Repository' });
|
||||||
|
|||||||
Reference in New Issue
Block a user