Files
houseplan-card/test/publish-push-refusal.test.mjs
T
Claudeandclaude[bot] d327ec3d93 feat(process): a failed show verdict re-routes to ask without a fresh budget (#726)
A non-green show verdict that found "something to decide" went down the same
path as "fix the code": S6 with a limit of 2. Promoting the task to track:ask
was left to the agent's memory, with no named criterion and no trace, and the
exhausted budget only surfaced on the next S7 - after a fix nobody would read.

The structured verdict now carries `route` (fix | reclassify) and an optional
`criterion` (one of the six show criteria of PROCESS.md section 5). The trust
boundary reads a missing route as fix, rejects one outside the dictionary and
rejects reclassify on a green verdict. `reviewRoute` in process-track.mjs is
the single decision: on a code review of an unconfirmed show it moves the task
to track:ask and S3-spec; on an owner-confirmed show it adds `blocked` and asks
the owner; anywhere else reclassify degrades to fix with a note. The verdict
that spends the last cycle sets review-4 at once; the stage budget is shared
across tracks, so promotion changes the limit (4), not the count.

The "Решение по вердикту" step makes one `process-track.mjs route` call (from
dev, like the track step) and only executes its output: comment from a file,
labels from add/remove lists, status via status-label.mjs as before. The track
step also emits `confirmed` and a `route_note` for the review prompt; the
review document anchor gains a route tail that the old reader still parses;
wait-verdict reports the two new pipeline comments. The guard's own
spent >= limit check stays as the safety net.

Issue: #726
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 03:06:49 +00:00

414 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PUSH_REFUSAL, classifyPushRefusal, refusalSummary } from '../scripts/merge-candidate.mjs';
import { buildIndex } from '../scripts/reviews-index.mjs';
// #723: два шага публикуют коммит и прежде любой отказ push считали сдвигом
// ветки — документ ревью релиза в `dev` (release-review.yml, три попытки) и
// документ ревью в ветку задачи (_process.yml, ребейз и второй push). Теперь
// отказ разбирает код слияния (`merge-candidate.mjs --push-refusal`, #705):
// устаревший lease — прежний повтор/ребейз, отказ GitHub — остановка без
// повторов, причина и ответ git без токена — в журнале и в сводке шага.
//
// Шаги исполняются как есть, настоящим bash и настоящим git во временных
// репозиториях. Подменён только транспорт: `git push` на github.com уходит в
// локальный origin, а заданный отказ GitHub отвечает записанным stderr. Сдвиг
// ветки — настоящий: сосед пушит в origin до push шага, и git сам отвечает
// `! [rejected] … (fetch first)`.
const SCRIPTS = fileURLToPath(new URL('../scripts', import.meta.url));
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
// Окружение git без GIT_* родителя (урок #633) и без глобального конфига (#496).
const ENV = {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))),
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_COUNT: '3',
GIT_CONFIG_KEY_0: 'core.autocrlf', GIT_CONFIG_VALUE_0: 'false',
GIT_CONFIG_KEY_1: 'core.eol', GIT_CONFIG_VALUE_1: 'lf',
GIT_CONFIG_KEY_2: 'init.defaultBranch', GIT_CONFIG_VALUE_2: 'dev',
};
const git = (cwd, ...args) => execFileSync('git', args, {
cwd, encoding: 'utf8', env: ENV, stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const commitAll = (cwd, msg) => { git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', msg); };
/** Замыкание относительных импортов модуля (и скриптов, которые он зовёт по `new URL`). */
function importClosure(entry, seen = new Set()) {
if (seen.has(entry)) return seen;
seen.add(entry);
const text = readFileSync(entry, 'utf8');
const specs = [
...text.matchAll(/^import[^'"]*['"](\.{1,2}\/[^'"]+)['"]/gm),
...text.matchAll(/new URL\('(\.{1,2}\/[^']+\.mjs)', import\.meta\.url\)/g),
].map((m) => m[1]);
for (const spec of specs) importClosure(resolve(dirname(entry), spec), seen);
return seen;
}
/** Скрипты, которые зовут оба шага, — с замыканием импортов. */
const STEP_SCRIPTS = ['release-review.mjs', 'reviews-index.mjs', 'review-doc-guard.mjs', 'merge-candidate.mjs']
.reduce((seen, name) => importClosure(join(SCRIPTS, name), seen), new Set());
const hasTools = () => process.platform !== 'win32'
&& ['bash', 'tar', 'jq', 'sha256sum'].every((tool) => spawnSync(tool, ['--version']).status === 0);
const FAKE_TOKEN = 'ghs_' + 'Q1w2E3r4T5y6U7i8O9p0A1s2D3f4G5h6J7k8';
const OTHER_TOKEN = 'ghp_' + 'M1n2B3v4C5x6Z7l8K9j0H1g2F3d4S5a6P7o8';
const SECRETS = [FAKE_TOKEN, OTHER_TOKEN, 'dG9rZW46c2VjcmV0'];
const remoteRejected = (ref, reason) => `To https://github.com/o/r\n ! [remote rejected] HEAD -> ${ref} (${reason})\n`
+ `error: failed to push some refs to 'https://x-access-token:${FAKE_TOKEN}@github.com/o/r'\n`;
const WORKFLOW_REASON = 'refusing to allow a Personal Access Token to create or update workflow `.github/workflows/validate.yml` without `workflow` scope';
// Ответ с заголовком и чужим токеном: так выглядит stderr при GIT_CURL_VERBOSE / GIT_TRACE.
const noisyRejected = (ref) => `> Authorization: Basic dG9rZW46c2VjcmV0\n`
+ `remote: token ${OTHER_TOKEN} is not allowed here\n${remoteRejected(ref, 'protected branch hook declined')}`;
const NETWORK = `fatal: unable to access 'https://x-access-token:${FAKE_TOKEN}@github.com/o/r/': The requested URL returned error: 403\n`;
/**
* Тело `run:` шага так, как его прочтёт YAML: блок кончается на первой
* непустой строке с отступом меньше десяти (PROCESS.md §10.4 п.4) — обрезанный
* скрипт тест увидит, а не пропустит.
*/
function stepRun(file, name) {
const text = readFileSync(join(WORKFLOWS, file), 'utf8');
const start = text.indexOf(` - name: ${name}\n`);
assert.ok(start >= 0, `шаг «${name}» в ${file}`);
const lines = text.slice(start).split('\n');
const from = lines.indexOf(' run: |');
assert.ok(from > 0, 'у шага есть run: |');
const body = [];
for (const line of lines.slice(from + 1)) {
if (line.trim() && !/^ {10}/.test(line)) break;
body.push(line.replace(/^ {10}/, ''));
}
return body.join('\n').replace(/\$\{\{ github\.repository \}\}/g, 'o/r');
}
const RELEASE_STEP = () => stepRun('release-review.yml', 'Опубликовать документ');
const REVIEW_DOC_STEP = () => stepRun('_process.yml', 'Опубликовать документ ревью');
/**
* Песочница: bare origin, рабочая копия, соседний клон и bin с подменами.
* `git` — настоящий, кроме транспорта push: `push-N.stderr` — ответ N-го push
* (отказ GitHub), `before-push-N` — сосед пушит свой коммит перед N-м push.
*/
/** Временный корень теста, убираемый after-хуком (#646). */
function tempRoot(t, prefix) {
const root = mkdtempSync(join(tmpdir(), prefix));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
function sandbox(root) {
const origin = join(root, 'origin.git');
const work = join(root, 'work');
const other = join(root, 'other');
const temp = join(root, 'runner');
const fake = join(root, 'fake');
const bin = join(root, 'bin');
for (const dir of [temp, fake, bin]) mkdirSync(dir);
git(root, 'init', '--bare', '-q', origin);
git(root, 'clone', '-q', origin, work);
git(work, 'checkout', '-q', '-b', 'dev');
// Скрипты шага — из репозитория как есть: их несёт dev временного origin.
mkdirSync(join(work, 'scripts'));
for (const file of STEP_SCRIPTS) copyFileSync(file, join(work, 'scripts', relative(SCRIPTS, file)));
mkdirSync(join(work, 'docs', 'reviews'), { recursive: true });
writeFileSync(join(work, 'docs', 'reviews', 'CODE-REVIEW-1-r1.md'), '# CODE-REVIEW-1-r1\nВердикт: **зелёный** · High: 0 · Medium: 0\n');
writeFileSync(join(work, 'docs', 'reviews', 'INDEX.md'), buildIndex(join(work, 'docs', 'reviews')));
writeFileSync(join(work, 'a.mjs'), 'export const a = 1;\n');
commitAll(work, 'base');
git(work, 'push', '-q', 'origin', 'dev');
const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
writeFileSync(join(bin, 'git'), [
'#!/usr/bin/env bash',
'echo "$*" >> "$FAKE_DIR/calls"',
'if [ "$1" = push ]; then',
' n=$(( $(cat "$FAKE_DIR/pushes" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$FAKE_DIR/pushes"',
' if [ -f "$FAKE_DIR/before-push-$n" ]; then "$REAL_GIT" -C "$FAKE_OTHER" push -q origin "$(cat "$FAKE_DIR/before-push-$n")"; fi',
' if [ -f "$FAKE_DIR/push-$n.stderr" ]; then cat "$FAKE_DIR/push-$n.stderr" >&2; exit 1; fi',
' args=(); for a in "$@"; do case "$a" in https://*) args+=("$FAKE_ORIGIN") ;; *) args+=("$a") ;; esac; done',
' exec "$REAL_GIT" "${args[@]}"',
'fi',
'exec "$REAL_GIT" "$@"',
'',
].join('\n'), { mode: 0o755 });
writeFileSync(join(bin, 'sleep'), '#!/bin/sh\necho "$*" >> "$FAKE_DIR/sleeps"\n', { mode: 0o755 });
const box = {
root, origin, work, other, temp, fake,
/** Сосед: коммит в `ref` origin, который уйдёт туда перед push номер `n`. */
neighbour(ref, n, file, content) {
if (!existsSync(other)) git(root, 'clone', '-q', origin, other);
git(other, 'fetch', '-q', 'origin');
git(other, 'checkout', '-q', '-B', ref, `origin/${ref}`);
writeFileSync(join(other, file), content);
commitAll(other, `neighbour ${file}`);
writeFileSync(join(fake, `before-push-${n}`), `HEAD:${ref}`);
},
refuse(n, stderr) { writeFileSync(join(fake, `push-${n}.stderr`), stderr); },
run(script, env) {
const summary = join(temp, 'summary.md');
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
cwd: work, encoding: 'utf8',
env: {
...ENV, ...env, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, GITHUB_STEP_SUMMARY: summary,
GITHUB_OUTPUT: join(temp, 'output'), TOKEN: FAKE_TOKEN,
FAKE_DIR: fake, FAKE_ORIGIN: origin, FAKE_OTHER: other, REAL_GIT: realGit,
},
});
const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
return {
status: r.status, stdout: r.stdout, stderr: r.stderr, summary: read(summary),
pushes: Number(read(join(fake, 'pushes')) || 0),
calls: read(join(fake, 'calls')).split('\n').filter(Boolean),
sleeps: read(join(fake, 'sleeps')).split('\n').filter(Boolean),
};
},
};
return box;
}
const noSecrets = (r) => {
for (const [where, text] of Object.entries({ stdout: r.stdout, stderr: r.stderr, summary: r.summary })) {
for (const secret of SECRETS) assert.ok(!text.includes(secret), `${where}: секрет в тексте`);
assert.doesNotMatch(text, /x-access-token:(?!\*\*\*@)/, `${where}: URL с учётными данными`);
}
};
// ---------- release-review.yml: документ ревью релиза в dev ----------
const TAG = 'v1.78.0';
const RELEASE_DOC = `docs/reviews/RELEASE-REVIEW-${TAG}.md`;
function runRelease(box) {
const dir = join(box.temp, 'release-review-result');
mkdirSync(dir);
const files = {
'release-review.md': `# Независимое ревью ${TAG}\n\nИтог: High 0 · Medium 1 · Low 2\n`,
'result.json': JSON.stringify({ high: 0, medium: 1, low: 2, summary: 'ok' }),
};
for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text);
writeFileSync(join(dir, 'manifest.sha256'), Object.entries(files)
.map(([name, text]) => `${createHash('sha256').update(text).digest('hex')} ${name}\n`).join(''));
return box.run(RELEASE_STEP(), {
TAG, DOC: RELEASE_DOC, CANDIDATE: 'c'.repeat(40), BASE: 'v1.77.0', ISSUES: '701,702',
RUN_URL: 'https://github.com/o/r/actions/runs/42',
});
}
test('#723 release-review.yml на настоящем bash: dev ушёл вперёд — прежний повтор, документ собран заново поверх соседа', (t) => {
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
const box = sandbox(tempRoot(t, 'hp-723-release-'));
box.neighbour('dev', 1, 'docs/reviews/CODE-REVIEW-8-r1.md', '# CODE-REVIEW-8-r1\nВердикт: **зелёный** · High: 0 · Medium: 0\n');
const r = runRelease(box);
assert.equal(r.status, 0, r.stderr + r.stdout);
assert.equal(r.pushes, 2, 'повтор после устаревшего lease');
assert.deepEqual(r.sleeps, ['10']);
assert.match(r.stdout, /::warning::dev ушёл вперёд — попытка 1 из 3/);
assert.match(r.stderr, /git push отклонён — stale \(fetch first\)/, 'разбор отказа — кодом слияния');
assert.match(r.summary, /^### Независимое ревью v1\.78\.0\nИтог: High 0 · Medium 1 · Low 2 — `docs\/reviews\/RELEASE-REVIEW-v1\.78\.0\.md` в dev\./m);
assert.doesNotMatch(r.summary, /отклонён/);
assert.equal(git(box.origin, 'log', '-1', '--format=%B', 'dev'),
`docs: release review for ${TAG}\n\nНезависимое ревью линии перед стабильным релизом (PROCESS.md §11.5).\n`
+ 'Итог: High 0 · Medium 1 · Low 2. Выпуск не блокирует; решение по находкам — за владельцем.\n\nIssue: #638\nUser-Visible: no');
assert.deepEqual(git(box.origin, 'show', '--name-only', '--format=', 'dev').split('\n').sort(),
['docs/reviews/INDEX.md', RELEASE_DOC], 'коммит несёт документ и индекс');
const index = git(box.origin, 'show', 'dev:docs/reviews/INDEX.md');
assert.match(index, /CODE-REVIEW-8-r1/, 'индекс собран поверх сдвинутого dev');
assert.match(index, /RELEASE-REVIEW-v1\.78\.0/);
assert.match(git(box.origin, 'show', `dev:${RELEASE_DOC}`), /<!-- hp-release-review-anchors -->\n### Материал ревью\n\n```\ntag v1\.78\.0\n/);
});
for (const [label, stderr, kind, reason] of [
['право на workflow', remoteRejected('dev', WORKFLOW_REASON), PUSH_REFUSAL.workflow, WORKFLOW_REASON],
['прочий [remote rejected] (с заголовком Authorization и чужим токеном)', noisyRejected('dev'), PUSH_REFUSAL.remote, 'protected branch hook declined'],
['не отказ (сеть, аутентификация)', NETWORK, PUSH_REFUSAL.unknown, ''],
]) {
test(`#723 release-review.yml на настоящем bash: ${label} — без повторов, причина и ответ git без токена в журнале и сводке`, (t) => {
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
const box = sandbox(tempRoot(t, 'hp-723-release-'));
const before = git(box.origin, 'rev-parse', 'dev');
box.refuse(1, stderr);
const r = runRelease(box);
assert.equal(r.status, 1, r.stderr + r.stdout);
assert.equal(r.pushes, 1, 'повторов нет');
assert.deepEqual(r.sleeps, []);
assert.doesNotMatch(r.stdout, /dev ушёл вперёд/, 'отказ GitHub — не сдвиг dev');
assert.match(r.stdout, new RegExp(`::error::push docs/reviews/RELEASE-REVIEW-v1\\.78\\.0\\.md в dev отклонён \\(${kind}\\) — это не сдвиг dev`));
assert.match(r.stderr, new RegExp(`git push отклонён — ${kind}`), 'ответ git — в журнале');
assert.match(r.summary, new RegExp(`^### git push в \`dev\` отклонён: ${kind} \\(#723\\)$`, 'm'));
assert.match(r.summary, /Документ независимого ревью релиза не опубликован в `dev`/);
if (reason) {
assert.ok(r.summary.includes(`Причина, которую назвал GitHub: «${reason}»`), r.summary);
assert.ok(r.stderr.includes(reason));
}
assert.match(r.summary, /Ответ git:\n\n```\n[\s\S]+\n```\n$/);
assert.doesNotMatch(r.summary, /Независимое ревью v1\.78\.0/, 'успеха в сводке нет');
noisySecretsGone(r);
assert.equal(git(box.origin, 'rev-parse', 'dev'), before, 'dev не тронут');
});
}
function noisySecretsGone(r) {
noSecrets(r);
assert.doesNotMatch(r.summary + r.stderr, /Authorization: Basic (?!\*\*\*)/i);
}
// ---------- _process.yml: документ ревью в ветку задачи ----------
const BRANCH = 'issue/9-fix';
const REVIEW_DOC = 'docs/reviews/CODE-REVIEW-9-r1.md';
function taskBranch(box) {
git(box.work, 'checkout', '-q', '-b', BRANCH);
writeFileSync(join(box.work, 'a.mjs'), 'export const a = 9;\n');
commitAll(box.work, 'fix: a (#9)');
git(box.work, 'push', '-q', 'origin', BRANCH);
// Как actions/checkout в integrate: рабочая копия на dev.
git(box.work, 'checkout', '-q', 'dev');
}
function runReviewDoc(box, out = '{"verdict":"green","high":0}') {
const source = join(box.temp, 'review-result', 'review-document.md');
mkdirSync(join(box.temp, 'review-result'));
writeFileSync(source, '# Код-ревью #9, раунд 1\n\nВердикт: **зелёный** · High: 0 · Medium: 0\n');
return box.run(REVIEW_DOC_STEP(), {
BRANCH, NUM: '9', STAGE: 'code', CYCLE: '1', SOURCE: source,
MATERIAL_SHA: git(box.origin, 'rev-parse', BRANCH), MATERIAL_TREE: git(box.origin, 'rev-parse', `${BRANCH}^{tree}`),
MATERIAL_SPECS: '', MATERIAL_ISSUE_BODY: '', OUT: out,
});
}
test('#723 _process.yml на настоящем bash: ветка ушла вперёд — прежний ребейз и второй push, документ на ветке', (t) => {
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
const box = sandbox(tempRoot(t, 'hp-723-doc-'));
taskBranch(box);
box.neighbour(BRANCH, 1, 'b.mjs', 'export const b = 1;\n');
const r = runReviewDoc(box);
assert.equal(r.status, 0, r.stderr + r.stdout);
assert.equal(r.pushes, 2);
assert.ok(r.calls.some((call) => /(^| )rebase origin\/issue\/9-fix$/.test(call)), 'ребейз на сдвинутую ветку');
assert.match(r.stderr, /git push отклонён — stale \(fetch first\)/);
assert.match(r.stdout, /документ опубликован в issue\/9-fix: docs\/reviews\/CODE-REVIEW-9-r1\.md/);
assert.equal(r.summary, '', 'сводка об отказе не пишется: повтор прошёл');
assert.equal(git(box.origin, 'log', '-1', '--format=%B', BRANCH), 'docs: review document for #9\n\nIssue: #9\nUser-Visible: no');
assert.deepEqual(git(box.origin, 'show', '--name-only', '--format=', BRANCH).split('\n'), [REVIEW_DOC], 'один документ, без индекса');
assert.equal(git(box.origin, 'log', '-1', '--format=%s', `${BRANCH}~1`), 'neighbour b.mjs', 'коммит автора не потерян');
});
for (const [label, stderr, kind] of [
['право на workflow', remoteRejected(BRANCH, WORKFLOW_REASON), PUSH_REFUSAL.workflow],
['прочий [remote rejected] (с заголовком Authorization и чужим токеном)', noisyRejected(BRANCH), PUSH_REFUSAL.remote],
]) {
test(`#723 _process.yml на настоящем bash: ${label} — без ребейза и второго push, причина в журнале и сводке`, (t) => {
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
const box = sandbox(tempRoot(t, 'hp-723-doc-'));
taskBranch(box);
const before = git(box.origin, 'rev-parse', BRANCH);
box.refuse(1, stderr);
const r = runReviewDoc(box);
assert.equal(r.status, 1, r.stderr + r.stdout);
assert.equal(r.pushes, 1, 'второго push нет');
assert.ok(!r.calls.some((call) => /(^| )rebase /.test(call)), 'ребейза нет');
assert.match(r.stdout, new RegExp(`::error::push документа ревью в issue/9-fix отклонён \\(${kind}\\) — это не сдвиг issue/9-fix`));
assert.match(r.stderr, new RegExp(`git push отклонён — ${kind}`));
assert.match(r.summary, new RegExp(`^### git push в \`issue/9-fix\` отклонён: ${kind} \\(#723\\)$`, 'm'));
assert.match(r.summary, /Документ ревью не опубликован в `issue\/9-fix`/);
if (kind === PUSH_REFUSAL.workflow) assert.match(r.summary, /Файлы: `\.github\/workflows\/validate\.yml`\./);
noisySecretsGone(r);
assert.equal(git(box.origin, 'rev-parse', BRANCH), before, 'ветка не тронута');
});
}
test('#723 _process.yml на настоящем bash: сдвиг, затем отказ GitHub на втором push — ошибка с причиной, без третьей попытки', (t) => {
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
const box = sandbox(tempRoot(t, 'hp-723-doc-'));
taskBranch(box);
box.neighbour(BRANCH, 1, 'b.mjs', 'export const b = 1;\n');
box.refuse(2, noisyRejected(BRANCH));
const r = runReviewDoc(box);
assert.equal(r.status, 1, r.stderr + r.stdout);
assert.equal(r.pushes, 2);
assert.match(r.stdout, /::error::документ ревью не опубликован в issue\/9-fix и после ребейза \(remote-rejected\)/);
assert.match(r.summary, /^### git push в `issue\/9-fix` отклонён: remote-rejected \(#723\)$/m);
noisySecretsGone(r);
});
test('#726 AC5 _process.yml на настоящем bash: публикация пишет маршрут вердикта хвостом строки якоря', async (t) => {
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
const { anchorVerdictFrom } = await import('../scripts/review-doc-guard.mjs');
const anchor = (doc) => doc.split('\n').find((line) => line.startsWith('- Вердикт конвейера:'));
const routed = sandbox(tempRoot(t, 'hp-726-doc-'));
taskBranch(routed);
const r = runReviewDoc(routed, JSON.stringify({ verdict: 'yellow', high: 0, medium: 1, summary: 's', route: 'reclassify', criterion: 'undocumented' }));
assert.equal(r.status, 0, r.stderr + r.stdout);
const doc = git(routed.origin, 'show', `${BRANCH}:${REVIEW_DOC}`);
assert.equal(anchor(doc), '- Вердикт конвейера: `yellow` · High 0 · маршрут `reclassify` (критерий `undocumented`)');
assert.deepEqual(anchorVerdictFrom(doc), { verdict: 'yellow', high: 0 });
// Вердикт без route (до #726) — прежняя строка.
const plain = sandbox(tempRoot(t, 'hp-726-doc-'));
taskBranch(plain);
assert.equal(runReviewDoc(plain).status, 0);
assert.equal(anchor(git(plain.origin, 'show', `${BRANCH}:${REVIEW_DOC}`)), '- Вердикт конвейера: `green` · High 0');
});
// ---------- AC3 и разбор: тексты — из кода, не из run ----------
test('#723 AC3: в run обоих шагов нет многострочного текста и heredoc; отказ разбирает код слияния', () => {
for (const [label, body, tools] of [['release-review.yml', RELEASE_STEP(), 'scripts'], ['_process.yml', REVIEW_DOC_STEP(), '"$tools/scripts']]) {
assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, `${label}: heredoc в run`);
assert.ok(body.includes(`kind=$(node ${tools}/merge-candidate.mjs`), `${label}: разбор — merge-candidate.mjs --push-refusal`);
assert.match(body, /--push-refusal="\$push_err"[^\n]*\\\n[^\n]*--summary="\$GITHUB_STEP_SUMMARY"\) \|\| kind=unknown/, `${label}: сводку пишет код`);
assert.match(body, /2> "\$push_err"; then/, `${label}: stderr push идёт в разбор`);
}
// Шаг _process.yml берёт разбор из dev: ветка задачи, отставшая от dev, его может не нести.
assert.match(REVIEW_DOC_STEP(), /git archive origin\/dev scripts \| tar -x -C "\$tools"/);
// Блок run не обрезан: последняя строка каждого шага на месте.
assert.match(RELEASE_STEP(), /echo "::error::документ ревью не опубликован в dev за три попытки"\nexit 1\n*$/);
assert.match(REVIEW_DOC_STEP(), /echo "документ опубликован в \$target: \$doc"\n*$/);
});
test('#723 AC2: сводка об отказе — без токена, URL с учётными данными и Authorization; причина и файлы названы', () => {
const refusal = classifyPushRefusal(`> Authorization: Bearer ${OTHER_TOKEN}\n${remoteRejected('dev', WORKFLOW_REASON)}`, { secrets: [FAKE_TOKEN] });
const text = refusalSummary(refusal, { ref: 'dev', stage: 'release-review' });
for (const secret of SECRETS) assert.ok(!text.includes(secret));
assert.doesNotMatch(text, /x-access-token:(?!\*\*\*@)/);
assert.match(text, /Authorization: Bearer \*\*\*/);
assert.match(text, /^### git push в `dev` отклонён: workflow \(#723\)\n\nДокумент независимого ревью релиза не опубликован в `dev`\. GitHub отклонил push по праву на workflow/);
assert.match(text, /Файлы: `\.github\/workflows\/validate\.yml`\./);
// ``` в ответе git не закрывает блок кода сводки
const fenced = refusalSummary(classifyPushRefusal(remoteRejected('dev', 'hook declined ```x```')), { ref: 'dev', stage: 'review-doc' });
assert.equal(fenced.match(/```/g).length, 2);
});
test('#723 CLI --summary: для устаревшего lease сводку не пишет (шаг повторяет), для отказа GitHub — дописывает', () => {
const temp = mkdtempSync(join(tmpdir(), 'hp-723-cli-'));
try {
const cli = join(SCRIPTS, 'merge-candidate.mjs');
const summary = join(temp, 'summary.md');
writeFileSync(summary, 'прежнее\n');
const run = (stderr) => {
writeFileSync(join(temp, 'push.stderr'), stderr);
return spawnSync(process.execPath, [cli, `--push-refusal=${join(temp, 'push.stderr')}`, '--ref=dev', '--stage=release-review', `--summary=${summary}`], {
encoding: 'utf8', env: { ...ENV, TOKEN: FAKE_TOKEN },
});
};
const stale = run(' ! [rejected] HEAD -> dev (fetch first)\nerror: failed to push some refs\n');
assert.equal(stale.status, 0);
assert.equal(stale.stdout, 'stale\n');
assert.equal(readFileSync(summary, 'utf8'), 'прежнее\n', 'stale — без сводки');
const refused = run(remoteRejected('dev', 'protected branch hook declined'));
assert.equal(refused.stdout, 'remote-rejected\n');
const text = readFileSync(summary, 'utf8');
assert.ok(text.startsWith('прежнее\n### git push в `dev` отклонён: remote-rejected (#723)\n'), 'дописывает, не затирает');
assert.ok(!text.includes(FAKE_TOKEN) && !refused.stderr.includes(FAKE_TOKEN));
} finally { rmSync(temp, { recursive: true, force: true }); }
});