mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
feat(process): nightly ship batch review, reused by the beta gate by patch set (#727)
Ship tasks merge without a model review and their code was first read by the batch review right before a beta: one session over the whole range, ten to forty-five minutes on the release path, days after the merge. The gate also knew a single document (SHIP-REVIEW-<tag>.md) and covered tasks by number only, so a commit that landed after the review under the same trailer still counted as read. - scripts/ship-review.mjs: the patch set of a task is the sorted `git patch-id --stable` of its range commits, without `Release:` commits (the beta candidate carries every Issue: of the line) and commits touching only docs/reviews/**; the diff options are explicit so a local git config cannot change it. shipCoverage rates every ship task from the documents of the same base (candidate and origin/dev, latest publication wins): clean, high, stale, none; documents without `patches` cover by number. `tag=nightly` is a reserved mode: the candidate is required, the document is SHIP-REVIEW-<base>-dev-<sha12>.md, only none/stale tasks are read and nothing runs when nothing is uncovered. The beta reads the same delta (force=true reads everything, as before); the brief names what the night already read. The gate refuses none/stale with the command and keeps the High refusal with force=true; all clean passes without a tag document. The machine block gains `mode` and `patches` at its end. comment-high writes one line per task of a nightly document with High, once per document (hp:ship-review-high). - _ship-review.yml: prepare refuses nightly without a candidate before defaulting to the dev tip, computes the document from base and SHA and no longer reads a prepare failure behind `| tee` as "no ship tasks"; publish takes mode and patches from prepare, never from the model result; a new step comments High at night with HP_PROCESS_TOKEN. - _nightly.yml: the Validate run SHA is a separate step output before the wait; a new job dispatches ship-review.yml -f tag=nightly on it whatever Validate's outcome, waits only for the run to appear and never colours the night. Thin files in main are unchanged. - reviews-index/reviews-archive: the nightly name is a ship document with nightly: true; a beta base archives with its line, a stable base with the nearest archived line newer than the base, or stays. - PROCESS.md §11.7, §10.4 and REVIEWER.md describe the nightly mode, patch set, coverage and beta delta; the digest test pins the key rule. Tests run the prepare, publish and comment steps and the nightly steps on real bash with real git in temporary repositories; only push transport and gh are faked. Issue: #727 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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-<sha12>.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 }}"
|
||||
|
||||
+38
-6
@@ -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=<SHA
|
||||
прогона Validate>`: ревью читает код, а не прогон. `nightly` — зарезервированное
|
||||
значение входа `tag`, кандидат в нём обязателен. Ночь читает только непокрытые
|
||||
ship-задачи диапазона «прошлый тег..кандидат» и пишет
|
||||
`docs/reviews/SHIP-REVIEW-<база>-dev-<sha12>.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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -178,6 +178,10 @@
|
||||
- Документ `docs/reviews/SHIP-REVIEW-<тег>.md` публикует детерминированный
|
||||
шаг; High не пускает бету, Medium и Low решает владелец
|
||||
([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
|
||||
- Ночной документ `docs/reviews/SHIP-REVIEW-<база>-dev-<sha12>.md` читает только
|
||||
непокрытые задачи — без документа или изменившиеся после ревью; бета читает
|
||||
только то, что ночь не прочла: гейт беты принимает ночной документ при том же
|
||||
патч-наборе ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
|
||||
|
||||
## Независимое ревью линии
|
||||
|
||||
|
||||
@@ -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} не входит в архивируемые линии` });
|
||||
|
||||
@@ -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-<sha12>.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) {
|
||||
|
||||
+384
-48
@@ -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=<sha> --out=<dir> [--repo=owner/name]
|
||||
* node scripts/ship-review.mjs doc --tag=nightly --candidate=<sha>
|
||||
* node scripts/ship-review.mjs mode --tag=<тег|nightly> [--candidate=<sha>]
|
||||
* node scripts/ship-review.mjs prepare --tag=<тег|nightly> --candidate=<sha> --out=<dir> [--force=true] [--repo=owner/name]
|
||||
* node scripts/ship-review.mjs check --tag=<тег> --candidate=<sha> [--repo=owner/name]
|
||||
* node scripts/ship-review.mjs comment-high --mode=<nightly|beta> --high=<N> --doc=<путь> --issues=<NN,…> [--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-<sha12>.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 = /<!-- hp:ship-review-high doc=([^\s>]+) -->/g;
|
||||
export const SHIP_MERGE_MARKER_RE = /<!-- hp:ship-merge material=([0-9a-f]{40}) -->/;
|
||||
/** #707: риск по участкам, с которым ship слит (трек подтверждён владельцем или риск только visual). */
|
||||
export const SHIP_RISK_MARKER_RE = /<!-- hp:ship-risk classes=([a-z,]+) -->/;
|
||||
export const SHIP_REVIEW_ANCHOR = '<!-- hp-ship-review-anchors -->';
|
||||
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<number, string[]>}
|
||||
*/
|
||||
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` машинного блока: `<NN>:<pid>+<pid>,…`, задачи и 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) <!-- hp:ship-review-high doc=${docName(path)} -->`;
|
||||
}
|
||||
|
||||
/** #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=<tag> | prepare --tag=<tag> --candidate=<sha> --out=<dir> [--repo=o/r] | check --tag=<tag> --candidate=<sha> [--repo=o/r]');
|
||||
throw new Error('usage: ship-review.mjs doc --tag=<tag> [--candidate=<sha>] | mode --tag=<tag> [--candidate=<sha>]'
|
||||
+ ' | prepare --tag=<tag> --candidate=<sha> --out=<dir> [--force=true] [--repo=o/r] | check --tag=<tag> --candidate=<sha> [--repo=o/r]'
|
||||
+ ' | comment-high --mode=<m> --high=<N> --doc=<path> --issues=<NN,…> [--repo=o/r]');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
|
||||
@@ -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-ревью не запущено/);
|
||||
});
|
||||
|
||||
@@ -105,6 +105,8 @@ const KEY_RULES = {
|
||||
['4-лимит-циклов-ревью-4', 'вердикт, исчерпавший бюджет, сразу ставит `review-4`'],
|
||||
['71-цепочка', 'Технический спор автора и ревьюера решается вердиктом, а не владельцем'],
|
||||
['12-запрещено', 'Medium-находки, оставленные как TODO в документе ревью'],
|
||||
// #727: ночное пакетное ревью ship переиспользуется гейтом беты по патч-набору.
|
||||
['117-пакетное-ревью-ship-перед-бетой', 'гейт беты принимает ночной документ при том же патч-наборе'],
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
+577
-3
@@ -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(`<!-- hp:ship-review-high doc=${NIGHT().replace(/\./g, '\\.')} -->$`));
|
||||
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 публикации');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user