mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +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();
|
||||
|
||||
Reference in New Issue
Block a user