mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
ci(process): раунд ревью ждёт Validate событием, а не сном раннера (#636)
Стадия prepare спала ≈ 28 минут на раунд, пока шёл Validate с мутантами на материале (модель работает 10–12); за неделю ≈ 420–500 job-минут простоя и потолок бюджета стадии 55 минут. - validate-gate.mjs: `--no-wait` — гейт диспатчит прогон, убеждается, что тот встал на материал (#539 сохранён), и возвращает `pending` (код 2) вместо ожидания; завершённый зелёный/красный отдаёт сразу, как прежде. - process.yml prepare: третий исход `proceed=pending`: запечатанный маркер `review-pending-<issue>-<run>-<attempt>` (issue, stage, branch, material_sha, validate run) и выход; модель и интеграция не запускаются; возврат автору — только на явном `false`. - process-resume.yml + scripts/process-resume.mjs: на `workflow_run: completed` Validate по ветке issue/* — если метка S7 стоит, активного прогона нет и последний прогон оставил маркер на этот SHA, переставить S7 (HP_PROCESS_TOKEN); новый прогон находит завершённый dispatch сразу. Без маркера не будит. - process-reconcile.mjs: читает маркер и состояние Validate на материале; идёт — wait, завершился/пропал без продолжения — retry; без маркера — прежний escalate. Общий loadSealedArtifact, экспорт processRuns/artifactNames. - preflight сверяет process-resume.yml между main и dev наравне с process.yml. - Тесты: validate-gate (4), process-resume (8, включая контракт трёх workflow), process-reconcile (2); мутанты gate-no-wait-still-sleeps, resume-wakes-round-without-marker, resume-ignores-active-run, reconcile-wakes-pending-while-validate-active. PROCESS.md §10.4, AGENTS.md. Issue: #636 User-Visible: no
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
name: Продолжение ревью после Validate
|
||||
run-name: "resume · ${{ github.event.workflow_run.head_branch }} · ${{ github.event.workflow_run.conclusion }}"
|
||||
|
||||
# #636. Стадия `prepare` конвейера (process.yml) больше не ждёт Validate с
|
||||
# мутантами на материале внутри job — раннер спал ≈ 28 минут на раунд при
|
||||
# 10–12 минутах работы модели. Она диспатчит прогон, кладёт запечатанный
|
||||
# маркер `review-pending-…` и выходит. Этот workflow просыпается на завершение
|
||||
# любого Validate и, если раунд ждал именно этот прогон (маркер на материале,
|
||||
# метка S7 стоит, активного прогона конвейера нет), переставляет метку S7 —
|
||||
# новый прогон `prepare` находит завершённый dispatch и продолжает раунд.
|
||||
# Ничего не оценивает: зелёный/красный разбирает сам конвейер. Страховка на
|
||||
# потерянное событие — process-reconcile.yml с тем же маркером.
|
||||
#
|
||||
# Как и process.yml, файл исполняется из ветки по умолчанию (main): для
|
||||
# события `workflow_run` GitHub берёт workflow только оттуда. Сверка копий —
|
||||
# в preflight validate.yml.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Проверка (CI)"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
resume:
|
||||
name: "Разбудить раунд, ждавший этот Validate"
|
||||
if: github.event.workflow_run.event == 'workflow_dispatch' && startsWith(github.event.workflow_run.head_branch, 'issue/')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: process-resume-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Код берётся из dev, как у reconcile: после штатного слияния действует
|
||||
# версия, которую проверил CI, а не копия из main.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
# Метка переставляется HP_PROCESS_TOKEN: событие от GITHUB_TOKEN не
|
||||
# запустило бы process.yml (см. шапку process.yml, п. 1).
|
||||
- name: Решить по маркеру ожидания и переставить S7
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
EVENT: ${{ github.event.workflow_run.event }}
|
||||
STATUS: ${{ github.event.workflow_run.status }}
|
||||
run: |
|
||||
node scripts/process-resume.mjs \
|
||||
--repo="$REPO" --branch="$BRANCH" --sha="$SHA" \
|
||||
--event="$EVENT" --status="$STATUS" --apply=true | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -582,14 +582,64 @@ jobs:
|
||||
{ echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA"; then
|
||||
echo 'proceed=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'proceed=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# #636: раннер не спит, пока идёт Validate (28 минут на раунд при
|
||||
# 10–12 минутах работы модели). Гейт диспатчит прогон, убеждается, что
|
||||
# тот встал на материал, и выходит с кодом 2 — «идёт». Раунд продолжит
|
||||
# событие завершения Validate (process-resume.yml переставит метку
|
||||
# S7), страховка — process-reconcile. Зелёный или красный завершённый
|
||||
# прогон гейт и без ожидания возвращает сразу.
|
||||
set +e
|
||||
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait
|
||||
code=$?
|
||||
set -e
|
||||
case "$code" in
|
||||
0) echo 'proceed=true' >> "$GITHUB_OUTPUT" ;;
|
||||
2) echo 'proceed=pending' >> "$GITHUB_OUTPUT" ;;
|
||||
*) echo 'proceed=false' >> "$GITHUB_OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# #636: прогон на материале идёт — записать маркер ожидания и освободить
|
||||
# раннер. Метка S7 остаётся; событие `workflow_run` по завершении Validate
|
||||
# переставит её, и новый прогон конвейера найдёт завершённый dispatch
|
||||
# сразу. Маркер читают process-resume.mjs и process-reconcile.mjs: без
|
||||
# него ни один из них не имеет права будить раунд — иначе «успешный
|
||||
# прогон без вердикта» неотличим от потерянного запроса.
|
||||
- name: Validate идёт — раунд продолжит событие
|
||||
id: pending
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'pending'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
SHA: ${{ steps.material.outputs.sha }}
|
||||
VALIDATE_RUN_ID: ${{ steps.gate.outputs.run_id }}
|
||||
VALIDATE_URL: ${{ steps.gate.outputs.url }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/review-pending"
|
||||
mkdir -p "$dir"
|
||||
jq -n -S \
|
||||
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
|
||||
--arg issue "$NUM" --arg stage "$STAGE" --arg branch "$BRANCH" \
|
||||
--arg material_sha "$SHA" --arg validate_run_id "$VALIDATE_RUN_ID" \
|
||||
--arg validate_url "$VALIDATE_URL" \
|
||||
'{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,branch:$branch,material_sha:$material_sha,validate_run_id:$validate_run_id,validate_url:$validate_url}' \
|
||||
> "$dir/pending.json"
|
||||
(cd "$dir" && sha256sum pending.json > manifest.sha256)
|
||||
echo "artifact=review-pending-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
short=$(git rev-parse --short "$SHA")
|
||||
echo "Validate с мутантами на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Сохранить маркер ожидания
|
||||
if: steps.gate.outputs.proceed == 'pending'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ${{ steps.pending.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/review-pending
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Validate красный — вернуть автору без ревью
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed != 'true'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
@@ -1181,6 +1231,11 @@ jobs:
|
||||
echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PROCEED" = "pending" ]; then
|
||||
echo "Validate на материале ещё идёт — раунд продолжит событие завершения (#636); интегрировать нечего"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PROCEED" != "true" ]; then
|
||||
echo "подготовка уже вернула задачу автору; интегрировать нечего"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -101,9 +101,10 @@ jobs:
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
# #472: расписание mutation-gate.yml тоже исполняется из ветки по
|
||||
# умолчанию — та же ловушка, что у process.yml. Сверяются оба.
|
||||
# умолчанию — та же ловушка, что у process.yml; #636 добавил
|
||||
# process-resume.yml (событие workflow_run). Сверяются все три.
|
||||
status=0
|
||||
for file in process.yml mutation-gate.yml; do
|
||||
for file in process.yml mutation-gate.yml process-resume.yml; do
|
||||
if diff <(git show "origin/main:.github/workflows/$file") \
|
||||
<(git show "origin/dev:.github/workflows/$file"); then
|
||||
echo "$file: main и dev идентичны"
|
||||
|
||||
@@ -425,7 +425,12 @@ until the verdict or the return arrives: a push on top of a running review cance
|
||||
it (10–20 runner minutes) and, after the material is fixed, also the merge (#312).
|
||||
Set `S7` once per round, not after every CI fix: the pipeline now runs Validate
|
||||
with the diff mutants on the material itself and returns a red one to `S6` without
|
||||
spending a review cycle. Mutants by diff run only where they are explicitly
|
||||
spending a review cycle; since #636 it does not sleep while Validate runs — the
|
||||
prepare stage leaves a sealed `review-pending` marker and exits, and the
|
||||
completion of Validate (`process-resume.yml`, `workflow_run`) re-applies `S7`
|
||||
so a fresh run finds the finished dispatch; `process-reconcile.yml` is the
|
||||
fallback for a lost event. A second `S7` from the pipeline itself is therefore
|
||||
normal and is not a new round. Mutants by diff run only where they are explicitly
|
||||
requested — the review candidate, the merge candidate (both dispatch Validate
|
||||
with `mutants=true`) and PRs (#510, #601). Ordinary pushes, the beta candidate
|
||||
(`Release:` trailer) and `full=true` do not request them: a routine push costs
|
||||
|
||||
+11
-3
@@ -959,7 +959,14 @@ Medium-находки вне скоупа задачи (#202), кладёт до
|
||||
|
||||
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
|
||||
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
|
||||
`workflow_dispatch validate.yml -f mutants=true`) и ждёт его до 45 минут. Красный или
|
||||
`workflow_dispatch validate.yml -f mutants=true`). **Ждёт его не раннер, а событие**
|
||||
(#636): подготовка убеждается, что dispatch встал на материал, кладёт запечатанный
|
||||
маркер ожидания `review-pending-…` и завершается; по завершении Validate
|
||||
`process-resume.yml` (`workflow_run`) переставляет метку `S7-code-review`, и новый
|
||||
прогон конвейера находит завершённый dispatch сразу. Страховка на потерянное
|
||||
событие — `process-reconcile.yml`: успешный прогон подготовки с маркером и уже
|
||||
завершённым Validate он будит повторной меткой, без маркера — как прежде, только
|
||||
диагностика. Будить без маркера нельзя: это второй вызов модели. Красный или
|
||||
пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой —
|
||||
код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут
|
||||
только по явному запросу: на кандидате ревью, кандидате слияния (#492) — оба
|
||||
@@ -976,8 +983,9 @@ Medium-находки вне скоупа задачи (#202), кладёт до
|
||||
|
||||
Ожидание gates, работа модели и публикация/интеграция — три независимых jobs
|
||||
(#551) с отдельными бюджетами 55, 45 и 55 минут. Поэтому долгий Validate не
|
||||
съедает время модели, а ожидание кандидата после зелёного вердикта не обрывает
|
||||
готовый review. Между jobs передаётся запечатанный artifact: run/attempt, issue,
|
||||
съедает время модели (с #636 — и не занимает раннер: до этого подготовка спала
|
||||
≈ 28 минут на раунд при 10–12 минутах работы модели), а ожидание кандидата после
|
||||
зелёного вердикта не обрывает готовый review. Между jobs передаётся запечатанный artifact: run/attempt, issue,
|
||||
этап, раунд, branch, SHA/tree материала, якоря ТЗ и результат Validate. Получатель
|
||||
сверяет полный набор файлов, SHA-256 и все поля с outputs предыдущей стадии;
|
||||
неполный, чужой или устаревший результат fail-closed не публикуется и не разрешает
|
||||
|
||||
@@ -8449,6 +8449,51 @@ const MUTANT_DEFINITIONS = [
|
||||
+ ' никогда — именно оно в этом процессе заменяет тестирование.',
|
||||
}],
|
||||
},
|
||||
// #636: раунд продолжается по событию завершения Validate, а не сном раннера.
|
||||
// Каждая защита — от второго вызова модели или от вечного ожидания.
|
||||
{
|
||||
id: 'gate-no-wait-still-sleeps',
|
||||
guard: 'node --test --test-name-pattern="#636" test/validate-gate.test.mjs',
|
||||
because: 'with --no-wait the gate must return pending for a running dispatch instead of polling '
|
||||
+ 'it inside the pipeline job — otherwise the runner sleeps 28 minutes per round again (#636)',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: " if (!wait) {\n // #636: прогон найден и идёт — ждать его будет событие, не раннер.",
|
||||
replace: " if (false && !wait) {\n // #636: прогон найден и идёт — ждать его будет событие, не раннер.",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'resume-wakes-round-without-marker',
|
||||
guard: 'node --test --test-name-pattern="#636" test/process-resume.test.mjs',
|
||||
because: 'a successful process run without a pending marker did not wait for Validate; relabelling '
|
||||
+ 'it would spend the model a second time (#636)',
|
||||
patches: [{
|
||||
file: 'scripts/process-resume.mjs',
|
||||
find: " if (!pending) return { action: 'noop', reason: 'latest process run left no pending marker — it did not wait for Validate' };",
|
||||
replace: " if (false && !pending) return { action: 'noop', reason: 'latest process run left no pending marker — it did not wait for Validate' };",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'resume-ignores-active-run',
|
||||
guard: 'node --test --test-name-pattern="#636" test/process-resume.test.mjs',
|
||||
because: 'relabelling while a process run is active queues a second round for the same request (#636)',
|
||||
patches: [{
|
||||
file: 'scripts/process-resume.mjs',
|
||||
find: " if (mine.some((run) => ACTIVE_RUN_STATES.has(run.status))) return { action: 'noop', reason: 'a process run for this issue is already active' };",
|
||||
replace: " if (false && mine.some((run) => ACTIVE_RUN_STATES.has(run.status))) return { action: 'noop', reason: 'a process run for this issue is already active' };",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'reconcile-wakes-pending-while-validate-active',
|
||||
guard: 'node --test --test-name-pattern="#636" test/process-reconcile.test.mjs',
|
||||
because: 'reconcile must wait while the Validate the round is pending on still runs; relabelling '
|
||||
+ 'early dispatches a second Validate and a second round (#636)',
|
||||
patches: [{
|
||||
file: 'scripts/process-reconcile.mjs',
|
||||
find: " if (run.pendingValidate === 'active') {",
|
||||
replace: " if (false && run.pendingValidate === 'active') {",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-reconcile-restarts-healthy-run',
|
||||
guard: 'node --test test/process-reconcile.test.mjs',
|
||||
|
||||
@@ -50,6 +50,10 @@ export function parseProcessRun(run = {}) {
|
||||
prepared: run.prepared || null,
|
||||
preparedArtifact: Boolean(run.preparedArtifact),
|
||||
resultArtifact: Boolean(run.resultArtifact),
|
||||
// #636: маркер «Validate на материале идёт, раунд продолжит событие» и
|
||||
// состояние этого Validate (`active` | `completed` | `missing`).
|
||||
pending: run.pending || null,
|
||||
pendingValidate: run.pendingValidate || null,
|
||||
evidenceError: run.evidenceError || null,
|
||||
};
|
||||
}
|
||||
@@ -143,6 +147,16 @@ export function decideReconciliation({
|
||||
if (Number.isFinite(settledAt) && now - settledAt < graceMs) {
|
||||
return result('wait', 'completed run is still within label-application grace', { label, stage, run });
|
||||
}
|
||||
if (run.conclusion === 'success' && run.pending) {
|
||||
// #636: успешный прогон без вердикта — это не потеря, а осознанный выход
|
||||
// подготовки: Validate с мутантами на материале ещё шёл. Пока он идёт —
|
||||
// ждать; завершился или пропал, а событие раунд не разбудило — разбудить
|
||||
// повторной меткой: новый прогон найдёт завершённый dispatch сразу.
|
||||
if (run.pendingValidate === 'active') {
|
||||
return result('wait', 'Validate on the material is still running; the round resumes on its completion', { label, stage, run });
|
||||
}
|
||||
return result('retry', `Validate on the material is ${run.pendingValidate || 'unknown'} but the round was not resumed`, { label, stage, run });
|
||||
}
|
||||
if (run.conclusion === 'success') {
|
||||
return result('escalate', 'successful run did not move the review label', { label, stage, run });
|
||||
}
|
||||
@@ -202,7 +216,7 @@ function openReviewIssues(repo) {
|
||||
.sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
function processRuns(repo, issues = []) {
|
||||
export function processRuns(repo, issues = []) {
|
||||
const pages = [1, 2].flatMap((page) => {
|
||||
const response = ghJson(['api', `repos/${repo}/actions/workflows/process.yml/runs?event=issues&per_page=100&page=${page}`]);
|
||||
return response.workflow_runs || [];
|
||||
@@ -227,30 +241,54 @@ function processRuns(repo, issues = []) {
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function artifactNames(repo, run) {
|
||||
export function artifactNames(repo, run) {
|
||||
const response = ghJson(['api', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]);
|
||||
return response.artifacts || [];
|
||||
}
|
||||
|
||||
function loadPreparedArtifact(repo, run, artifact) {
|
||||
/** Sealed JSON artifact of the pipeline: one file plus its sha256 manifest. */
|
||||
export function loadSealedArtifact(repo, run, artifact, file) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'houseplan-process-reconcile-'));
|
||||
try {
|
||||
const downloaded = gh(['run', 'download', String(run.id), '--repo', repo,
|
||||
'--name', artifact.name, '--dir', dir], { allowFailure: true });
|
||||
if (downloaded.status !== 0) throw new Error(`artifact download failed: ${(downloaded.stderr || '').trim()}`);
|
||||
const file = join(dir, 'prepared.json');
|
||||
const path = join(dir, file);
|
||||
const manifest = join(dir, 'manifest.sha256');
|
||||
if (!existsSync(file) || !existsSync(manifest)) throw new Error('prepared artifact is incomplete');
|
||||
const body = readFileSync(file);
|
||||
if (!existsSync(path) || !existsSync(manifest)) throw new Error(`${file} artifact is incomplete`);
|
||||
const body = readFileSync(path);
|
||||
const expected = readFileSync(manifest, 'utf8').trim().split(/\s+/)[0];
|
||||
const actual = createHash('sha256').update(body).digest('hex');
|
||||
if (expected !== actual) throw new Error('prepared artifact checksum mismatch');
|
||||
if (expected !== actual) throw new Error(`${file} artifact checksum mismatch`);
|
||||
return JSON.parse(body.toString('utf8'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function loadPreparedArtifact(repo, run, artifact) {
|
||||
return loadSealedArtifact(repo, run, artifact, 'prepared.json');
|
||||
}
|
||||
|
||||
export const pendingArtifactName = (issue, run) => `review-pending-${issue.number ?? issue}-${run.id}-${run.attempt}`;
|
||||
|
||||
/**
|
||||
* #636: состояние Validate с мутантами на материале — по dispatch-прогонам на
|
||||
* SHA. `active` пока хоть один не завершён; `completed`, если завершённый есть;
|
||||
* иначе `missing` (диспатч не появился — новый прогон конвейера повторит его).
|
||||
*/
|
||||
export function validateStateOnMaterial(runs = []) {
|
||||
const dispatches = (runs || []).filter((run) => (run.event || run.workflowEvent) === 'workflow_dispatch');
|
||||
if (dispatches.some((run) => ACTIVE_RUN_STATES.has(String(run.status || '')))) return 'active';
|
||||
if (dispatches.some((run) => String(run.status || '') === 'completed')) return 'completed';
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
function validateRunsOnSha(repo, sha) {
|
||||
const response = ghJson(['api', `repos/${repo}/actions/workflows/validate.yml/runs?head_sha=${sha}&per_page=20`]);
|
||||
return response.workflow_runs || [];
|
||||
}
|
||||
|
||||
function hydrateRunEvidence(repo, issue, run) {
|
||||
if (!run || run.status !== 'completed') return run;
|
||||
try {
|
||||
@@ -259,8 +297,14 @@ function hydrateRunEvidence(repo, issue, run) {
|
||||
const resultName = `review-result-${issue.number}-${run.id}-${run.attempt}`;
|
||||
const preparedArtifacts = artifacts.filter((artifact) => artifact.name === preparedName && !artifact.expired);
|
||||
const resultArtifacts = artifacts.filter((artifact) => artifact.name === resultName && !artifact.expired);
|
||||
if (preparedArtifacts.length > 1 || resultArtifacts.length > 1) {
|
||||
return { ...run, evidenceError: 'duplicate prepared/result artifacts' };
|
||||
const pendingName = pendingArtifactName(issue, run);
|
||||
const pendingArtifacts = artifacts.filter((artifact) => artifact.name === pendingName && !artifact.expired);
|
||||
if (preparedArtifacts.length > 1 || resultArtifacts.length > 1 || pendingArtifacts.length > 1) {
|
||||
return { ...run, evidenceError: 'duplicate prepared/result/pending artifacts' };
|
||||
}
|
||||
const pending = pendingArtifacts.length === 1 ? loadSealedArtifact(repo, run, pendingArtifacts[0], 'pending.json') : null;
|
||||
if (pending && (String(pending.issue) !== String(issue.number) || String(pending.run_id) !== String(run.id))) {
|
||||
return { ...run, evidenceError: 'pending marker belongs to another issue/run' };
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
@@ -268,6 +312,8 @@ function hydrateRunEvidence(repo, issue, run) {
|
||||
resultArtifact: resultArtifacts.length === 1,
|
||||
prepared: preparedArtifacts.length === 1
|
||||
? loadPreparedArtifact(repo, run, preparedArtifacts[0]) : null,
|
||||
pending,
|
||||
pendingValidate: pending ? validateStateOnMaterial(validateRunsOnSha(repo, pending.material_sha)) : null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { ...run, evidenceError: error instanceof Error ? error.message : String(error) };
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
// #636: продолжение раунда ревью по событию завершения Validate.
|
||||
//
|
||||
// До #636 стадия `prepare` конвейера ждала Validate с мутантами на материале
|
||||
// внутри job: раннер спал ≈ 28 минут на раунд при 10–12 минутах работы модели
|
||||
// и упирался в бюджет стадии. Теперь `prepare` диспатчит прогон, кладёт
|
||||
// запечатанный маркер `review-pending-<issue>-<run>-<attempt>` и выходит.
|
||||
// Этот скрипт запускает `process-resume.yml` на `workflow_run: completed`
|
||||
// Validate и делает ровно одно: если раунд действительно ждёт этот прогон —
|
||||
// переставляет метку S7, и обычный контроллер продолжает с завершённым
|
||||
// dispatch на руках. Ничего не оценивает и не публикует: вердикт Validate
|
||||
// (зелёный или красный) разбирает новый прогон `prepare`.
|
||||
//
|
||||
// Без маркера ожидания будить раунд нельзя: «успешный прогон без вердикта»
|
||||
// иначе неотличим от потерянного запроса, а лишняя метка — это второй вызов
|
||||
// модели. Страховка на потерянное событие — process-reconcile (тот же маркер).
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import {
|
||||
ACTIVE_RUN_STATES, artifactNames, loadSealedArtifact, parseProcessRun, pendingArtifactName,
|
||||
processRuns, relabel,
|
||||
} from './process-reconcile.mjs';
|
||||
|
||||
export const REVIEW_LABEL = 'S7-code-review';
|
||||
const STOP_LABELS = ['blocked', 'review-4'];
|
||||
|
||||
/** `issue/612-view-conflict` → 612; иначе null. */
|
||||
export function issueNumberFromBranch(branch) {
|
||||
const match = /^issue\/(\d+)-/.exec(String(branch || ''));
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
const at = (value) => {
|
||||
const parsed = Date.parse(String(value || ''));
|
||||
return Number.isFinite(parsed) ? parsed : NaN;
|
||||
};
|
||||
|
||||
/**
|
||||
* Чистое решение: будить раунд или нет.
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {string[]} p.labels метки issue
|
||||
* @param {object} p.validateRun завершённый прогон Validate: { event, status, headSha }
|
||||
* @param {string} p.sha SHA материала, на котором завершился Validate
|
||||
* @param {object[]} p.runs прогоны конвейера этой issue (parseProcessRun-совместимые)
|
||||
* @param {(run) => object|null} p.pendingOf маркер ожидания прогона либо null
|
||||
* @returns {{action:'resume'|'noop', reason:string, run?:object}}
|
||||
*/
|
||||
export function decideResume({ labels = [], validateRun, sha, runs = [], pendingOf = () => null }) {
|
||||
if (validateRun?.event !== 'workflow_dispatch') return { action: 'noop', reason: 'not a dispatch run — push runs carry no mutants' };
|
||||
if (validateRun?.status !== 'completed') return { action: 'noop', reason: 'validate run is not completed' };
|
||||
if (validateRun?.headSha && sha && validateRun.headSha !== sha) return { action: 'noop', reason: 'validate run head differs from the material' };
|
||||
if (!labels.includes(REVIEW_LABEL)) return { action: 'noop', reason: 'issue is not awaiting code review' };
|
||||
const stop = STOP_LABELS.find((label) => labels.includes(label));
|
||||
if (stop) return { action: 'noop', reason: `owner stopped the review (${stop})` };
|
||||
const mine = runs.map((run) => run.issue ? run : parseProcessRun(run)).filter(Boolean)
|
||||
.filter((run) => run.label === REVIEW_LABEL)
|
||||
.sort((a, b) => at(b.createdAt) - at(a.createdAt) || Number(b.id) - Number(a.id));
|
||||
if (mine.some((run) => ACTIVE_RUN_STATES.has(run.status))) return { action: 'noop', reason: 'a process run for this issue is already active' };
|
||||
const latest = mine[0];
|
||||
if (!latest) return { action: 'noop', reason: 'no process run for this issue — reconcile owns lost requests' };
|
||||
if (latest.status !== 'completed' || latest.conclusion !== 'success') {
|
||||
return { action: 'noop', reason: `latest process run is ${latest.status}/${latest.conclusion || 'none'} — nothing was left pending` };
|
||||
}
|
||||
const pending = pendingOf(latest);
|
||||
if (!pending) return { action: 'noop', reason: 'latest process run left no pending marker — it did not wait for Validate' };
|
||||
if (String(pending.material_sha) !== String(sha)) {
|
||||
return { action: 'noop', reason: `pending marker waits for ${String(pending.material_sha).slice(0, 8)}, not ${String(sha).slice(0, 8)}` };
|
||||
}
|
||||
return { action: 'resume', reason: 'the round was waiting for exactly this Validate run', run: latest };
|
||||
}
|
||||
|
||||
function gh(args) {
|
||||
return execFileSync('gh', args, { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
export async function resume({ repo, branch, sha, validateRun, apply = true, ops = null }) {
|
||||
const issue = issueNumberFromBranch(branch);
|
||||
if (!issue) return { action: 'noop', reason: `branch ${branch} is not an issue branch`, issue: null };
|
||||
const io = ops || {
|
||||
labels: () => JSON.parse(gh(['issue', 'view', String(issue), '--repo', repo, '--json', 'labels'])).labels.map((label) => label.name),
|
||||
runs: () => processRuns(repo, []),
|
||||
pendingOf: (run) => {
|
||||
const name = pendingArtifactName(issue, run);
|
||||
const artifact = artifactNames(repo, run).find((item) => item.name === name && !item.expired);
|
||||
if (!artifact) return null;
|
||||
const pending = loadSealedArtifact(repo, run, artifact, 'pending.json');
|
||||
return String(pending.issue) === String(issue) && String(pending.run_id) === String(run.id) ? pending : null;
|
||||
},
|
||||
relabel: () => relabel(repo, { number: issue }, REVIEW_LABEL),
|
||||
};
|
||||
const labels = io.labels();
|
||||
const runs = io.runs().filter((run) => run.issue === issue);
|
||||
const decision = decideResume({ labels, validateRun, sha, runs, pendingOf: io.pendingOf });
|
||||
if (decision.action === 'resume' && apply) io.relabel();
|
||||
return { ...decision, issue, applied: decision.action === 'resume' && apply };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const repo = arg('repo') || process.env.GITHUB_REPOSITORY;
|
||||
const branch = arg('branch');
|
||||
const sha = arg('sha');
|
||||
if (!repo || !branch || !sha) {
|
||||
console.error('usage: process-resume.mjs --repo=<owner/repo> --branch=<issue/NN-slug> --sha=<sha> --event=<event> --status=<status> [--apply=false]');
|
||||
process.exit(2);
|
||||
}
|
||||
const outcome = await resume({
|
||||
repo, branch, sha,
|
||||
validateRun: { event: arg('event'), status: arg('status') || 'completed', headSha: sha },
|
||||
apply: arg('apply') !== 'false',
|
||||
});
|
||||
const lines = [`action=${outcome.action}`, `issue=${outcome.issue ?? ''}`, `reason=${outcome.reason}`, `applied=${outcome.applied ? 'true' : 'false'}`];
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
}
|
||||
@@ -7,10 +7,15 @@
|
||||
* доказательство для ревью — dispatch-прогон на точном SHA материала. Push-
|
||||
* прогон на том же SHA зелёный не считается: в нём мутантов нет.
|
||||
*
|
||||
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml]
|
||||
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml] [--no-wait]
|
||||
*
|
||||
* Печатает `result=green|failed|missing` и `url=…` (и в $GITHUB_OUTPUT, если он
|
||||
* задан); код выхода 0 только при green. Логика — чистая функция `validateGate`
|
||||
* Печатает `result=green|failed|missing|pending`, `url=…`, `run_id=…` (и в
|
||||
* $GITHUB_OUTPUT, если он задан); код выхода 0 только при green, 2 — pending.
|
||||
* `--no-wait` (#636): гейт диспатчит прогон и убеждается, что тот появился на
|
||||
* материале, но не ждёт его завершения — раннер конвейера освобождается, а
|
||||
* раунд продолжает `process-resume.yml` по событию `workflow_run` (страховка —
|
||||
* reconcile). Зелёный или красный завершённый прогон и с `--no-wait`
|
||||
* возвращается сразу. Логика — чистая функция `validateGate`
|
||||
* поверх инъектируемых `ops`, чтобы тесты и мутанты гоняли её без gh.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
@@ -58,9 +63,14 @@ export function provesMutants(jobs) {
|
||||
* @param {string} p.ref ветка, на которой запускать
|
||||
* @param {string} p.sha SHA материала
|
||||
* @param {object} p.ops GitHub run/proof operations plus dispatch, sleep and clock.
|
||||
* @returns {Promise<{result:'green'|'failed'|'missing', url:string|null, note:string}>}
|
||||
* @param {boolean} [p.wait] `false` — не ждать идущий прогон, а вернуть `pending` (#636):
|
||||
* раннер конвейера не спит 28 минут; продолжение разбудит событие
|
||||
* `workflow_run` (process-resume.yml) либо reconcile.
|
||||
* @returns {Promise<{result:'green'|'failed'|'missing'|'pending', url:string|null, note:string, runId?:number}>}
|
||||
*/
|
||||
export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
|
||||
export async function validateGate({
|
||||
ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS, wait = true,
|
||||
}) {
|
||||
const started = ops.now();
|
||||
const candidateTree = await ops.candidateTree(sha);
|
||||
const ignored = new Set(); // завершённые dispatch без применимого proof
|
||||
@@ -84,6 +94,13 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M
|
||||
tracked = null;
|
||||
continue;
|
||||
}
|
||||
if (!wait) {
|
||||
// #636: прогон найден и идёт — ждать его будет событие, не раннер.
|
||||
return {
|
||||
result: 'pending', url: run.url || null, runId: run.databaseId,
|
||||
note: `Validate с мутантами идёт (${run.status}); продолжение — по завершении прогона`,
|
||||
};
|
||||
}
|
||||
} else if (dispatchedAt === null) {
|
||||
await ops.dispatch(ref);
|
||||
dispatchedAt = ops.now();
|
||||
@@ -151,9 +168,11 @@ if (invokedDirectly) {
|
||||
console.error('usage: validate-gate.mjs --repo=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml]');
|
||||
process.exit(2);
|
||||
}
|
||||
const outcome = await validateGate({ ref, sha, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `note=${outcome.note}`];
|
||||
const wait = !process.argv.includes('--no-wait');
|
||||
const outcome = await validateGate({ ref, sha, wait, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `run_id=${outcome.runId || ''}`, `note=${outcome.note}`];
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
process.exit(outcome.result === 'green' ? 0 : 1);
|
||||
// 0 — зелёный, 2 — идёт (только с --no-wait), 1 — красный/пропавший.
|
||||
process.exit(outcome.result === 'green' ? 0 : outcome.result === 'pending' ? 2 : 1);
|
||||
}
|
||||
|
||||
@@ -381,7 +381,7 @@ test('#472 AC7: отсутствие Telegram-секретов не роняет
|
||||
});
|
||||
|
||||
test('#472 AC8: Validate сверяет mutation-gate.yml между main и dev наравне с process.yml', () => {
|
||||
assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml; do/);
|
||||
assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml process-resume\.yml; do/);
|
||||
});
|
||||
|
||||
// #475. Свидетель гниёт двумя способами: изменился файл, который он патчит,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
validateStateOnMaterial,
|
||||
alreadyReported, applyReconciliationDecision, decideReconciliation, latestReviewRequest, markerFor,
|
||||
parseProcessRun, preparedEvidenceError, reconcileAll, reconciliationKey, relabel,
|
||||
} from '../scripts/process-reconcile.mjs';
|
||||
@@ -35,7 +36,7 @@ test('#555 maps a stable process run-name to issue and stage', () => {
|
||||
id: 70, attempt: 2, issue: 555, label: 'S7-code-review', stage: 'code',
|
||||
status: 'in_progress', conclusion: '', createdAt: '2026-09-13T10:00:01Z',
|
||||
updatedAt: null, url: null, prepared: null, preparedArtifact: false,
|
||||
resultArtifact: false, evidenceError: null,
|
||||
resultArtifact: false, pending: null, pendingValidate: null, evidenceError: null,
|
||||
});
|
||||
assert.equal(parseProcessRun({ display_title: 'unrelated label event' }), null);
|
||||
});
|
||||
@@ -215,3 +216,41 @@ test('#555 a fresh label/run completion stays inside grace instead of duplicatin
|
||||
runs: [run({ conclusion: 'cancelled', updatedAt: '2026-09-13T11:58:00Z' })], now: NOW,
|
||||
}).action, 'wait');
|
||||
});
|
||||
|
||||
// #636: подготовка вышла успешно, оставив маркер ожидания — Validate на
|
||||
// материале ещё шёл. Это не «успешный прогон без вердикта»: пока Validate идёт,
|
||||
// reconcile ждёт; завершился или пропал, а событие раунд не разбудило —
|
||||
// повторная метка. Маркер обязателен: без него правило прежнее (escalate),
|
||||
// иначе любой успешный прогон без вердикта будил бы модель второй раз.
|
||||
test('#636 pending marker: running Validate waits, finished Validate retries, no marker still escalates', () => {
|
||||
const pending = { schema: 1, issue: 636, material_sha: 'a'.repeat(40) };
|
||||
const waiting = decideReconciliation({
|
||||
issue: issue(), request, runs: [run({ conclusion: 'success', pending, pendingValidate: 'active' })], now: NOW,
|
||||
});
|
||||
assert.equal(waiting.action, 'wait');
|
||||
assert.match(waiting.reason, /still running/);
|
||||
|
||||
for (const state of ['completed', 'missing']) {
|
||||
const done = decideReconciliation({
|
||||
issue: issue(), request, runs: [run({ conclusion: 'success', pending, pendingValidate: state })], now: NOW,
|
||||
});
|
||||
assert.equal(done.action, 'retry', state);
|
||||
assert.match(done.reason, /was not resumed/);
|
||||
}
|
||||
|
||||
const noMarker = decideReconciliation({ issue: issue(), request, runs: [run({ conclusion: 'success' })], now: NOW });
|
||||
assert.equal(noMarker.action, 'escalate');
|
||||
const parsed = parseProcessRun({ display_title: 'process #555 · S7-code-review · x', id: 1, pending, pendingValidate: 'active' });
|
||||
assert.equal(parsed.pending, pending);
|
||||
assert.equal(parsed.pendingValidate, 'active');
|
||||
});
|
||||
|
||||
test('#636 validateStateOnMaterial reads only dispatch runs and prefers active over completed', () => {
|
||||
assert.equal(validateStateOnMaterial([]), 'missing');
|
||||
assert.equal(validateStateOnMaterial([{ event: 'push', status: 'completed' }]), 'missing');
|
||||
assert.equal(validateStateOnMaterial([{ event: 'workflow_dispatch', status: 'completed' }]), 'completed');
|
||||
assert.equal(validateStateOnMaterial([
|
||||
{ event: 'workflow_dispatch', status: 'completed' }, { event: 'workflow_dispatch', status: 'in_progress' },
|
||||
]), 'active');
|
||||
assert.equal(validateStateOnMaterial([{ event: 'workflow_dispatch', status: 'queued' }]), 'active');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// #636: раунд ревью продолжается по событию завершения Validate, а не сном
|
||||
// раннера. Решение чистое: будить можно только раунд, который сам оставил
|
||||
// маркер ожидания на этот материал, при стоящей метке S7 и без активного
|
||||
// прогона конвейера. Всё остальное — noop: лишняя метка = второй вызов модели.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { decideResume, issueNumberFromBranch, resume, REVIEW_LABEL } from '../scripts/process-resume.mjs';
|
||||
|
||||
const SHA = 'a'.repeat(40);
|
||||
const OTHER = 'b'.repeat(40);
|
||||
const validateRun = (over = {}) => ({ event: 'workflow_dispatch', status: 'completed', headSha: SHA, ...over });
|
||||
const processRun = (over = {}) => ({
|
||||
id: 70, attempt: 1, issue: 636, label: REVIEW_LABEL, stage: 'code', status: 'completed', conclusion: 'success',
|
||||
createdAt: '2026-09-23T10:00:00Z', ...over,
|
||||
});
|
||||
const pending = { schema: 1, issue: 636, run_id: 70, material_sha: SHA };
|
||||
const pendingOf = (run) => (run.id === 70 ? pending : null);
|
||||
|
||||
test('#636 branch → issue number', () => {
|
||||
assert.equal(issueNumberFromBranch('issue/636-review-wait-event'), 636);
|
||||
assert.equal(issueNumberFromBranch('issue/7-x'), 7);
|
||||
assert.equal(issueNumberFromBranch('dev'), null);
|
||||
assert.equal(issueNumberFromBranch('issue/abc-x'), null);
|
||||
assert.equal(issueNumberFromBranch(''), null);
|
||||
});
|
||||
|
||||
test('#636 the round that waited for exactly this Validate is resumed', () => {
|
||||
const decision = decideResume({ labels: ['bug', REVIEW_LABEL], validateRun: validateRun(), sha: SHA, runs: [processRun()], pendingOf });
|
||||
assert.equal(decision.action, 'resume');
|
||||
assert.equal(decision.run.id, 70);
|
||||
});
|
||||
|
||||
test('#636 push runs, unfinished runs and foreign SHAs never wake a round', () => {
|
||||
const base = { labels: [REVIEW_LABEL], sha: SHA, runs: [processRun()], pendingOf };
|
||||
assert.equal(decideResume({ ...base, validateRun: validateRun({ event: 'push' }) }).action, 'noop');
|
||||
assert.equal(decideResume({ ...base, validateRun: validateRun({ status: 'in_progress' }) }).action, 'noop');
|
||||
assert.equal(decideResume({ ...base, validateRun: validateRun({ headSha: OTHER }) }).action, 'noop');
|
||||
});
|
||||
|
||||
test('#636 label state gates the wake-up: no S7, blocked or review-4 → noop', () => {
|
||||
const base = { validateRun: validateRun(), sha: SHA, runs: [processRun()], pendingOf };
|
||||
assert.equal(decideResume({ ...base, labels: ['S6-in-progress'] }).action, 'noop');
|
||||
assert.equal(decideResume({ ...base, labels: [REVIEW_LABEL, 'blocked'] }).action, 'noop');
|
||||
assert.equal(decideResume({ ...base, labels: [REVIEW_LABEL, 'review-4'] }).action, 'noop');
|
||||
});
|
||||
|
||||
test('#636 an active process run, a run without a pending marker or a marker for another SHA → noop', () => {
|
||||
const base = { labels: [REVIEW_LABEL], validateRun: validateRun(), sha: SHA };
|
||||
const active = decideResume({ ...base, runs: [processRun({ id: 71, status: 'in_progress', conclusion: '', createdAt: '2026-09-23T10:05:00Z' }), processRun()], pendingOf });
|
||||
assert.equal(active.action, 'noop');
|
||||
assert.match(active.reason, /already active/);
|
||||
const noMarker = decideResume({ ...base, runs: [processRun()], pendingOf: () => null });
|
||||
assert.equal(noMarker.action, 'noop');
|
||||
assert.match(noMarker.reason, /no pending marker/);
|
||||
const otherSha = decideResume({ ...base, runs: [processRun()], pendingOf: () => ({ ...pending, material_sha: OTHER }) });
|
||||
assert.equal(otherSha.action, 'noop');
|
||||
assert.match(otherSha.reason, /waits for bbbbbbbb/);
|
||||
const failed = decideResume({ ...base, runs: [processRun({ conclusion: 'failure' })], pendingOf });
|
||||
assert.equal(failed.action, 'noop');
|
||||
const none = decideResume({ ...base, runs: [], pendingOf });
|
||||
assert.match(none.reason, /reconcile owns lost requests/);
|
||||
});
|
||||
|
||||
test('#636 the newest process run decides, not an older pending one', () => {
|
||||
const older = processRun({ id: 60, createdAt: '2026-09-23T09:00:00Z' });
|
||||
const newerReturned = processRun({ id: 71, conclusion: 'failure', createdAt: '2026-09-23T11:00:00Z' });
|
||||
const decision = decideResume({
|
||||
labels: [REVIEW_LABEL], validateRun: validateRun(), sha: SHA, runs: [older, newerReturned],
|
||||
pendingOf: (run) => (run.id === 60 ? pending : null),
|
||||
});
|
||||
assert.equal(decision.action, 'noop');
|
||||
});
|
||||
|
||||
test('#636 resume() relabels only on a resume decision and only with apply', async () => {
|
||||
const relabels = [];
|
||||
const ops = {
|
||||
labels: () => [REVIEW_LABEL],
|
||||
runs: () => [processRun()],
|
||||
pendingOf,
|
||||
relabel: () => relabels.push(REVIEW_LABEL),
|
||||
};
|
||||
const applied = await resume({ repo: 'o/r', branch: 'issue/636-x', sha: SHA, validateRun: validateRun(), ops });
|
||||
assert.equal(applied.action, 'resume');
|
||||
assert.equal(applied.applied, true);
|
||||
assert.deepEqual(relabels, [REVIEW_LABEL]);
|
||||
const dry = await resume({ repo: 'o/r', branch: 'issue/636-x', sha: SHA, validateRun: validateRun(), ops, apply: false });
|
||||
assert.equal(dry.applied, false);
|
||||
assert.deepEqual(relabels, [REVIEW_LABEL], 'dry run does not relabel');
|
||||
const foreign = await resume({ repo: 'o/r', branch: 'dev', sha: SHA, validateRun: validateRun(), ops });
|
||||
assert.equal(foreign.action, 'noop');
|
||||
assert.equal(foreign.issue, null);
|
||||
});
|
||||
|
||||
test('#636 workflows: prepare exits pending with a sealed marker, resume relabels by the marker, preflight mirrors the new file', () => {
|
||||
const process = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const resumeWf = readFileSync(new URL('../.github/workflows/process-resume.yml', import.meta.url), 'utf8');
|
||||
const validate = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8');
|
||||
assert.match(process, /validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA" --no-wait/);
|
||||
assert.match(process, /2\) echo 'proceed=pending' >> "\$GITHUB_OUTPUT"/);
|
||||
assert.match(process, /review-pending-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/);
|
||||
assert.match(process, /sha256sum pending\.json > manifest\.sha256/);
|
||||
assert.match(process, /Validate красный — вернуть автору без ревью\n\s+if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed == 'false'/);
|
||||
assert.match(resumeWf, /workflow_run:\n\s+workflows: \["Проверка \(CI\)"\]\n\s+types: \[completed\]/);
|
||||
assert.match(resumeWf, /github\.event\.workflow_run\.event == 'workflow_dispatch' && startsWith\(github\.event\.workflow_run\.head_branch, 'issue\/'\)/);
|
||||
assert.match(resumeWf, /GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}/);
|
||||
assert.match(resumeWf, /node scripts\/process-resume\.mjs/);
|
||||
assert.ok(!/issues: write/.test(resumeWf), 'resume relabels with HP_PROCESS_TOKEN only');
|
||||
assert.match(validate, /for file in process\.yml mutation-gate\.yml process-resume\.yml; do/);
|
||||
assert.equal(readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8').includes('name: Проверка (CI)'), true, 'workflow_run listens to the Validate workflow name');
|
||||
});
|
||||
@@ -628,7 +628,8 @@ test('#510 AC2: конвейер запускает Validate с мутантам
|
||||
assert.match(gateStep, /\{ echo 'proceed=true'; echo 'result=skipped'; \}/, 'skipped = proceed');
|
||||
assert.doesNotMatch(workflow.slice(modelJob), /steps\.gate\.outputs/, 'следующие jobs не читают локальные outputs prepare');
|
||||
const backStep = workflow.slice(back, deps);
|
||||
assert.match(backStep, /if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed != 'true'/);
|
||||
// #636: третий исход гейта — pending (Validate идёт); возврат автору только на явном false
|
||||
assert.match(backStep, /if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed == 'false'/);
|
||||
assert.match(backStep, /--add-label S6-in-progress --remove-label S7-code-review/);
|
||||
assert.match(backStep, /цикл ревью не израсходован/);
|
||||
assert.match(workflow.slice(modelJob, deps), /if: needs\.prepare\.outputs\.proceed == 'true'/,
|
||||
|
||||
@@ -170,3 +170,44 @@ test('#510 r1 M1: a cancelled dispatch with no replacement gets one dispatch, no
|
||||
assert.equal(outcome.result, 'green');
|
||||
assert.deepEqual(fake.dispatched, ['issue/1']);
|
||||
});
|
||||
|
||||
// #636: раннер конвейера не ждёт Validate внутри job. С `wait: false` гейт
|
||||
// возвращает завершённый прогон как раньше, а идущий — `pending`, не поллит его;
|
||||
// прогон, который ещё не появился, гейт всё же диспатчит и дожидается его
|
||||
// появления на материале (#539), потому что иначе событию завершения нечего
|
||||
// будить.
|
||||
test('#636: без ожидания завершённый зелёный dispatch принимается сразу, как и красный', async () => {
|
||||
const green = fakeOps({ snapshots: [[run()]] });
|
||||
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: green.ops, wait: false })).result, 'green');
|
||||
const red = fakeOps({ snapshots: [[run({ conclusion: 'failure', url: 'https://run/red' })]] });
|
||||
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: red.ops, wait: false })).result, 'failed');
|
||||
assert.deepEqual(green.dispatched, []);
|
||||
});
|
||||
|
||||
test('#636: идущий dispatch на материале — pending с его id и url, без единого sleep', async () => {
|
||||
const fake = fakeOps({ snapshots: [[run({ status: 'in_progress', conclusion: null, url: 'https://run/live', databaseId: 42 })]] });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, wait: false, pollMs: 1000 });
|
||||
assert.equal(outcome.result, 'pending');
|
||||
assert.equal(outcome.runId, 42);
|
||||
assert.equal(outcome.url, 'https://run/live');
|
||||
assert.equal(fake.ops.now(), 0, 'гейт не спал');
|
||||
assert.deepEqual(fake.dispatched, []);
|
||||
});
|
||||
|
||||
test('#636: без прогона гейт диспатчит, ждёт появления и возвращает pending, не завершение', async () => {
|
||||
const pushOnly = [run({ event: 'push', databaseId: 7 })];
|
||||
const live = [...pushOnly, run({ status: 'queued', conclusion: null, databaseId: 9 })];
|
||||
const fake = fakeOps({ snapshots: [pushOnly, pushOnly, live, [...pushOnly, run({ databaseId: 9 })]] });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, wait: false, pollMs: 1000 });
|
||||
assert.equal(outcome.result, 'pending');
|
||||
assert.equal(outcome.runId, 9);
|
||||
assert.deepEqual(fake.dispatched, ['issue/1']);
|
||||
assert.equal(fake.calls(), 3, 'остановился на первом снимке с прогоном, до его завершения не дошёл');
|
||||
});
|
||||
|
||||
test('#636: с ожиданием (умолчание) поведение прежнее — идущий прогон дожидается', async () => {
|
||||
const fake = fakeOps({ snapshots: [[run({ status: 'in_progress', conclusion: null })], [run()]] });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
|
||||
assert.equal(outcome.result, 'green');
|
||||
assert.ok(fake.ops.now() > 0, 'один poll прошёл');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user