diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index 5076cdd5..40742255 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -1768,10 +1768,13 @@ jobs: # когда кандидат не слит (конфликт, красный Validate, изменившийся # patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6. TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }} + # #706: исход `rereview` возвращает задачу в ту же метку. Одним вызовом + # `gh` она добавлялась и тут же снималась — задача оставалась без + # статуса. Скрипт снимает и ставит её заново, чтобы событие `labeled` + # запустило новый заход. run: | - gh issue edit "$NUM" --repo "${{ github.repository }}" \ - --add-label "$TO" --remove-label "$FROM" - echo "$FROM -> $TO" + node scripts/status-label.mjs --repo="${{ github.repository }}" \ + --issue="$NUM" --from="$FROM" --to="$TO" - name: Сводка длительности стадий if: always() diff --git a/PROCESS.md b/PROCESS.md index 249da7e2..373172d6 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1289,7 +1289,8 @@ npm ci, Python и Chromium, оставаясь исполненной job: до - если `dev` не двигался — push с `--force-with-lease` на текущую вершину; - если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт - к другому диффу не применим, §2.10), публикация кандидата в ветку задачи, запуск + к другому диффу не применим, §2.10; метка снимается и ставится заново, чтобы + событие запустило новый заход, #706), публикация кандидата в ветку задачи, запуск Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту вершину, поверх которой кандидат собран. На `show`/`ship` мутантов нет, и diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index fb552668..9e74bc76 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -4069,6 +4069,29 @@ const MUTANT_DEFINITIONS = [ replace: ' return safe;', }], }, + // #706: исход rereview возвращает задачу в ту же метку; один вызов gh её снимал. + { + id: 'rereview-relabel-in-one-call', + guard: 'node --test --test-name-pattern="#706 rereview" test/status-label.test.mjs', + because: '#706: --add-label X --remove-label X in one gh call leaves the issue without a status; ' + + 'the same label must be removed and set again so the labeled event starts a new round', + patches: [{ + file: 'scripts/status-label.mjs', + find: ' if (from === to) {', + replace: ' if (false) { // mutant: one combined call', + }], + }, + { + id: 'process-label-step-combined-again', + guard: 'node --test --test-name-pattern="#706 шаг конвейера" test/status-label.test.mjs', + because: '#706: the pipeline step must move the label through status-label.mjs, not through the ' + + 'combined gh call that stripped S7-code-review on rereview', + patches: [{ + file: '.github/workflows/_process.yml', + find: ' node scripts/status-label.mjs --repo="${{ github.repository }}" \\\n --issue="$NUM" --from="$FROM" --to="$TO"\n', + replace: ' gh issue edit "$NUM" --repo "${{ github.repository }}" \\\n --add-label "$TO" --remove-label "$FROM"\n', + }], + }, { id: 'vac-trail-drop-warn-removed', guard: 'node --test --test-name-pattern="#369" test/vacuum.test.mjs', diff --git a/scripts/status-label.mjs b/scripts/status-label.mjs new file mode 100644 index 00000000..05958c77 --- /dev/null +++ b/scripts/status-label.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Перестановка статусной метки после интеграции ревью (#706). + * + * node scripts/status-label.mjs --repo=owner/name --issue=NN --from=<метка> --to=<метка> + * + * Обычный исход — одна правка: `--add-label TO --remove-label FROM`. Исход + * `rereview` (#492) возвращает задачу в ту же метку, из которой она пришла: + * `TO == FROM == S7-code-review`. Одним вызовом `gh` такую метку добавляет и + * тут же снимает — задача оставалась без статуса, и нового захода не было + * (#699, 2026-09-28). Поэтому одинаковая метка снимается и ставится заново + * двумя вызовами, как в автосверке (`relabel`, #555): событие `labeled` + * запускает новый заход ревью, а сбой повторной постановки роняет шаг, и + * конвейер зовёт владельца. + */ +import { spawnSync } from 'node:child_process'; +import { isMainModule } from './spawn-portable.mjs'; +import { relabel } from './process-reconcile.mjs'; + +function gh(args, { allowFailure = false } = {}) { + const result = spawnSync('gh', args, { encoding: 'utf8' }); + if (!allowFailure && (result.error || result.status !== 0)) { + throw new Error(`gh ${args.join(' ')} → ${(result.stderr || result.error?.message || '').trim()}`); + } + return result; +} + +/** @returns {'moved' | 'relabeled'} */ +export function moveStatusLabel({ repo, issue, from = '', to, execute = gh }) { + if (!repo || !issue || !to) throw new Error('usage: status-label.mjs --repo=owner/name --issue=NN --from=<метка> --to=<метка>'); + if (from === to) { + relabel(repo, { number: issue }, to, execute); + return 'relabeled'; + } + const args = ['issue', 'edit', String(issue), '--repo', repo, '--add-label', to]; + if (from) args.push('--remove-label', from); + execute(args); + return 'moved'; +} + +if (isMainModule(import.meta.url)) { + const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? ''; + try { + const how = moveStatusLabel({ repo: arg('repo'), issue: arg('issue'), from: arg('from'), to: arg('to') }); + console.log(`${arg('from') || '(без метки)'} -> ${arg('to')}${how === 'relabeled' ? ' (снята и поставлена заново — новый заход, #706)' : ''}`); + } catch (error) { + console.error(`::error::${error.message}`); + process.exit(1); + } +} diff --git a/test/status-label.test.mjs b/test/status-label.test.mjs new file mode 100644 index 00000000..6ba26f40 --- /dev/null +++ b/test/status-label.test.mjs @@ -0,0 +1,56 @@ +// #706: исход rereview возвращает задачу в ту же метку `S7-code-review`, и +// одним вызовом `gh` она добавлялась и тут же снималась — задача оставалась без +// статуса, новый заход не стартовал. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { moveStatusLabel } from '../scripts/status-label.mjs'; + +const REPO = 'Matysh/houseplan-card'; +const recorder = (fail = () => false) => { + const calls = []; + const execute = (args, opts = {}) => { + calls.push(args.slice(5).join(' ')); + const status = fail(args, calls.length) ? 1 : 0; + if (status && !opts.allowFailure) throw new Error(`gh ${args.join(' ')} → boom`); + return { status, stderr: status ? 'boom' : '' }; + }; + return { calls, execute }; +}; + +test('#706 rereview: та же метка снимается и ставится заново отдельными вызовами', () => { + const { calls, execute } = recorder(); + const how = moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: 'S7-code-review', execute }); + assert.equal(how, 'relabeled'); + assert.deepEqual(calls, ['--remove-label S7-code-review', '--add-label S7-code-review'], + 'снятие, затем постановка — событие labeled запускает новый заход'); +}); + +test('#706 обычный исход — одна правка: новая метка ставится, прежняя снимается', () => { + const { calls, execute } = recorder(); + assert.equal(moveStatusLabel({ repo: REPO, issue: 700, from: 'S7-code-review', to: 'S8-merged', execute }), 'moved'); + assert.deepEqual(calls, ['--add-label S8-merged --remove-label S7-code-review']); + const none = recorder(); + moveStatusLabel({ repo: REPO, issue: 700, from: '', to: 'S6-in-progress', execute: none.execute }); + assert.deepEqual(none.calls, ['--add-label S6-in-progress'], 'без исходной метки снимать нечего'); +}); + +test('#706 сбой повторной постановки роняет шаг, а не оставляет задачу без статуса молча', () => { + const once = recorder((args, n) => n === 2); + moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: 'S7-code-review', execute: once.execute }); + assert.deepEqual(once.calls, ['--remove-label S7-code-review', '--add-label S7-code-review', '--add-label S7-code-review'], + 'одна попытка восстановления, как в автосверке (#555)'); + const always = recorder((args) => args.includes('--add-label')); + assert.throws(() => moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: 'S7-code-review', execute: always.execute }), + /could not restore S7-code-review/); + assert.throws(() => moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: '', execute: always.execute }), /usage/); +}); + +test('#706 шаг конвейера переставляет метку через скрипт, а не одним вызовом gh', () => { + const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/_process.yml', import.meta.url)), 'utf8'); + const step = workflow.slice(workflow.indexOf('- name: Переставить метку'), workflow.indexOf('- name: Сводка длительности стадий')); + assert.ok(step.length > 0, 'шаг найден'); + assert.match(step, /node scripts\/status-label\.mjs --repo="\$\{\{ github\.repository \}\}" \\\n\s+--issue="\$NUM" --from="\$FROM" --to="\$TO"/); + assert.doesNotMatch(step, /gh issue edit/, 'совмещённый вызов снимал ту же метку, которую ставил'); +});