mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
Правило 10 гейта (#311): DoR сверяется с моментом НАПИСАНИЯ кода — authorDate коммита класса A не может предшествовать первому labeled-событию S5-ready+ из timeline issue; продвижение метки больше не прячет нарушение, ребейзы конвейера его не смывают (authorDate переживает их). Проверка вторичная к правилу 8: недоступный timeline — warn, правило 8 остаётся fail-closed. LOG_FORMAT несёт authorDate третьим полем (append-совместимо). Шаг слияния конвейера (#312): сливается только проверенный SHA — вершина ветки сверяется с материалом ревью (допустим ровно один doc-коммит публикации с диффом только docs/reviews/ поверх); расхождение отменяет слияние с возвратом в S6-in-progress тем же путём, что конфликт (инвариант «метка меняется всегда» сохранён). PROCESS.md §2.7 фиксирует правило «вердикт привязан к SHA» и для ревьюера. Issue: #311 Issue: #312 User-Visible: no
This commit is contained in:
@@ -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 <<EOF
|
||||
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
|
||||
|
||||
Ревью выполнялось на \\`$(git rev-parse --short "$reviewed")\\`, а вершина ветки сейчас \\`$(git rev-parse --short "$actual")\\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
|
||||
|
||||
Задача переведена в \\`S6-in-progress\\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \\`S7-code-review\\` — новый заход ревью проверит актуальный код.
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
|
||||
exit 0
|
||||
fi
|
||||
git checkout -q -B merge-into-dev "$actual"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase origin/dev; then
|
||||
|
||||
@@ -194,6 +194,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
- **Вердикт привязан к SHA (#312).** Все числа и факты отчёта сверяются с
|
||||
`git rev-parse HEAD` непосредственно перед подведением итогов, а не с SHA,
|
||||
зафиксированным в начале разбора: во время ревью в ветку может прилететь
|
||||
fix-up. Серверный стопор — шаг слияния конвейера сверяет вершину ветки с
|
||||
SHA материала ревью (допустим только собственный doc-коммит публикации
|
||||
поверх) и при расхождении отменяет слияние с возвратом в `S6-in-progress`.
|
||||
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ export const RULES = {
|
||||
7: 'лимит документов ревью',
|
||||
8: 'статус issue',
|
||||
9: 'Gates: light',
|
||||
10: 'DoR по моменту коммита',
|
||||
};
|
||||
|
||||
export function classify(path) {
|
||||
@@ -103,9 +104,9 @@ export function classify(path) {
|
||||
// коммите с абзацем в теле.
|
||||
export const FS = '\x1f';
|
||||
export const RS = '\x1e';
|
||||
export const LOG_FORMAT = `%H${FS}%s${FS}%b${RS}`;
|
||||
export const LOG_FORMAT = `%H${FS}%s${FS}%aI${FS}%b${RS}`;
|
||||
|
||||
export function makeCommit({ sha = '', subject = '', body = '', files = [] }) {
|
||||
export function makeCommit({ sha = '', subject = '', body = '', files = [], authorDate = '' }) {
|
||||
const text = `${subject}\n${body}`;
|
||||
const all = (name) =>
|
||||
[...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;
|
||||
|
||||
@@ -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)), []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user