mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
fix(process-metrics): task volume, merge return reasons and spec drafts (#752)
The weekly report disagreed with its own definitions (#728) and with the pipeline texts that appeared after it (#705, #723, #729). Volume. A task's volume counted documentation, so the archive move of #682 weighed 333 060 lines and #680/#681 thousands, all landing in the "> 1000" bucket and shifting the cohort medians; and every commit with a Release: trailer was dropped, including the task's own golden acceptance and test re-pinning commits. Volume is now the +/- of class A and B files only. Only beta commits are left out: the beta or release candidate (Release: trailer plus the candidate subject or a changed bundle, bundle-policy.mjs, drops every Release: commit, so it is not reused. A task whose commits touch only docs/reviews/** has no volume and is no longer read as infrastructure: the pipeline writes those documents, not the task. Return reasons. Every non-merge outcome of merge-candidate.mjs fell to "unknown". merge-candidate.mjs now exports a sign for the heading of each outcome comment (OUTCOME_SIGNS; the step-failure text moved into commentFor as 'error', byte for byte), and a test on the templates themselves holds every case to its own sign. The report maps merge-stage outcomes after a green verdict to "merge" and a push refused while rebasing before review to the new "push-refused" reason. Spec drafts. A new section after "По трекам" counts the S4-spec-review epochs on the ask track for tasks whose first S5-ready falls in the window, the epochs with a "Черновик:" comment (§7.2) and whether the draft went to S5 or was thrown at S3, Spec-Draft: commits in dev for the window, and S5 -> S7 per track for tasks with and without a draft, "мало данных" under three. The snapshot also reads timelines of tasks in S5-S7. Three #728 assertions pinned the old behaviour (a Release: fixture without the candidate subject, and the rebase workflow refusal read as unknown); they now expect the new definitions. Issue: #752 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -226,11 +226,40 @@ export function commentFor(action, ctx) {
|
||||
+ ' · слито'
|
||||
+ (ctx.branchDeleted === true ? ` · ветка \`${ctx.branch}\` удалена` : '')
|
||||
+ (ctx.branchDeleted === false ? ` · ветка \`${ctx.branch}\` оставлена: её вершина сдвинулась после слияния` : '');
|
||||
// Сбой самого шага (`action=error`): `ctx.error` — уже без секретов и не длиннее 1500 знаков.
|
||||
case 'error':
|
||||
return `**Слияние не выполнено: сбой шага слияния (#492).**\n\n\`\`\`\n${ctx.error ?? ''}\n\`\`\`\n\n`
|
||||
+ 'Вердикт ревью в силе. Задача в `S6-in-progress`; после разбора сбоя вернуть `S7-code-review`.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #752: признаки исходов — заголовок каждого комментария `commentFor`, кроме
|
||||
* успешного слияния. `stage` — где исход случается: `merge` — слияние после
|
||||
* зелёного вердикта, `rebase` — страж ребейза до ревью (`describePushRefusal`,
|
||||
* `_process.yml`). Отчёт процесса (`process-metrics.mjs`) узнаёт по ним
|
||||
* причину возврата; переименование заголовка без признака краснит тест на
|
||||
* самих шаблонах, а не даёт молча `unknown`.
|
||||
*/
|
||||
export const OUTCOME_SIGNS = Object.freeze([
|
||||
{ action: 'reject-stale', stage: 'merge', re: /^\*\*Слияние отменено: ветка изменилась после проверенного материала/m },
|
||||
{ action: 'conflict', stage: 'merge', re: /^\*\*Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно\.\*\* Не удалось только слияние/m },
|
||||
{ action: 'rereview', stage: 'merge', re: /^\*\*Дифф изменился при ребейзе на `dev@[^`]*` — вердикт к нему не применим/m },
|
||||
{ action: 'validation-red', stage: 'merge', re: /^\*\*Кандидат после ребейза на `dev@[^`]*` красный/m },
|
||||
{ action: 'validation-missing', stage: 'merge', re: /^\*\*Validate на кандидате `[^`]*` не появился за \d+ мин/m },
|
||||
{ action: 'give-up', stage: 'merge', re: /^\*\*`dev` движется быстрее слияния:/m },
|
||||
{ action: 'push-refused-workflow', stage: 'merge', re: /^\*\*Слияние не выполнено: кандидат меняет workflow-файл/m },
|
||||
{ action: 'push-refused-workflow', stage: 'rebase', re: /^\*\*Ревью не запускалось: кандидат меняет workflow-файл/m },
|
||||
{ action: 'push-refused', stage: 'merge', re: /^\*\*Слияние не выполнено: GitHub отклонил push /m },
|
||||
{ action: 'push-refused', stage: 'rebase', re: /^\*\*Ревью не запускалось: GitHub отклонил push /m },
|
||||
{ action: 'error', stage: 'merge', re: /^\*\*Слияние не выполнено: сбой шага слияния/m },
|
||||
].map((sign) => Object.freeze(sign)));
|
||||
|
||||
/** Исход по тексту комментария: признак `OUTCOME_SIGNS` или `null`. */
|
||||
export const outcomeOf = (body) => OUTCOME_SIGNS.find((sign) => sign.re.test(String(body ?? ''))) || null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Исполнение: git + gh через `ops`, чтобы тест подменял их целиком.
|
||||
|
||||
@@ -604,7 +633,7 @@ else if (isMainModule(import.meta.url)) { // #496: переносимо для W
|
||||
// #705: текст сбоя уходит в issue — без токена и URL с учётными данными.
|
||||
console.error(redactSecrets(err && err.stack || err, [token]));
|
||||
try {
|
||||
ops.comment(issue, `**Слияние не выполнено: сбой шага слияния (#492).**\n\n\`\`\`\n${redactSecrets(String(err && err.message || err), [token]).slice(0, 1500)}\n\`\`\`\n\nВердикт ревью в силе. Задача в \`S6-in-progress\`; после разбора сбоя вернуть \`S7-code-review\`.`);
|
||||
ops.comment(issue, commentFor('error', { error: redactSecrets(String(err && err.message || err), [token]).slice(0, 1500) }));
|
||||
} catch (e) { console.error(e); }
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'merged=false\nto=S6-in-progress\naction=error\n');
|
||||
process.exit(0);
|
||||
|
||||
+202
-18
@@ -21,6 +21,15 @@
|
||||
//
|
||||
// #761: токены — только за окно отчёта: документ ревью входит в неделю, если
|
||||
// коммит, добавивший его в `dev`, попал в окно (перенос в архив — не добавление).
|
||||
// #752: объём задачи — `+/−` строк классов A и B (продукт, тесты, инструменты)
|
||||
// в её коммитах с трейлером `Issue: #NN`. Не входят коммиты беты, а не задачи:
|
||||
// кандидат беты или релиза (трейлер `Release:` и подпись кандидата или бандл,
|
||||
// `bundle-policy.mjs`) и коммит бота `beta-derived` (#697). Коммиты задачи с
|
||||
// `Release:` — приёмка эталонов, перепривязка тестов к бете — входят.
|
||||
// Документация, документы ревью и бандл в объём не входят: перенос архива
|
||||
// (#682) — не работа над кодом. Возвраты слияния и стража ребейза узнаются по
|
||||
// признакам `merge-candidate.mjs`; раздел «Черновик ТЗ» — эпохи `S4` с
|
||||
// черновиком (#729, §11.8) и S5 → S7 с черновиком и без.
|
||||
//
|
||||
// node scripts/process-metrics.mjs --repo=<owner/repo> --days=7 [--until=ISO] [--output=path.md] [--json=path.json]
|
||||
// [--compare=2026-09-28] [--compare-days=28]
|
||||
@@ -35,6 +44,8 @@ import { USAGE_KEYS, lastUsageIn } from './model-usage.mjs';
|
||||
import { ANCHOR_MARKER, verdictDeclaration } from './review-doc-guard.mjs';
|
||||
import { SHIP_REVIEW_ANCHOR, parseAnchorBlock } from './ship-review.mjs';
|
||||
import { PIPELINE_EVENTS } from './wait-verdict.mjs';
|
||||
import { OUTCOME_SIGNS, outcomeOf } from './merge-candidate.mjs';
|
||||
import { committedBundleMustMatch, isReleaseMessage } from './bundle-policy.mjs';
|
||||
|
||||
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
|
||||
const PROCESS_RUN = /^process #(\d+) · (S4-spec-review|S7-code-review)(?: ·|$)/;
|
||||
@@ -195,7 +206,7 @@ export const JOBS_RUN_CAP = 600;
|
||||
export const TIMELINE_PAGE_CAP = 10;
|
||||
export const TRACKS = ['ship', 'show', 'ask'];
|
||||
export const SEGMENTS = ['queue', 'spec', 'work', 'review', 'rework', 'blocked'];
|
||||
export const RETURN_REASONS = ['verdict-yellow', 'verdict-red', 'validate-red', 'conflict', 'merge', 'reclassify', 'owner-question', 'unknown'];
|
||||
export const RETURN_REASONS = ['verdict-yellow', 'verdict-red', 'validate-red', 'conflict', 'push-refused', 'merge', 'reclassify', 'owner-question', 'unknown'];
|
||||
export const PIPELINE_STAGES = ['guard', 'prepare', 'model', 'integrate'];
|
||||
export const VOLUME_BUCKETS = ['≤30', '31–200', '201–1000', '>1000'];
|
||||
export const VALIDATE_WORKFLOW = 'Проверка (CI)';
|
||||
@@ -225,6 +236,19 @@ const NOT_RUN_RE = pipelineEvent('conflict');
|
||||
/** Неудачное слияние после ревью: «Слияние отменено», «Код-ревью зелёное — вердикт выше в силе». */
|
||||
const MERGE_RES = [pipelineEvent('stale'), pipelineEvent('merge-conflict')];
|
||||
|
||||
/**
|
||||
* #752: причина по исходу `merge-candidate.mjs` (`OUTCOME_SIGNS`). Отказ push
|
||||
* стража ребейза до ревью (#705) — `push-refused`: код не читали, действует
|
||||
* автор или владелец правом на workflow. Исход слияния после зелёного вердикта
|
||||
* (кандидат красный или пропал, `dev` ушёл, отказ push, сбой шага) — `merge`:
|
||||
* вердикт был, слить не удалось. `rereview` возвращает задачу в `S7`, а не
|
||||
* автору, — причины не называет.
|
||||
*/
|
||||
if (!OUTCOME_SIGNS.some((sign) => sign.action === 'rereview')) {
|
||||
throw new Error("process-metrics: в OUTCOME_SIGNS (merge-candidate.mjs) нет исхода 'rereview'");
|
||||
}
|
||||
const outcomeReason = (sign) => (sign.stage === 'rebase' ? 'push-refused' : sign.action === 'rereview' ? null : 'merge');
|
||||
|
||||
const toMs = (value) => (typeof value === 'number' ? value : at(value));
|
||||
|
||||
/** События меток таймлайна в порядке времени (при равном времени — в порядке таймлайна). */
|
||||
@@ -362,12 +386,15 @@ export function stageVerdict(body, { stage = 'code', number } = {}) {
|
||||
/**
|
||||
* К3. Признак причины возврата в одном комментарии; `null` — комментарий
|
||||
* причины не называет. «Ревью не запускалось» с другим продолжением —
|
||||
* `unknown`: семейство узнано, причина не угадывается.
|
||||
* `unknown`: семейство узнано, причина не угадывается. Исходы слияния и
|
||||
* стража ребейза — по признакам `merge-candidate.mjs` (#752).
|
||||
*/
|
||||
export function returnSignal(body, { stage = 'code', number } = {}) {
|
||||
const text = String(body ?? '');
|
||||
const route = ROUTE_RE.exec(text);
|
||||
if (route) return route[1];
|
||||
const outcome = outcomeOf(text);
|
||||
if (outcome) return outcomeReason(outcome);
|
||||
if (NOT_RUN_RE.test(text)) {
|
||||
if (NOT_RUN_VALIDATE_RE.test(text)) return 'validate-red';
|
||||
if (NOT_RUN_CONFLICT_RE.test(text)) return 'conflict';
|
||||
@@ -530,24 +557,45 @@ export function readCommits(git, { ref = 'origin/dev', since } = {}) {
|
||||
return parseGitLog(git(args));
|
||||
}
|
||||
|
||||
/** Строка объёма К7: не класс D (`classify`) и не `docs/reviews/**`. */
|
||||
const countsToVolume = (path) => classify(path) !== 'D' && !String(path).startsWith('docs/reviews/');
|
||||
/** Строка объёма К7 (#752): классы A и B — продукт, тесты, инструменты. */
|
||||
const countsToVolume = (path) => ['A', 'B'].includes(classify(path));
|
||||
/** Файл задачи для признака инфраструктуры (#752): A, B, C, кроме документов ревью — их пишет конвейер. */
|
||||
const isTaskFile = (path) => ['A', 'B', 'C'].includes(classify(path)) && !String(path).startsWith('docs/reviews/');
|
||||
|
||||
/**
|
||||
* Изменения задачи по коммитам с трейлером `Issue: #NN`, кроме коммитов
|
||||
* `Release:` (они несут трейлеры всех задач беты, бандлы и версию манифеста):
|
||||
* объём К7 (`+/−` без класса D и `docs/reviews/**`) и файлы для признака
|
||||
* инфраструктуры К1.
|
||||
* Подпись коммита бота `beta-derived` (#697). Константы у конвейера нет —
|
||||
* сообщение собирает `_beta-derived.yml`; копию держит контрактный тест.
|
||||
*/
|
||||
export const BETA_DERIVED_SUBJECT = /^docs: accept derived artifacts on dev for v\d/;
|
||||
|
||||
/**
|
||||
* Коммит беты, а не задачи (#752): он несёт трейлеры всех задач линии.
|
||||
* Кандидат беты или релиза — трейлер `Release:` и подпись кандидата либо
|
||||
* изменённый бандл (`committedBundleMustMatch`: с #657 бандл меняет только
|
||||
* кандидат); коммит бота `beta-derived` — по подписи. Прочий коммит с
|
||||
* `Release:` (приёмка эталонов, перепривязка тестов к бете) — коммит задачи.
|
||||
*/
|
||||
export function isBetaCommit(commit) {
|
||||
const body = String(commit?.body ?? '');
|
||||
const subject = body.split('\n', 1)[0];
|
||||
if (BETA_DERIVED_SUBJECT.test(subject)) return true;
|
||||
return isReleaseMessage(body) && committedBundleMustMatch({ subject, files: (commit?.files || []).map((file) => file.path) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Изменения задачи по коммитам с трейлером `Issue: #NN`, кроме коммитов беты
|
||||
* (`isBetaCommit`): объём К7 (`+/−` классов A и B) и файлы задачи для признака
|
||||
* инфраструктуры К1 (A, B, C без `docs/reviews/**`).
|
||||
*/
|
||||
export function issueChanges(commits = []) {
|
||||
const byIssue = new Map();
|
||||
for (const commit of commits || []) {
|
||||
if (/^Release:/m.test(String(commit.body || ''))) continue;
|
||||
if (isBetaCommit(commit)) continue;
|
||||
for (const number of commit.issues || issueTrailers(commit.body)) {
|
||||
const entry = byIssue.get(number) || { lines: 0, files: new Set(), commits: 0 };
|
||||
entry.commits += 1;
|
||||
for (const file of commit.files || []) {
|
||||
entry.files.add(file.path);
|
||||
if (isTaskFile(file.path)) entry.files.add(file.path);
|
||||
if (countsToVolume(file.path)) entry.lines += (file.added ?? 0) + (file.deleted ?? 0);
|
||||
}
|
||||
byIssue.set(number, entry);
|
||||
@@ -556,9 +604,15 @@ export function issueChanges(commits = []) {
|
||||
return byIssue;
|
||||
}
|
||||
|
||||
/** Инфраструктура (§1): в коммитах задачи есть файлы и ни одного класса A. */
|
||||
/**
|
||||
* Инфраструктура (§1): у задачи есть файлы задачи и ни одного класса A.
|
||||
* Задача только с документами ревью — признак не доказан.
|
||||
*/
|
||||
export const isInfra = (change) => Boolean(change && change.files.size && [...change.files].every((path) => classify(path) !== 'A'));
|
||||
|
||||
/** Объём задачи К7: строки A+B; без файлов задачи (только документы ревью, бандл) — `null`, объёма нет. */
|
||||
export const changeVolume = (change) => (change && change.files.size ? change.lines : null);
|
||||
|
||||
/** Корзина объёма К7: ≤ 30, 31–200, 201–1000, > 1000 строк. */
|
||||
export function volumeBucket(lines) {
|
||||
if (!Number.isFinite(lines)) return null;
|
||||
@@ -578,9 +632,10 @@ export function issueTrackMetrics(issue, events = [], { change = null, ship = nu
|
||||
const s8 = labelEvents(events).find((event) => event.type === 'labeled' && event.label === 'S8-merged');
|
||||
if (!s8) return null;
|
||||
const infra = isInfra(change);
|
||||
const volume = changeVolume(change);
|
||||
const base = {
|
||||
number, title: String(issue.title || ''), stateReason: issue.state_reason || null, s8At: s8.at,
|
||||
infra, volume: change ? change.lines : null, bucket: change ? volumeBucket(change.lines) : null,
|
||||
infra, volume, bucket: volumeBucket(volume),
|
||||
};
|
||||
if (truncated) return { ...base, truncated: true, track: null, path: [], leadMs: null, segments: null, returns: [], rounds: [], ship: null };
|
||||
const seg = issueSegments(events);
|
||||
@@ -654,6 +709,100 @@ export function trackSection(rows = [], { shipDocs = [] } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #752 П.4: черновик ТЗ (#729, PROCESS.md §11.8).
|
||||
|
||||
/**
|
||||
* Комментарий «Черновик:» по шаблону §7.2 — пишет автор, не конвейер; копию
|
||||
* держит контрактный тест на PROCESS.md.
|
||||
*/
|
||||
export const DRAFT_COMMENT_RE = /^\s*Черновик:/;
|
||||
/** Трейлер чернового коммита (§11.8) — как читает его `process-gate.mjs`. */
|
||||
const SPEC_DRAFT_TRAILER = /^Spec-Draft:\s*\S/mi;
|
||||
|
||||
/** Трек на момент `t`, включая метки, поставленные в сам момент `t` (как начало пути трека). */
|
||||
const trackDuring = (events, t, infra) => labelTrack({ labels: labelsAt(events, t, { inclusive: true }), infrastructure: infra });
|
||||
|
||||
/**
|
||||
* Эпохи `S4-spec-review`: от постановки `S4` до следующей статусной метки с
|
||||
* другим именем — статус как в `issueSegments`, повторная постановка `S4`
|
||||
* эпоху не начинает. `close` — метка, закрывшая эпоху; открытая — `null`.
|
||||
*/
|
||||
export function specEpochs(events = []) {
|
||||
const epochs = [];
|
||||
let status = null;
|
||||
let open = null;
|
||||
for (const event of labelEvents(events)) {
|
||||
if (event.type !== 'labeled' || !STATUS_LABELS.includes(event.label) || event.label === status) continue;
|
||||
if (open) epochs.push({ ...open, end: event.at, close: event.label });
|
||||
status = event.label;
|
||||
open = status === 'S4-spec-review' ? { start: event.at } : null;
|
||||
}
|
||||
if (open) epochs.push({ ...open, end: null, close: null });
|
||||
return epochs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Раздел «Черновик ТЗ» по задачам с первым `S5-ready` в окне:
|
||||
* - эпохи `S4` на треке ask (трек — на момент начала эпохи), из них с
|
||||
* комментарием «Черновик:» внутри эпохи и их исход: `S5-ready` — черновик
|
||||
* пошёл в дело, `S3-spec` — выброшен, прочее — открыта или закрыта иначе;
|
||||
* - S5 → S7 — от первого `S5-ready` до первого `S7-code-review` после него
|
||||
* (не позже `until`), по треку на момент S5; «с черновиком» — эпоху `S4`,
|
||||
* закрытую этим `S5-ready`, вёл черновик. n < `MIN_COHORT` — мало данных;
|
||||
* - коммиты `dev` с трейлером `Spec-Draft:` и датой коммиттера в окне.
|
||||
*/
|
||||
export function draftSection({ issues = [], timelines = new Map(), changes = new Map(), commits = [], since, until } = {}) {
|
||||
const from = toMs(since);
|
||||
const to = toMs(until);
|
||||
const inWindow = (moment) => moment >= from && moment <= to;
|
||||
const epochs = { total: 0, withDraft: 0, used: 0, thrown: 0, other: 0 };
|
||||
const spans = [];
|
||||
const noS7 = [];
|
||||
const tasks = [];
|
||||
const seen = new Set();
|
||||
for (const issue of issues || []) {
|
||||
const number = Number(issue.number);
|
||||
if (seen.has(number)) continue;
|
||||
seen.add(number);
|
||||
const events = timelines.get(number) || [];
|
||||
const labels = labelEvents(events).filter((event) => event.type === 'labeled');
|
||||
const s5 = labels.find((event) => event.label === 'S5-ready');
|
||||
if (!s5 || !inWindow(s5.at)) continue;
|
||||
tasks.push(number);
|
||||
const infra = isInfra(changes.get(number));
|
||||
const drafts = commentEvents(events).filter((comment) => DRAFT_COMMENT_RE.test(comment.body));
|
||||
let draftedS5 = false;
|
||||
for (const epoch of specEpochs(events)) {
|
||||
const drafted = drafts.some((comment) => comment.at >= epoch.start && (epoch.end === null || comment.at <= epoch.end));
|
||||
if (epoch.close === 'S5-ready' && epoch.end === s5.at) draftedS5 = drafted;
|
||||
if (trackDuring(events, epoch.start, infra) !== 'ask') continue;
|
||||
epochs.total += 1;
|
||||
if (!drafted) continue;
|
||||
epochs.withDraft += 1;
|
||||
if (epoch.close === 'S5-ready') epochs.used += 1;
|
||||
else if (epoch.close === 'S3-spec') epochs.thrown += 1;
|
||||
else epochs.other += 1;
|
||||
}
|
||||
const s7 = labels.find((event) => event.label === 'S7-code-review' && event.at >= s5.at);
|
||||
if (!s7 || s7.at > to) { noS7.push(number); continue; }
|
||||
spans.push({ number, track: trackDuring(events, s5.at, infra), drafted: draftedS5, ms: s7.at - s5.at });
|
||||
}
|
||||
const tracks = [...new Set([...TRACKS, ...spans.map((span) => span.track)])].sort((a, b) => trackOrder(a) - trackOrder(b));
|
||||
const group = (list) => ({ n: list.length, hours: medianHours(list.map((span) => span.ms)), enough: list.length >= MIN_COHORT });
|
||||
const drafted = (commits || []).filter((commit) => SPEC_DRAFT_TRAILER.test(String(commit.body || '')) && inWindow(at(commit.date)));
|
||||
return {
|
||||
tasks: tasks.length,
|
||||
epochs,
|
||||
s5ToS7: tracks.map((track) => {
|
||||
const own = spans.filter((span) => span.track === track);
|
||||
return { track, draft: group(own.filter((span) => span.drafted)), plain: group(own.filter((span) => !span.drafted)) };
|
||||
}),
|
||||
noS7: noS7.sort((a, b) => a - b),
|
||||
specDraftCommits: { count: drafted.length, issues: [...new Set(drafted.flatMap((commit) => commit.issues || []))].sort((a, b) => a - b) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Стадия конвейера по имени job тела — части после « / »: «Страж…», «Ревью: материал…», … */
|
||||
export function jobStage(name = '') {
|
||||
const text = String(name);
|
||||
@@ -745,8 +894,8 @@ export function stageMinutes({ runs = [], jobsByRun = new Map(), trackOf = () =>
|
||||
* К7. Сравнение сопоставимых задач до и после `cutover`: первый `S8-merged`
|
||||
* в `[cutover − days, cutover)` и `[cutover, min(cutover + days, until))`,
|
||||
* когорта — трек на момент S8 и корзина объёма. n < `MIN_COHORT` на любой
|
||||
* стороне — «мало данных», разницы нет. Задачи без коммитов с трейлером
|
||||
* объёма не имеют и в когорты не входят.
|
||||
* стороне — «мало данных», разницы нет. Задачи без коммитов с трейлером или
|
||||
* только с документами ревью объёма не имеют и в когорты не входят.
|
||||
*/
|
||||
export function compareCohorts(rows = [], { cutover = TRACKS_CUTOVER, days = COMPARE_DAYS, until = null } = {}) {
|
||||
const border = toMs(cutover);
|
||||
@@ -860,6 +1009,7 @@ function trackReport({ since, until, issues, timelines, timelineTruncated, revie
|
||||
};
|
||||
return {
|
||||
tracks: trackSection(rows.filter((row) => row.s8At >= from && row.s8At <= to), { shipDocs: ship.docs }),
|
||||
drafts: draftSection({ issues, timelines, changes, commits, since, until }),
|
||||
stages: jobsByRun ? stageMinutes({ runs, jobsByRun, trackOf }) : null,
|
||||
// #761: токены и `missing` — по документам, добавленным в dev за окно; `window` — окно применено.
|
||||
tokens: reviewDocAdded
|
||||
@@ -914,6 +1064,7 @@ export function renderMarkdown(report) {
|
||||
lines.push('</details>');
|
||||
// #728: новые разделы — после прежних; прежние строки не меняются.
|
||||
if (report.tracks) renderTracks(lines, report.tracks);
|
||||
if (report.drafts) renderDrafts(lines, report.drafts);
|
||||
if ('stages' in report) renderStages(lines, report.stages);
|
||||
if ('tokens' in report) renderTokens(lines, report.tokens);
|
||||
if (report.compare) renderCompare(lines, report.compare);
|
||||
@@ -975,6 +1126,38 @@ function renderTracks(lines, t) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderDrafts(lines, d) {
|
||||
lines.push('');
|
||||
lines.push('### Черновик ТЗ (#729)');
|
||||
lines.push('');
|
||||
lines.push(`Задачи с первым \`S5-ready\` в окне: **${d.tasks}**. Эпоха \`S4-spec-review\` — от постановки \`S4\` до следующей статусной метки; в счёт идут эпохи на треке ask (трек — на момент начала эпохи). Черновик — комментарий «Черновик:» (§7.2) внутри эпохи; исход — \`S5-ready\` (черновик пошёл в дело) или \`S3-spec\` (выброшен).`);
|
||||
lines.push('');
|
||||
const e = d.epochs;
|
||||
if (!e.withDraft) {
|
||||
lines.push(`Черновиков нет: эпох \`S4-spec-review\` на ask — ${e.total}, комментария «Черновик:» нет ни в одной.`);
|
||||
} else {
|
||||
lines.push('| Эпох S4 (ask) | С черновиком | Пошёл в дело (S5) | Выброшен (S3) | Открыта или иначе |');
|
||||
lines.push('|---:|---:|---:|---:|---:|');
|
||||
lines.push(`| ${e.total} | ${e.withDraft} | ${e.used} | ${e.thrown} | ${e.other} |`);
|
||||
}
|
||||
lines.push('');
|
||||
const commits = d.specDraftCommits;
|
||||
lines.push(commits.count
|
||||
? `Коммиты с трейлером \`Spec-Draft:\` в \`dev\` за окно: ${commits.count}${commits.issues.length ? ` (${commits.issues.map((n) => `#${n}`).join(', ')})` : ''}.`
|
||||
: 'Коммитов с трейлером `Spec-Draft:` в `dev` за окно нет.');
|
||||
lines.push('');
|
||||
lines.push(`S5 → S7 — от первого \`S5-ready\` до первого \`S7-code-review\` после него, медиана в часах; трек — на момент S5; «с черновиком» — эпоху \`S4\`, закрытую этим \`S5-ready\`, вёл черновик. n < ${MIN_COHORT} — мало данных.`);
|
||||
lines.push('');
|
||||
lines.push('| Трек | С черновиком: n | S5 → S7, ч | Без черновика: n | S5 → S7, ч |');
|
||||
lines.push('|---|---:|---|---:|---|');
|
||||
const cell = (group) => (group.enough ? h(group.hours) : 'мало данных');
|
||||
for (const row of d.s5ToS7) lines.push(`| ${row.track} | ${row.draft.n} | ${cell(row.draft)} | ${row.plain.n} | ${cell(row.plain)} |`);
|
||||
if (d.noS7.length) {
|
||||
lines.push('');
|
||||
lines.push(`Без \`S7-code-review\` к концу окна (в S5 → S7 не входят): ${d.noS7.map((n) => `#${n}`).join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStages(lines, s) {
|
||||
lines.push('');
|
||||
lines.push('### Job-минуты по стадиям');
|
||||
@@ -1031,7 +1214,7 @@ function renderCompare(lines, c) {
|
||||
lines.push('');
|
||||
lines.push(`### До и после ${c.cutover}`);
|
||||
lines.push('');
|
||||
lines.push(`Первый \`S8-merged\` в [${isoDay(c.before.from)}, ${isoDay(c.before.to)}) и [${isoDay(c.after.from)}, ${c.after.to ? isoDay(c.after.to) : '…'}), окно ${c.days} дн. Когорта — трек на момент S8 и объём задачи: \`+/−\` строк коммитов \`Issue: #NN\` без \`Release:\`, класса D и \`docs/reviews/**\` (корзины ${VOLUME_BUCKETS.join(', ')}). Медианы в часах «до → после (разница)»; n < ${MIN_COHORT} на любой стороне — мало данных. Job-минуты в сравнение не входят.`);
|
||||
lines.push(`Первый \`S8-merged\` в [${isoDay(c.before.from)}, ${isoDay(c.before.to)}) и [${isoDay(c.after.from)}, ${c.after.to ? isoDay(c.after.to) : '…'}), окно ${c.days} дн. Когорта — трек на момент S8 и объём задачи: \`+/−\` строк классов A и B (продукт, тесты, инструменты) в коммитах \`Issue: #NN\`, кроме коммитов кандидата беты или релиза и бота \`beta-derived\`; документация, документы ревью и бандл не входят (корзины ${VOLUME_BUCKETS.join(', ')}). Медианы в часах «до → после (разница)»; n < ${MIN_COHORT} на любой стороне — мало данных. Job-минуты в сравнение не входят.`);
|
||||
lines.push('');
|
||||
if (!c.cohorts.length) {
|
||||
lines.push('Мало данных: в окне сравнения нет задач с коммитами.');
|
||||
@@ -1049,7 +1232,7 @@ function renderCompare(lines, c) {
|
||||
}
|
||||
if (c.noCommits.length) {
|
||||
lines.push('');
|
||||
lines.push(`Без коммитов с трейлером (объёма нет, в когорты не входят): ${c.noCommits.map((n) => `#${n}`).join(', ')}.`);
|
||||
lines.push(`Без коммитов с трейлером или только с документами ревью (объёма нет, в когорты не входят): ${c.noCommits.map((n) => `#${n}`).join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1066,7 +1249,7 @@ function ghJson(args) {
|
||||
* - issue `state=all` с `since` самого раннего окна (неделя отчёта или окно
|
||||
* сравнения К7): задачи в `S8-merged` закрываются только с бетой (§2.8).
|
||||
* Прежняя выборка (`issues`) — закрытые в окне, как раньше;
|
||||
* - таймлайн — у прежней выборки, у задач с `S8-merged` или закрытых и у
|
||||
* - таймлайн — у прежней выборки, у задач с `S5-ready`…`S8-merged` или закрытых и у
|
||||
* задач прогонов конвейера окна; не дальше `TIMELINE_PAGE_CAP` страниц,
|
||||
* иначе задача помечается «таймлайн усечён»;
|
||||
* - jobs (К5) — для `jobRuns`, недоступные jobs — `null`;
|
||||
@@ -1101,8 +1284,9 @@ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, com
|
||||
}
|
||||
const { selected } = jobRuns(runs);
|
||||
const runIssues = new Set(selected.map((run) => Number(PROCESS_TITLE.exec(String(run.display_title || ''))?.[1])).filter(Number.isFinite));
|
||||
// #752: задачи в S5–S7 — раздел «Черновик ТЗ» считает их по первому S5-ready.
|
||||
const allIssues = all.filter((issue) => closedInWindow(issue)
|
||||
|| (issue.labels || []).some((label) => (label?.name ?? label) === 'S8-merged')
|
||||
|| (issue.labels || []).some((label) => ['S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'].includes(label?.name ?? label))
|
||||
|| (issue.state === 'closed' && at(issue.closed_at) >= at(earliestIso))
|
||||
|| runIssues.has(Number(issue.number)));
|
||||
const timelines = new Map();
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
MAX_ATTEMPTS, MAX_COMMAND_OUTPUT_BYTES, PUSH_REFUSAL, PushRefusal, classifyPushRefusal, commentFor, decideMerge,
|
||||
describePushRefusal, mergeCandidate, realOps, redactSecrets, sh,
|
||||
describePushRefusal, mergeCandidate, realOps, redactSecrets, sh, OUTCOME_SIGNS, outcomeOf,
|
||||
} from '../scripts/merge-candidate.mjs';
|
||||
import { buildCiProof } from '../scripts/ci-proof.mjs';
|
||||
import { buildIndex } from '../scripts/reviews-index.mjs';
|
||||
@@ -877,3 +877,39 @@ test('#705 на настоящем git: отказ сервера `[remote rejec
|
||||
rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
||||
}
|
||||
});
|
||||
|
||||
// #752: признаки исходов — заголовки самих шаблонов. Отчёт процесса узнаёт по
|
||||
// ним причину возврата; заголовок, переименованный без признака, краснит здесь.
|
||||
test('#752 AC2: каждый шаблон исхода commentFor и describePushRefusal узнаётся своим признаком и только им', () => {
|
||||
const ctx = {
|
||||
material: 'a'.repeat(40), actual: 'b'.repeat(40), candidate: 'c'.repeat(40), devNow: 'd'.repeat(40),
|
||||
branch: 'issue/752-x', ref: 'dev', runUrl: 'https://github.com/o/r/actions/runs/1', attempt: 3, error: 'git push dev: boom',
|
||||
refusal: { kind: PUSH_REFUSAL.workflow, reason: 'r', files: ['.github/workflows/x.yml'], stderr: 'e' }, pipelineUrl: 'https://run/1',
|
||||
};
|
||||
const outcomes = [
|
||||
['reject-stale', 'merge'], ['conflict', 'merge'], ['rereview', 'merge'], ['validation-red', 'merge'],
|
||||
['validation-missing', 'merge'], ['give-up', 'merge'], ['push-refused-workflow', 'merge'], ['push-refused-workflow', 'rebase'],
|
||||
['push-refused', 'merge'], ['push-refused', 'rebase'], ['error', 'merge'],
|
||||
];
|
||||
const key = (sign) => `${sign.action}/${sign.stage}`;
|
||||
for (const [action, stage] of outcomes) {
|
||||
const text = commentFor(action, { ...ctx, stage });
|
||||
assert.ok(text, `${action}: шаблон не пуст`);
|
||||
assert.deepEqual(OUTCOME_SIGNS.filter((sign) => sign.re.test(text)).map(key), [`${action}/${stage}`], `${action} (${stage}) — ровно свой признак`);
|
||||
assert.equal(key(outcomeOf(text)), `${action}/${stage}`);
|
||||
}
|
||||
assert.deepEqual(OUTCOME_SIGNS.map(key).sort(), outcomes.map(([action, stage]) => `${action}/${stage}`).sort(), 'признак на каждый исход, лишних нет');
|
||||
for (const action of ['push', 'fast-forward']) assert.equal(outcomeOf(commentFor(action, ctx)), null, `${action} — слито, не исход возврата`);
|
||||
// Каждый case шаблонов — либо слияние, либо исход с признаком: новый case без признака краснит здесь.
|
||||
const source = readFileSync(fileURLToPath(new URL('../scripts/merge-candidate.mjs', import.meta.url)), 'utf8');
|
||||
const body = source.slice(source.indexOf('export function commentFor('), source.indexOf('\n}\n', source.indexOf('export function commentFor(')));
|
||||
const cases = [...body.matchAll(/^\s+case '([a-z-]+)':/gm)].map((m) => m[1]);
|
||||
assert.deepEqual([...new Set(cases)].sort(), [...new Set([...outcomes.map(([action]) => action), 'push', 'fast-forward'])].sort());
|
||||
// Страж ребейза (`_process.yml`) получает текст из describePushRefusal.
|
||||
const rebase = (reason) => describePushRefusal(remoteRejected(reason), { ref: 'issue/752-x', branch: 'issue/752-x', candidate: 'c'.repeat(40), stage: 'rebase' }).comment;
|
||||
assert.equal(key(outcomeOf(rebase(WORKFLOW_REFUSALS['GitHub App / GITHUB_TOKEN']))), 'push-refused-workflow/rebase');
|
||||
assert.equal(key(outcomeOf(rebase('protected branch hook declined'))), 'push-refused/rebase');
|
||||
// Сбой шага: тот же текст, что писал прежний inline-шаблон.
|
||||
assert.equal(commentFor('error', { error: 'boom' }),
|
||||
'**Слияние не выполнено: сбой шага слияния (#492).**\n\n```\nboom\n```\n\nВердикт ревью в силе. Задача в `S6-in-progress`; после разбора сбоя вернуть `S7-code-review`.');
|
||||
});
|
||||
|
||||
@@ -7,12 +7,12 @@ import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewDocNames, reviewRounds, runMetrics,
|
||||
NOT_RUN_CONFLICT_RE, NOT_RUN_VALIDATE_RE, TIMELINE_PAGE_CAP, TOKENS_NO_DATA, TRACKS_CUTOVER,
|
||||
compareCohorts, fetchSnapshot, isInfra, issueChanges, issueSegments, issueTrackMetrics, jobRuns, jobStage,
|
||||
readCommits, returnSignal, reviewDocAddedAt, shipFindings, stageMinutes, tokenDocs, tokenUsage, trackAt, trackPath, trackSection, volumeBucket,
|
||||
BETA_DERIVED_SUBJECT, DRAFT_COMMENT_RE, NOT_RUN_CONFLICT_RE, NOT_RUN_VALIDATE_RE, RETURN_REASONS, TIMELINE_PAGE_CAP, TOKENS_NO_DATA, TRACKS_CUTOVER,
|
||||
changeVolume, compareCohorts, draftSection, fetchSnapshot, isBetaCommit, isInfra, issueChanges, issueSegments, issueTrackMetrics, jobRuns, jobStage,
|
||||
readCommits, returnSignal, reviewDocAddedAt, shipFindings, specEpochs, stageMinutes, tokenDocs, tokenUsage, trackAt, trackPath, trackSection, volumeBucket,
|
||||
} from '../scripts/process-metrics.mjs';
|
||||
import { labelTrack } from '../scripts/process-track.mjs';
|
||||
import { commentFor } from '../scripts/merge-candidate.mjs';
|
||||
import { commentFor, describePushRefusal } from '../scripts/merge-candidate.mjs';
|
||||
import { anchorBlock } from '../scripts/ship-review.mjs';
|
||||
import { withMaterialAnchors } from '../scripts/review-doc-guard.mjs';
|
||||
import { formatUsage } from '../scripts/model-usage.mjs';
|
||||
@@ -201,11 +201,11 @@ test('#728 trackAt: трек на момент события и путь тре
|
||||
assert.equal(trackAt(both, T(1)), labelTrack({ labels: ['track:ship', 'track:ask'] }));
|
||||
assert.equal(trackAt(both, T(1)), 'ask');
|
||||
|
||||
// infra — ни одного файла класса A в коммитах задачи; Release:-коммит не в счёт.
|
||||
// infra — ни одного файла класса A в коммитах задачи; коммит кандидата беты не в счёт (#752).
|
||||
const commits = [
|
||||
{ sha: 'a'.repeat(40), body: 'x\n\nIssue: #801\n', issues: [801], files: [{ added: 5, deleted: 0, path: 'scripts/x.mjs' }] },
|
||||
{ sha: 'b'.repeat(40), body: 'x\n\nIssue: #802\n', issues: [802], files: [{ added: 5, deleted: 0, path: 'src/x.ts' }] },
|
||||
{ sha: 'c'.repeat(40), body: 'rel\n\nIssue: #801\nRelease: v1.0.0-beta.1\n', issues: [801], files: [{ added: 1, deleted: 1, path: 'custom_components/houseplan/manifest.json' }] },
|
||||
{ sha: 'c'.repeat(40), body: 'Release v1.0.0-beta.1 candidate\n\nIssue: #801\nRelease: v1.0.0-beta.1\n', issues: [801], files: [{ added: 1, deleted: 1, path: 'custom_components/houseplan/manifest.json' }] },
|
||||
];
|
||||
const timelines = new Map([[801, [labeled('S6-in-progress', 0), labeled('S8-merged', 2)]], [802, [labeled('S6-in-progress', 0), labeled('S8-merged', 2)]]]);
|
||||
const report = buildReport({ since: T(0), until: T(48), issues: [{ number: 801 }, { number: 802 }], timelines, commits });
|
||||
@@ -281,7 +281,7 @@ test('#728 returnSignal: причины возврата на текстах к
|
||||
stage: 'rebase', candidate: 'c'.repeat(40), branch: 'issue/701-x', refusal: { files: ['.github/workflows/x.yml'] },
|
||||
});
|
||||
assert.match(refused, /^\*\*Ревью не запускалось: кандидат меняет workflow-файл/);
|
||||
assert.equal(sig(refused), null, 'отказ push по праву на workflow (#705) — без признака, возврат уйдёт в unknown');
|
||||
assert.equal(sig(refused), 'push-refused', '#752: отказ push по праву на workflow на ребейзе (#705) — признак merge-candidate.mjs');
|
||||
assert.equal(sig('Текст.\n\n<!-- hp:route reclassify criterion=undocumented -->'), 'reclassify');
|
||||
assert.equal(sig('Текст.\n\n<!-- hp:route owner-question criterion=undocumented -->'), 'owner-question');
|
||||
assert.equal(sig('Пока шло ревью, `dev` продвинулся на 2 коммит(ов).'), null);
|
||||
@@ -332,7 +332,7 @@ test('#728 returnReason: последний комментарий с призн
|
||||
const row = issueTrackMetrics(issue, events);
|
||||
assert.deepEqual(row.returns.map((r) => r.reason), [
|
||||
'verdict-yellow', 'verdict-red', 'validate-red', 'validate-red', 'conflict', 'merge', 'verdict-yellow',
|
||||
'unknown', 'unknown', 'unknown', 'unknown', 'owner-question', 'reclassify', 'verdict-red',
|
||||
'unknown', 'unknown', 'unknown', 'push-refused', 'owner-question', 'reclassify', 'verdict-red',
|
||||
]);
|
||||
assert.deepEqual(row.returns.map((r) => r.track), [
|
||||
'ship', 'ship', 'ship', 'ship', 'show', 'show', 'show', 'show', 'show', 'show', 'show', 'show', 'show', 'ask',
|
||||
@@ -342,7 +342,7 @@ test('#728 returnReason: последний комментарий с призн
|
||||
const section = trackSection([row]);
|
||||
const ship = section.events.find((e) => e.track === 'ship');
|
||||
assert.deepEqual([ship.returns, ship.reasons['verdict-yellow'], ship.reasons['validate-red']], [4, 1, 2]);
|
||||
assert.equal(section.events.find((e) => e.track === 'show').reasons.unknown, 4);
|
||||
assert.deepEqual(['unknown', 'push-refused'].map((reason) => section.events.find((e) => e.track === 'show').reasons[reason]), [3, 1]);
|
||||
// Раунды — объявления вердикта этапа, по треку и блокирующие/зелёные.
|
||||
assert.deepEqual([ship.rounds.code.blocking, ship.rounds.code.green], [2, 0]);
|
||||
assert.deepEqual(section.events.find((e) => e.track === 'ask').rounds.spec, { blocking: 1, green: 0 });
|
||||
@@ -681,3 +681,213 @@ test('#761 токены за окно: документ вне окна, пер
|
||||
assert.equal(tokenDocs(snap.reviewDocs).length, 5);
|
||||
assert.equal(tokenDocs(snap.reviewDocs, { added: new Map(), since, until }).length, 0, 'добавление не найдено — вне окна');
|
||||
});
|
||||
|
||||
// #752: объём задачи, причины возвратов слияния, черновик ТЗ.
|
||||
|
||||
/** Временный git-репозиторий: коммиты и `readCommits` по нему. */
|
||||
function tempRepo(t, prefix) {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const git = (...args) => {
|
||||
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout;
|
||||
};
|
||||
git('init', '-q', '-b', 'dev');
|
||||
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
|
||||
git('config', 'core.hooksPath', '/dev/null');
|
||||
const write = (path, lines, tag = 'l') => {
|
||||
mkdirSync(join(dir, dirname(path)), { recursive: true });
|
||||
writeFileSync(join(dir, path), `${Array.from({ length: lines }, (_, k) => `${tag}${k}`).join('\n')}\n`);
|
||||
};
|
||||
const commit = (message) => { git('add', '-A'); git('commit', '-q', '-m', message); };
|
||||
const commits = () => readCommits((args) => git(...args), { ref: 'HEAD', since: '2000-01-01' });
|
||||
return { dir, git, write, commit, commits };
|
||||
}
|
||||
|
||||
test('#752 AC1 объём: классы A и B; документация, перенос в архив и документы ревью — нет', (t) => {
|
||||
const repo = tempRepo(t, 'hp-752-volume-');
|
||||
repo.write('docs/reviews/R.md', 3);
|
||||
repo.commit('docs: base');
|
||||
repo.write('src/a.ts', 10); repo.write('docs/x.md', 5000);
|
||||
repo.commit('feat: a (#751)\n\nIssue: #751\nUser-Visible: no');
|
||||
mkdirSync(join(repo.dir, 'legacy/reviews/v1'), { recursive: true });
|
||||
repo.git('mv', 'docs/reviews/R.md', 'legacy/reviews/v1/R.md');
|
||||
repo.commit('docs: archive (#751)\n\nIssue: #751\nUser-Visible: no');
|
||||
repo.write('docs/reviews/SPEC-REVIEW-7-r1.md', 40);
|
||||
repo.commit('docs: review document for #7\n\nIssue: #7\nUser-Visible: no');
|
||||
let changes = issueChanges(repo.commits());
|
||||
assert.equal(changeVolume(changes.get(751)), 10, 'src +10; docs/x.md +5000 и перенос R.md в legacy/reviews — не объём');
|
||||
assert.equal(volumeBucket(changeVolume(changes.get(751))), '≤30');
|
||||
assert.equal(isInfra(changes.get(751)), false);
|
||||
assert.equal(isInfra(changes.get(7)), false, 'только документ ревью — признак инфраструктуры не доказан');
|
||||
assert.equal(changeVolume(changes.get(7)), null, 'только документ ревью — объёма нет');
|
||||
repo.write('test/a.test.mjs', 40);
|
||||
repo.commit('test: a (#751)\n\nIssue: #751\nUser-Visible: no');
|
||||
changes = issueChanges(repo.commits());
|
||||
assert.equal(changeVolume(changes.get(751)), 50, 'тесты — класс B, входят');
|
||||
// Строка задачи без объёма — «без коммитов» сравнения, а не корзина «≤30».
|
||||
const row = issueTrackMetrics({ number: 7 }, [labeled('S4-spec-review', 0), labeled('S8-merged', 1)], { change: changes.get(7) });
|
||||
assert.deepEqual([row.infra, row.volume, row.bucket], [false, null, null]);
|
||||
});
|
||||
|
||||
test('#752 AC1 объём: Release:-коммиты задачи входят, кандидат беты, промоушен и бот beta-derived — нет', (t) => {
|
||||
const repo = tempRepo(t, 'hp-752-release-');
|
||||
repo.write('src/a.ts', 10);
|
||||
repo.commit('feat: a (#751)\n\nIssue: #751\nUser-Visible: no');
|
||||
// Приёмка эталонов задачи: аттестация (класс B) и кадры (класс D).
|
||||
repo.write('demo/golden/attestation.json', 6); repo.write('demo/golden/baselines/scene.png', 50);
|
||||
repo.commit('test(golden): accept the reviewed frames (#751)\n\nIssue: #751\nUser-Visible: no\nRelease: v1.0.0-beta.2\nBaseline-Reviewed: https://github.com/o/r/actions/runs/1');
|
||||
// Перепривязка тестов к бете.
|
||||
repo.write('test/b.test.mjs', 4);
|
||||
repo.commit('test(daycycle): witnesses follow the beta (#751)\n\nRelease: v1.0.0-beta.2\nIssue: #751\nUser-Visible: no');
|
||||
// Кандидат беты: трейлеры всех задач линии, версия и бандл.
|
||||
repo.write('src/version.ts', 300, 'v'); repo.write('dist/houseplan-card.js', 500);
|
||||
repo.commit('Release v1.0.0-beta.2 candidate\n\nIssue: #751\nIssue: #752\nUser-Visible: yes\nRelease: v1.0.0-beta.2');
|
||||
// Кандидат без пересобранного бандла — по подписи.
|
||||
repo.write('package.json', 7, 'p');
|
||||
repo.commit('Release v1.0.0-beta.2 candidate refresh\n\nIssue: #751\nRelease: v1.0.0-beta.2');
|
||||
// Промоушен стабильного — по бандлу.
|
||||
repo.write('src/version.ts', 300, 'w'); repo.write('dist/houseplan-card.js', 500, 'd');
|
||||
repo.commit('release: promote v1.0.0 from beta line\n\nIssue: #751\nIssue: #752\nUser-Visible: yes\nRelease: v1.0.0');
|
||||
// Бот beta-derived (#697): отпечаток скриншотов и эталоны на dev.
|
||||
repo.write('docs/images/screenshots.json', 12); repo.write('demo/golden/baselines/other.png', 30);
|
||||
repo.commit('docs: accept derived artifacts on dev for v1.0.0-beta.3\n\nПроизводные артефакты беты.\n\nRelease: v1.0.0-beta.3\nBaseline-Reviewed: https://github.com/o/r/actions/runs/2\nIssue: #697\nUser-Visible: no');
|
||||
const commits = repo.commits();
|
||||
assert.deepEqual(commits.filter(isBetaCommit).map((c) => c.body.split('\n')[0]), [
|
||||
'docs: accept derived artifacts on dev for v1.0.0-beta.3', 'release: promote v1.0.0 from beta line',
|
||||
'Release v1.0.0-beta.2 candidate refresh', 'Release v1.0.0-beta.2 candidate',
|
||||
]);
|
||||
const changes = issueChanges(commits);
|
||||
assert.equal(changeVolume(changes.get(751)), 20, 'src 10 + аттестация 6 + тест 4; кадры (D) и коммиты беты — нет');
|
||||
assert.equal(changes.has(752), false, 'задача, названная только кандидатом, объёма не получает');
|
||||
assert.equal(changes.has(697), false, 'коммит бота — не работа #697');
|
||||
// Текст в теле, начатый словом «Release:», — не трейлер: коммит задачи остаётся коммитом задачи.
|
||||
assert.equal(isBetaCommit({ body: 'Release v1.0.0 candidate notes\n\n- dist/x\nRelease: notes wrap here\n\nIssue: #1\n', files: [{ path: 'dist/x.js' }] }), false);
|
||||
});
|
||||
|
||||
test('#752 контракт: подпись коммита бота beta-derived — из _beta-derived.yml', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_beta-derived.yml', import.meta.url), 'utf8');
|
||||
const echo = /echo "(docs: accept derived artifacts on dev for )\$TAG"/.exec(workflow);
|
||||
assert.ok(echo, 'тема коммита бота в _beta-derived.yml');
|
||||
assert.match(`${echo[1]}v1.79.0-beta.1`, BETA_DERIVED_SUBJECT);
|
||||
assert.doesNotMatch('docs: accept the #689 source fingerprint', BETA_DERIVED_SUBJECT);
|
||||
});
|
||||
|
||||
test('#752 AC2 причины: исходы слияния после зелёного вердикта — merge, отказ push на ребейзе — push-refused', () => {
|
||||
const ctx = { material: 'a'.repeat(40), candidate: 'c'.repeat(40), devNow: 'd'.repeat(40), branch: 'issue/701-x', ref: 'dev', runUrl: 'https://run/1', attempt: 3 };
|
||||
const remote = (reason) => `To https://github.com/o/r\n ! [remote rejected] 0123abcd -> dev (${reason})\nerror: failed to push some refs`;
|
||||
const mergeRefused = commentFor('push-refused', { ...ctx, stage: 'merge', refusal: { kind: 'remote-rejected', reason: 'protected branch hook declined', stderr: 'x' } });
|
||||
const mergeWorkflow = commentFor('push-refused-workflow', { ...ctx, stage: 'merge', refusal: { kind: 'workflow', files: ['.github/workflows/x.yml'] } });
|
||||
const merge = [
|
||||
commentFor('validation-red', ctx), commentFor('validation-missing', ctx), commentFor('give-up', ctx),
|
||||
mergeRefused, mergeWorkflow, commentFor('error', { error: 'git push dev: boom' }),
|
||||
];
|
||||
assert.match(mergeRefused, /^\*\*Слияние не выполнено: GitHub отклонил push/);
|
||||
assert.match(merge.at(-1), /^\*\*Слияние не выполнено: сбой шага слияния/);
|
||||
const rebaseWorkflow = describePushRefusal(remote('refusing to allow a GitHub App to create or update workflow `.github/workflows/x.yml` without `workflows` permission'),
|
||||
{ ref: 'issue/701-x', branch: 'issue/701-x', candidate: 'c'.repeat(40), stage: 'rebase' }).comment;
|
||||
assert.match(rebaseWorkflow, /^\*\*Ревью не запускалось: кандидат меняет workflow-файл/);
|
||||
const rebaseRemote = describePushRefusal(remote('protected branch hook declined'), { ref: 'issue/701-x', branch: 'issue/701-x', stage: 'rebase' }).comment;
|
||||
const sig = (body) => returnSignal(body, { stage: 'code', number: NUM });
|
||||
for (const text of merge) assert.equal(sig(text), 'merge', text.split('\n')[0]);
|
||||
assert.equal(sig(rebaseWorkflow), 'push-refused');
|
||||
assert.equal(sig(rebaseRemote), 'push-refused');
|
||||
assert.equal(sig(commentFor('rereview', ctx)), null, 'дифф изменился — задача идёт в S7, а не автору: причины нет');
|
||||
assert.ok(RETURN_REASONS.includes('push-refused'));
|
||||
|
||||
// В окне: зелёный вердикт, затем исход слияния; возврат без комментария — unknown.
|
||||
const GREEN = `Вердикт: зелёный · заход r1 · High: 0 · Документ: docs/reviews/CODE-REVIEW-${NUM}-r1.md`;
|
||||
const cycle = (h, comments) => [
|
||||
labeled('S7-code-review', h), ...comments.map((body, k) => commented(body, h + 0.1 + k * 0.1)), labeled('S6-in-progress', h + 0.5),
|
||||
];
|
||||
const events = [
|
||||
labeled('S1-new', 0), labeled('track:show', 0), labeled('S6-in-progress', 1),
|
||||
...merge.flatMap((text, k) => cycle(2 + k, [GREEN, text])),
|
||||
...cycle(10, [rebaseWorkflow]),
|
||||
...cycle(11, []),
|
||||
labeled('S7-code-review', 12), commented(GREEN, 12.1), labeled('S8-merged', 12.5),
|
||||
];
|
||||
const row = issueTrackMetrics({ number: NUM }, events);
|
||||
assert.deepEqual(row.returns.map((r) => r.reason), [...merge.map(() => 'merge'), 'push-refused', 'unknown']);
|
||||
const show = trackSection([row]).events.find((e) => e.track === 'show');
|
||||
assert.deepEqual([show.reasons.merge, show.reasons['push-refused'], show.reasons.unknown], [6, 1, 1]);
|
||||
const md = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [{ number: NUM }], timelines: new Map([[NUM, events]]) }));
|
||||
assert.match(md, /\| Трек \| Возвратов \| verdict-yellow \| verdict-red \| validate-red \| conflict \| push-refused \| merge \|/);
|
||||
assert.match(md, /\| show \| 8 \| 0 \| 0 \| 0 \| 0 \| 1 \| 6 \| 0 \| 0 \| 1 \|/);
|
||||
});
|
||||
|
||||
const DRAFT = 'Черновик: автор · сессия s1 · локальная ветка issue/760-x · Spec-Draft sha256:0123456789ab';
|
||||
|
||||
test('#752 AC3 черновик ТЗ: эпохи S4 на ask, черновик выброшен или пошёл в дело, show не в счёт', () => {
|
||||
const ask = (n, draftEpoch) => [
|
||||
labeled('S1-new', 0), labeled('track:ask', 0), labeled('S3-spec', 0.5),
|
||||
labeled('S4-spec-review', 1), ...(draftEpoch === 1 ? [commented(DRAFT, 2)] : []), labeled('S3-spec', 3),
|
||||
labeled('S4-spec-review', 4), labeled('S4-spec-review', 4.2), ...(draftEpoch === 2 ? [commented(DRAFT, 4.5)] : []), labeled('S5-ready', 5),
|
||||
labeled('S6-in-progress', 5.5), labeled('S7-code-review', 5 + n),
|
||||
];
|
||||
assert.deepEqual(specEpochs(ask(1, 1)).map((e) => [e.close, (e.end - e.start) / HOUR]), [['S3-spec', 2], ['S5-ready', 1]], 'повторная S4 эпоху не начинает');
|
||||
const thrown = draftSection({ issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 1)]]), since: T(0), until: T(48) });
|
||||
assert.deepEqual(thrown.epochs, { total: 2, withDraft: 1, used: 0, thrown: 1, other: 0 });
|
||||
const used = draftSection({ issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 2)]]), since: T(0), until: T(48) });
|
||||
assert.deepEqual(used.epochs, { total: 2, withDraft: 1, used: 1, thrown: 0, other: 0 });
|
||||
// show: эпоха S4 не на ask — в счёт не входит.
|
||||
const show = [labeled('S1-new', 0), labeled('track:show', 0), labeled('S4-spec-review', 1), commented(DRAFT, 1.5), labeled('S5-ready', 2)];
|
||||
const onlyShow = draftSection({ issues: [{ number: 761 }], timelines: new Map([[761, show]]), since: T(0), until: T(48) });
|
||||
assert.deepEqual([onlyShow.tasks, onlyShow.epochs.total, onlyShow.epochs.withDraft], [1, 0, 0]);
|
||||
// Первый S5 вне окна — задачи нет.
|
||||
assert.equal(draftSection({ issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 2)]]), since: T(6), until: T(48) }).tasks, 0);
|
||||
|
||||
// Нет черновиков — строка «черновиков нет», а не ноль в таблице.
|
||||
const none = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [{ number: 762 }], timelines: new Map([[762, ask(1, 0)]]) }));
|
||||
const section = none.slice(none.indexOf('### Черновик ТЗ (#729)'), none.indexOf('### Токены'));
|
||||
assert.match(section, /Черновиков нет: эпох `S4-spec-review` на ask — 2, комментария «Черновик:» нет ни в одной\./);
|
||||
assert.doesNotMatch(section, /\| Эпох S4/);
|
||||
assert.ok(none.indexOf('### По трекам') < none.indexOf('### Черновик ТЗ (#729)'), 'раздел — после «По трекам»');
|
||||
const drafted = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 1)]]) }));
|
||||
assert.match(drafted, /\| Эпох S4 \(ask\) \| С черновиком \| Пошёл в дело \(S5\) \| Выброшен \(S3\) \| Открыта или иначе \|\n\|---:\|---:\|---:\|---:\|---:\|\n\| 2 \| 1 \| 0 \| 1 \| 0 \|/);
|
||||
});
|
||||
|
||||
test('#752 AC3 черновик ТЗ: S5 → S7 с черновиком и без по трекам, n < 3 — мало данных; коммиты Spec-Draft за окно', () => {
|
||||
const ask = (hoursToS7, draft) => [
|
||||
labeled('S1-new', 0), labeled('track:ask', 0), labeled('S4-spec-review', 1), ...(draft ? [commented(DRAFT, 1.5)] : []),
|
||||
labeled('S5-ready', 2), labeled('S6-in-progress', 2.1), labeled('S7-code-review', 2 + hoursToS7),
|
||||
];
|
||||
const ship = (hoursToS7) => [labeled('S1-new', 0), labeled('track:ship', 0), labeled('S5-ready', 2), labeled('S7-code-review', 2 + hoursToS7)];
|
||||
// Черновик выброшен в первой эпохе, эпоху, закрытую S5, вёл не черновик — «без черновика».
|
||||
const thrownThenPlain = [
|
||||
labeled('S1-new', 0), labeled('track:ask', 0), labeled('S4-spec-review', 0.2), commented(DRAFT, 0.3), labeled('S3-spec', 0.5),
|
||||
labeled('S4-spec-review', 1), labeled('S5-ready', 2), labeled('S7-code-review', 32),
|
||||
];
|
||||
const timelines = new Map([
|
||||
[1, ask(1, true)], [2, ask(2, true)], [3, ask(3, true)], [4, ask(10, false)], [5, thrownThenPlain],
|
||||
[6, ship(4)], [7, ship(5)], [8, ship(6)], [9, ask(100, true)],
|
||||
]);
|
||||
const commits = [
|
||||
{ sha: 'a'.repeat(40), date: T(3), body: 'feat: x (#1)\n\nIssue: #1\nSpec-Draft: sha256:' + 'f'.repeat(64) + '\n', issues: [1], files: [] },
|
||||
{ sha: 'b'.repeat(40), date: T(60), body: 'feat: y (#2)\n\nIssue: #2\nSpec-Draft: sha256:' + 'e'.repeat(64) + '\n', issues: [2], files: [] },
|
||||
{ sha: 'c'.repeat(40), date: T(3), body: 'feat: z (#3)\n\nIssue: #3\n', issues: [3], files: [] },
|
||||
];
|
||||
const d = draftSection({ issues: [...timelines.keys()].map((number) => ({ number })), timelines, commits, since: T(0), until: T(48) });
|
||||
const byTrack = Object.fromEntries(d.s5ToS7.map((row) => [row.track, row]));
|
||||
assert.deepEqual(byTrack.ask.draft, { n: 3, hours: 2, enough: true });
|
||||
assert.deepEqual(byTrack.ask.plain, { n: 2, hours: 20, enough: false });
|
||||
assert.deepEqual(byTrack.ship.plain, { n: 3, hours: 5, enough: true });
|
||||
assert.deepEqual(byTrack.show, { track: 'show', draft: { n: 0, hours: null, enough: false }, plain: { n: 0, hours: null, enough: false } });
|
||||
assert.deepEqual(d.noS7, [9], 'S7 после конца окна — в S5 → S7 не входит');
|
||||
assert.deepEqual(d.specDraftCommits, { count: 1, issues: [1] }, 'коммит вне окна и без трейлера — нет');
|
||||
const md = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [...timelines.keys()].map((number) => ({ number })), timelines, commits }));
|
||||
assert.match(md, /\| ask \| 3 \| 2 \| 2 \| мало данных \|/);
|
||||
assert.match(md, /\| ship \| 0 \| мало данных \| 3 \| 5 \|/);
|
||||
assert.match(md, /Коммиты с трейлером `Spec-Draft:` в `dev` за окно: 1 \(#1\)\./);
|
||||
assert.match(md, /Без `S7-code-review` к концу окна \(в S5 → S7 не входят\): #9\./);
|
||||
});
|
||||
|
||||
test('#752 контракт: комментарий «Черновик:» — шаблон PROCESS.md §7.2', () => {
|
||||
const canon = readFileSync(new URL('../PROCESS.md', import.meta.url), 'utf8');
|
||||
const template = /- \*\*Черновик\*\* \(§11\.8\): `(Черновик: [^`]+)`/.exec(canon.replace(/\n {2}/g, ' '));
|
||||
assert.ok(template, 'шаблон «Черновик» в §7.2');
|
||||
assert.match(template[1], DRAFT_COMMENT_RE);
|
||||
assert.match(DRAFT, DRAFT_COMMENT_RE);
|
||||
assert.doesNotMatch('Взял: автор · сессия s1 · ветка issue/760-x', DRAFT_COMMENT_RE);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user