mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Six workflows run from the default branch (issues, schedule, workflow_run): process, process-resume, process-reconcile, mutation-gate, nightly, process-metrics. Their bodies move to _<name>.yml (on: workflow_call); the original files keep only triggers, run-name, permissions, concurrency and one job `uses: Matysh/houseplan-card/.github/workflows/_<name>.yml@dev` with `secrets: inherit`. A pipeline change becomes one commit to dev. - caller job permissions = union of body job permissions (#556 minimum kept per job inside the body); caller `if` repeats the body guard for process and process-resume so unrelated events stay skipped; - dispatch inputs forwarded via workflow_call inputs of the same names; - _mutation-gate.yml keys evidence/marker on job.workflow_sha (the body SHA): in a called workflow github.workflow_sha belongs to the caller in main; - action-pins: narrow exception for this repo's _*.yml at @dev with a reason; - preflight workflow_sync compares all six thin callers (was 3 of 6); performance.yml excluded: its schedule judges main with main's own body; - tests read bodies from _*.yml; new test/default-branch-workflows.test.mjs; six mutants; PROCESS.md §10.4, AGENTS.md, REVIEWER.md updated. Issue: #623 User-Visible: no
143 lines
8.7 KiB
JavaScript
143 lines
8.7 KiB
JavaScript
// #637: еженедельный замер процесса — чистые функции над снимками GitHub.
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import {
|
||
buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewRounds, runMetrics,
|
||
} from '../scripts/process-metrics.mjs';
|
||
|
||
const T = (h) => new Date(Date.UTC(2026, 8, 15, 0, Math.round(h * 60))).toISOString();
|
||
const labeled = (name, h) => ({ event: 'labeled', label: { name }, created_at: T(h) });
|
||
|
||
test('#637 issueMetrics: вход по первой статусной метке, S7/S8 по первой постановке, повторы считаются отдельно', () => {
|
||
const m = issueMetrics({ number: 600, title: 't', closed_at: T(30), state_reason: 'completed' }, [
|
||
labeled('bug', 0), labeled('S2-analysis', 1), labeled('S3-spec', 2), labeled('S4-spec-review', 3),
|
||
labeled('S5-ready', 5), labeled('S6-in-progress', 6), labeled('S7-code-review', 10),
|
||
{ event: 'unlabeled', label: { name: 'S7-code-review' }, created_at: T(11) },
|
||
labeled('S7-code-review', 12), labeled('S8-merged', 20),
|
||
]);
|
||
assert.equal(m.enteredAt, Date.parse(T(1)), 'bug — не статус');
|
||
assert.equal(m.leadToS7Ms, 9 * 3_600_000);
|
||
assert.equal(m.reviewToMergeMs, 10 * 3_600_000);
|
||
assert.equal(m.leadToS8Ms, 19 * 3_600_000);
|
||
assert.equal(m.specLeadMs, 2 * 3_600_000);
|
||
assert.equal(m.s7Requests, 2);
|
||
assert.equal(m.s4Requests, 1);
|
||
const bare = issueMetrics({ number: 1, closed_at: 'x', state_reason: 'not_planned' }, []);
|
||
assert.equal(bare.leadToS7Ms, null);
|
||
assert.equal(bare.closedAt, null);
|
||
});
|
||
|
||
test('#637 reviewRounds: максимум раунда по документам, отдельно CODE и SPEC', () => {
|
||
const rounds = reviewRounds(['CODE-REVIEW-600-r1.md', 'CODE-REVIEW-600-r2.md', 'SPEC-REVIEW-600-r1.md', 'CODE-REVIEW-601-r1.md', 'README.md', 'CODE-REVIEW-issue-5.md']);
|
||
assert.equal(rounds.get('CODE:600'), 2);
|
||
assert.equal(rounds.get('SPEC:600'), 1);
|
||
assert.equal(rounds.get('CODE:601'), 1);
|
||
assert.equal(rounds.size, 3);
|
||
});
|
||
|
||
const run = (over) => ({
|
||
name: 'Проверка (CI)', event: 'push', conclusion: 'success', run_started_at: T(0), updated_at: T(1), ...over,
|
||
});
|
||
|
||
test('#637 runMetrics: по workflow — исходы, wall-time, события; прогоны конвейера сведены в одну строку', () => {
|
||
const rows = runMetrics([
|
||
run(), run({ conclusion: 'failure', event: 'workflow_dispatch' }), run({ conclusion: 'cancelled', updated_at: T(0) }),
|
||
run({ name: 'Ревью-конвейер', display_title: 'process #600 · S7-code-review · x', event: 'issues', updated_at: T(2) }),
|
||
run({ name: 'Ревью-конвейер', display_title: 'process #600 · bug · x', event: 'issues', conclusion: 'skipped', updated_at: T(0) }),
|
||
]);
|
||
const validate = rows.find((r) => r.workflow === 'Проверка (CI)');
|
||
assert.deepEqual([validate.runs, validate.success, validate.failure, validate.cancelled], [3, 1, 1, 1]);
|
||
assert.equal(validate.wallMs, 2 * 3_600_000, 'отменённый без длительности не считается');
|
||
assert.deepEqual(validate.events, { push: 2, workflow_dispatch: 1 });
|
||
const process = rows.find((r) => r.workflow === 'process');
|
||
assert.deepEqual([process.runs, process.skipped], [2, 1]);
|
||
});
|
||
|
||
test('#637 pipelineMetrics: минуты S7/S4 по имени прогона, skipped не считаются', () => {
|
||
const p = pipelineMetrics([
|
||
run({ display_title: 'process #600 · S7-code-review · x', updated_at: T(1) }),
|
||
run({ display_title: 'process #600 · S7-code-review · x', updated_at: T(2) }),
|
||
run({ display_title: 'process #601 · S7-code-review · x', conclusion: 'skipped', updated_at: T(2) }),
|
||
run({ display_title: 'process #602 · S4-spec-review · x', run_started_at: T(0), updated_at: T(0.5) }),
|
||
]);
|
||
assert.deepEqual(p['S7-code-review'], { runs: 2, issues: 1, wallMinutes: 180, perRunMinutes: 90 });
|
||
assert.deepEqual(p['S4-spec-review'], { runs: 1, issues: 1, wallMinutes: 30, perRunMinutes: 30 });
|
||
});
|
||
|
||
test('#637 jobMinutes: доля «Мутанты» по именам job', () => {
|
||
const jobs = new Map([[1, [
|
||
{ name: 'Мутанты по диффу (1/6): x', started_at: T(0), completed_at: T(1) },
|
||
{ name: 'Фронтенд: типы', started_at: T(0), completed_at: T(0.5) },
|
||
{ name: 'сломан', started_at: 'x', completed_at: T(1) },
|
||
]]]);
|
||
assert.deepEqual(jobMinutes(jobs), { totalMinutes: 90, mutantMinutes: 60, mutantShare: 67 });
|
||
assert.deepEqual(jobMinutes(new Map()), { totalMinutes: 0, mutantMinutes: 0, mutantShare: null });
|
||
});
|
||
|
||
test('#637 buildReport + renderMarkdown: сводка воспроизводит цифры аудита на фикстуре', () => {
|
||
// Форма аудита 22.09: 30 issue с код-ревью — 15 r1, 13 r2, 2 r3 → 1.57; Validate 226 прогонов.
|
||
const issues = [];
|
||
const timelines = new Map();
|
||
const reviewFiles = [];
|
||
for (let n = 1; n <= 30; n++) {
|
||
issues.push({ number: n, title: `t${n}`, closed_at: T(48), state_reason: 'completed' });
|
||
timelines.set(n, [labeled('S1-new', 0), labeled('S7-code-review', 24), labeled('S8-merged', 30)]);
|
||
const rounds = n <= 15 ? 1 : n <= 28 ? 2 : 3;
|
||
for (let r = 1; r <= rounds; r++) reviewFiles.push(`CODE-REVIEW-${n}-r${r}.md`);
|
||
}
|
||
issues.push({ number: 99, title: 'dropped', closed_at: T(48), state_reason: 'not_planned' });
|
||
const runs = Array.from({ length: 226 }, (_, k) => run({ conclusion: k < 178 ? 'success' : k < 215 ? 'failure' : 'cancelled' }));
|
||
const report = buildReport({ since: T(0), until: T(48), issues, timelines, reviewFiles, runs });
|
||
assert.equal(report.issues.completed, 30);
|
||
assert.equal(report.issues.notPlanned, 1);
|
||
assert.equal(Math.round(report.issues.codeReviewRounds.mean * 100) / 100, 1.57);
|
||
assert.deepEqual(report.issues.codeReviewRounds.distribution, { 1: 15, 2: 13, 3: 2 });
|
||
assert.equal(report.issues.medianLeadToS7Hours, 24);
|
||
assert.equal(report.issues.medianReviewToMergeHours, 6);
|
||
const validate = report.runs.find((r) => r.workflow === 'Проверка (CI)');
|
||
assert.deepEqual([validate.runs, validate.success, validate.failure, validate.cancelled], [226, 178, 37, 11]);
|
||
const md = renderMarkdown(report);
|
||
assert.match(md, /Закрыто issue: \*\*31\*\* \(completed 30, not_planned 1\)/);
|
||
assert.match(md, /Раундов код-ревью на issue \| 1\.57 \(r1: 15, r2: 13, r3: 2\)/);
|
||
assert.match(md, /\| Проверка \(CI\) \| 226 \| 178 \| 37 \| 11 \| 0 \| 13560 \|/);
|
||
assert.match(md, /\| #99 \| not_planned \| — \| — \| — \/ — \| 0 \|/);
|
||
assert.equal(report.jobs, null, 'без jobs job-минуты не выдумываются');
|
||
});
|
||
|
||
test('#637 buildReport: нулевая медиана не смешивается с отсутствием данных', () => {
|
||
const issue = { number: 637, title: 'fast', closed_at: T(4 / 60), state_reason: 'completed' };
|
||
const timelines = new Map([[637, [
|
||
labeled('S1-new', 0),
|
||
labeled('S4-spec-review', 0),
|
||
labeled('S5-ready', 1 / 60),
|
||
labeled('S7-code-review', 2 / 60),
|
||
labeled('S8-merged', 3 / 60),
|
||
]] ]);
|
||
const report = buildReport({ since: T(0), until: T(1), issues: [issue], timelines });
|
||
assert.deepEqual([
|
||
report.issues.medianLeadToS7Hours,
|
||
report.issues.medianReviewToMergeHours,
|
||
report.issues.medianLeadToS8Hours,
|
||
report.issues.medianSpecLeadHours,
|
||
], [0, 0, 0.1, 0]);
|
||
assert.match(renderMarkdown(report), /Медиана вход → S7 \| 0 ч/);
|
||
|
||
const empty = buildReport({ since: T(0), until: T(1) });
|
||
assert.equal(empty.issues.medianLeadToS7Hours, null);
|
||
assert.match(renderMarkdown(empty), /Медиана вход → S7 \| —/);
|
||
});
|
||
|
||
test('#637 workflow: еженедельный запуск читает только, публикует summary и artifact', () => {
|
||
const wf = readFileSync(new URL('../.github/workflows/_process-metrics.yml', import.meta.url), 'utf8');
|
||
// #623: расписание — у тонкого вызывающего файла, тело — в `_process-metrics.yml`.
|
||
const caller = readFileSync(new URL('../.github/workflows/process-metrics.yml', import.meta.url), 'utf8');
|
||
assert.match(caller, /schedule:\n(?:\s+#[^\n]*\n)*\s+- cron: '/);
|
||
assert.match(caller, /workflow_dispatch:/);
|
||
assert.ok(!/issues: write/.test(caller), 'потолок прав вызывающего тоже без записи в issue');
|
||
assert.match(wf, /permissions:\n\s+contents: read\n\s+actions: read\n\s+issues: read/);
|
||
assert.match(wf, /node scripts\/process-metrics\.mjs[\s\S]*--output=artifacts\/process-metrics\/report\.md/);
|
||
assert.match(wf, /GITHUB_STEP_SUMMARY/);
|
||
assert.ok(!/issues: write/.test(wf), 'метрики ничего не пишут в issue');
|
||
});
|