diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 45107006..0604ef5a 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -287,6 +287,16 @@ jobs: echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT" echo "ветка $BRANCH приведена к dev: $short_before -> $short_after" + # Материал ревью — конкретный SHA (#312). Вердикт применим только к + # нему: если во время ревью в ветку прилетит коммит, шаг слияния обязан + # это заметить и отказаться, а не молча увезти в dev непроверенный код. + - name: Зафиксировать SHA материала ревью + id: material + if: steps.rebase.outputs.conflict != 'true' + run: | + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "материал ревью: $(git rev-parse --short HEAD)" + # Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона # метка меняется всегда» при этом держится: возврат в S6-in-progress — # тоже смена метки, и автор не ждёт впустую. @@ -701,14 +711,42 @@ jobs: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ steps.branch.outputs.name }} NUM: ${{ github.event.issue.number }} + MATERIAL_SHA: ${{ steps.material.outputs.sha }} run: | if [ -z "$BRANCH" ]; then echo "::error::ветки задачи нет — сливать нечего" echo "merged=false" >> "$GITHUB_OUTPUT" exit 0 fi - git fetch -q origin dev - git checkout -q -B merge-into-dev "origin/$BRANCH" + git fetch -q origin dev "$BRANCH" + # #312: сливается только проверенный код. Допустимые вершины ветки: + # сам SHA материала либо он же плюс ровно один коммит публикации + # документа ревью (дифф только docs/reviews/). Любой другой коммит — + # ветка уехала после ревью, вердикт к ней не применим: возврат в + # S6-in-progress через merged=false, как при конфликте. + actual=$(git rev-parse "origin/$BRANCH") + reviewed="$MATERIAL_SHA" + fresh=false + if [ "$actual" = "$reviewed" ]; then + fresh=true + elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \ + && [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then + fresh=true + fi + if [ "$fresh" != true ]; then + echo "merged=false" >> "$GITHUB_OUTPUT" + echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)" + cat > /tmp/stale-verdict.md < [...text.matchAll(new RegExp(`^${name}:\\s*(.+)$`, 'gmi'))].map((m) => m[1].trim()); @@ -114,6 +115,7 @@ export function makeCommit({ sha = '', subject = '', body = '', files = [] }) { sha, short: sha.slice(0, 8), subject, + authorDate, files, classes: new Set(files.map(classify)), issues: all('Issue'), @@ -135,8 +137,8 @@ export function parseRecords(raw, filesOf = () => []) { .map((r) => r.replace(/^\n/, '')) .filter((r) => r.trim()) .map((rec) => { - const [sha, subject, body = ''] = rec.split(FS); - return makeCommit({ sha, subject, body, files: filesOf(sha) }); + const [sha, subject, authorDate = '', body = ''] = rec.split(FS); + return makeCommit({ sha, subject, body, files: filesOf(sha), authorDate }); }); } @@ -418,6 +420,67 @@ export function checkIssueStatuses( return out; } +// 10. DoR по моменту коммита (#311). Правило 8 читает ТЕКУЩУЮ метку issue: +// нарушение «код написан до Готово к разработке» становится невидимым, как +// только статус штатно продвигается. Здесь метка сверяется с моментом +// НАПИСАНИЯ кода: authorDate коммита класса A не может предшествовать первому +// достижению issue разрешённого статуса (labeled-событие из timeline). +// authorDate переживает ребейзы конвейера — окно нарушения не закрывается. +// Проверка вторичная к правилу 8, поэтому недоступный timeline — warn, а не +// fail: основная fail-closed проверка статуса остаётся за правилом 8. +export function checkCommitEraStatuses( + commits, timelineRunner, { allowed = ALLOWED_STATUS } = {}, +) { + const out = []; + const byIssue = new Map(); + for (const c of commits) { + if (!c.classes.has('A') || c.isRelease) continue; + if (!c.authorDate) continue; + for (const t of c.issues) { + const nn = t.slice(1); + const list = byIssue.get(nn) ?? []; + list.push(c); + byIssue.set(nn, list); + } + } + for (const [nn, list] of byIssue) { + const r = timelineRunner(nn); + if (!r || r.ok !== true || !Array.isArray(r.events)) { + out.push({ + level: 'warn', rule: 10, sha: '-', + msg: `issue #${nn}: timeline недоступен — проверка DoR по моменту коммита пропущена`, + }); + continue; + } + const readyAt = r.events + .filter((e) => allowed.includes(e.label) && e.at) + .map((e) => Date.parse(e.at)) + .filter(Number.isFinite) + .sort((a, b) => a - b)[0]; + if (readyAt === undefined) { + // Правило 8 уже требует текущий разрешённый статус; отсутствие событий + // при живом статусе — неполный timeline, честный warn. + out.push({ + level: 'warn', rule: 10, sha: '-', + msg: `issue #${nn}: в timeline нет событий ${allowed.join('/')} — проверка DoR по моменту коммита пропущена`, + }); + continue; + } + for (const c of list) { + const wrote = Date.parse(c.authorDate); + if (Number.isFinite(wrote) && wrote < readyAt) { + out.push({ + level: 'fail', rule: 10, sha: c.short, + msg: `issue #${nn}: коммит класса A написан ${c.authorDate}, ` + + `до первого достижения задачей статуса из ${allowed.join('/')} ` + + `(${new Date(readyAt).toISOString()}) — код раньше «Готово к разработке» (§12)`, + }); + } + } + } + return out; +} + export function buildReport({ range, branch, commits, findings }) { const fails = findings.filter((f) => f.level === 'fail'); return { @@ -441,6 +504,25 @@ function git(args, repo) { return r.stdout; } +function ghTimelineRunner(nwo, bin) { + return (nn) => { + const r = spawnSync(bin, ['api', `repos/${nwo}/issues/${nn}/timeline`, '--paginate', + '-q', '[.[] | select(.event == "labeled") | {label: .label.name, at: .created_at}]'], + { encoding: 'utf8' }); + if (r.status !== 0) { + return { ok: false, error: (r.stderr || 'gh api завершился с ошибкой').trim() }; + } + try { + // --paginate печатает по массиву на страницу — склеиваем все. + const events = r.stdout.trim().split('\n').filter(Boolean) + .flatMap((line) => JSON.parse(line)); + return { ok: true, events }; + } catch (e) { + return { ok: false, error: e.message }; + } + }; +} + function ghRunner(nwo, bin) { return (nn) => { const r = spawnSync(bin, ['issue', 'view', String(nn), '--repo', nwo, '--json', 'number,state,labels'], @@ -603,6 +685,16 @@ function main(argv) { }); } findings.push(...checkIssueStatuses(numbers, cached, { allowed, statusOptional })); + if (!statusOptional) { + const timelineRunner = ghTimelineRunner( + process.env.HP_REPO ?? 'Matysh/houseplan-card', process.env.GH_BIN ?? 'gh', + ); + const timelineCache = new Map(); + findings.push(...checkCommitEraStatuses(statusCommits, (nn) => { + if (!timelineCache.has(nn)) timelineCache.set(nn, timelineRunner(nn)); + return timelineCache.get(nn); + }, { allowed })); + } labelsOf = (nn) => { const r = cached(nn); if (!r || r.ok !== true) return null; diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs index 9117fa35..77ea842b 100644 --- a/test/process-gate.test.mjs +++ b/test/process-gate.test.mjs @@ -11,6 +11,7 @@ import { STRICT_STATUS, buildReport, checkBranchRule, + checkCommitEraStatuses, isPipelineReviewDocCommit, checkIssueStatuses, checkReviewDocLimit, @@ -309,14 +310,16 @@ test('issue status check accepts the working statuses and refuses the rest', () test('the log record parser survives multi-line commit bodies', () => { // Разбор по строкам ломался здесь: тело содержит пустые строки и абзацы. + // Формат несёт четыре поля (#311 добавил authorDate третьим). const raw = [ - `aaaaaaaaaaaa${FS}First subject${FS}Some prose.\n\nMore prose.\n\nIssue: #1\nUser-Visible: no\n${RS}`, - `bbbbbbbbbbbb${FS}Second subject${FS}Issue: #2\nUser-Visible: yes\n${RS}`, + `aaaaaaaaaaaa${FS}First subject${FS}2026-08-25T19:00:00+03:00${FS}Some prose.\n\nMore prose.\n\nIssue: #1\nUser-Visible: no\n${RS}`, + `bbbbbbbbbbbb${FS}Second subject${FS}2026-08-25T20:00:00+03:00${FS}Issue: #2\nUser-Visible: yes\n${RS}`, ].join(''); const list = parseRecords(raw, () => ['src/a.ts']); assert.equal(list.length, 2); assert.deepEqual(list.map((c) => c.issues), [['#1'], ['#2']]); assert.equal(list[0].subject, 'First subject'); + assert.equal(list[0].authorDate, '2026-08-25T19:00:00+03:00'); assert.deepEqual(parseRecords('', () => []), []); }); @@ -680,3 +683,40 @@ test('rule 2 exempts a pipeline review document proven by subject and diff (#305 assert.equal(isPipelineReviewDocCommit(blind), false); assert.equal(checkBranchRule('issue/302-junction-node-material', [blind]).length, 1); }); + +test('rule 10 pins DoR to the commit author date, not to the current label (#311)', () => { + const codeAt = (iso) => makeCommit({ + sha: 'e'.repeat(40), subject: 'feat: work (#266)', + body: 'Issue: #266\nUser-Visible: no', + files: ['src/houseplan-card.ts'], authorDate: iso, + }); + const timeline = (events) => () => ({ ok: true, events }); + const ready = [{ label: 'S5-ready', at: '2026-08-25T20:00:00Z' }]; + + // Written BEFORE the issue ever reached S5-ready — the violation stays + // visible no matter how far the label has advanced since. + const early = checkCommitEraStatuses([codeAt('2026-08-25T19:00:00Z')], timeline(ready)); + assert.equal(early.length, 1); + assert.equal(early[0].level, 'fail'); + assert.equal(early[0].rule, 10); + + // Written after readiness — clean. + assert.deepEqual( + checkCommitEraStatuses([codeAt('2026-08-25T21:00:00Z')], timeline(ready)), []); + + // Secondary check degrades to a warn without timeline data; rule 8 stays + // the fail-closed primary. + const blind = checkCommitEraStatuses([codeAt('2026-08-25T19:00:00Z')], () => ({ ok: false })); + assert.equal(blind.length, 1); + assert.equal(blind[0].level, 'warn'); + const empty = checkCommitEraStatuses([codeAt('2026-08-25T19:00:00Z')], timeline([])); + assert.equal(empty.length, 1); + assert.equal(empty[0].level, 'warn'); + + // Non-code commits (spec/docs before S5) are legitimate and out of scope. + const doc = makeCommit({ + sha: 'f'.repeat(40), subject: 'docs: spec for #266', + body: 'Issue: #266', files: ['docs/specs/266-x.md'], authorDate: '2026-08-25T19:00:00Z', + }); + assert.deepEqual(checkCommitEraStatuses([doc], timeline(ready)), []); +});