diff --git a/.github/workflows/_nightly.yml b/.github/workflows/_nightly.yml index a1701f4b..5315ff29 100644 --- a/.github/workflows/_nightly.yml +++ b/.github/workflows/_nightly.yml @@ -18,6 +18,11 @@ # успехом в момент постановки Validate в очередь, и красный полный прогон не # делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт # его: успешный dispatch — не успешная проверка. +# +# #727: после Validate при любом его исходе ночь запускает пакетное ревью ship +# (`ship-review.yml -f tag=nightly`) на SHA прогона Validate: оно читает код, а +# не прогон. Ship-задача узнаёт о дефектах в ночь после слияния, а бета читает +# только то, что ночь не прочла (PROCESS.md §11.7). Цвет ночи — цвет Validate. name: "Ночной полный прогон dev · тело (#623)" on: @@ -34,6 +39,9 @@ jobs: name: "Запустить Validate на dev с полным набором и дождаться результата" runs-on: ubuntu-24.04 timeout-minutes: 90 + outputs: + # #727: SHA прогона Validate — отдельным шагом до ожидания, красный Validate его не теряет. + head_sha: ${{ steps.validate.outputs.head_sha }} steps: # #658: при плане 02:30 UTC ночь фактически стартовала в 07:42–08:05 и # кончалась в рабочее время владельца. Сдвиг старта больше часа — видимое @@ -55,7 +63,9 @@ jobs: if [ "$lag" -gt 60 ]; then echo "::warning::ночной прогон стартовал на $lag мин позже расписания ($SCHEDULE UTC) — очередь расписаний GitHub (#658)" fi - - env: + - name: "Запустить Validate и найти его прогон" + id: validate + env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} run: | @@ -80,5 +90,62 @@ jobs: url="${{ github.server_url }}/$REPO/actions/runs/$run_id" echo "дочерний прогон: $url" echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY" + head_sha=$(gh run view "$run_id" --repo "$REPO" --json headSha --jq .headSha) + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" + - name: "Дождаться Validate" + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_ID: ${{ steps.validate.outputs.run_id }} + run: | + set -euo pipefail + run_id="$RUN_ID" # Ждём завершения; красный дочерний прогон — красный ночной. gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30 + + # #727 К6: ночное пакетное ревью ship на SHA прогона Validate — при любом его + # исходе. Dispatch тонкого `ship-review.yml` (`github.token` порождает прогон, + # как у Validate; входы и права тонких файлов уже в main). Job ждёт только + # появления прогона, не его конца; не появился — предупреждение, не красная + # ночь: цвет ночи — цвет Validate. + ship_review: + name: "Запустить ночное пакетное ревью ship на SHA прогона Validate" + needs: dispatch + if: always() + continue-on-error: true + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: "Запустить ship-ревью и дождаться появления прогона" + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + CANDIDATE: ${{ needs.dispatch.outputs.head_sha }} + run: | + set -euo pipefail + if [ -z "$CANDIDATE" ]; then + echo "::warning::нет SHA прогона Validate — ночное ship-ревью не запущено" + echo "- Ship-ревью ночью: не запущено — нет SHA прогона Validate" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + since=$(date -u +%FT%TZ) + if ! gh workflow run ship-review.yml --ref dev -f tag=nightly -f candidate="$CANDIDATE" --repo "$REPO"; then + echo "::warning::ship-ревью ночью не запущено: dispatch отклонён — ночь не красится" + echo "- Ship-ревью ночью: dispatch отклонён" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + run_id="" + for _ in $(seq 1 18); do + sleep 10 + run_id=$(gh run list --repo "$REPO" --workflow ship-review.yml --branch dev \ + --event workflow_dispatch --json databaseId,createdAt --limit 5 \ + --jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty") + [ -n "$run_id" ] && break + done + if [ -z "$run_id" ]; then + echo "::warning::прогон ship-ревью не появился за 3 минуты — ночь не красится, её цвет — цвет Validate" + echo "- Ship-ревью ночью (\`$CANDIDATE\`): прогон не появился за 3 минуты" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "- Ship-ревью ночью (\`$CANDIDATE\`): ${{ github.server_url }}/$REPO/actions/runs/$run_id" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/_ship-review.yml b/.github/workflows/_ship-review.yml index 7b2f0bfc..159ea224 100644 --- a/.github/workflows/_ship-review.yml +++ b/.github/workflows/_ship-review.yml @@ -8,6 +8,11 @@ name: "Бета: пакетное ревью ship · тело (#716)" # # Три job, как у release-review.yml (#551, #556): детерминированный вход, # модель без права записи, детерминированная публикация документа в `dev`. +# +# #727: `tag=nightly` — ночной режим. Его запускает `_nightly.yml` после +# полного Validate с `candidate` = голова `dev` того прогона. Читаются только +# ship-задачи без покрытия или изменившиеся после ревью (`shipCoverage`), документ — +# `SHIP-REVIEW-<база>-dev-.md`. Бета так же читает только дельту. on: # #716: тело вызывается тонким файлом `ship-review.yml` по ссылке `@dev`; @@ -15,6 +20,7 @@ on: # файла в ветке по умолчанию GitHub не даёт запустить workflow_dispatch вовсе. workflow_call: inputs: + # #727: `nightly` — зарезервированное значение (ночной режим, candidate обязателен). tag: description: "Beta tag the review is for, for example v1.79.0-beta.1" required: true @@ -49,6 +55,9 @@ jobs: base: ${{ steps.range.outputs.base }} doc: ${{ steps.range.outputs.doc }} issues: ${{ steps.range.outputs.issues }} + mode: ${{ steps.range.outputs.mode }} + patches: ${{ steps.range.outputs.patches }} + subject: ${{ steps.range.outputs.subject }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -69,24 +78,31 @@ jobs: FORCE: ${{ inputs.force }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - doc=$(node scripts/ship-review.mjs doc --tag="$TAG") + # Отказ prepare за `| tee` не должен читаться как «ship-задач нет» (#727). + set -o pipefail git fetch -q --tags origin + # #727: tag=nightly без candidate — отказ до подстановки вершины dev. + node scripts/ship-review.mjs mode --tag="$TAG" --candidate="$CANDIDATE" if [ -z "$CANDIDATE" ]; then CANDIDATE=$(git rev-parse origin/dev); fi git cat-file -e "$CANDIDATE^{commit}" CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}") + doc=$(node scripts/ship-review.mjs doc --tag="$TAG" --candidate="$CANDIDATE") if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then echo "::notice::$doc уже есть в dev — повторное ревью не запускается (force=true, чтобы переснять)" echo "proceed=false" >> "$GITHUB_OUTPUT" exit 0 fi out="$RUNNER_TEMP/ship-review-input" - node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" \ + node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" --force="$FORCE" \ --out="$out" --repo="${{ github.repository }}" --run-url="$RUN_URL" | tee "$RUNNER_TEMP/range.txt" cat "$RUNNER_TEMP/range.txt" >> "$GITHUB_OUTPUT" echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT" issues=$(sed -n 's/^issues=//p' "$RUNNER_TEMP/range.txt") if [ -z "$issues" ]; then - echo "::notice::ship-задач в диапазоне нет — ревью не нужно" + # #727 К5: пустая дельта — все ship-задачи покрыты документами базы. + note=$(sed -n 's/^note=//p' "$RUNNER_TEMP/range.txt") + echo "::notice::${note:-ship-задач в диапазоне нет — ревью не нужно}" + if [ -n "$note" ]; then echo "- $note" >> "$GITHUB_STEP_SUMMARY"; fi echo "proceed=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -183,8 +199,9 @@ jobs: prompt: | Ты ревьюер кода проекта House Plan. Язык ответа — русский. - Бета: ${{ inputs.tag }} · кандидат ${{ needs.prepare.outputs.candidate }} - (рабочая копия уже на нём) · база диапазона: ${{ needs.prepare.outputs.base || 'нет' }}. + ${{ needs.prepare.outputs.subject }} + Кандидат ${{ needs.prepare.outputs.candidate }} (рабочая копия уже на нём) · база + диапазона: ${{ needs.prepare.outputs.base || 'нет' }}. Задачи: ${{ needs.prepare.outputs.issues }} — вход в $REVIEW_INPUT/brief.md (строка ТЗ каждой задачи и её коммиты) и $REVIEW_INPUT/ship-issues.json. @@ -271,7 +288,8 @@ jobs: path: ${{ runner.temp }}/ship-review-result # Модель пишет только текст. Путь, машинный блок (его читает гейт беты), - # индекс и коммит решает этот шаг. + # индекс и коммит решает этот шаг. #727: `mode` и `patches` блока — из + # prepare (детерминированный патч-набор), не из результата модели. - name: Опубликовать документ env: TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} @@ -280,11 +298,13 @@ jobs: CANDIDATE: ${{ needs.prepare.outputs.candidate }} BASE: ${{ needs.prepare.outputs.base }} ISSUES: ${{ needs.prepare.outputs.issues }} + MODE: ${{ needs.prepare.outputs.mode }} + PATCHES: ${{ needs.prepare.outputs.patches }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | dir="$RUNNER_TEMP/ship-review-result" (cd "$dir" && sha256sum -c manifest.sha256) - test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG")" + test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG" --candidate="$CANDIDATE")" high=$(jq -r '.high' "$dir/result.json") medium=$(jq -r '.medium' "$dir/result.json") low=$(jq -r '.low' "$dir/result.json") @@ -301,7 +321,7 @@ jobs: tag: process.env.TAG, candidate: process.env.CANDIDATE, base: process.env.BASE || null, issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number), high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]), - runUrl: process.env.RUN_URL, + runUrl: process.env.RUN_URL, mode: process.env.MODE, patches: m.parsePatches(process.env.PATCHES), }))); ' "$high" "$medium" "$low" } > "$DOC" @@ -331,3 +351,19 @@ jobs: done echo "::error::документ ревью не опубликован в dev за три попытки" exit 1 + + # #727 К8: High ночного документа — одна строка в каждую его задачу. + # Повтор на тот же документ не пишется (метка hp:ship-review-high doc=…), + # Medium и Low в issue не идут — их решает владелец (§11.7). Режим и + # High проверяет и скрипт: шаг вне ночи или без High ничего не пишет. + - name: High ночью — строка в задачи документа + if: needs.prepare.outputs.mode == 'nightly' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + MODE: ${{ needs.prepare.outputs.mode }} + DOC: ${{ needs.prepare.outputs.doc }} + ISSUES: ${{ needs.prepare.outputs.issues }} + run: | + high=$(jq -r '.high' "$RUNNER_TEMP/ship-review-result/result.json") + node scripts/ship-review.mjs comment-high --mode="$MODE" --high="$high" --doc="$DOC" \ + --issues="$ISSUES" --repo="${{ github.repository }}" diff --git a/PROCESS.md b/PROCESS.md index c1b1b999..57d28cd4 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1280,7 +1280,9 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit` возвращает задачу. `performance.yml` в список не входит: по расписанию он судит `main` собственным телом из `main`. Прочие workflow по кнопке приезжают в `main` целиком с промоушеном; новый такой файл, который -нужен до него, заводится тонким и входит в список. +нужен до него, заводится тонким и входит в список. Ночь (`_nightly.yml`) после +Validate запускает `ship-review.yml` dispatch'ем с `-f tag=nightly` (§11.7, +#727): входы и права для этого в `main` уже есть, тонкие файлы не меняются. **Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек снимает `scripts/process-track.mjs` в стадии подготовки — по текущим меткам и @@ -1686,15 +1688,45 @@ action ревью пускает ровно этого бота (`allowed_bots`, ревью. Комментарии до #707 строки не несут — риск не записан. Правила ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»; - **выход** — `docs/reviews/SHIP-REVIEW-<тег>.md` в `dev` с машинным блоком: - задачи и счёт High/Medium/Low. У модели нет прав записи, документ публикует + задачи, счёт High/Medium/Low, режим (`mode`) и патч-набор каждой задачи + (`patches`, #727). У модели нет прав записи, документ публикует детерминированный шаг. +**Ночь** (#727). После ночного полного Validate, при любом его исходе, +`_nightly.yml` запускает `ship-review.yml -f tag=nightly -f candidate=`: ревью читает код, а не прогон. `nightly` — зарезервированное +значение входа `tag`, кандидат в нём обязателен. Ночь читает только непокрытые +ship-задачи диапазона «прошлый тег..кандидат» и пишет +`docs/reviews/SHIP-REVIEW-<база>-dev-.md`. Читать нечего — модели и +документа нет; документ на этот SHA уже есть — прогон ничего не делает +(`force=true` — переснять). High ночью — одна строка в каждую задачу документа +(метка `hp:ship-review-high`, повтор на тот же документ не пишется); Medium и +Low в issue не пишутся. Цвет ночи — цвет Validate. + +**Патч-набор и покрытие** (#727). Патч-набор задачи — `git patch-id --stable` +её коммитов диапазона, кроме коммитов с трейлером `Release:` (кандидат беты +несёт `Issue:` всех задач линии) и коммитов только в `docs/reviews/**`; его +пишет детерминированный шаг, не модель. Покрытие задачи решает последний +опубликованный документ той же базы, где она есть: тот же патч-набор и High 0 — +`clean`, High больше нуля — `high` (High снимает покрытие со всех задач +документа), другой патч-набор — `stale`: код изменился после ревью. Задачи нет +ни в одном документе — `none`. Документ без строки `patches` (до #727) +покрывает по номеру; документы другой базы не учитываются. + +**Бета читает дельту** (#727). `ship-review.yml -f tag=<тег>` читает только +задачи `none` и `stale`, бриф называет прочитанные ночью. Задачи `high` без +`force=true` не перечитываются: их держит гейт. Дельта пуста — модель не +запускается, документа тега нет. Документ тега перечисляет только прочитанные +в нём задачи. `force=true` читает все ship-задачи диапазона и на ночные +документы не смотрит. + **Гейт беты.** `ship-review.mjs check` стоит в обоих путях публикации — `publish-prerelease.yml` и `npm run release:prerelease` (включая `--check`). -Если в диапазоне есть ship-задачи, документ обязан лежать в кандидате или в -`dev`, покрывать их все и не нести High. Задача, слитая после ревью, требует -пересъёмки (`-f force=true`). High чинится отдельной задачей, затем ревью -переснимается. Medium и Low решает владелец, как в §11.5. +Каждая ship-задача диапазона обязана быть `clean`: гейт беты принимает ночной +документ при том же патч-наборе, документ тега для этого не нужен. `none` и +`stale` — отказ с номерами и командой `ship-review.yml -f tag=<тег>`. High +чинится отдельной задачей, затем ревью переснимается (`-f force=true`). Medium +и Low решает владелец, как в §11.5. --- diff --git a/docs/process/REVIEWER.md b/docs/process/REVIEWER.md index 51f595cb..9a94df99 100644 --- a/docs/process/REVIEWER.md +++ b/docs/process/REVIEWER.md @@ -178,6 +178,10 @@ - Документ `docs/reviews/SHIP-REVIEW-<тег>.md` публикует детерминированный шаг; High не пускает бету, Medium и Low решает владелец ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)). +- Ночной документ `docs/reviews/SHIP-REVIEW-<база>-dev-.md` читает только + непокрытые задачи — без документа или изменившиеся после ревью; бета читает + только то, что ночь не прочла: гейт беты принимает ночной документ при том же + патч-наборе ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)). ## Независимое ревью линии diff --git a/scripts/reviews-archive.mjs b/scripts/reviews-archive.mjs index 85d20c31..6c464db1 100644 --- a/scripts/reviews-archive.mjs +++ b/scripts/reviews-archive.mjs @@ -88,8 +88,17 @@ export function archivePlan({ names, lines, open, through, addedIn = new Map() } if (name === INDEX_FILE) continue; const doc = parseDocName(name); if (!doc) { kept.push({ name, reason: 'вне схемы имён' }); continue; } + if (doc.nightly && STABLE_TAG_RE.test(doc.tag)) { + // #727: ночной документ со стабильной базой читал код следующей линии — + // ближайшей архивируемой строго новее базы, а не самой базы (она выпущена). + const next = ordered.find((line) => compareStable(line.tag, doc.tag) > 0); + if (next) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${next.tag}/${name}`, tag: next.tag, issue: null }); + else kept.push({ name, reason: `ночное ревью после ${doc.tag}: архивируемой линии новее базы нет` }); + continue; + } if (doc.stage === 'release' || doc.stage === 'ship') { - // #696: пакетное ревью беты уходит в каталог своей стабильной линии. + // #696: пакетное ревью беты уходит в каталог своей стабильной линии; + // #727: ночной документ с базой-бетой — туда же, куда документ этой беты. const line = doc.tag.replace(/-beta\.\d+$/, ''); if (tags.has(line)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${line}/${name}`, tag: line, issue: null }); else kept.push({ name, reason: `ревью линии ${line} не входит в архивируемые линии` }); diff --git a/scripts/reviews-index.mjs b/scripts/reviews-index.mjs index 2b0f13e7..3bb46fec 100644 --- a/scripts/reviews-index.mjs +++ b/scripts/reviews-index.mjs @@ -29,7 +29,8 @@ export const INDEX_FILE = 'INDEX.md'; const DOC_NAME = /^(CODE|SPEC)-REVIEW-(?:issue-)?(\d+)(?:-r(\d+))?(?:-([a-z0-9-]+))?\.md$/i; const RELEASE_DOC_NAME = /^RELEASE-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\.md$/i; // #696: пакетное ревью ship-задач перед бетой (PROCESS.md §11.7) — по тегу беты. -const SHIP_DOC_NAME = /^SHIP-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.(?:0|[1-9]\d*))?)\.md$/i; +// #727: ночной документ `SHIP-REVIEW-<база>-dev-.md` — тег здесь база диапазона. +const SHIP_DOC_NAME = /^SHIP-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.(?:0|[1-9]\d*))?)(-dev-[0-9a-f]{12})?\.md$/i; const COLOUR = { 'зелёный': 'зелёный', 'зеленый': 'зелёный', green: 'зелёный', 'жёлтый': 'жёлтый', 'желтый': 'жёлтый', yellow: 'жёлтый', @@ -46,7 +47,7 @@ export function parseDocName(name) { const release = RELEASE_DOC_NAME.exec(String(name)); if (release) return { stage: 'release', issue: null, round: null, suffix: null, tag: release[1] }; const ship = SHIP_DOC_NAME.exec(String(name)); - if (ship) return { stage: 'ship', issue: null, round: null, suffix: null, tag: ship[1] }; + if (ship) return { stage: 'ship', issue: null, round: null, suffix: null, tag: ship[1], ...(ship[2] ? { nightly: true } : {}) }; const match = DOC_NAME.exec(String(name)); if (!match) return null; return { @@ -315,7 +316,9 @@ export function renderIndex({ entries, skipped = [] }) { const av = tagKey(a.tag); const bv = tagKey(b.tag); for (let i = 0; i < 4; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i]; - return a.stage.localeCompare(b.stage); + // #727: ночной документ читал код после своей базы — он новее документов этого тега. + if (Boolean(a.nightly) !== Boolean(b.nightly)) return a.nightly ? -1 : 1; + return a.stage.localeCompare(b.stage) || a.name.localeCompare(b.name); }); const byIssue = new Map(); for (const entry of entries.filter((item) => item.issue != null)) { @@ -332,7 +335,8 @@ export function renderIndex({ entries, skipped = [] }) { lines.push('| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |'); lines.push('|---|---|---|---|---:|---:|---|---|'); for (const doc of releaseDocs) { - const [who, what] = doc.stage === 'ship' ? ['бета', 'пакетное ревью ship'] : ['линия', 'ревью линии']; + const [who, what] = doc.nightly ? ['ночь после', 'ночное пакетное ревью ship'] + : doc.stage === 'ship' ? ['бета', 'пакетное ревью ship'] : ['линия', 'ревью линии']; lines.push(`| ${who} ${doc.tag} | [${doc.name}](${doc.name}) | ${what} · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`); } for (const issue of issues) { diff --git a/scripts/ship-review.mjs b/scripts/ship-review.mjs index 3fa882ad..992f8067 100644 --- a/scripts/ship-review.mjs +++ b/scripts/ship-review.mjs @@ -3,8 +3,11 @@ * Пакетное ревью задач `track:ship` перед бетой (#696, PROCESS.md §11.7). * * node scripts/ship-review.mjs doc --tag=v1.79.0-beta.1 - * node scripts/ship-review.mjs prepare --tag=<тег> --candidate= --out= [--repo=owner/name] + * node scripts/ship-review.mjs doc --tag=nightly --candidate= + * node scripts/ship-review.mjs mode --tag=<тег|nightly> [--candidate=] + * node scripts/ship-review.mjs prepare --tag=<тег|nightly> --candidate= --out= [--force=true] [--repo=owner/name] * node scripts/ship-review.mjs check --tag=<тег> --candidate= [--repo=owner/name] + * node scripts/ship-review.mjs comment-high --mode= --high= --doc=<путь> --issues= [--repo=owner/name] * * `ship` сливается без ревью модели (§5): правка в механических рамках и * зелёный лёгкий Validate. Прочитать её код обязан кто-то до того, как она @@ -17,23 +20,34 @@ * лишний разбор дешевле пропущенного. Состав диапазона — трейлеры `Issue: #NN`, * тот же построитель, что у `RELEASE-MEMBERSHIP.json` (#547). * - * `check` — гейт публикации беты: если в диапазоне есть ship-задачи, документ - * `docs/reviews/SHIP-REVIEW-<тег>.md` обязан быть в кандидате или в `dev`, - * покрывать их все машинным блоком и не нести High. + * `check` — гейт публикации беты: каждая ship-задача диапазона покрыта + * документами ревью той же базы (`shipCoverage`, #727) — последний документ, + * где она есть, прочитал тот же патч-набор и не несёт High. + * + * Ночной режим (#727): `tag=nightly` — ночь после полного Validate читает + * непокрытые ship-задачи головы `dev` и пишет + * `docs/reviews/SHIP-REVIEW-<база>-dev-.md`. Бета затем читает только + * дельту — задачи без покрытия или изменившиеся после ревью. */ import { mkdirSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { isMainModule } from './spawn-portable.mjs'; import { issueTrailers, readCandidateHistory } from './release-membership.mjs'; +import { parseDocName } from './reviews-index.mjs'; export const SHIP_REVIEW_DIR = 'docs/reviews'; +/** #727: зарезервированное значение входа `tag` — ночной режим ship-ревью. */ +export const NIGHTLY_TAG = 'nightly'; +/** #727 К8: метка комментария о High ночного документа; одна на документ и задачу. */ +export const SHIP_HIGH_MARKER_RE = //g; export const SHIP_MERGE_MARKER_RE = //; /** #707: риск по участкам, с которым ship слит (трек подтверждён владельцем или риск только visual). */ export const SHIP_RISK_MARKER_RE = //; export const SHIP_REVIEW_ANCHOR = ''; export const RELEASE_TAG_RE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.(0|[1-9]\d*))?$/; const SHA_RE = /^[0-9a-f]{40,64}$/; +const PATCH_ID_RE = /^[0-9a-f]{40,64}$/; const TZ_LIMIT = 1500; /** Путь документа пакетного ревью для тега беты или стабильного. */ @@ -42,6 +56,38 @@ export function shipReviewDocPath(tag) { return `${SHIP_REVIEW_DIR}/SHIP-REVIEW-${tag}.md`; } +/** + * #727 К2: режим по входу `tag`. `nightly` зарезервирован; кандидат в нём + * обязателен — это голова `dev` прогона ночного Validate, а не «вершина сейчас». + */ +export function shipReviewMode({ tag, candidate = '' } = {}) { + if (tag === NIGHTLY_TAG) { + if (!String(candidate || '').trim()) { + throw new Error('tag=nightly требует candidate — SHA головы dev, на которой шёл ночной полный Validate'); + } + return 'nightly'; + } + shipReviewDocPath(tag); + return 'beta'; +} + +/** #727 К2: ночной документ — по базе (прошлый тег кандидата) и SHA кандидата. */ +export function nightlyDocPath({ base, candidate }) { + if (!RELEASE_TAG_RE.test(String(base || ''))) { + throw new Error(`ночной документ без базы: у кандидата ${candidate} нет прошлого тега (${base || '—'})`); + } + if (!SHA_RE.test(String(candidate))) throw new Error(`invalid candidate SHA: ${candidate}`); + return `${SHIP_REVIEW_DIR}/SHIP-REVIEW-${base}-dev-${candidate.slice(0, 12)}.md`; +} + +/** Документ прогона: ночной — по базе и SHA, бета — по тегу. */ +export function shipDocPath({ tag, candidate = '', base = null }) { + return shipReviewMode({ tag, candidate }) === 'nightly' ? nightlyDocPath({ base, candidate }) : shipReviewDocPath(tag); +} + +const docName = (path) => String(path).slice(String(path).lastIndexOf('/') + 1); +const docPath = (name) => `${SHIP_REVIEW_DIR}/${name}`; + /** Задача — ship, если конвейер слил её без модели или на ней стоит `track:ship`. */ export function isShipIssue({ labels = [], comments = [] } = {}) { const names = labels.map((label) => (typeof label === 'string' ? label : label?.name)); @@ -100,14 +146,183 @@ export function shipIssuesInRange({ commits = [], issueData }) { return out; } -export function renderShipBrief({ tag, candidate, base, ship, runUrl = '' }) { +/** #727 К1: трейлер `Release:` — кандидат беты, коммит бота `beta-derived`, приёмка эталонов. */ +export function hasReleaseTrailer(message = '') { + return String(message).split(/\r?\n/).some((line) => /^Release:\s*\S/.test(line.trim())); +} + +/** + * #727 К1: входит ли коммит задачи в её патч-набор. Не входят коммиты с + * трейлером `Release:` (кандидат беты несёт `Issue:` всех задач линии) и + * коммиты только в `docs/reviews/**` (индекс, документ ревью): без этого набор + * ночи никогда не совпадёт с набором на кандидате. + */ +export function countsForPatchSet({ message = '', files = [] } = {}) { + if (hasReleaseTrailer(message)) return false; + return !(files.length && files.every((file) => String(file).startsWith(`${SHIP_REVIEW_DIR}/`))); +} + +// Дифф для patch-id — с явными опциями: конфиг git владельца (`npm run +// release:prerelease` судит у него) не должен менять форму диффа и с ней набор. +const PATCH_DIFF = [ + '-c', 'core.quotePath=true', 'diff-tree', '--stdin', '-r', '--root', '-p', '--no-color', '--no-ext-diff', + '--no-textconv', '--no-renames', '--no-relative', '--diff-algorithm=myers', '--indent-heuristic', '-U3', + '--inter-hunk-context=0', '--src-prefix=a/', '--dst-prefix=b/', +]; + +/** + * `git patch-id --stable` коммитов: Map sha → patch-id. Коммит без диффа + * (пустой, слияние) patch-id не имеет и в Map не попадает. + */ +export function commitPatchIds(shas, { cwd } = {}) { + const list = [...new Set(shas)]; + if (!list.length) return new Map(); + const diff = git(PATCH_DIFF, { cwd, input: `${list.join('\n')}\n` }); + const ids = git(['patch-id', '--stable'], { cwd, input: diff }); + const out = new Map(); + for (const line of ids.split('\n')) { + const [pid, sha] = line.trim().split(/\s+/); + if (pid && sha && list.includes(sha)) out.set(sha, pid); + } + return out; +} + +/** Изменённые файлы коммитов: Map sha → [путь]. */ +export function commitFiles(shas, { cwd } = {}) { + const list = [...new Set(shas)]; + const out = new Map(list.map((sha) => [sha, []])); + if (!list.length) return out; + const text = git(['diff-tree', '--stdin', '-r', '--root', '--no-renames', '--name-only'], { cwd, input: `${list.join('\n')}\n` }); + let current = null; + for (const line of text.split('\n')) { + if (out.has(line)) { current = line; continue; } + if (current && line) out.get(current).push(line); + } + return out; +} + +/** + * #727 К1: патч-набор каждой задачи — отсортированные `git patch-id --stable` + * её коммитов диапазона (`readCandidateHistory`), без `Release:` и коммитов + * только в `docs/reviews/**`. Порядок коммитов не важен. + * @returns {Map} + */ +export function issuePatchSets({ commits = [], numbers = [], cwd } = {}) { + const wanted = new Set(numbers.map(Number)); + const sets = new Map([...wanted].map((number) => [number, new Set()])); + const relevant = commits.filter((commit) => issueTrailers(commit.message).some((number) => wanted.has(number))); + const files = commitFiles(relevant.map((commit) => commit.sha), { cwd }); + const counted = relevant.filter((commit) => countsForPatchSet({ message: commit.message, files: files.get(commit.sha) })); + const ids = commitPatchIds(counted.map((commit) => commit.sha), { cwd }); + for (const commit of counted) { + const pid = ids.get(commit.sha); + if (!pid) continue; + for (const number of issueTrailers(commit.message)) if (wanted.has(number)) sets.get(number).add(pid); + } + return new Map([...sets].sort((a, b) => a[0] - b[0]).map(([number, set]) => [number, [...set].sort()])); +} + +/** `patches` машинного блока: `:+,…`, задачи и patch-id по порядку. */ +export function formatPatches(patches) { + const entries = [...(patches instanceof Map ? patches : new Map(Object.entries(patches || {})))] + .map(([number, pids]) => [Number(number), [...new Set(pids || [])].sort()]) + .sort((a, b) => a[0] - b[0]); + return entries.length ? entries.map(([number, pids]) => `${number}:${pids.join('+')}`).join(',') : '—'; +} + +/** Обратное к `formatPatches`: Map номер → отсортированные patch-id. Испорченная запись пропускается. */ +export function parsePatches(text = '') { + const out = new Map(); + const value = String(text ?? '').trim(); + if (!value || value === '—') return out; + for (const entry of value.split(',')) { + const match = /^([1-9]\d*):([0-9a-f+]*)$/.exec(entry.trim()); + if (!match) continue; + const pids = match[2].split('+').filter(Boolean); + if (pids.some((pid) => !PATCH_ID_RE.test(pid))) continue; + out.set(Number(match[1]), [...new Set(pids)].sort()); + } + return out; +} + +function samePatchSet(recorded, current) { + if (!Array.isArray(recorded) || !Array.isArray(current) || recorded.length !== current.length) return false; + const [a, b] = [[...recorded].sort(), [...current].sort()]; + return a.every((pid, i) => pid === b[i]); +} + +/** + * #727 К3: покрытие ship-задач документами пакетного ревью той же базы. + * `docs` — по возрастанию публикации (последний — позже всех), `{ name, text }` + * или `{ name, block }`. `base` — тег базы диапазона; `undefined` — не фильтровать. + * + * - `clean` — последний документ, где задача есть, записал тот же патч-набор, High 0; + * - `high` — то же, но High > 0 (или числа High нет): High снимает покрытие со всех задач документа; + * - `stale` — последний документ записал другой патч-набор: код изменился после ревью; + * - `none` — задачи нет ни в одном документе. + * Документ без строки `patches` (до #727) покрывает по номеру. + */ +export function shipCoverage({ ship = [], docs = [], base } = {}) { + const read = docs.map((doc) => ({ name: doc.name, block: doc.block ?? parseAnchorBlock(doc.text ?? '') })) + .filter((doc) => doc.block && (base === undefined || (doc.block.base ?? null) === (base ?? null))); + return ship.map((issue) => { + const last = read.findLast((doc) => doc.block.issues.includes(issue.number)); + if (!last) return { number: issue.number, status: 'none', doc: null }; + const { block } = last; + if (block.patches && !samePatchSet(block.patches.get(issue.number), issue.patches)) { + return { number: issue.number, status: 'stale', doc: last.name }; + } + return { number: issue.number, status: block.high === 0 ? 'clean' : 'high', doc: last.name }; + }); +} + +const NO_SHIP_NOTE = 'ship-задач в диапазоне нет — ревью не нужно'; + +/** + * #727 К2, К5: что читать этому прогону. Без `force` — только `none` и + * `stale`; `high` держит гейт до починки и `force=true`, `clean` уже прочитаны. + * `force` читает все ship-задачи диапазона и на документы не смотрит. + */ +export function planShipReview({ tag, ship = [], docs = [], base = null, force = false } = {}) { + const mode = tag === NIGHTLY_TAG ? 'nightly' : 'beta'; + if (force) return { mode, read: ship, covered: [], held: [], note: ship.length ? '' : NO_SHIP_NOTE }; + const coverage = shipCoverage({ ship, docs, base }); + const status = new Map(coverage.map((item) => [item.number, item])); + const read = ship.filter((issue) => ['none', 'stale'].includes(status.get(issue.number).status)); + const covered = coverage.filter((item) => item.status === 'clean').map(({ number, doc }) => ({ number, doc })); + const held = coverage.filter((item) => item.status === 'high').map(({ number, doc }) => ({ number, doc })); + let note = ''; + if (!ship.length) note = NO_SHIP_NOTE; + else if (!read.length && !held.length) note = `все ship-задачи покрыты: ${[...new Set(covered.map((item) => item.doc))].join(', ')} — модель не запускается`; + else if (!read.length) { + note = `читать нечего: ${held.map((item) => `#${item.number} — High в ${item.doc}`).join(', ')}` + + ' — гейт беты стоит до починки и пересъёмки (force=true)'; + } + return { mode, read, covered, held, note }; +} + +/** #727 К2: строка промпта о кандидате. */ +export function reviewSubject(tag) { + return tag === NIGHTLY_TAG + ? 'Ночное пакетное ревью: кандидат — голова `dev` после ночного полного Validate.' + : `Бета: ${tag}.`; +} + +export function renderShipBrief({ tag, candidate, base, ship, runUrl = '', doc = null, covered = [], held = [] }) { + const nightly = tag === NIGHTLY_TAG; + const byDoc = (items) => items.map((item) => `#${item.number} — \`${docPath(item.doc)}\``).join(', '); + const atNight = covered.filter((item) => parseDocName(item.doc)?.nightly); + const before = covered.filter((item) => !parseDocName(item.doc)?.nightly); const lines = [ - `# Вход пакетного ревью ship ${tag}`, + nightly ? '# Вход ночного пакетного ревью ship' : `# Вход пакетного ревью ship ${tag}`, '', - `- Кандидат: \`${candidate}\``, + `- Кандидат: \`${candidate}\`${nightly ? ' — голова `dev` после ночного полного Validate' : ''}`, `- Диапазон: ${base ? `\`${base.tag}\` · \`${base.sha}\`` : 'нет прошлого тега — всё дерево'} .. кандидат`, ...(runUrl ? [`- Прогон: ${runUrl}`] : []), - `- Документ: \`${shipReviewDocPath(tag)}\``, + `- Документ: \`${doc || shipReviewDocPath(tag)}\``, + ...(atNight.length ? [`- Прочитаны ночью: ${byDoc(atNight)} — не перечитывать`] : []), + ...(before.length ? [`- Прочитаны прежним ревью: ${byDoc(before)} — не перечитывать`] : []), + ...(held.length ? [`- High ждёт починки: ${byDoc(held)} — не перечитывать, их держит гейт беты (§11.7)`] : []), '', `## Задачи ship (${ship.length}) — слиты без ревью модели`, '', @@ -123,8 +338,13 @@ export function renderShipBrief({ tag, candidate, base, ship, runUrl = '' }) { return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`; } -/** Машинный блок документа: его пишет публикация, читает `check`. */ -export function anchorBlock({ tag, candidate, base = null, issues = [], high = 0, medium = 0, low = 0, runUrl = '' }) { +/** + * Машинный блок документа: его пишет публикация, читает `check`. #727: строки + * `mode` и `patches` дописываются в конец, прежние строки не меняются. + */ +export function anchorBlock({ + tag, candidate, base = null, issues = [], high = 0, medium = 0, low = 0, runUrl = '', mode = null, patches = null, +}) { return [ SHIP_REVIEW_ANCHOR, '### Материал пакетного ревью', @@ -138,11 +358,17 @@ export function anchorBlock({ tag, candidate, base = null, issues = [], high = 0 `medium ${medium}`, `low ${low}`, `run ${runUrl || '—'}`, + ...(mode ? [`mode ${mode}`] : []), + ...(patches ? [`patches ${formatPatches(patches)}`] : []), '```', '', ].join('\n'); } +/** + * Поля машинного блока. `base`, `mode` и `patches` появляются, только если + * блок их несёт: документ до #727 без `patches` покрывает задачи по номеру. + */ export function parseAnchorBlock(text = '') { const at = String(text).lastIndexOf(SHIP_REVIEW_ANCHOR); if (at < 0) return null; @@ -153,6 +379,7 @@ export function parseAnchorBlock(text = '') { return space < 0 ? [line, ''] : [line.slice(0, space), line.slice(space + 1).trim()]; })); const number = (value) => (/^\d+$/.test(String(value)) ? Number(value) : null); + const given = (value) => value != null && value !== '' && value !== '—'; return { tag: fields.tag || null, candidate: fields.candidate || null, @@ -160,62 +387,126 @@ export function parseAnchorBlock(text = '') { high: number(fields.high), medium: number(fields.medium), low: number(fields.low), + ...(given(fields.base) ? { base: fields.base } : {}), + ...(given(fields.mode) ? { mode: fields.mode } : {}), + ...('patches' in fields ? { patches: parsePatches(fields.patches) } : {}), }; } /** * Причины не публиковать бету; пустой список — гейт пройден. Без ship-задач в * диапазоне документ не нужен: пакетному ревью нечего читать. + * + * #727 К4: судит покрытие (`shipCoverage`) документами той же базы `docs` — + * ночными и документом тега. Все задачи `clean` — гейт пройден и без + * документа тега. `docText` — прежний вызов: только документ тега. */ -export function shipReviewProblems({ tag, ship = [], docText = null }) { +export function shipReviewProblems({ tag, ship = [], docText = null, docs = null, base } = {}) { if (!ship.length) return []; const doc = shipReviewDocPath(tag); - const numbers = ship.map((issue) => issue.number); const run = `gh workflow run ship-review.yml --ref dev -f tag=${tag}`; - if (!docText) { - return [`${doc} нет ни в кандидате, ни в dev: ship-задачи ${numbers.map((n) => `#${n}`).join(', ')} слиты без ревью модели, и их код до беты не читал никто (PROCESS.md §11.7). Запустить: ${run}`]; - } - const block = parseAnchorBlock(docText); - if (!block) return [`${doc} без машинного блока ${SHIP_REVIEW_ANCHOR}: покрытие задач не доказано. Переснять: ${run} -f force=true`]; + const range = docs ?? (docText ? [{ name: docName(doc), text: docText }] : []); + const own = range.find((item) => item.name === docName(doc)) || null; const problems = []; - if (block.tag !== tag) problems.push(`${doc} записан для тега ${block.tag}, а публикуется ${tag}`); - const missing = numbers.filter((n) => !block.issues.includes(n)); - if (missing.length) { - problems.push(`${doc} не покрывает ship-задачи ${missing.map((n) => `#${n}`).join(', ')} — они слиты после ревью. Переснять: ${run} -f force=true`); + if (own) { + const block = parseAnchorBlock(own.text); + if (!block) return [`${doc} без машинного блока ${SHIP_REVIEW_ANCHOR}: покрытие задач не доказано. Переснять: ${run} -f force=true`]; + if (block.tag !== tag) problems.push(`${doc} записан для тега ${block.tag}, а публикуется ${tag}`); + if (base !== undefined && (block.base ?? null) !== (base ?? null)) { + problems.push(`${doc} записан для базы ${block.base ?? '—'}, а диапазон кандидата — от ${base ?? '—'}`); + } + } + // Документ тега уже в dev: ship-review.yml без force его не переснимает. + const rerun = own ? `${run} -f force=true` : run; + const coverage = shipCoverage({ ship, docs: range, base }); + const list = (numbers) => numbers.map((n) => `#${n}`).join(', '); + const sameBase = range.filter((item) => base === undefined || (parseAnchorBlock(item.text)?.base ?? null) === (base ?? null)); + const missing = coverage.filter((item) => item.status === 'none').map((item) => item.number); + if (missing.length) { + problems.push(own || sameBase.length + ? `${own ? doc : `ни один документ ревью базы ${base ?? '—'} (${sameBase.map((item) => item.name).join(', ')})`} не покрывает ship-задачи ${list(missing)} — они не прочитаны: слиты после ревью. Запустить: ${rerun}` + : `${doc} нет ни в кандидате, ни в dev: ship-задачи ${list(missing)} слиты без ревью модели, и их код до беты не читал никто (PROCESS.md §11.7). Запустить: ${run}`); + } + for (const item of coverage.filter((entry) => entry.status === 'stale')) { + problems.push(`ship-задача #${item.number} изменилась после ревью ${docPath(item.doc)}: в нём записан другой патч-набор, новый код не прочитан. Запустить: ${rerun}`); + } + for (const name of [...new Set(coverage.filter((item) => item.status === 'high').map((item) => item.doc))]) { + const block = parseAnchorBlock(range.findLast((item) => item.name === name)?.text ?? '') ?? {}; + const where = `${docPath(name)} (задачи ${list(coverage.filter((item) => item.doc === name && item.status === 'high').map((item) => item.number))})`; + if (block.high == null) problems.push(`${where}: в машинном блоке нет числа High`); + else if (block.high > 0) problems.push(`${where}: High ${block.high} — бета ждёт починки: находка чинится отдельной задачей, затем ревью переснимается: ${run} -f force=true`); } - if (block.high == null) problems.push(`${doc}: в машинном блоке нет числа High`); - else if (block.high > 0) problems.push(`${doc}: High ${block.high} — бета ждёт починки: находка чинится отдельной задачей, затем ревью переснимается`); return problems; } -function git(args, { allowFailure = false } = {}) { - const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); +/** #727 К8: строка в задачу ночного документа с High. */ +export function highCommentBody(doc) { + const path = String(doc).includes('/') ? String(doc) : docPath(doc); + return `Ночное пакетное ревью ship нашло High: \`${path}\`. Бета не выйдет, пока находка не починена отдельной задачей` + + ` и ревью не переснято (§11.7) `; +} + +/** #727 К8: задачи, куда строка ещё не писалась — повтор на тот же документ не пишется. */ +export function highCommentTargets({ doc, issues = [] }) { + const name = docName(doc); + return issues.filter((issue) => !(issue.comments || []).some((comment) => [...String(comment?.body ?? '') + .matchAll(SHIP_HIGH_MARKER_RE)].some((match) => match[1] === name))).map((issue) => issue.number); +} + +function git(args, { allowFailure = false, cwd, input } = {}) { + const r = spawnSync('git', args, { cwd, input, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); if (r.status !== 0 && !allowFailure) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`); return r.status === 0 ? r.stdout : null; } -function ghIssue(repo, number) { - const r = spawnSync('gh', ['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,labels,comments'], { - encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, - }); - if (r.status !== 0) throw new Error(`gh issue view ${number}: ${(r.stderr || r.stdout || '').trim()}`); - return JSON.parse(r.stdout); +function gh(args) { + const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (r.status !== 0) throw new Error(`gh ${args.slice(0, 3).join(' ')}: ${(r.stderr || r.stdout || '').trim()}`); + return r.stdout; } -/** Документ — из кандидата, иначе из `origin/dev`: ревью могло лечь в dev после Release-коммита. */ -export function readShipDoc(tag, candidate) { - const path = shipReviewDocPath(tag); - return git(['show', `${candidate}:${path}`], { allowFailure: true }) - ?? git(['show', `origin/dev:${path}`], { allowFailure: true }); +function ghIssue(repo, number) { + return JSON.parse(gh(['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,labels,comments'])); +} + +/** + * #727 К3: документы пакетного ревью из кандидата и из `devRef`, по + * возрастанию публикации. Публикация — коммит, положивший текущий текст + * документа (`git log -1` по пути); порядок — его глубина в истории + * (`rev-list --count`: в линейной `dev` потомок глубже предка). Документ, + * который есть в обоих, берётся из `devRef`: документ могли переснять в `dev` + * после Release-коммита. Ночные документы чужой базы + * отсекаются по имени, остальные — `shipCoverage` по строке `base` блока. + */ +export function readRangeDocs({ candidate, base = undefined, devRef = 'origin/dev', cwd } = {}) { + const found = new Map(); + for (const ref of [...new Set([candidate, devRef].filter(Boolean))]) { + const listing = git(['ls-tree', '--name-only', ref, '--', `${SHIP_REVIEW_DIR}/`], { allowFailure: true, cwd }); + if (listing == null) continue; + for (const path of listing.split('\n').filter(Boolean)) { + const name = docName(path); + const meta = parseDocName(name); + if (meta?.stage !== 'ship') continue; + if (meta.nightly && base !== undefined && meta.tag !== base) continue; + const text = git(['show', `${ref}:${path}`], { allowFailure: true, cwd }); + const commit = git(['log', '-1', '--format=%H', ref, '--', path], { allowFailure: true, cwd })?.trim(); + if (text == null || !commit) continue; + const order = Number(git(['rev-list', '--count', commit], { cwd }).trim()); + found.set(name, { name, text, ref, commit, order }); + } + } + return [...found.values()].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)); } function collect({ tag, candidate, repo }) { - shipReviewDocPath(tag); + const mode = shipReviewMode({ tag, candidate }); if (!SHA_RE.test(String(candidate))) throw new Error(`invalid candidate SHA: ${candidate}`); if (!repo) throw new Error('--repo (или GITHUB_REPOSITORY) обязателен'); const { base, commits } = readCandidateHistory(candidate); const ship = shipIssuesInRange({ commits, issueData: (number) => ghIssue(repo, number) }); - return { base, ship }; + const patches = issuePatchSets({ commits, numbers: ship.map((issue) => issue.number) }); + for (const issue of ship) issue.patches = patches.get(issue.number); + return { mode, base, ship }; } if (isMainModule(import.meta.url)) { @@ -224,28 +515,73 @@ if (isMainModule(import.meta.url)) { const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? ''; const tag = value('tag'); const repo = value('repo') || process.env.GITHUB_REPOSITORY || ''; + const devRef = value('dev-ref') || 'origin/dev'; if (command === 'doc') { - console.log(shipReviewDocPath(tag)); + const candidate = value('candidate'); + const base = shipReviewMode({ tag, candidate }) === 'nightly' ? readCandidateHistory(candidate).base?.tag : null; + console.log(shipDocPath({ tag, candidate, base })); + } else if (command === 'mode') { + console.log(shipReviewMode({ tag, candidate: value('candidate') })); } else if (command === 'prepare') { const candidate = value('candidate'); const out = resolve(value('out') || '.'); - const { base, ship } = collect({ tag, candidate, repo }); + const force = value('force') === 'true'; + if (shipReviewMode({ tag, candidate }) === 'nightly' + && git(['merge-base', '--is-ancestor', candidate, devRef], { allowFailure: true }) == null) { + throw new Error(`candidate ${candidate} не предок ${devRef}: ночное ревью читает только код dev`); + } + const { mode, base, ship } = collect({ tag, candidate, repo }); + const doc = shipDocPath({ tag, candidate, base: base?.tag ?? null }); + const docs = force ? [] : readRangeDocs({ candidate, base: base?.tag ?? null, devRef }); + const plan = planShipReview({ tag, ship, docs, base: base?.tag ?? null, force }); + const patches = new Map(plan.read.map((issue) => [issue.number, issue.patches])); mkdirSync(out, { recursive: true }); - writeFileSync(join(out, 'ship-issues.json'), `${JSON.stringify({ schema: 1, tag, candidate, base, issues: ship }, null, 2)}\n`); - writeFileSync(join(out, 'brief.md'), renderShipBrief({ tag, candidate, base, ship, runUrl: value('run-url') })); - console.log(`doc=${shipReviewDocPath(tag)}`); + writeFileSync(join(out, 'ship-issues.json'), `${JSON.stringify({ + schema: 1, tag, mode, candidate, base, doc, issues: plan.read, covered: plan.covered, held: plan.held, + }, null, 2)}\n`); + writeFileSync(join(out, 'brief.md'), renderShipBrief({ + tag, candidate, base, ship: plan.read, runUrl: value('run-url'), doc, covered: plan.covered, held: plan.held, + })); + console.log(`mode=${mode}`); + console.log(`doc=${doc}`); console.log(`base=${base ? base.tag : ''}`); - console.log(`issues=${ship.map((issue) => issue.number).join(',')}`); + console.log(`issues=${plan.read.map((issue) => issue.number).join(',')}`); + console.log(`patches=${plan.read.length ? formatPatches(patches) : ''}`); + console.log(`subject=${reviewSubject(tag)}`); + if (plan.note) console.log(`note=${plan.note}`); } else if (command === 'check') { const candidate = value('candidate'); - const { ship } = collect({ tag, candidate, repo }); - const problems = shipReviewProblems({ tag, ship, docText: readShipDoc(tag, candidate) }); + if (tag === NIGHTLY_TAG) throw new Error('check судит публикацию беты: нужен её тег, а не nightly'); + const { base, ship } = collect({ tag, candidate, repo }); + const docs = readRangeDocs({ candidate, base: base?.tag ?? null, devRef }); + const problems = shipReviewProblems({ tag, ship, docs, base: base?.tag ?? null }); if (!ship.length) console.log('ship-задач в диапазоне нет — пакетное ревью не требуется'); - else if (!problems.length) console.log(`пакетное ревью ${shipReviewDocPath(tag)} покрывает ship-задачи ${ship.map((i) => `#${i.number}`).join(', ')}`); + else if (!problems.length) { + const coverage = shipCoverage({ ship, docs, base: base?.tag ?? null }); + console.log(`пакетное ревью покрывает ship-задачи: ${coverage.map((item) => `#${item.number} — ${docPath(item.doc)}`).join(', ')}`); + } for (const problem of problems) console.error(`::error::${problem}`); process.exit(problems.length ? 1 : 0); + } else if (command === 'comment-high') { + const doc = value('doc'); + const high = Number(value('high')); + if (value('mode') !== 'nightly' || !(high > 0)) { + console.log(`комментарий о High не нужен: режим ${value('mode') || '—'}, High ${value('high') || '—'}`); + } else { + if (!repo) throw new Error('--repo (или GITHUB_REPOSITORY) обязателен'); + const numbers = value('issues').split(',').filter(Boolean).map(Number); + const issues = numbers.map((number) => ({ + number, comments: JSON.parse(gh(['issue', 'view', String(number), '--repo', repo, '--json', 'comments'])).comments, + })); + const targets = highCommentTargets({ doc, issues }); + for (const number of targets) gh(['issue', 'comment', String(number), '--repo', repo, '--body', highCommentBody(doc)]); + console.log(targets.length ? `High ${high} в ${doc}: строка в ${targets.map((n) => `#${n}`).join(', ')}` + : `High ${high} в ${doc}: строка во всех задачах документа уже есть`); + } } else { - throw new Error('usage: ship-review.mjs doc --tag= | prepare --tag= --candidate= --out= [--repo=o/r] | check --tag= --candidate= [--repo=o/r]'); + throw new Error('usage: ship-review.mjs doc --tag= [--candidate=] | mode --tag= [--candidate=]' + + ' | prepare --tag= --candidate= --out= [--force=true] [--repo=o/r] | check --tag= --candidate= [--repo=o/r]' + + ' | comment-high --mode= --high= --doc= --issues= [--repo=o/r]'); } } catch (error) { console.error(`::error::${error.message}`); diff --git a/test/nightly-workflow.test.mjs b/test/nightly-workflow.test.mjs index 2392f58d..450c1c4b 100644 --- a/test/nightly-workflow.test.mjs +++ b/test/nightly-workflow.test.mjs @@ -1,6 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; // #492 §7: ночной workflow обязан ждать дочерний Validate и наследовать его // исход — успешный dispatch не равен успешной проверке. @@ -27,3 +30,135 @@ test('ночная job носит русское имя и не выдаёт о assert.match(nightly, /name: "Запустить Validate на dev с полным набором и дождаться результата"/); assert.ok(!/поставлен в очередь[^\n]*\n\s*$/.test(nightly), 'echo про очередь не может быть последним шагом'); }); + +// ---------- #727 К6: ночное пакетное ревью ship после Validate ---------- + +/** Блок job верхнего уровня `jobs:` по имени. */ +function job(text, name) { + const start = text.indexOf(`\n ${name}:\n`); + assert.ok(start >= 0, `job ${name}`); + const rest = text.slice(start + 1); + const end = rest.slice(1).search(/\n [a-z_]+:\n/); + return end < 0 ? rest : rest.slice(0, end + 1); +} + +/** Тело `run:` шага, как его прочтёт YAML (блок кончается на строке с отступом меньше десяти). */ +function stepRun(text, name) { + const start = text.indexOf(` - name: "${name}"\n`); + assert.ok(start >= 0, `шаг «${name}»`); + const lines = text.slice(start).split('\n'); + const from = lines.indexOf(' run: |'); + assert.ok(from > 0, `у шага «${name}» есть 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\.server_url \}\}/g, 'https://github.com'); +} + +const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0; + +/** + * Шаг на настоящем bash с подменённым `gh`: `workflow run` — в журнал (или отказ + * при FAKE_DISPATCH=fail), `run list --jq …` — FAKE_RUN_ID (то, что вернул бы + * фильтр), `run view --jq .headSha` — FAKE_HEAD, `run watch` — код FAKE_WATCH. + */ +function runStep(t, script, env = {}) { + const root = mkdtempSync(join(tmpdir(), 'hp-727-night-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const bin = join(root, 'bin'); + mkdirSync(bin); + writeFileSync(join(bin, 'gh'), [ + '#!/usr/bin/env bash', + 'echo "gh $*" >> "$FAKE_LOG"', + 'case "$1 $2" in', + ' "workflow run") [ "${FAKE_DISPATCH:-ok}" = ok ] || { echo "HTTP 403" >&2; exit 1; } ;;', + ' "run list") printf "%s\\n" "${FAKE_RUN_ID:-}" ;;', + ' "run view") printf "%s\\n" "$FAKE_HEAD" ;;', + ' "run watch") exit "${FAKE_WATCH:-0}" ;;', + ' *) echo "unexpected gh $*" >&2; exit 1 ;;', + 'esac', + '', + ].join('\n'), { mode: 0o755 }); + writeFileSync(join(bin, 'sleep'), '#!/bin/sh\necho "sleep $*" >> "$FAKE_LOG"\n', { mode: 0o755 }); + const files = { log: join(root, 'log'), output: join(root, 'output'), summary: join(root, 'summary.md') }; + const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', script], { + encoding: 'utf8', + env: { + ...process.env, PATH: `${bin}:${process.env.PATH}`, REPO: 'o/r', GH_TOKEN: 'x', + FAKE_LOG: files.log, GITHUB_OUTPUT: files.output, GITHUB_STEP_SUMMARY: files.summary, ...env, + }, + }); + const text = (path) => { try { return readFileSync(path, 'utf8'); } catch { return ''; } }; + return { status: r.status, stdout: r.stdout, stderr: r.stderr, log: text(files.log).split('\n').filter(Boolean), output: text(files.output), summary: text(files.summary) }; +} + +const HEAD = 'abcdef0123456789abcdef0123456789abcdef01'; + +test('#727 AC6 К6: ночь после Validate при любом его исходе запускает ship-ревью на SHA прогона Validate', () => { + const nightly = read('_nightly.yml'); + const dispatch = job(nightly, 'dispatch'); + const ship = job(nightly, 'ship_review'); + assert.match(ship, /\n needs: dispatch\n/); + assert.match(ship, /\n if: always\(\)\n/, 'при любом исходе Validate'); + assert.match(ship, /\n continue-on-error: true\n/, 'цвет ночи — цвет Validate'); + assert.match(ship, /gh workflow run ship-review\.yml --ref dev -f tag=nightly -f candidate="\$CANDIDATE"/); + assert.match(ship, /CANDIDATE: \$\{\{ needs\.dispatch\.outputs\.head_sha \}\}/, 'SHA — из прогона Validate'); + assert.doesNotMatch(ship, /gh run watch/, 'ждёт только появления прогона, не конца'); + assert.match(ship, /for _ in \$\(seq 1 18\); do\n\s+sleep 10/, 'до трёх минут, как у Validate'); + assert.doesNotMatch(ship, /permissions:/, 'права — ночи (actions: write), тонкий файл не меняется'); + // SHA прогона Validate выводится отдельным шагом до ожидания. + assert.match(dispatch, /head_sha: \$\{\{ steps\.validate\.outputs\.head_sha \}\}/); + const found = dispatch.indexOf(' - name: "Запустить Validate и найти его прогон"\n id: validate\n'); + const watch = dispatch.indexOf(' - name: "Дождаться Validate"'); + assert.ok(found > 0 && watch > found, 'вывод SHA — до шага ожидания'); + assert.match(stepRun(nightly, 'Запустить Validate и найти его прогон'), /head_sha=\$\(gh run view "\$run_id" --repo "\$REPO" --json headSha --jq \.headSha\)\necho "run_id=\$run_id" >> "\$GITHUB_OUTPUT"\necho "head_sha=\$head_sha" >> "\$GITHUB_OUTPUT"/); + assert.doesNotMatch(stepRun(nightly, 'Запустить Validate и найти его прогон'), /gh run watch/); + for (const name of ['Запустить Validate и найти его прогон', 'Дождаться Validate', 'Запустить ship-ревью и дождаться появления прогона']) { + const body = stepRun(nightly, name); + assert.equal(spawnSync('bash', ['-n', '-c', body]).status, 0, `bash -n: ${name}`); + assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, `${name}: heredoc в run`); + } +}); + +test('#727 AC6 на настоящем bash: SHA прогона Validate — в выходах до ожидания; красный Validate — красная job ожидания', (t) => { + if (!hasBash()) { t.skip('bash недоступен'); return; } + const nightly = read('_nightly.yml'); + const found = runStep(t, stepRun(nightly, 'Запустить Validate и найти его прогон'), { FAKE_RUN_ID: '42', FAKE_HEAD: HEAD }); + assert.equal(found.status, 0, found.stderr); + assert.match(found.output, /^run_id=42$/m); + assert.match(found.output, new RegExp(`^head_sha=${HEAD}$`, 'm')); + assert.ok(found.log.includes('gh workflow run validate.yml --repo o/r --ref dev -f full=true')); + assert.ok(!found.log.some((call) => call.startsWith('gh run watch')), 'шаг вывода не ждёт'); + const red = runStep(t, stepRun(nightly, 'Дождаться Validate'), { RUN_ID: '42', FAKE_WATCH: '1' }); + assert.equal(red.status, 1, 'красный Validate — красная ночь'); + assert.ok(red.log.includes('gh run watch 42 --repo o/r --exit-status --interval 30')); + const lost = runStep(t, stepRun(nightly, 'Запустить Validate и найти его прогон'), { FAKE_RUN_ID: '' }); + assert.equal(lost.status, 1); + assert.match(lost.stdout, /::error::прогон Validate не появился за 3 минуты/); +}); + +test('#727 AC6 на настоящем bash: ship-ревью ночью — dispatch с tag=nightly, ждёт только появления; сбой — предупреждение', (t) => { + if (!hasBash()) { t.skip('bash недоступен'); return; } + const step = stepRun(read('_nightly.yml'), 'Запустить ship-ревью и дождаться появления прогона'); + const ok = runStep(t, step, { CANDIDATE: HEAD, FAKE_RUN_ID: '77' }); + assert.equal(ok.status, 0, ok.stderr); + assert.equal(ok.log[0], `gh workflow run ship-review.yml --ref dev -f tag=nightly -f candidate=${HEAD} --repo o/r`); + assert.ok(ok.log.some((call) => call.startsWith('gh run list --repo o/r --workflow ship-review.yml --branch dev --event workflow_dispatch'))); + assert.ok(!ok.log.some((call) => call.startsWith('gh run watch')), 'конца прогона не ждёт'); + assert.equal(ok.summary, `- Ship-ревью ночью (\`${HEAD}\`): https://github.com/o/r/actions/runs/77\n`); + // Прогон не появился за три минуты — предупреждение, не красная ночь. + const late = runStep(t, step, { CANDIDATE: HEAD, FAKE_RUN_ID: '' }); + assert.equal(late.status, 0); + assert.equal(late.log.filter((call) => call === 'sleep 10').length, 18); + assert.match(late.stdout, /::warning::прогон ship-ревью не появился за 3 минуты — ночь не красится/); + // Dispatch отклонён — предупреждение; SHA нет (Validate не появился) — dispatch не делается. + const refused = runStep(t, step, { CANDIDATE: HEAD, FAKE_DISPATCH: 'fail' }); + assert.equal(refused.status, 0); + assert.match(refused.stdout, /::warning::ship-ревью ночью не запущено: dispatch отклонён/); + const empty = runStep(t, step, { CANDIDATE: '' }); + assert.equal(empty.status, 0); + assert.deepEqual(empty.log, []); + assert.match(empty.stdout, /::warning::нет SHA прогона Validate — ночное ship-ревью не запущено/); +}); diff --git a/test/process-digests.test.mjs b/test/process-digests.test.mjs index 20b6bf32..b4be2827 100644 --- a/test/process-digests.test.mjs +++ b/test/process-digests.test.mjs @@ -105,6 +105,8 @@ const KEY_RULES = { ['4-лимит-циклов-ревью-4', 'вердикт, исчерпавший бюджет, сразу ставит `review-4`'], ['71-цепочка', 'Технический спор автора и ревьюера решается вердиктом, а не владельцем'], ['12-запрещено', 'Medium-находки, оставленные как TODO в документе ревью'], + // #727: ночное пакетное ревью ship переиспользуется гейтом беты по патч-набору. + ['117-пакетное-ревью-ship-перед-бетой', 'гейт беты принимает ночной документ при том же патч-наборе'], ], }; diff --git a/test/ship-review.test.mjs b/test/ship-review.test.mjs index 838c4a93..f7d7a23e 100644 --- a/test/ship-review.test.mjs +++ b/test/ship-review.test.mjs @@ -2,11 +2,15 @@ // читает пакетное ревью диапазона перед бетой, и гейт беты требует документ. import assert from 'node:assert/strict'; import test from 'node:test'; -import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - SHIP_MERGE_MARKER_RE, anchorBlock, isShipIssue, parseAnchorBlock, renderShipBrief, shipIssuesInRange, shipReviewDocPath, - shipReviewProblems, shipRiskFrom, specSection, + SHIP_MERGE_MARKER_RE, anchorBlock, commitPatchIds, countsForPatchSet, formatPatches, highCommentBody, highCommentTargets, + isShipIssue, issuePatchSets, nightlyDocPath, parseAnchorBlock, parsePatches, planShipReview, renderShipBrief, reviewSubject, + shipCoverage, shipDocPath, shipIssuesInRange, shipReviewDocPath, shipReviewMode, shipReviewProblems, shipRiskFrom, specSection, } from '../scripts/ship-review.mjs'; import { parseDocName, renderIndex } from '../scripts/reviews-index.mjs'; import { archivePlan } from '../scripts/reviews-archive.mjs'; @@ -138,3 +142,573 @@ test('#707 AC12: бриф ship печатает строку риска из к assert.doesNotMatch(section(702), /Риск по участкам/); assert.equal(ship.find((i) => i.number === 702).risk, undefined); }); + +// ---------- #727: ночное пакетное ревью ship и переиспользование по патч-набору ---------- + +const PID = (c) => c.repeat(40); +const NIGHT = (sha12 = '108427dc1234', base = 'v1.79.0-beta.1') => `SHIP-REVIEW-${base}-dev-${sha12}.md`; +const shipDoc = (name, fields) => ({ + name, + text: `# Ночное ревью\nИтог: …\n\n${anchorBlock({ tag: 'nightly', candidate: sha('e'), base: 'v1.79.0-beta.1', mode: 'nightly', ...fields })}`, +}); + +/** Временный git-репозиторий без конфига и хуков пользователя (#496, #633). */ +function tempRepo(t) { + const dir = mkdtempSync(join(tmpdir(), 'hp-727-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + 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', + }; + const git = (...args) => { + const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8', env }); + assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); + }; + git('init', '-q', '-b', 'dev'); + git('config', 'core.hooksPath', '/dev/null'); + const commit = (files, message) => { + for (const [path, text] of Object.entries(files)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), text); + } + git('add', '-A'); + git('commit', '-q', '-m', message); + return { sha: git('rev-parse', 'HEAD'), message: `${message}\n` }; + }; + return { dir, git, commit }; +} + +test('#727 AC1 К1: патч-набор — patch-id коммитов задачи; Release: и только docs/reviews/** не входят; cherry-pick тот же', (t) => { + const repo = tempRepo(t); + repo.commit({ 'src/a.txt': 'a\n', 'src/b.txt': 'b\n', 'src/c.txt': 'c\n' }, 'base'); + const base = repo.git('rev-parse', 'HEAD'); + const one = repo.commit({ 'src/a.txt': 'a\nA\n' }, 'fix: a\n\nIssue: #701\nUser-Visible: no'); + const two = repo.commit({ 'src/b.txt': 'b\nB\n' }, 'fix: b\n\nIssue: #701\nUser-Visible: no'); + const release = repo.commit({ 'src/c.txt': 'c\nversion\n' }, 'Release v1.80.0-beta.1 candidate\n\nIssue: #701\nIssue: #702\nUser-Visible: yes\nRelease: v1.80.0-beta.1'); + const docsOnly = repo.commit({ 'docs/reviews/CODE-REVIEW-701-r1.md': '# r1\n', 'docs/reviews/INDEX.md': 'x\n' }, 'docs: review document for #701\n\nIssue: #701\nUser-Visible: no'); + const mixed = repo.commit({ 'docs/reviews/INDEX.md': 'y\n', 'src/c.txt': 'c\nversion\nC\n' }, 'fix: c\n\nIssue: #702\nUser-Visible: no'); + const commits = [mixed, docsOnly, release, two, one]; + const sets = issuePatchSets({ commits, numbers: [701, 702], cwd: repo.dir }); + const ids = commitPatchIds([one.sha, two.sha, release.sha, docsOnly.sha, mixed.sha], { cwd: repo.dir }); + assert.equal(new Set(ids.values()).size, 5, 'у каждого коммита свой patch-id'); + assert.deepEqual(sets.get(701), [ids.get(one.sha), ids.get(two.sha)].sort(), 'два коммита задачи → два patch-id'); + assert.ok(!sets.get(701).includes(ids.get(release.sha)), 'Release:-коммит с трейлером задачи не входит'); + assert.ok(!sets.get(701).includes(ids.get(docsOnly.sha)), 'коммит только в docs/reviews/** не входит'); + assert.deepEqual(sets.get(702), [ids.get(mixed.sha)], 'коммит кода и docs/reviews/** входит'); + assert.deepEqual(issuePatchSets({ commits: [...commits].reverse(), numbers: [701, 702], cwd: repo.dir }), sets, 'порядок не влияет'); + // Ночь до Release-коммита и бета на нём читают один набор: Release: исключён. + assert.deepEqual(issuePatchSets({ commits: [two, one], numbers: [701], cwd: repo.dir }).get(701), sets.get(701)); + // Тот же дифф в другом коммите (cherry-pick в обратном порядке) — тот же patch-id. + repo.git('checkout', '-q', '-b', 'other', base); + repo.git('cherry-pick', two.sha); + const pickedTwo = { sha: repo.git('rev-parse', 'HEAD'), message: two.message }; + repo.git('cherry-pick', one.sha); + const pickedOne = { sha: repo.git('rev-parse', 'HEAD'), message: one.message }; + assert.notEqual(pickedOne.sha, one.sha); + assert.deepEqual(issuePatchSets({ commits: [pickedOne, pickedTwo], numbers: [701], cwd: repo.dir }).get(701), sets.get(701)); + assert.equal(countsForPatchSet({ message: 'x\n\nRelease: v1.80.0', files: ['src/a.ts'] }), false); + assert.equal(countsForPatchSet({ message: 'x', files: ['docs/reviews/INDEX.md'] }), false); + assert.equal(countsForPatchSet({ message: 'x', files: ['docs/reviews/INDEX.md', 'src/a.ts'] }), true); + assert.equal(countsForPatchSet({ message: 'Release v1 candidate (упоминание Release: в теме не трейлер)', files: ['a'] }), true); +}); + +test('#727 AC2 К2: ночной режим — кандидат обязателен, имя по базе и SHA, блок несёт mode и patches', () => { + assert.throws(() => shipReviewMode({ tag: 'nightly', candidate: '' }), /tag=nightly требует candidate/); + assert.equal(shipReviewMode({ tag: 'nightly', candidate: sha('a') }), 'nightly'); + assert.equal(shipReviewMode({ tag: 'v1.79.0-beta.2' }), 'beta'); + assert.throws(() => shipReviewMode({ tag: 'v1.79' }), /not a release tag/); + const candidate = `108427dc1234${'f'.repeat(28)}`; + assert.equal(nightlyDocPath({ base: 'v1.79.0-beta.1', candidate }), `docs/reviews/${NIGHT()}`); + assert.equal(shipDocPath({ tag: 'nightly', candidate, base: 'v1.78.0' }), 'docs/reviews/SHIP-REVIEW-v1.78.0-dev-108427dc1234.md'); + assert.equal(shipDocPath({ tag: 'v1.79.0-beta.2', candidate, base: 'v1.79.0-beta.1' }), 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md'); + assert.throws(() => nightlyDocPath({ base: null, candidate }), /ночной документ без базы/); + // Блок: прежние строки на месте, mode и patches — в конце; parseAnchorBlock их читает. + const patches = new Map([[702, [PID('b')]], [701, [PID('c'), PID('a')]]]); + const block = anchorBlock({ tag: 'nightly', candidate, base: 'v1.79.0-beta.1', issues: [701, 702], high: 0, mode: 'nightly', patches }); + assert.match(block, new RegExp(`\`\`\`\\ntag nightly\\ncandidate ${candidate}\\nbase v1\\.79\\.0-beta\\.1\\nissues 701,702\\nhigh 0\\nmedium 0\\nlow 0\\nrun —\\nmode nightly\\npatches 701:${PID('a')}\\+${PID('c')},702:${PID('b')}\\n\`\`\``)); + assert.deepEqual(parseAnchorBlock(block), { + tag: 'nightly', candidate, issues: [701, 702], high: 0, medium: 0, low: 0, base: 'v1.79.0-beta.1', mode: 'nightly', + patches: new Map([[701, [PID('a'), PID('c')]], [702, [PID('b')]]]), + }); + assert.deepEqual(parsePatches(formatPatches(patches)), new Map([[701, [PID('a'), PID('c')]], [702, [PID('b')]]])); + assert.deepEqual(parsePatches('693:'), new Map([[693, []]]), 'задача без своих коммитов — пустой набор'); + assert.deepEqual(parsePatches('701:zz,702:' + PID('b')), new Map([[702, [PID('b')]]]), 'испорченная запись пропускается'); + // Все задачи clean → читать нечего: proceed=false, issues пусто. + const ship = [{ number: 701, patches: [PID('a')] }, { number: 702, patches: [PID('b')] }]; + const docs = [shipDoc(NIGHT('aaaaaaaaaaaa'), { issues: [701, 702], high: 0, patches: new Map([[701, [PID('a')]], [702, [PID('b')]]]) })]; + const plan = planShipReview({ tag: 'nightly', ship, docs, base: 'v1.79.0-beta.1' }); + assert.equal(plan.mode, 'nightly'); + assert.deepEqual(plan.read, []); + assert.match(plan.note, /^все ship-задачи покрыты: SHIP-REVIEW-v1\.79\.0-beta\.1-dev-aaaaaaaaaaaa\.md/); + assert.equal(reviewSubject('nightly'), 'Ночное пакетное ревью: кандидат — голова `dev` после ночного полного Validate.'); + assert.equal(reviewSubject('v1.79.0-beta.2'), 'Бета: v1.79.0-beta.2.'); +}); + +test('#727 AC3 К3: покрытие — четыре статуса, последний документ главнее, чужая база не в счёт, старый документ — по номеру', () => { + const base = 'v1.79.0-beta.1'; + const ship = [ + { number: 701, patches: [PID('a')] }, { number: 702, patches: [PID('b')] }, + { number: 703, patches: [PID('c'), PID('d')] }, { number: 704, patches: [PID('e')] }, + ]; + const docs = [ + shipDoc(NIGHT('111111111111'), { issues: [701, 703], high: 0, patches: new Map([[701, [PID('a')]], [703, [PID('c')]]]) }), + shipDoc(NIGHT('222222222222'), { issues: [702], high: 1, patches: new Map([[702, [PID('b')]]]) }), + ]; + const status = (list, opts = {}) => Object.fromEntries(shipCoverage({ ship, docs: list, base, ...opts }).map((c) => [c.number, `${c.status}:${c.doc}`])); + assert.deepEqual(status(docs), { + 701: `clean:${NIGHT('111111111111')}`, 702: `high:${NIGHT('222222222222')}`, + 703: `stale:${NIGHT('111111111111')}`, 704: 'none:null', + }); + // Последний главнее: clean, затем high на том же наборе → high; и наоборот → clean. + const clean = shipDoc(NIGHT('333333333333'), { issues: [701], high: 0, patches: new Map([[701, [PID('a')]]]) }); + const high = shipDoc(NIGHT('444444444444'), { issues: [701], high: 2, patches: new Map([[701, [PID('a')]]]) }); + assert.equal(status([clean, high])[701], `high:${NIGHT('444444444444')}`, 'позже опубликованный high отменяет ранний clean'); + assert.equal(status([high, clean])[701], `clean:${NIGHT('333333333333')}`); + // Документ другой базы не покрывает. + const foreign = { name: 'SHIP-REVIEW-v1.79.0-beta.1.md', text: anchorBlock({ tag: 'v1.79.0-beta.1', candidate: sha('e'), base: 'v1.78.0', issues: [704], high: 0 }) }; + assert.equal(status([foreign])[704], 'none:null', 'база v1.78.0 ≠ v1.79.0-beta.1'); + // Старый документ без patches (#696) покрывает по номеру — тот же номер, любой код. + const legacy = { name: 'SHIP-REVIEW-v1.79.0-beta.2.md', text: anchorBlock({ tag: 'v1.79.0-beta.2', candidate: sha('e'), base, issues: [704], high: 0 }) }; + assert.equal(status([legacy])[704], 'clean:SHIP-REVIEW-v1.79.0-beta.2.md'); + // Документ с patches без записи о задаче, число High не записано — не clean. + const partial = shipDoc(NIGHT('555555555555'), { issues: [704], high: 0, patches: new Map() }); + assert.equal(status([partial])[704], `stale:${NIGHT('555555555555')}`); + const noHigh = { name: NIGHT('666666666666'), text: shipDoc('x', { issues: [704], patches: new Map([[704, [PID('e')]]]) }).text.replace('\nhigh 0\n', '\nhigh —\n') }; + assert.equal(status([noHigh])[704], `high:${NIGHT('666666666666')}`); +}); + +test('#727 AC4 К4: гейт по покрытию — clean без документа тега проходит; stale, none, high — отказ', () => { + const tag = 'v1.79.0-beta.2'; + const base = 'v1.79.0-beta.1'; + const ship = [{ number: 701, patches: [PID('a')] }, { number: 702, patches: [PID('b')] }]; + const night = shipDoc(NIGHT(), { issues: [701, 702], high: 0, patches: new Map([[701, [PID('a')]], [702, [PID('b')]]]) }); + assert.deepEqual(shipReviewProblems({ tag, ship, docs: [night], base }), [], 'всё clean без документа тега'); + assert.deepEqual(shipReviewProblems({ tag, ship: [], docs: [], base }), [], 'без ship-задач документ не нужен'); + // stale: код задачи изменился после ночного ревью. + const changed = [{ number: 701, patches: [PID('a'), PID('f')] }, ship[1]]; + const stale = shipReviewProblems({ tag, ship: changed, docs: [night], base }); + assert.equal(stale.length, 1); + assert.match(stale[0], new RegExp(`ship-задача #701 изменилась после ревью docs/reviews/${NIGHT().replace(/\./g, '\\.')}`)); + assert.match(stale[0], /gh workflow run ship-review\.yml --ref dev -f tag=v1\.79\.0-beta\.2$/, 'документа тега нет — дельта без force'); + // none: задача слита после ночи. + const none = shipReviewProblems({ tag, ship: [...ship, { number: 709, patches: [PID('9')] }], docs: [night], base }); + assert.equal(none.length, 1); + assert.match(none[0], /не покрывает ship-задачи #709 — они не прочитаны/); + assert.match(none[0], /Запустить: gh workflow run ship-review\.yml --ref dev -f tag=v1\.79\.0-beta\.2$/); + // Документ тега уже лежит: без force его не переснять — команда с force=true. + const own = { name: 'SHIP-REVIEW-v1.79.0-beta.2.md', text: anchorBlock({ tag, candidate: sha('e'), base, issues: [701], high: 0, mode: 'beta', patches: new Map([[701, [PID('a')]]]) }) }; + assert.match(shipReviewProblems({ tag, ship, docs: [own], base })[0], /не покрывает ship-задачи #702[\s\S]*-f force=true$/); + // high: прежний отказ, с документом и командой пересъёмки. + const red = shipDoc(NIGHT('777777777777'), { issues: [702], high: 1, patches: new Map([[702, [PID('b')]]]) }); + const high = shipReviewProblems({ tag, ship, docs: [night, red], base }); + assert.equal(high.length, 1); + assert.match(high[0], new RegExp(`docs/reviews/${NIGHT('777777777777').replace(/\./g, '\\.')} \\(задачи #702\\): High 1 — бета ждёт починки`)); + assert.match(high[0], /-f force=true$/); + // Документ тега записан для чужой базы — названо явно. + const shifted = { ...own, text: own.text.replace(`base ${base}`, 'base v1.78.0') }; + assert.ok(shipReviewProblems({ tag, ship, docs: [night, shifted], base }).some((p) => /записан для базы v1\.78\.0, а диапазон кандидата — от v1\.79\.0-beta\.1/.test(p))); +}); + +test('#727 AC5 К5: бета читает дельту — бриф только none/stale и «прочитаны ночью»; force — все; пустая дельта — «все покрыты»', () => { + const tag = 'v1.79.0-beta.2'; + const base = 'v1.79.0-beta.1'; + const issue = (number, patches) => ({ number, title: `Задача ${number}`, spec: '## ТЗ\n\nстрока', patches, commits: [{ sha: sha(String(number % 10)), subject: `fix: ${number}` }] }); + const ship = [issue(701, [PID('a')]), issue(702, [PID('b'), PID('f')]), issue(703, [PID('c')]), issue(704, [PID('d')])]; + const docs = [ + shipDoc(NIGHT('111111111111'), { issues: [701, 702], high: 0, patches: new Map([[701, [PID('a')]], [702, [PID('b')]]]) }), + shipDoc(NIGHT('222222222222'), { issues: [704], high: 1, patches: new Map([[704, [PID('d')]]]) }), + ]; + const plan = planShipReview({ tag, ship, docs, base }); + assert.equal(plan.mode, 'beta'); + assert.deepEqual(plan.read.map((i) => i.number), [702, 703], 'stale и none; high не перечитывается без force'); + assert.deepEqual(plan.covered, [{ number: 701, doc: NIGHT('111111111111') }]); + assert.deepEqual(plan.held, [{ number: 704, doc: NIGHT('222222222222') }]); + const brief = renderShipBrief({ tag, candidate: sha('e'), base: { tag: base, sha: sha('f') }, ship: plan.read, doc: shipReviewDocPath(tag), covered: plan.covered, held: plan.held }); + assert.match(brief, /^## Задачи ship \(2\)/m); + assert.match(brief, /### #702 · /); + assert.match(brief, /### #703 · /); + assert.doesNotMatch(brief, /### #701 |### #704 /, 'прочитанные и High в бриф как задачи не входят'); + assert.match(brief, new RegExp(`^- Прочитаны ночью: #701 — \`docs/reviews/${NIGHT('111111111111').replace(/\./g, '\\.')}\``, 'm')); + assert.match(brief, /^- High ждёт починки: #704 — `docs\/reviews\/SHIP-REVIEW-v1\.79\.0-beta\.1-dev-222222222222\.md`/m); + // force=true — все ship-задачи, на ночные документы не смотрит. + const forced = planShipReview({ tag, ship, docs, base, force: true }); + assert.deepEqual(forced.read.map((i) => i.number), [701, 702, 703, 704]); + assert.deepEqual([forced.covered, forced.held], [[], []]); + // Пустая дельта: модели нет, сводка «все ship-задачи покрыты: <документы>». + const empty = planShipReview({ tag, ship: [ship[0]], docs, base }); + assert.deepEqual(empty.read, []); + assert.equal(empty.note, `все ship-задачи покрыты: ${NIGHT('111111111111')} — модель не запускается`); + assert.match(planShipReview({ tag, ship: [ship[3]], docs, base }).note, /^читать нечего: #704 — High в .*force=true/); + assert.equal(planShipReview({ tag, ship: [], docs, base }).note, 'ship-задач в диапазоне нет — ревью не нужно'); + // Ночной бриф называет кандидата головой dev и пишет в ночной документ. + const nightly = renderShipBrief({ tag: 'nightly', candidate: sha('e'), base: { tag: base, sha: sha('f') }, ship: plan.read, doc: `docs/reviews/${NIGHT()}` }); + assert.match(nightly, /^# Вход ночного пакетного ревью ship$/m); + assert.match(nightly, /- Кандидат: `e{40}` — голова `dev` после ночного полного Validate/); + assert.match(nightly, new RegExp(`- Документ: \`docs/reviews/${NIGHT().replace(/\./g, '\\.')}\``)); +}); + +test('#727 AC7 К7: индекс и архив знают ночной документ; база-бета — её линия, стабильная база — ближайшая новее', () => { + assert.deepEqual(parseDocName(NIGHT()), { stage: 'ship', issue: null, round: null, suffix: null, tag: 'v1.79.0-beta.1', nightly: true }); + assert.deepEqual(parseDocName('SHIP-REVIEW-v1.78.0-dev-0123456789ab.md'), { stage: 'ship', issue: null, round: null, suffix: null, tag: 'v1.78.0', nightly: true }); + assert.equal(parseDocName('SHIP-REVIEW-v1.78.0-dev-0123.md'), null, 'SHA — ровно 12 знаков'); + assert.equal(parseDocName('SHIP-REVIEW-nightly.md'), null); + const entry = (name, tag, extra = {}) => ({ name, stage: 'ship', issue: null, tag, verdict: '—', high: 0, medium: 0, findings: [], files: [], ...extra }); + const md = renderIndex({ entries: [ + entry('SHIP-REVIEW-v1.79.0-beta.1.md', 'v1.79.0-beta.1'), + entry(NIGHT(), 'v1.79.0-beta.1', { nightly: true, high: 1 }), + entry('SHIP-REVIEW-v1.79.0-beta.2.md', 'v1.79.0-beta.2'), + ] }); + const rows = md.split('\n').filter((line) => /^\| (бета|ночь)/.test(line)); + assert.deepEqual(rows.map((r) => r.split('|')[1].trim()), ['бета v1.79.0-beta.2', 'ночь после v1.79.0-beta.1', 'бета v1.79.0-beta.1']); + assert.match(rows[1], new RegExp(`\\[${NIGHT().replace(/\./g, '\\.')}\\]\\(${NIGHT().replace(/\./g, '\\.')}\\) \\| ночное пакетное ревью ship · — \\| ⚪ — \\| 1 \\|`)); + const plan = (names, lines, through) => archivePlan({ names, lines: lines.map((tag) => ({ tag, issues: [] })), open: [], through }); + const night = 'SHIP-REVIEW-v1.78.0-dev-0123456789ab.md'; + // (а) база-бета → каталог её стабильной линии + assert.deepEqual(plan([NIGHT()], ['v1.79.0'], 'v1.79.0').moves.map((m) => m.to), [`legacy/reviews/v1.79.0/${NIGHT()}`]); + // (б) стабильная база → линия новее базы, не каталог самой базы + const later = plan([night], ['v1.78.0', 'v1.79.0'], 'v1.79.0'); + assert.deepEqual(later.moves.map((m) => m.to), [`legacy/reviews/v1.79.0/${night}`]); + assert.ok(!later.moves.some((m) => m.to.startsWith('legacy/reviews/v1.78.0/'))); + // (в) несколько линий новее — ближайшая, не последняя + assert.deepEqual(plan([night], ['v1.78.0', 'v1.78.1', 'v1.79.0'], 'v1.79.0').moves.map((m) => m.to), [`legacy/reviews/v1.78.1/${night}`]); + // (г) линии новее нет — остаётся на месте с причиной + const none = plan([night], ['v1.77.0', 'v1.78.0'], 'v1.78.0'); + assert.deepEqual(none.moves, []); + assert.deepEqual(none.kept, [{ name: night, reason: 'ночное ревью после v1.78.0: архивируемой линии новее базы нет' }]); +}); + +test('#727 AC8 К8: строка о High ночью — с меткой документа, повтор на тот же документ не пишется', () => { + const doc = `docs/reviews/${NIGHT()}`; + const body = highCommentBody(doc); + assert.equal(body.split('\n').length, 1, 'одна строка'); + assert.ok(body.startsWith(`Ночное пакетное ревью ship нашло High: \`${doc}\`. Бета не выйдет, пока находка не починена отдельной задачей и ревью не переснято (§11.7)`)); + assert.match(body, new RegExp(`$`)); + assert.equal(highCommentBody(NIGHT()), body, 'имя и путь дают одну строку'); + const issues = [ + { number: 701, comments: [{ body: 'обсуждение' }] }, + { number: 702, comments: [{ body: body }] }, + { number: 703, comments: [{ body: highCommentBody(NIGHT('999999999999')) }] }, + ]; + assert.deepEqual(highCommentTargets({ doc, issues }), [701, 703], 'метка того же документа — повтора нет; другой документ — пишется'); +}); + +// ---------- #727: шаги _ship-review.yml на настоящем bash и git ---------- +// +// Шаги исполняются как есть, из файла workflow: подготовка (prepare), +// публикация документа и строка о High. Подменены транспорт push (github.com → +// локальный origin) и `gh` (issue из файлов песочницы, комментарии — в журнал). +// Патч-набор, база, документы диапазона и покрытие — настоящий git. + +const SCRIPTS = fileURLToPath(new URL('../scripts', import.meta.url)); +const SHIP_WORKFLOW = fileURLToPath(new URL('../.github/workflows/_ship-review.yml', import.meta.url)); +const hasTools = () => process.platform !== 'win32' + && ['bash', 'jq', 'sha256sum'].every((tool) => spawnSync(tool, ['--version']).status === 0); +const GIT_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: '1', GIT_CONFIG_KEY_0: 'init.defaultBranch', GIT_CONFIG_VALUE_0: 'dev', +}; + +/** Тело `run:` шага, как его прочтёт YAML (блок кончается на строке с отступом меньше десяти). */ +function stepRun(name) { + const text = readFileSync(SHIP_WORKFLOW, 'utf8'); + const start = text.indexOf(` - name: ${name}\n`); + assert.ok(start >= 0, `шаг «${name}»`); + const lines = text.slice(start).split('\n'); + const from = lines.indexOf(' run: |'); + assert.ok(from > 0, `у шага «${name}» есть 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'); +} + +/** Замыкание относительных импортов скрипта. */ +function importClosure(entry, seen = new Set()) { + if (seen.has(entry)) return seen; + seen.add(entry); + for (const m of readFileSync(entry, 'utf8').matchAll(/^import[^'"]*['"](\.{1,2}\/[^'"]+)['"]/gm)) { + importClosure(join(dirname(entry), m[1]), seen); + } + return seen; +} + +function shipSandbox(t) { + const root = mkdtempSync(join(tmpdir(), 'hp-727-wf-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const origin = join(root, 'origin.git'); + const work = join(root, 'work'); + const temp = join(root, 'runner'); + const fake = join(root, 'fake'); + const bin = join(root, 'bin'); + for (const dir of [temp, fake, bin]) mkdirSync(dir); + const git = (cwd, ...args) => { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: GIT_ENV }); + assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); + }; + git(root, 'init', '--bare', '-q', origin); + git(root, 'clone', '-q', origin, work); + git(work, 'checkout', '-q', '-b', 'dev'); + mkdirSync(join(work, 'scripts')); + const scripts = ['ship-review.mjs', 'reviews-index.mjs', 'review-doc-guard.mjs'] + .reduce((seen, name) => importClosure(join(SCRIPTS, name), seen), new Set()); + for (const file of scripts) writeFileSync(join(work, 'scripts', file.slice(SCRIPTS.length + 1)), readFileSync(file)); + mkdirSync(join(work, 'docs', 'reviews'), { recursive: true }); + writeFileSync(join(work, 'docs', 'reviews', 'INDEX.md'), '# Индекс ревью\n'); + for (const name of ['a', 'b', 'c']) writeFileSync(join(work, `${name}.mjs`), `export const ${name} = 0;\n`); + const commit = (files, message) => { + for (const [path, text] of Object.entries(files)) { + mkdirSync(dirname(join(work, path)), { recursive: true }); + writeFileSync(join(work, path), text); + } + git(work, 'add', '-A'); + git(work, 'commit', '-q', '-m', message); + return git(work, 'rev-parse', 'HEAD'); + }; + commit({}, 'base'); + git(work, 'tag', 'v1.0.0'); + const realGit = spawnSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).stdout.trim(); + writeFileSync(join(bin, 'git'), [ + '#!/usr/bin/env bash', + 'if [ "$1" = push ]; then', + ' 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 }); + // gh: `issue view N … --json …` — файл issue-N.json; `issue comment N … --body X` — в журнал. + writeFileSync(join(bin, 'gh'), [ + '#!/usr/bin/env bash', + 'echo "$*" >> "$FAKE_DIR/gh-calls"', + 'if [ "$1 $2" = "issue view" ]; then cat "$FAKE_DIR/issue-$3.json"; exit 0; fi', + 'if [ "$1 $2" = "issue comment" ]; then', + ' while [ $# -gt 0 ]; do if [ "$1" = --body ]; then printf "%s\\t%s\\n" "$NUM" "$2" >> "$FAKE_DIR/comments"; fi; NUM=${NUM:-$3}; shift; done', + ' exit 0', + 'fi', + 'echo "unexpected gh $*" >&2; exit 1', + '', + ].join('\n'), { mode: 0o755 }); + // Коммит публикации документа несёт `Issue: #696` — задача не ship. + writeFileSync(join(fake, 'issue-696.json'), JSON.stringify({ number: 696, title: 'Пакетное ревью ship', body: '', labels: [], comments: [] })); + const box = { + root, origin, work, temp, fake, git, commit, + push() { git(work, 'push', '-q', '--tags', 'origin', 'HEAD:dev'); git(work, 'fetch', '-q', 'origin'); }, + issue(number, { comments = [{ body: `Слияние без ревью модели\n\n${MARKER}` }], labels = [] } = {}) { + writeFileSync(join(fake, `issue-${number}.json`), JSON.stringify({ + number, title: `Задача ${number}`, body: '## ТЗ\n\nстрока', labels, comments, + })); + }, + run(script, env) { + for (const file of ['output', 'summary.md']) rmSync(join(temp, file), { force: true }); + const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', script], { + cwd: work, encoding: 'utf8', + env: { + ...GIT_ENV, ...env, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, GH_TOKEN: 'x', TOKEN: 'x', + GITHUB_OUTPUT: join(temp, 'output'), GITHUB_STEP_SUMMARY: join(temp, 'summary.md'), + FAKE_DIR: fake, FAKE_ORIGIN: origin, REAL_GIT: realGit, + }, + }); + const read = (path) => { try { return readFileSync(path, 'utf8'); } catch { return ''; } }; + const output = {}; + for (const line of read(join(temp, 'output')).split('\n')) { + const at = line.indexOf('='); + if (at > 0) output[line.slice(0, at)] = line.slice(at + 1); + } + return { status: r.status, stdout: r.stdout, stderr: r.stderr, output, summary: read(join(temp, 'summary.md')) }; + }, + prepare(env) { + return box.run(stepRun('Кандидат, база и ship-задачи'), { + FORCE: 'false', CANDIDATE: '', RUN_URL: 'https://github.com/o/r/actions/runs/1', ...env, + }); + }, + /** Публикация так, как её видит job publish: выходы prepare и запечатанный результат модели. */ + publish(prepared, result, extra = {}) { + const dir = join(temp, 'ship-review-result'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir); + writeFileSync(join(dir, 'ship-review.md'), `# Пакетное ревью ship\n\nИтог: High ${result.high} · Medium ${result.medium} · Low ${result.low}\n`); + writeFileSync(join(dir, 'result.json'), JSON.stringify(result)); + spawnSync('bash', ['-c', 'sha256sum ship-review.md result.json > manifest.sha256'], { cwd: dir }); + const o = prepared.output; + const env = { + TAG: extra.TAG, DOC: o.doc, CANDIDATE: o.candidate, BASE: o.base, ISSUES: o.issues, MODE: o.mode, PATCHES: o.patches, + RUN_URL: 'https://github.com/o/r/actions/runs/2', + }; + const published = box.run(stepRun('Опубликовать документ'), env); + git(work, 'fetch', '-q', 'origin'); + return published; + }, + check(tag, candidate) { + return spawnSync(process.execPath, ['scripts/ship-review.mjs', 'check', `--tag=${tag}`, `--candidate=${candidate}`, '--repo=o/r'], { + cwd: work, encoding: 'utf8', env: { ...GIT_ENV, PATH: `${bin}:${process.env.PATH}`, FAKE_DIR: fake, REAL_GIT: realGit }, + }); + }, + }; + return box; +} + +test('#727 AC2/AC5 _ship-review.yml на настоящем bash: ночь читает непокрытое, бета — дельту, гейт принимает ночной документ', (t) => { + if (!hasTools()) { t.skip('bash/jq/sha256sum недоступны'); return; } + const box = shipSandbox(t); + box.issue(701); + box.issue(702); + box.issue(703, { comments: [] }); // show: в пакет не входит + box.commit({ 'a.mjs': 'export const a = 1;\n' }, 'fix: a (#701)\n\nIssue: #701\nUser-Visible: no'); + box.commit({ 'b.mjs': 'export const b = 1;\n' }, 'fix: b (#702)\n\nIssue: #702\nUser-Visible: no'); + box.commit({ 'c.mjs': 'export const c = 1;\n' }, 'feat: c (#703)\n\nIssue: #703\nUser-Visible: no'); + box.push(); + const head = box.git(box.work, 'rev-parse', 'HEAD'); + + // tag=nightly без candidate — отказ до подстановки вершины dev. + const refused = box.prepare({ TAG: 'nightly' }); + assert.notEqual(refused.status, 0); + assert.match(refused.stderr, /tag=nightly требует candidate/); + assert.equal(refused.output.proceed, undefined); + + // Первая ночь: читать обе ship-задачи, документ по базе и SHA. + const first = box.prepare({ TAG: 'nightly', CANDIDATE: head }); + assert.equal(first.status, 0, first.stderr); + const nightDoc = `docs/reviews/SHIP-REVIEW-v1.0.0-dev-${head.slice(0, 12)}.md`; + assert.deepEqual([first.output.proceed, first.output.mode, first.output.doc, first.output.base, first.output.issues], + ['true', 'nightly', nightDoc, 'v1.0.0', '701,702']); + assert.match(first.output.patches, /^701:[0-9a-f]{40},702:[0-9a-f]{40}$/); + assert.equal(first.output.subject, 'Ночное пакетное ревью: кандидат — голова `dev` после ночного полного Validate.'); + assert.match(readFileSync(join(box.temp, 'ship-review-input', 'brief.md'), 'utf8'), /^# Вход ночного пакетного ревью ship$/m); + + // Публикация: mode и patches — из prepare; поле patches в результате модели не читается. + const bogus = { high: 0, medium: 1, low: 0, summary: 'ok', patches: '701:' + 'd'.repeat(40) }; + const published = box.publish(first, bogus, { TAG: 'nightly' }); + assert.equal(published.status, 0, published.stderr + published.stdout); + const text = box.git(box.work, 'show', `origin/dev:${nightDoc}`); + const block = parseAnchorBlock(text); + assert.equal(block.mode, 'nightly'); + assert.equal(block.base, 'v1.0.0'); + assert.deepEqual(block.issues, [701, 702]); + assert.equal(formatPatches(block.patches), first.output.patches, 'patches — ровно из prepare'); + assert.match(box.git(box.work, 'show', 'origin/dev:docs/reviews/INDEX.md'), /ночь после v1\.0\.0/); + + // Документ на этот SHA уже в dev — ночь ничего не делает. + const again = box.prepare({ TAG: 'nightly', CANDIDATE: head }); + assert.equal(again.status, 0, again.stderr); + assert.equal(again.output.proceed, 'false'); + assert.match(again.stdout, /уже есть в dev — повторное ревью не запускается/); + + // Кандидат беты — Release:-коммит поверх ночной головы: гейт принимает ночной документ, документа тега нет. + box.git(box.work, 'reset', '-q', '--hard', 'origin/dev'); + const release = box.commit({ 'package.json': '{"version":"1.1.0-beta.1"}\n' }, + 'Release v1.1.0-beta.1 candidate\n\nIssue: #701\nIssue: #702\nIssue: #703\nUser-Visible: yes\nRelease: v1.1.0-beta.1'); + const pass = box.check('v1.1.0-beta.1', release); + assert.equal(pass.status, 0, pass.stderr); + assert.match(pass.stdout, new RegExp(`#701 — ${nightDoc.replace(/\./g, '\\.')}, #702 — ${nightDoc.replace(/\./g, '\\.')}`)); + box.git(box.work, 'reset', '-q', '--hard', 'origin/dev'); + + // Пустая дельта беты — модели нет, сводка «все ship-задачи покрыты». + const covered = box.prepare({ TAG: 'v1.1.0-beta.1' }); + assert.equal(covered.status, 0, covered.stderr); + assert.equal(covered.output.proceed, 'false'); + assert.equal(covered.output.issues, ''); + assert.match(covered.summary, new RegExp(`^- все ship-задачи покрыты: SHIP-REVIEW-v1\\.0\\.0-dev-${head.slice(0, 12)}\\.md — модель не запускается$`, 'm')); + + // Документ только в docs/reviews/** с трейлером задачи — покрытие держится. + box.commit({ 'docs/reviews/CODE-REVIEW-702-r1.md': '# r1\nВердикт: **зелёный** · High: 0 · Medium: 0\n' }, 'docs: review document for #702\n\nIssue: #702\nUser-Visible: no'); + // #701 получила коммит после ночи → stale; гейт отказывает, бета читает только её. + box.commit({ 'a.mjs': 'export const a = 2;\n' }, 'fix: a again (#701)\n\nIssue: #701\nUser-Visible: no'); + box.push(); + const tip = box.git(box.work, 'rev-parse', 'HEAD'); + const stale = box.check('v1.1.0-beta.1', tip); + assert.equal(stale.status, 1); + assert.match(stale.stderr, new RegExp(`::error::ship-задача #701 изменилась после ревью ${nightDoc.replace(/\./g, '\\.')}`)); + assert.doesNotMatch(stale.stderr, /#702/, 'коммит только в docs/reviews/** покрытие не снимает'); + const delta = box.prepare({ TAG: 'v1.1.0-beta.1' }); + assert.equal(delta.status, 0, delta.stderr); + assert.deepEqual([delta.output.proceed, delta.output.mode, delta.output.doc, delta.output.issues], + ['true', 'beta', 'docs/reviews/SHIP-REVIEW-v1.1.0-beta.1.md', '701']); + assert.match(delta.output.patches, /^701:[0-9a-f]{40}\+[0-9a-f]{40}$/); + const brief = readFileSync(join(box.temp, 'ship-review-input', 'brief.md'), 'utf8'); + assert.match(brief, new RegExp(`^- Прочитаны ночью: #702 — \`${nightDoc.replace(/\./g, '\\.')}\``, 'm')); + assert.doesNotMatch(brief, /### #702 /); + // force=true — все ship-задачи, ночные документы не в счёт. + const forced = box.prepare({ TAG: 'v1.1.0-beta.1', FORCE: 'true' }); + assert.equal(forced.output.issues, '701,702'); + + // Ночь после: High на #701 → задача держится, следующие ночи её не читают, гейт стоит. + const night2 = box.prepare({ TAG: 'nightly', CANDIDATE: tip }); + assert.equal(night2.output.issues, '701'); + assert.equal(box.publish(night2, { high: 1, medium: 0, low: 0, summary: 'x' }, { TAG: 'nightly' }).status, 0); + box.git(box.work, 'reset', '-q', '--hard', 'origin/dev'); + box.commit({ 'c.mjs': 'export const c = 2;\n' }, 'feat: c again (#703)\n\nIssue: #703\nUser-Visible: no'); + box.push(); + const night3 = box.prepare({ TAG: 'nightly', CANDIDATE: box.git(box.work, 'rev-parse', 'HEAD') }); + assert.equal(night3.status, 0, night3.stderr); + assert.equal(night3.output.proceed, 'false', 'High не перечитывается без force'); + assert.match(night3.summary, /читать нечего: #701 — High в SHIP-REVIEW-v1\.0\.0-dev-[0-9a-f]{12}\.md/); + const high = box.check('v1.1.0-beta.1', box.git(box.work, 'rev-parse', 'HEAD')); + assert.equal(high.status, 1); + assert.match(high.stderr, /\(задачи #701\): High 1 — бета ждёт починки[^\n]*-f force=true/); + + // Кандидат не предок dev — отказ prepare. + box.git(box.work, 'checkout', '-q', '-b', 'side', 'HEAD~1'); + const side = box.commit({ 'b.mjs': 'export const b = 9;\n' }, 'fix: side (#702)\n\nIssue: #702\nUser-Visible: no'); + const foreign = box.prepare({ TAG: 'nightly', CANDIDATE: side }); + assert.notEqual(foreign.status, 0); + assert.match(foreign.stderr, /не предок origin\/dev/); +}); + +test('#727 AC8 _ship-review.yml на настоящем bash: строка о High — только ночью и только при High > 0, без повтора', (t) => { + if (!hasTools()) { t.skip('bash/jq/sha256sum недоступны'); return; } + const box = shipSandbox(t); + const doc = 'docs/reviews/SHIP-REVIEW-v1.0.0-dev-0123456789ab.md'; + const step = stepRun('High ночью — строка в задачи документа'); + const run = (mode, high) => { + const dir = join(box.temp, 'ship-review-result'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'result.json'), JSON.stringify({ high, medium: 3, low: 1, summary: 'x' })); + rmSync(join(box.fake, 'comments'), { force: true }); + const r = box.run(step, { MODE: mode, DOC: doc, ISSUES: '701,702' }); + assert.equal(r.status, 0, r.stderr); + let comments = ''; + try { comments = readFileSync(join(box.fake, 'comments'), 'utf8'); } catch { /* нет комментариев */ } + return comments.split('\n').filter(Boolean); + }; + box.issue(701, { comments: [] }); + box.issue(702, { comments: [{ body: highCommentBody(doc) }] }); + assert.deepEqual(run('beta', 2), [], 'бета: High держит гейт, строки в задачи нет'); + assert.deepEqual(run('nightly', 0), [], 'ночь без High: Medium и Low в issue не пишутся'); + assert.deepEqual(run('nightly', 2), [`701\t${highCommentBody(doc)}`], 'в #702 строка о том же документе уже есть'); + const workflow = readFileSync(SHIP_WORKFLOW, 'utf8'); + const at = workflow.indexOf(' - name: High ночью — строка в задачи документа'); + assert.match(workflow.slice(at, workflow.indexOf('run: |', at)), /if: needs\.prepare\.outputs\.mode == 'nightly'\n[\s\S]*GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}/); +}); + +test('#727 AC2 _ship-review.yml: ночной режим в теле, patches — не из результата модели, run без heredoc в новых шагах', () => { + const workflow = readFileSync(SHIP_WORKFLOW, 'utf8'); + const prepare = stepRun('Кандидат, база и ship-задачи'); + // nightly без candidate отказывает до подстановки вершины dev + assert.ok(prepare.indexOf('ship-review.mjs mode --tag="$TAG" --candidate="$CANDIDATE"') < prepare.indexOf('CANDIDATE=$(git rev-parse origin/dev)')); + assert.match(prepare, /doc=\$\(node scripts\/ship-review\.mjs doc --tag="\$TAG" --candidate="\$CANDIDATE"\)/); + assert.match(prepare, /--force="\$FORCE"/); + assert.match(prepare, /^set -o pipefail$/m, 'отказ prepare за | tee — не «ship-задач нет»'); + for (const output of ['mode', 'patches', 'subject']) { + assert.match(workflow, new RegExp(`\\n ${output}: \\$\\{\\{ steps\\.range\\.outputs\\.${output} \\}\\}`)); + } + const model = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n publish:')); + assert.match(model, /\$\{\{ needs\.prepare\.outputs\.subject \}\}/, 'промпт называет кандидата по режиму'); + assert.doesNotMatch(model, /Бета: \$\{\{ inputs\.tag \}\}/); + const publish = stepRun('Опубликовать документ'); + assert.match(publish, /mode: process\.env\.MODE, patches: m\.parsePatches\(process\.env\.PATCHES\)/); + assert.match(workflow, /\n {10}PATCHES: \$\{\{ needs\.prepare\.outputs\.patches \}\}\n/); + assert.deepEqual([...publish.matchAll(/jq -r '([^']+)'/g)].map((m) => m[1]), ['.high', '.medium', '.low'], 'из результата модели — только счёт'); + assert.match(publish, /doc --tag="\$TAG" --candidate="\$CANDIDATE"/); + for (const body of [prepare, stepRun('High ночью — строка в задачи документа')]) { + assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, 'heredoc в run'); + assert.equal(spawnSync('bash', ['-n', '-c', body]).status, 0, 'bash -n'); + } + assert.equal(spawnSync('bash', ['-n', '-c', publish]).status, 0, 'bash -n публикации'); +});