mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
r2 H1: INDEX.md — снимок каталога, и ребейз ветки на dev, получивший чужие документы ревью, устаревал его молча. Теперь `--commit-if-stale` пересобирает и коммитит индекс коммитом конвейера после приведения к dev (process.yml) и после ребейза кандидата (merge-candidate.mjs); тест «индекс свеж» сравнивает закоммиченный файл с пересборкой и красит Validate при расхождении. r2 M1: находка без заголовка — первый абзац секции, склеенный из перенесённых строк, без маркера буллета и кода `**M1.**`; «не найдено», служебные скобки «(унаследовано…)» — не находка. Нумерованные пункты тоже забирают перенесённые строки. Мутант reviews-index-paragraph-tail. PROCESS.md §2.10 дополнен. Issue: #635 User-Visible: no
1680 lines
122 KiB
YAML
1680 lines
122 KiB
YAML
name: Ревью-конвейер
|
||
run-name: "process #${{ github.event.issue.number }} · ${{ github.event.label.name }} · ${{ github.event.issue.title }}"
|
||
|
||
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
|
||
# сообщение: она порождает событие, событие запускает следующий шаг.
|
||
#
|
||
# S4-spec-review -> ревью ТЗ -> S5-ready | S3-spec
|
||
# S7-code-review -> код-ревью -> слияние в dev -> S8-merged | S6-in-progress
|
||
#
|
||
# Три вещи, без которых конвейер молча не работает:
|
||
#
|
||
# 1. Метки переставляются токеном HP_PROCESS_TOKEN, а не GITHUB_TOKEN. GitHub
|
||
# намеренно не запускает workflow от событий, вызванных GITHUB_TOKEN, чтобы
|
||
# не было циклов — цепочка оборвалась бы после первого шага.
|
||
# 2. Этот файл обязан лежать в ветке по умолчанию (main). Для события `issues`
|
||
# GitHub берёт workflow только оттуда, независимо от того, что в dev.
|
||
# 3. Многострочный текст внутри `run:` — только через heredoc. Строка с нулевым
|
||
# отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
|
||
# Проверять не только YAML, но и каждый `run` через `bash -n`.
|
||
|
||
on:
|
||
issues:
|
||
types: [labeled]
|
||
|
||
# Concurrency стоит на job, а не на workflow (#499). На уровне workflow в
|
||
# группу issue попадал КАЖДЫЙ прогон — и от `polish`, и от `P2`, и от метки,
|
||
# которую переставил сам конвейер. GitHub держит в группе один идущий и один
|
||
# ожидающий прогон, и новый ожидающий вытесняет старого: ожидавший S7-code-review
|
||
# отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает
|
||
# ни одной job (`if` на guard) и в группу не входит.
|
||
|
||
# #556: права выдаются по job, а не одной строкой на весь workflow. Общий блок
|
||
# давал `issues: write` и OIDC даже той стадии, которая ничего не пишет, —
|
||
# модели. Ниже минимум на каждую: писать в issue умеют только детерминированные
|
||
# стадии, OIDC нужен исключительно `claude-code-action`.
|
||
permissions:
|
||
contents: read
|
||
|
||
jobs:
|
||
guard:
|
||
name: "Страж: ребейз на dev и предпосылки ревью"
|
||
# Читает и переставляет метки, комментирует отказ.
|
||
permissions:
|
||
contents: read
|
||
issues: write
|
||
# Только статусные метки этапов ревью запускают конвейер (#499). Остальные
|
||
# события помечаются skipped и не занимают место в группе concurrency.
|
||
if: github.event.label.name == 'S4-spec-review' || github.event.label.name == 'S7-code-review'
|
||
runs-on: ubuntu-latest
|
||
concurrency:
|
||
group: process-issue-${{ github.event.issue.number }}
|
||
cancel-in-progress: false
|
||
outputs:
|
||
stage: ${{ steps.decide.outputs.stage }}
|
||
cycle: ${{ steps.decide.outputs.cycle }}
|
||
spent: ${{ steps.decide.outputs.spent }}
|
||
limit: ${{ steps.decide.outputs.limit }}
|
||
steps:
|
||
# Мелкий checkout: guard остаётся лёгким, но ему нужен
|
||
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
|
||
# inline-shell не покрывается тестами (#454). Node на раннере
|
||
# предустановлен, setup-node не нужен.
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||
with:
|
||
fetch-depth: 1
|
||
ref: dev
|
||
persist-credentials: false
|
||
- id: decide
|
||
env:
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
REPO: ${{ github.repository }}
|
||
LABEL: ${{ github.event.label.name }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
run: |
|
||
# Контроллер идемпотентен (#499): событие только будит его, а состояние
|
||
# читается ТЕКУЩЕЕ, не из снимка события. Прогон мог простоять в
|
||
# очереди concurrency, пока владелец снял метку или поставил blocked —
|
||
# снимок события об этом не знает, а исполнять отозванный запрос нельзя.
|
||
current=$(gh issue view "$NUM" --repo "$REPO" --json labels --jq '.labels[].name')
|
||
has() { printf '%s\n' "$current" | grep -qx -- "$1"; }
|
||
BLOCKED=false; EXHAUSTED=false; SMALL=false; TRIVIAL=false
|
||
has blocked && BLOCKED=true
|
||
has review-4 && EXHAUSTED=true
|
||
has small && SMALL=true
|
||
has trivial && TRIVIAL=true
|
||
|
||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||
stage=""; marker=""
|
||
case "$LABEL" in
|
||
S4-spec-review) stage="spec"; marker="SPEC-REVIEW" ;;
|
||
S7-code-review) stage="code"; marker="CODE-REVIEW" ;;
|
||
*) echo "метка $LABEL конвейер не запускает" ;;
|
||
esac
|
||
# Метка, породившая событие, уже снята — запрос отозван. Это не отказ
|
||
# и не повод для комментария: владелец передумал сам, шум ему не нужен.
|
||
if [ -n "$stage" ] && ! has "$LABEL"; then
|
||
echo "метка $LABEL уже снята с issue — запрос отозван, конвейер не запускается (#499)"
|
||
echo "запрос отозван: \`$LABEL\` снята до старта" >> "$GITHUB_STEP_SUMMARY"
|
||
stage=""
|
||
fi
|
||
|
||
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
|
||
limit=4
|
||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||
|
||
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
|
||
#
|
||
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
|
||
# только для имени документа и метки: два захода с одинаковым номером
|
||
# означают, что второй документ перезапишет первый и артефакт ревью
|
||
# исчезнет.
|
||
#
|
||
# `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это
|
||
# «отправка на ревью → вердикт с блокирующими находками → возврат
|
||
# автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты.
|
||
# Зелёный ничего на правки не вернул и цикла не образует.
|
||
#
|
||
# Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер
|
||
# наказывал за то, что предписывал сам: при неудавшемся слиянии он
|
||
# велит вернуть S7-code-review после ребейза, и этот заход добивал
|
||
# бюджет. На #225 (лёгкий трек, лимит 2) последовательность
|
||
# жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и
|
||
# зелёным CI: работа встала, хотя после вердикта не было ни одной
|
||
# правки продуктового кода.
|
||
#
|
||
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
|
||
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
|
||
# имени документа — раньше по подстроке маркера в теле комментария,
|
||
# теперь по имени документа ЭТОЙ задачи, `<MARKER>-<NUM>`: голая
|
||
# подстрока протекала на прозе. #454 поймала это на себе — разбор
|
||
# чужих задач в комментарии содержал `CODE-REVIEW`, и первый же
|
||
# код-ревью получил заход r3.
|
||
#
|
||
# Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ
|
||
# ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария,
|
||
# только если ревьюер сам назвал имя файла, — то есть прежний счёт
|
||
# зависел от формулировки. На #449 первый спек-вердикт файла не
|
||
# назвал, заход r2 получил номер r1, и документ второго раунда лёг
|
||
# ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна
|
||
# ровно здесь: номер захода входит в имя файла, и повтор номера — не
|
||
# лишний заход, а потеря артефакта.
|
||
#
|
||
# Поэтому рядом встаёт второй источник — опубликованные документы:
|
||
# их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся
|
||
# МАКСИМУМ двух источников: недосчёт возможен только при отказе
|
||
# обоих, перерасчёт невозможен по построению.
|
||
attempt=1; spent=0; spent_list=""
|
||
if [ -n "$stage" ]; then
|
||
comments=$(mktemp)
|
||
gh issue view "$NUM" --repo "$REPO" --json comments > "$comments"
|
||
|
||
# Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту.
|
||
# Её нет у задач, размеченных до появления конвейера; тогда счёт по
|
||
# файлам даёт ноль и работает страховка по комментариям.
|
||
branch=""; newest=""
|
||
for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \
|
||
--jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do
|
||
date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true)
|
||
if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then
|
||
newest="$date"; branch="$ref"
|
||
fi
|
||
done
|
||
target="${branch:-dev}"
|
||
names=$(mktemp); docs=$(mktemp -d)
|
||
# #621: каталог перечисляется Git Trees API, а не `contents`.
|
||
# У `contents` потолок 1 000 записей, после которого ответ молча
|
||
# обрезается; docs/reviews подошёл к нему (986 файлов на 23.09,
|
||
# +≈60 в неделю), и счёт по файлам начал бы занижаться — то есть
|
||
# повторять номер захода и класть документ поверх предыдущего.
|
||
# Дерево спускается по одному уровню: commit → root → docs →
|
||
# reviews; лимит дерева — 100 000 записей, флаг `truncated` здесь
|
||
# трактуется как отказ, а не как частичный список.
|
||
tree_names() {
|
||
local sha entry
|
||
sha=$(gh api "repos/$REPO/commits/$1" --jq '.commit.tree.sha') || return 1
|
||
for entry in docs reviews; do
|
||
sha=$(gh api "repos/$REPO/git/trees/$sha" \
|
||
--jq ".tree[] | select(.type == \"tree\" and .path == \"$entry\") | .sha") || return 1
|
||
[ -n "$sha" ] || return 1
|
||
done
|
||
gh api "repos/$REPO/git/trees/$sha" \
|
||
--jq 'if .truncated then error("truncated") else .tree[] | select(.type == "blob") | .path end'
|
||
}
|
||
if ! tree_names "$target" > "$names" 2>/dev/null; then
|
||
: > "$names"
|
||
echo "::warning::дерево docs/reviews на $target не получено — счёт по файлам отключён"
|
||
fi
|
||
# Тела нужны только своим документам этапа: их единицы.
|
||
for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do
|
||
gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \
|
||
-H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name"
|
||
done
|
||
list=$(mktemp)
|
||
counters=$(node scripts/review-doc-guard.mjs --counters \
|
||
--marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \
|
||
--comments="$comments" --spent-list="$list")
|
||
spent_list=$(cat "$list")
|
||
# Пустой ответ означает, что скрипт не отработал. Тогда остаются
|
||
# значения по умолчанию (заход 1, циклов 0): guard обязан
|
||
# продолжить работу, а не встать.
|
||
new_attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p')
|
||
new_spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p')
|
||
new_blocking=$(printf '%s\n' "$counters" | sed -n 's/^blocking=//p')
|
||
case "$new_attempt" in ''|*[!0-9]*) echo "::warning::счётчик раундов не дал числа — работают значения по умолчанию" ;; *) attempt="$new_attempt" ;; esac
|
||
case "$new_spent" in ''|*[!0-9]*) : ;; *) spent="$new_spent" ;; esac
|
||
# Перечень учтённого обязан сходиться с числом: если цикл виден
|
||
# только документом, ссылка на комментарий его не объяснит.
|
||
if [ -n "$new_blocking" ]; then
|
||
spent_list="$spent_list
|
||
- документы: $new_blocking"
|
||
fi
|
||
echo "ветка материала: ${branch:-нет, читался dev}"
|
||
fi
|
||
|
||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||
# Ревьюшная метка обещает работу; если конвейер её не начал и промолчал,
|
||
# задача стоит в этом статусе бесконечно и никто об этом не узнаёт.
|
||
# Так и вышло на #123: чужой issue довели до S4-spec-review, guard
|
||
# отказался за 9 секунд, и в issue не было ни слова.
|
||
#
|
||
# Пишем только когда пытались запустить ревью, то есть stage опознан.
|
||
# Иначе комментарий уходил бы на каждую смену любой метки.
|
||
refuse() {
|
||
echo "$1"
|
||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||
"Конвейер ревью не запущен: $2
|
||
|
||
Метка \`$LABEL\` обещает работу, которая не начнётся, поэтому статус лучше вернуть в предыдущий — иначе задача простоит здесь бесконечно. [Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
|
||
stage=""
|
||
}
|
||
|
||
# Автор issue здесь не проверяется (решение владельца 2026-08-13).
|
||
# Проверка стоит на входе в процесс, а не на каждом шаге: как только
|
||
# задача получила статусную метку, она в работе, и кто её завёл — не
|
||
# имеет значения. Само присвоение метки и есть явное подтверждение
|
||
# владельца, причём проверенное платформой: метки может ставить только
|
||
# тот, у кого есть право записи в репозиторий. Прежняя проверка здесь
|
||
# дублировала эту гарантию и заставляла переоформлять чужие отчёты
|
||
# своими issue — чистая работа впустую, как на #123.
|
||
if [ -z "$stage" ]; then
|
||
:
|
||
elif [ "$BLOCKED" = "true" ]; then
|
||
refuse "стоит blocked — конвейер не запускается" \
|
||
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
|
||
elif [ "$EXHAUSTED" = "true" ]; then
|
||
# Метку снимает владелец, а не конвейер: автоматика, отменяющая
|
||
# остановку работы, дороже ручного снятия. Но пересчёт печатается —
|
||
# метка могла остаться от прежнего правила, когда бюджет тратил и
|
||
# зелёный вердикт (#227).
|
||
stale=""
|
||
if [ "$spent" -lt "$limit" ]; then
|
||
stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец."
|
||
fi
|
||
refuse "стоит review-4 — решение за владельцем" \
|
||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale"
|
||
elif [ "$spent" -ge "$limit" ]; then
|
||
echo "блокирующих циклов этапа $stage: $spent из $limit — лимит исчерпан"
|
||
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
|
||
# Перечень учтённого обязателен: иначе владельцу приходится читать
|
||
# всю ленту, чтобы понять, из чего сложился счёт.
|
||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||
"Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4).
|
||
|
||
Учтены вердикты с блокирующими находками — зелёные бюджет не тратят:
|
||
$spent_list"
|
||
stage=""
|
||
else
|
||
echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit"
|
||
fi
|
||
echo "stage=$stage" >> "$GITHUB_OUTPUT"
|
||
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
|
||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||
|
||
prepare:
|
||
name: "Ревью: материал и deterministic gates"
|
||
# Ребейзит ветку задачи и комментирует возврат автору.
|
||
permissions:
|
||
contents: read
|
||
issues: write
|
||
needs: guard
|
||
if: needs.guard.outputs.stage != ''
|
||
runs-on: ubuntu-latest
|
||
concurrency:
|
||
group: process-issue-${{ github.event.issue.number }}
|
||
cancel-in-progress: false
|
||
# Ожидание Validate не отнимает бюджет у модели (#551). Сам gate может
|
||
# ждать 45 минут; подготовке оставлен отдельный запас на checkout/rebase.
|
||
timeout-minutes: 55
|
||
outputs:
|
||
proceed: ${{ steps.gate.outputs.proceed }}
|
||
branch: ${{ steps.branch.outputs.name }}
|
||
rebase_note: ${{ steps.rebase.outputs.note }}
|
||
material_sha: ${{ steps.material.outputs.sha }}
|
||
material_tree: ${{ steps.material.outputs.tree }}
|
||
material_specs: ${{ steps.material.outputs.specs }}
|
||
material_issue_body: ${{ steps.material.outputs.issue_body }}
|
||
reuse: ${{ steps.reuse.outputs.reuse }}
|
||
reuse_doc: ${{ steps.reuse.outputs.doc }}
|
||
reuse_round: ${{ steps.reuse.outputs.round }}
|
||
reuse_tree: ${{ steps.reuse.outputs.tree }}
|
||
validate_result: ${{ steps.gate.outputs.result }}
|
||
validate_url: ${{ steps.gate.outputs.url }}
|
||
validated_note: ${{ steps.validated.outputs.note }}
|
||
spec_body_changed: ${{ steps.spec_body.outputs.changed }}
|
||
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
|
||
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
|
||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||
steps:
|
||
- name: Начать измерение стадии
|
||
id: clock
|
||
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||
with:
|
||
fetch-depth: 0
|
||
ref: dev
|
||
# Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с
|
||
# мёртвым PAT молча уходит от github-actions[bot] — 403 при
|
||
# contents: read. Отказ обязан быть громким и правильным.
|
||
persist-credentials: false
|
||
|
||
# Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился
|
||
# только на публикации документа — после сорока минут работы ревьюера.
|
||
- name: Секрет HP_PROCESS_TOKEN жив
|
||
env:
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
run: |
|
||
if [ -z "$GH_TOKEN" ]; then
|
||
echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен"
|
||
exit 1
|
||
fi
|
||
if ! login=$(gh api user -q .login 2>/dev/null); then
|
||
echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN"
|
||
exit 1
|
||
fi
|
||
echo "токен жив, действует от: $login"
|
||
|
||
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
|
||
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
|
||
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
|
||
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
|
||
# тяжёлых job, здесь его не было.
|
||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||
with:
|
||
node-version: 22
|
||
cache: npm
|
||
|
||
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
|
||
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
|
||
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
|
||
- name: Перейти на ветку задачи
|
||
id: branch
|
||
env:
|
||
NUM: ${{ github.event.issue.number }}
|
||
run: |
|
||
# Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом
|
||
# жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую.
|
||
git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true
|
||
branches=$(git for-each-ref --sort=-committerdate \
|
||
--format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*")
|
||
branch=$(printf '%s\n' "$branches" | head -1)
|
||
if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then
|
||
echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить."
|
||
fi
|
||
if [ -n "$branch" ]; then
|
||
git checkout -q "origin/$branch"
|
||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||
echo "name=$branch" >> "$GITHUB_OUTPUT"
|
||
# Якоря материала (sha, tree, specs) снимает шаг `material` — ПОСЛЕ
|
||
# ребейза (#515): снятые здесь, они после force-push приведённой
|
||
# ветки указывали на осиротевший коммит, и ни reuse (#499), ни
|
||
# страховка #414 не находили дерева в свежем клоне.
|
||
else
|
||
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
|
||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||
fi
|
||
|
||
# Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше
|
||
# ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и
|
||
# слитый SHA были разными коммитами. Пока расхождение с dev текстовое,
|
||
# ребейз упирается в конфликт и это видно; смысловое расхождение git
|
||
# склеивает молча, и в dev уезжает комбинация, которую никто не читал.
|
||
# Именно так пришёл регресс #234.
|
||
#
|
||
# Заодно снимается плата за конфликт: он обнаруживался ПОСЛЕ сорока минут
|
||
# ревью и потраченных лимитов подписки, хотя виден за пять секунд до них.
|
||
#
|
||
# Этап spec не затрагивается: ветку ТЗ в dev никто не сливает, и трогать
|
||
# чужую ветку без нужды — лишний риск.
|
||
- name: Привести ветку к dev
|
||
id: rebase
|
||
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
|
||
env:
|
||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
# #539: тем же токеном спрашивается REST — через него идёт и диспатч.
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
BRANCH: ${{ steps.branch.outputs.name }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||
# свои процессы и требует личность в окружении, иначе падает с
|
||
# «unable to auto-detect email address».
|
||
GIT_AUTHOR_NAME: claude[bot]
|
||
GIT_AUTHOR_EMAIL: 209825114+claude[bot]@users.noreply.github.com
|
||
GIT_COMMITTER_NAME: claude[bot]
|
||
GIT_COMMITTER_EMAIL: 209825114+claude[bot]@users.noreply.github.com
|
||
run: |
|
||
git fetch -q origin dev
|
||
if git merge-base --is-ancestor origin/dev HEAD; then
|
||
echo "ветка содержит весь dev — ребейз не нужен"
|
||
exit 0
|
||
fi
|
||
behind=$(git rev-list --count "HEAD..origin/dev")
|
||
before=$(git rev-parse "origin/$BRANCH")
|
||
echo "dev впереди на $behind коммит(ов) — привожу ветку"
|
||
if ! git rebase origin/dev; then
|
||
# Список снимается ДО abort: он же снимает состояние конфликта, и
|
||
# тогда автору достаётся «не ребейзится» без единого имени файла (#364).
|
||
files=$(git diff --name-only --diff-filter=U | sort -u | paste -sd'\n' -)
|
||
git rebase --abort || true
|
||
{
|
||
echo 'conflict=true'
|
||
echo 'conflicts<<EOF_FILES'
|
||
printf '%s\n' "${files:-(git не назвал файлы)}"
|
||
echo 'EOF_FILES'
|
||
} >> "$GITHUB_OUTPUT"
|
||
echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается"
|
||
printf 'конфликтуют:\n%s\n' "${files:-(git не назвал файлы)}"
|
||
exit 0
|
||
fi
|
||
# #635 r2: ребейз мог принести в docs/reviews документы других задач,
|
||
# а закоммиченный INDEX.md — снимок каталога — их не знает. Свежесть
|
||
# держит тест «индекс свеж» в Validate, поэтому индекс пересобирается
|
||
# здесь же, коммитом конвейера (класс C), до фиксации материала.
|
||
node scripts/reviews-index.mjs --dir=docs/reviews --commit-if-stale --issue="$NUM"
|
||
# --force-with-lease с явным ожидаемым значением обязателен: между
|
||
# fetch и push автор мог запушить коммит, и слепой --force потерял бы
|
||
# его молча. Расхождение lease — падение прогона, а не предупреждение:
|
||
# ревью пошло бы по коду, которого на ветке уже нет.
|
||
if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \
|
||
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||
"HEAD:refs/heads/$BRANCH"; then
|
||
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
|
||
exit 1
|
||
fi
|
||
# #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже
|
||
# просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать
|
||
# нельзя. 12.09 на #536 диспатч, отправленный через три секунды после
|
||
# этого push, встал на ДОпушевый SHA: гейт не нашёл прогона на
|
||
# материале и вернул задачу автору, которому чинить было нечего.
|
||
# Поэтому шаг не заканчивается, пока REST не отдаст новую вершину —
|
||
# именно REST, потому что через него же идёт и сам диспатч.
|
||
after=$(git rev-parse HEAD)
|
||
settled=false
|
||
for _ in $(seq 1 30); do
|
||
seen=$(gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH" \
|
||
--jq .object.sha 2>/dev/null || true)
|
||
if [ "$seen" = "$after" ]; then settled=true; break; fi
|
||
sleep 2
|
||
done
|
||
if [ "$settled" != "true" ]; then
|
||
echo "::error::ссылка $BRANCH за минуту не стала указывать на $after — диспатч встал бы на устаревший SHA"
|
||
exit 1
|
||
fi
|
||
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
|
||
# и без этого он ребейзил бы заново уже приведённое.
|
||
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
|
||
short_before=$(git rev-parse --short "$before")
|
||
short_after=$(git rev-parse --short HEAD)
|
||
echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT"
|
||
echo "ветка $BRANCH приведена к dev: $short_before -> $short_after"
|
||
|
||
# Материал ревью — конкретный SHA (#312). Вердикт применим только к
|
||
# нему: если во время ревью в ветку прилетит коммит, шаг слияния обязан
|
||
# это заметить и отказаться, а не молча увезти в dev непроверенный код.
|
||
- name: Зафиксировать SHA материала ревью
|
||
id: material
|
||
if: steps.rebase.outputs.conflict != 'true'
|
||
env:
|
||
NUM: ${{ github.event.issue.number }}
|
||
REPO: ${{ github.repository }}
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
run: |
|
||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
|
||
# хешем. Снимаются здесь, ПОСЛЕ приведения к dev (#515): рабочая
|
||
# копия равна тому, что ревьюер прочтёт, и коммит с этим деревом
|
||
# уже запушен в ветку — следующий прогон найдёт его в свежем клоне.
|
||
# В шаге публикации дерево уже сброшено на целевую ветку, и
|
||
# спрашивать его поздно.
|
||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
|
||
# где-нибудь достижим. Файлов может не быть (инфраструктурная
|
||
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
|
||
# длиннее одного.
|
||
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
|
||
| awk '{print $2" "$4}' | tr '\n' ';')
|
||
echo "specs=$specs" >> "$GITHUB_OUTPUT"
|
||
# Тело issue — материал ревью ТЗ (#517): с переходом на ТЗ в теле это
|
||
# единственный якорь, доказывающий «вердикт вынесен на этом тексте».
|
||
# Читается здесь, а не из github.event.issue.body: между событием
|
||
# метки и вызовом модели проходят минуты (ребейз, гейт #510, ожидание
|
||
# Validate), и снимок события описывал бы не тот текст.
|
||
body=$(mktemp)
|
||
if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then
|
||
digest=$(node -e '
|
||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||
const { readFileSync } = await import("node:fs");
|
||
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
|
||
});
|
||
' "$body")
|
||
echo "issue_body=$digest" >> "$GITHUB_OUTPUT"
|
||
echo "тело issue: ${digest:0:12}"
|
||
else
|
||
echo "::warning::тело issue $NUM не прочитано — якорь ТЗ в документ не попадёт"
|
||
fi
|
||
echo "материал ревью: $(git rev-parse --short HEAD), дерево $(git rev-parse --short 'HEAD^{tree}')"
|
||
|
||
# Повторное применение зелёного вердикта без вызова модели (#499). Сценарий
|
||
# #437 r4: зелёный r3 не слился (страж #312), задача вернулась в S6 и тут же
|
||
# в S7, и ревьюер двенадцать минут заново разбирал дерево, в котором с r3
|
||
# изменился ровно один файл — его собственный документ r3. Правило узкое:
|
||
# последний документ этапа несёт записанный конвейером вердикт `green`
|
||
# с High 0, и `git diff` между его якорем-деревом и HEAD пуст вне
|
||
# docs/reviews/**. Любое иное отличие — ребейз, тест, фикстура, скрипт,
|
||
# ТЗ — даёт полный разбор. Только этап code: материал spec может жить в
|
||
# теле issue, которого в дереве нет.
|
||
- name: "Зелёный вердикт прошлого захода применим без ревью (#499)"
|
||
id: reuse
|
||
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
|
||
env:
|
||
NUM: ${{ github.event.issue.number }}
|
||
# Правка ТЗ между раундами обязана отменять повторное применение
|
||
# зелёного вердикта: иначе вызов модели пропускается и находку
|
||
# «ТЗ менялось» некому напечатать (#517 AC6).
|
||
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||
run: |
|
||
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
|
||
--issue-body="${ISSUE_BODY}")
|
||
printf '%s\n' "$out"
|
||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||
if printf '%s\n' "$out" | grep -qx 'reuse=true'; then
|
||
echo "вердикт прошлого захода применяется повторно: модель не вызывается" >> "$GITHUB_STEP_SUMMARY"
|
||
fi
|
||
|
||
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
|
||
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
|
||
# тоже смена метки, и автор не ждёт впустую.
|
||
- name: Конфликт с dev — вернуть автору без ревью
|
||
if: steps.rebase.outputs.conflict == 'true'
|
||
env:
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
BRANCH: ${{ steps.branch.outputs.name }}
|
||
CONFLICTS: ${{ steps.rebase.outputs.conflicts }}
|
||
run: |
|
||
cat > /tmp/stale.md <<EOF
|
||
**Ревью не запускалось:** ветка \`$BRANCH\` не ребейзится на \`dev\` без конфликта. Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||
|
||
Конфликтуют:
|
||
|
||
\`\`\`
|
||
$CONFLICTS
|
||
\`\`\`
|
||
|
||
Проверка стоит до ревью намеренно: конфликт всё равно вернул бы задачу, но уже после сорока минут работы ревьюера и потраченных лимитов.
|
||
|
||
Задача переведена в \`S6-in-progress\`. Осталось:
|
||
|
||
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
|
||
2. запушить ветку;
|
||
3. вернуть метку \`S7-code-review\`.
|
||
|
||
[Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||
EOF
|
||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale.md
|
||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||
--add-label S6-in-progress --remove-label S7-code-review
|
||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||
|
||
# Мутанты по диффу бегут только по запросу (#510): до ревью конвейер
|
||
# запускает Validate с мутантами на материале и ждёт его. Красный или
|
||
# пропавший прогон возвращает задачу автору без ревью — цикл не
|
||
# тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6
|
||
# после запущенного 15-минутного ревью). Этап spec кода не несёт и
|
||
# гейт не проходит; повторное применение вердикта (#499) — тоже: там
|
||
# слияние само дожидается Validate на кандидате.
|
||
- name: Validate с мутантами на материале
|
||
id: gate
|
||
if: steps.rebase.outputs.conflict != 'true'
|
||
env:
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
STAGE: ${{ needs.guard.outputs.stage }}
|
||
REUSE: ${{ steps.reuse.outputs.reuse }}
|
||
BRANCH: ${{ steps.branch.outputs.name }}
|
||
SHA: ${{ steps.material.outputs.sha }}
|
||
run: |
|
||
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
|
||
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
|
||
{ echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
# #636: раннер не спит, пока идёт Validate (28 минут на раунд при
|
||
# 10–12 минутах работы модели). Гейт диспатчит прогон, убеждается, что
|
||
# тот встал на материал, и выходит с кодом 2 — «идёт». Раунд продолжит
|
||
# событие завершения Validate (process-resume.yml переставит метку
|
||
# S7), страховка — process-reconcile. Зелёный или красный завершённый
|
||
# прогон гейт и без ожидания возвращает сразу.
|
||
set +e
|
||
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait
|
||
code=$?
|
||
set -e
|
||
case "$code" in
|
||
0) echo 'proceed=true' >> "$GITHUB_OUTPUT" ;;
|
||
2) echo 'proceed=pending' >> "$GITHUB_OUTPUT" ;;
|
||
*) echo 'proceed=false' >> "$GITHUB_OUTPUT" ;;
|
||
esac
|
||
|
||
# #636: прогон на материале идёт — записать маркер ожидания и освободить
|
||
# раннер. Метка S7 остаётся; событие `workflow_run` по завершении Validate
|
||
# переставит её, и новый прогон конвейера найдёт завершённый dispatch
|
||
# сразу. Маркер читают process-resume.mjs и process-reconcile.mjs: без
|
||
# него ни один из них не имеет права будить раунд — иначе «успешный
|
||
# прогон без вердикта» неотличим от потерянного запроса.
|
||
- name: Validate идёт — раунд продолжит событие
|
||
id: pending
|
||
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'pending'
|
||
env:
|
||
NUM: ${{ github.event.issue.number }}
|
||
STAGE: ${{ needs.guard.outputs.stage }}
|
||
BRANCH: ${{ steps.branch.outputs.name }}
|
||
SHA: ${{ steps.material.outputs.sha }}
|
||
VALIDATE_RUN_ID: ${{ steps.gate.outputs.run_id }}
|
||
VALIDATE_URL: ${{ steps.gate.outputs.url }}
|
||
run: |
|
||
dir="$RUNNER_TEMP/review-pending"
|
||
mkdir -p "$dir"
|
||
jq -n -S \
|
||
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
|
||
--arg issue "$NUM" --arg stage "$STAGE" --arg branch "$BRANCH" \
|
||
--arg material_sha "$SHA" --arg validate_run_id "$VALIDATE_RUN_ID" \
|
||
--arg validate_url "$VALIDATE_URL" \
|
||
'{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,branch:$branch,material_sha:$material_sha,validate_run_id:$validate_run_id,validate_url:$validate_url}' \
|
||
> "$dir/pending.json"
|
||
(cd "$dir" && sha256sum pending.json > manifest.sha256)
|
||
echo "artifact=review-pending-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||
short=$(git rev-parse --short "$SHA")
|
||
echo "Validate с мутантами на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY"
|
||
|
||
- name: Сохранить маркер ожидания
|
||
if: steps.gate.outputs.proceed == 'pending'
|
||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||
with:
|
||
name: ${{ steps.pending.outputs.artifact }}
|
||
path: ${{ runner.temp }}/review-pending
|
||
if-no-files-found: error
|
||
retention-days: 1
|
||
|
||
- name: Validate красный — вернуть автору без ревью
|
||
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'false'
|
||
env:
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
BRANCH: ${{ steps.branch.outputs.name }}
|
||
SHA: ${{ steps.material.outputs.sha }}
|
||
RESULT: ${{ steps.gate.outputs.result }}
|
||
NOTE: ${{ steps.gate.outputs.note }}
|
||
URL: ${{ steps.gate.outputs.url }}
|
||
run: |
|
||
short=$(git rev-parse --short "$SHA")
|
||
cat > /tmp/gate.md <<EOF
|
||
**Ревью не запускалось:** Validate с мутантами на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||
|
||
Гейт стоит до ревью намеренно (#510): красный CI всё равно вернул бы задачу, но уже после потраченного ревью.
|
||
|
||
Задача переведена в \`S6-in-progress\`. Осталось:
|
||
|
||
1. починить то, что назвал прогон, и запушить ветку **одним** коммитом-заходом;
|
||
2. дождаться зелёного дешёвого Validate на пуше;
|
||
3. вернуть метку \`S7-code-review\` — конвейер сам запустит Validate с мутантами и ревью.
|
||
|
||
[Прогон конвейера](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||
EOF
|
||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/gate.md
|
||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||
--add-label S6-in-progress --remove-label S7-code-review
|
||
echo "S7-code-review -> S6-in-progress (Validate с мутантами: $RESULT)"
|
||
|
||
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
|
||
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
|
||
# ревью тратится на повторение CI вместо чтения кода.
|
||
#
|
||
# Доказательство здесь такое же строгое, как у reuse-маркеров (#208): не
|
||
# «недавно было зелено», а «completed success ровно на этом SHA». После
|
||
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
|
||
- name: Зелёные гейты на этом SHA
|
||
id: validated
|
||
if: steps.gate.outputs.proceed == 'true'
|
||
env:
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
run: |
|
||
sha=$(git rev-parse HEAD)
|
||
short=$(git rev-parse --short HEAD)
|
||
row=$(gh run list --repo "${{ github.repository }}" --workflow validate.yml \
|
||
--commit "$sha" --limit 5 \
|
||
--json status,conclusion,url \
|
||
--jq '[.[] | select(.status=="completed" and .conclusion=="success")][0] // empty')
|
||
{
|
||
echo 'note<<EOF_NOTE'
|
||
if [ -n "$row" ]; then
|
||
url=$(printf '%s' "$row" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).url||""))')
|
||
echo "**Дешёвые гейты на этом SHA уже подтверждены** (#343). Validate на \`$short\` завершился success: $url"
|
||
echo ""
|
||
echo "Значит \`npx tsc --noEmit\`, \`npm test\` и \`npm run build\` со сверкой копий бандла перегонять не нужно — сошлись на этом прогоне, назвав его ссылкой. Бюджет раунда тратится на чтение кода."
|
||
echo ""
|
||
echo "Что Validate НЕ покрывает и остаётся за тобой: смоки, выбранные по диффу; golden, если diff трогает рендер; инварианты модели на конкретной конфигурации; и любой гейт, который требуют AC задачи."
|
||
else
|
||
echo "**Зелёного Validate на этом SHA (\`$short\`) нет** — прогон не найден, не завершён либо не success. Дешёвые гейты прогоняешь сам и называешь результат."
|
||
fi
|
||
echo 'EOF_NOTE'
|
||
} >> "$GITHUB_OUTPUT"
|
||
if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi
|
||
|
||
# Это deterministic evidence для промпта, поэтому вычисляется до запуска
|
||
# модели и передаётся вместе с неизменяемым контрактом материала (#551).
|
||
- name: "ТЗ менялось после зелёного ревью ТЗ (#517)"
|
||
id: spec_body
|
||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code'
|
||
env:
|
||
NUM: ${{ github.event.issue.number }}
|
||
DIGEST: ${{ steps.material.outputs.issue_body }}
|
||
run: |
|
||
if [ -z "$DIGEST" ]; then
|
||
echo "хеша тела нет — сравнивать не с чем"
|
||
exit 0
|
||
fi
|
||
out=$(node -e '
|
||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||
const { execFileSync } = await import("node:child_process");
|
||
const [num, digest] = process.argv.slice(1);
|
||
const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } };
|
||
const names = git(["ls-tree", "--name-only", "HEAD:docs/reviews"]).split("\n")
|
||
.filter((name) => new RegExp(`^SPEC-REVIEW-${num}-r\\d+\\.md$`).test(name));
|
||
const docs = names.map((name) => ({ name, text: git(["show", `HEAD:docs/reviews/${name}`]) }));
|
||
const changed = m.issueBodyChanged(docs, digest);
|
||
if (changed) process.stdout.write(`changed=true\ndoc=${changed.doc}\nrecorded=${changed.recorded}\n`);
|
||
else process.stdout.write("changed=false\n");
|
||
});
|
||
' "$NUM" "$DIGEST")
|
||
printf '%s\n' "$out"
|
||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Собрать контракт материала между стадиями
|
||
id: prepared
|
||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||
env:
|
||
NUM: ${{ github.event.issue.number }}
|
||
STAGE: ${{ needs.guard.outputs.stage }}
|
||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||
BRANCH: ${{ steps.branch.outputs.name }}
|
||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||
MATERIAL_TREE: ${{ steps.material.outputs.tree }}
|
||
MATERIAL_SPECS: ${{ steps.material.outputs.specs }}
|
||
MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||
VALIDATE_RESULT: ${{ steps.gate.outputs.result }}
|
||
VALIDATE_URL: ${{ steps.gate.outputs.url }}
|
||
REBASE_NOTE: ${{ steps.rebase.outputs.note }}
|
||
VALIDATED_NOTE: ${{ steps.validated.outputs.note }}
|
||
SPEC_BODY_CHANGED: ${{ steps.spec_body.outputs.changed }}
|
||
SPEC_BODY_DOC: ${{ steps.spec_body.outputs.doc }}
|
||
SPEC_BODY_RECORDED: ${{ steps.spec_body.outputs.recorded }}
|
||
run: |
|
||
dir="$RUNNER_TEMP/review-prepared"
|
||
mkdir -p "$dir"
|
||
jq -n -S \
|
||
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
|
||
--arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \
|
||
--arg branch "$BRANCH" --arg material_sha "$MATERIAL_SHA" \
|
||
--arg material_tree "$MATERIAL_TREE" --arg material_specs "$MATERIAL_SPECS" \
|
||
--arg material_issue_body "$MATERIAL_ISSUE_BODY" \
|
||
--arg validate_result "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \
|
||
--arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \
|
||
--arg spec_body_changed "$SPEC_BODY_CHANGED" --arg spec_body_doc "$SPEC_BODY_DOC" \
|
||
--arg spec_body_recorded "$SPEC_BODY_RECORDED" \
|
||
'{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,cycle:$cycle,branch:$branch,material_sha:$material_sha,material_tree:$material_tree,material_specs:$material_specs,material_issue_body:$material_issue_body,validate_result:$validate_result,validate_url:$validate_url,rebase_note:$rebase_note,validated_note:$validated_note,spec_body_changed:$spec_body_changed,spec_body_doc:$spec_body_doc,spec_body_recorded:$spec_body_recorded}' \
|
||
> "$dir/prepared.json"
|
||
(cd "$dir" && sha256sum prepared.json > manifest.sha256)
|
||
echo "artifact=review-prepared-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Передать подтверждённый материал модели
|
||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||
with:
|
||
name: ${{ steps.prepared.outputs.artifact }}
|
||
path: ${{ runner.temp }}/review-prepared
|
||
if-no-files-found: error
|
||
retention-days: 1
|
||
|
||
- name: Зафиксировать длительность подготовки
|
||
id: duration
|
||
if: always()
|
||
env:
|
||
STARTED: ${{ steps.clock.outputs.started }}
|
||
run: |
|
||
seconds=$(( $(date +%s) - STARTED ))
|
||
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
|
||
echo "- deterministic prerequisites: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY"
|
||
|
||
model_review:
|
||
name: "Ревью: работа модели"
|
||
# Единственная недоверенная стадия: модель читает материал и кладёт
|
||
# запечатанный artifact. Писать в issue и в репозиторий ей незачем — это
|
||
# делает `integrate`, проверив происхождение: в репозиторий модель не пишет
|
||
# вообще, а в issue — только своим комментарием и отдельным issue по §12.
|
||
#
|
||
# Права здесь реальны только вместе с `github_token` у шага Review. Без него
|
||
# claude-code-action меняет OIDC на собственный App-токен с дефолтом
|
||
# `contents/issues/pull_requests: write` (`src/github/token.ts`), и блок ниже
|
||
# не ограничивает ничего: токен `ghs_…` от claude[bot] лежит прямо в
|
||
# окружении Bash-инструмента модели — это поймало ревью r1 по #556 в
|
||
# собственной же сессии. С переданным `secrets.GITHUB_TOKEN` обмена не
|
||
# происходит, `id-token` больше не нужен, и этот список становится потолком.
|
||
#
|
||
# `issues: write` остаётся: процесс требует от ревьюера комментарий с
|
||
# вердиктом (§7.2) и отдельный issue на Medium вне скоупа (§12). Снять его
|
||
# можно только перенеся и то и другое в `integrate` — это отдельная правка
|
||
# конвейера, не эта задача. Что остаётся модели этим правом: комментарий,
|
||
# метки, правка тела issue. Чего не остаётся: запись в репозиторий, слияние
|
||
# (его решает запечатанный verdict.json в `integrate`), релиз.
|
||
permissions:
|
||
contents: read
|
||
issues: write
|
||
needs: [guard, prepare]
|
||
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||
runs-on: ubuntu-latest
|
||
concurrency:
|
||
group: process-issue-${{ github.event.issue.number }}
|
||
cancel-in-progress: false
|
||
# Весь бюджет принадлежит модели и её локальным проверкам; ожидания Validate
|
||
# в этом job больше нет (#551).
|
||
timeout-minutes: 45
|
||
outputs:
|
||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||
steps:
|
||
- name: Начать измерение стадии
|
||
id: clock
|
||
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||
with:
|
||
fetch-depth: 0
|
||
ref: ${{ needs.prepare.outputs.material_sha }}
|
||
persist-credentials: false
|
||
|
||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||
with:
|
||
node-version: 22
|
||
cache: npm
|
||
|
||
- name: Получить контракт подготовленного материала
|
||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||
with:
|
||
name: review-prepared-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||
path: ${{ runner.temp }}/review-prepared
|
||
|
||
- name: Проверить контракт и exact material
|
||
env:
|
||
NUM: ${{ github.event.issue.number }}
|
||
STAGE: ${{ needs.guard.outputs.stage }}
|
||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
|
||
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
|
||
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
|
||
VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }}
|
||
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
|
||
REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }}
|
||
VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }}
|
||
SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }}
|
||
SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }}
|
||
SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }}
|
||
run: |
|
||
dir="$RUNNER_TEMP/review-prepared"
|
||
(cd "$dir" && sha256sum -c manifest.sha256)
|
||
jq -e \
|
||
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
|
||
--arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \
|
||
--arg branch "$BRANCH" --arg sha "$MATERIAL_SHA" --arg tree "$MATERIAL_TREE" \
|
||
--arg specs "$MATERIAL_SPECS" --arg body "$MATERIAL_ISSUE_BODY" \
|
||
--arg validate "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \
|
||
--arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \
|
||
--arg spec_changed "$SPEC_BODY_CHANGED" --arg spec_doc "$SPEC_BODY_DOC" \
|
||
--arg spec_recorded "$SPEC_BODY_RECORDED" \
|
||
'.schema == 1 and .run_id == $run_id and .run_attempt == $run_attempt and .issue == $issue and .stage == $stage and .cycle == $cycle and .branch == $branch and .material_sha == $sha and .material_tree == $tree and .material_specs == $specs and .material_issue_body == $body and .validate_result == $validate and .validate_url == $validate_url and .rebase_note == $rebase_note and .validated_note == $validated_note and .spec_body_changed == $spec_changed and .spec_body_doc == $spec_doc and .spec_body_recorded == $spec_recorded' \
|
||
"$dir/prepared.json"
|
||
test "$(git rev-parse HEAD)" = "$MATERIAL_SHA"
|
||
test "$(git rev-parse 'HEAD^{tree}')" = "$MATERIAL_TREE"
|
||
|
||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||
- name: Установить зависимости
|
||
run: npm ci
|
||
|
||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||
- name: Кэш браузеров Playwright
|
||
id: pw
|
||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||
with:
|
||
path: ~/.cache/ms-playwright
|
||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||
|
||
- name: Установить Chromium
|
||
if: steps.pw.outputs.cache-hit != 'true'
|
||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
|
||
# внятной ошибкой — тогда флаг вернуть.
|
||
run: npx playwright install chromium
|
||
|
||
# Action ревью ставит Claude Code через `claude install`, и с его
|
||
# v1.0.218 (Claude Code 2.1.265) лаунчер ~/.local/bin/claude на
|
||
# ubuntu-latest иногда не появляется, хотя установщик рапортует об успехе;
|
||
# action верит рапорту и падает на ENOENT (anthropics, issue 1817).
|
||
# Кладём бинарник сами: версию берём ту, что пинит сам action (он уже
|
||
# скачан в _actions к началу job), контрольную сумму — из манифеста релиза.
|
||
- name: Установить Claude Code детерминированно
|
||
id: claude_bin
|
||
run: |
|
||
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
|
||
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||
ver="${ver:-2.1.265}"
|
||
base=https://downloads.claude.ai/claude-code-releases
|
||
bin="$HOME/.local/bin/claude"
|
||
mkdir -p "$(dirname "$bin")"
|
||
curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin"
|
||
sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum')
|
||
echo "$sum $bin" | sha256sum -c -
|
||
chmod +x "$bin"
|
||
"$bin" --version
|
||
echo "path=$bin" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Review
|
||
id: review
|
||
uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1
|
||
env:
|
||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||
# уничтожать его собственный артефакт (#220).
|
||
REVIEW_DOC: ${{ runner.temp }}/review-document.md
|
||
with:
|
||
# Подписка, а не отдельный счёт API: токен выпускается через
|
||
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
|
||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||
# Ambient job-scoped токен вместо App-обмена (#556). Со ним
|
||
# `permissions:` этой job — настоящий потолок прав модели: ни записи в
|
||
# репозиторий, ни постановки метки, ни комментария. Строку нельзя
|
||
# снять, не вернув модели право двигать процесс.
|
||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
|
||
prompt: |
|
||
Ты ревьюер проекта House Plan. Язык ответа — русский.
|
||
|
||
Issue: #${{ github.event.issue.number }}
|
||
Репозиторий: ${{ github.repository }}
|
||
Этап: ${{ needs.guard.outputs.stage }}
|
||
spec — ревью ТЗ (PROCESS.md §2.4)
|
||
code — код-ревью (PROCESS.md §2.7)
|
||
Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }}
|
||
Бюджет §4 тратят только жёлтые и красные вердикты: зелёный
|
||
ничего не вернул на правки и цикла не образует (#227).
|
||
Номер захода нужен для имени документа — два документа с
|
||
одинаковым номером затёрли бы друг друга.
|
||
|
||
${{ needs.prepare.outputs.rebase_note }}
|
||
|
||
${{ needs.prepare.outputs.spec_body_changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', needs.prepare.outputs.spec_body_doc, needs.prepare.outputs.spec_body_recorded) || '' }}
|
||
|
||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||
всех раундов, и повторный цикл заново выводил продуктовую рамку и
|
||
перепроверял AC, которых правка не касалась: r2 по #150 стоил
|
||
полного прогона ради одной строки в тестовой фикстуре.
|
||
|
||
Порядок для r2 и дальше:
|
||
1. найди вердикт предыдущего раунда в комментариях issue и SHA,
|
||
на котором он получен. SHA в вердикте не назван — это находка;
|
||
2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла
|
||
ТЗ или тела issue для spec. Дельта — предмет этого раунда;
|
||
3. по каждой находке предыдущего раунда покажи, чем именно она
|
||
закрыта: строка кода или текста, а не заявление автора;
|
||
4. заново проверяй только те AC, чьё доказательство дельта
|
||
задевает. Остальные наследуй;
|
||
5. в документе обязателен раздел «Унаследовано из r<N-1>»: что
|
||
принято без повторной проверки, со ссылкой на документ того
|
||
раунда и SHA, на котором вывод получен. Без этого перечня
|
||
сокращение — молчаливое доверие, а такой тихий успех уже
|
||
дважды стоил дня (#171, #207).
|
||
|
||
Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший
|
||
вперёд dev (после ребейза это другой код, §7.2), смена контракта
|
||
поведения, задета новая подсистема, либо объём дельты сопоставим с
|
||
исходной задачей. Сомневаешься — разбирай полностью и скажи почему.
|
||
|
||
Сокращается объём РАЗБОРА, а не строгость: правка по замечанию
|
||
способна сломать AC, который предыдущий раунд признал выполненным —
|
||
так появилась регрессия #102. Поэтому граница не «только находки», а
|
||
«находки плюс всё, до чего дотягивается дельта».
|
||
|
||
Прочитай в этом порядке, прежде чем судить:
|
||
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
|
||
ограничитель: «features are built, improved and accepted only
|
||
if they serve a job listed here». Первый вопрос к задаче —
|
||
какую строку Core user jobs она закрывает.
|
||
2. AGENTS.md и PROCESS.md — процесс, классы изменений, трейлеры,
|
||
лимит циклов, формат вердикта.
|
||
3. Тело issue #${{ github.event.issue.number }} и все комментарии.
|
||
4. Если меняется видимое поведение — docs/USER-GUIDE.ru.md:
|
||
терминология интерфейса берётся оттуда, а не изобретается.
|
||
5. Канонический документ затронутой подсистемы: docs/SUN.md,
|
||
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
|
||
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
|
||
|
||
Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл.
|
||
Файлы docs/specs/<NN>-*.md — архив ТЗ до 2026-09-10: если такой файл
|
||
есть у старой задачи, он и есть материал, новые не создаются.
|
||
Проверь обязательные разделы §7.1, однозначность каждого AC и
|
||
указание способа доказательства. Отдельно проверь, что автор не
|
||
выдал догадку за решение: утверждение о поведении, которого нет ни
|
||
в одном документе и которое не помечено как предположение, —
|
||
замечание. Не бывает сложной задачи без единого открытого вопроса.
|
||
|
||
Владельцу задаются только продуктовые вопросы: что человек видит или
|
||
делает и каков объём видимых изменений в этом issue. Технический
|
||
вопрос, вынесенный владельцу, — тоже замечание: ты его снимаешь и
|
||
решаешь по существу в своём вердикте.
|
||
|
||
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
|
||
и `git diff origin/dev...HEAD`. **Материал ревью — ровно
|
||
`${{ needs.prepare.outputs.material_sha }}`, рабочая копия уже на нём.** Не
|
||
делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт
|
||
привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с
|
||
ним. Если автор в issue называет более новый коммит, которого в
|
||
материале нет, — это находка «материал не был запушен до метки», а не
|
||
повод подтянуть его самому (#437 r3→r4 стоил лишнего раунда именно так,
|
||
#499). Ручного тестирования в цикле нет,
|
||
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
|
||
По каждому AC: либо он доказан автотестом и ты убедился, что тест
|
||
умеет падать, либо разобран по коду с явной записью «проверено
|
||
чтением, не исполнением». «Verified» без названной команды и её
|
||
результата доказательством не является. Зависимости уже установлены
|
||
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
|
||
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
|
||
changelog в том же коммите.
|
||
|
||
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
|
||
правке — не тщательность, а потеря времени: полные наборы это
|
||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||
|
||
${{ needs.prepare.outputs.validated_note }}
|
||
|
||
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
|
||
и в повторном раунде тоже: код изменился, а стоят они минуты:
|
||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
|
||
`src/**`: отпечаток скриншотов документации считается по всему
|
||
`src/**`, поэтому любая правка фронтенда делает его устаревшим —
|
||
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
|
||
с красным job `docs` до следующей задачи (#237).
|
||
|
||
Если diff трогает геометрию или ссылки на неё — рёбра комнат,
|
||
записи толщины, `layout`, `marker.space`, `open_spans` — обязательны
|
||
инварианты модели (#254): `npm test` уже гоняет их на всех моделях
|
||
проекта, а на конкретной конфигурации они проверяются командой
|
||
`npm run invariants -- --config <экспорт или ответ config/get>`.
|
||
Три вопроса, на которые они отвечают, и все три уже стоили
|
||
продукту дефектов: не исчезла ли запись толщины (#253), разрешима ли
|
||
каждая ссылка (#244, #252) и равен ли ключ записи толщины ключу
|
||
решёточного ребра (#258, #259). Последний сравнивает строки без
|
||
допусков: сдвиг ключа на один шаг решётки равен допуску первых двух,
|
||
поэтому они на нём промахиваются. Если задача меняет геометрию, а
|
||
инварианты в отчёте не названы — это непрогнанный гейт, а не мелочь.
|
||
|
||
По необходимости, и «необходимость» определяется diff'ом и AC:
|
||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те,
|
||
что печатает `node scripts/smoke-select.mjs --base <base> --head <head>`.
|
||
Сколько их всего — считает `ls demo/smoke_*.mjs | wc -l`; вшитое
|
||
в этот текст число трижды расходилось с деревом, поэтому его
|
||
здесь больше нет. Прогон всех уместен только когда задача
|
||
действительно задевает всё. Выбирать по теме недостаточно: регресс #234 поймал
|
||
`smoke_wall_junctions`, который по названию про стыки стен, а не
|
||
про толщину отрезка. Инструмент печатает три вида ответа, и они
|
||
разные: «прямое совпадение» — смок называет изменённый символ,
|
||
«зарегистрированная связь» — смок проверяет следствие контракта,
|
||
не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не
|
||
разрешение ничего не прогонять. Вывод инструмента прикладывается
|
||
к комментарию ревью вместе с решением по каждой строке: прогнал
|
||
либо не прогнал и почему. Слабые связи (одно распространённое
|
||
имя) — повод посмотреть, а не обязанность прогонять;
|
||
- `npm run golden:verify` — если diff может изменить видимый
|
||
результат: рендер, геометрия, стили, слои;
|
||
- `python -m pytest tests_backend -q` — если тронут
|
||
`custom_components/**/*.py`;
|
||
- performance-профили — если названы в AC либо тронуты
|
||
чувствительные к перфу пути.
|
||
|
||
**Одно число — один источник.** Если дифф добавляет или меняет
|
||
величину, видимую пользователю, назови в отчёте прямо: какое число
|
||
видно дважды (превью против записи, подпись против площади,
|
||
подсветка инструмента против сохранённого значения) и один ли у него
|
||
источник. Три дефекта подряд имели именно эту причину — #234, #233 и
|
||
способ, которым #234 обнаружили. Механическая часть закреплена
|
||
тестом `test/single-source-numbers.test.mjs`, смысловая — твоя.
|
||
|
||
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
|
||
тем тестам, которые ты прогонял.
|
||
|
||
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
|
||
почему.** Это условие честности такого сужения: непрогнанный гейт
|
||
становится видимым решением, а не молчаливым пропуском. Раздел «чего
|
||
не проверял» в документе ревью — не формальность, а главный его
|
||
раздел на коротких задачах.
|
||
|
||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||
|
||
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
|
||
же — без High это жёлтый вердикт и возврат автору, отдельный issue
|
||
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
|
||
обслуживание issue дороже правки на месте); Low либо правится,
|
||
либо снимается с записью. Жёлтый вердикт допустим и при полностью
|
||
выполненных AC, если изменение не решает заявленный сценарий или
|
||
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
|
||
отменяет AC и не даёт права менять скоуп.
|
||
|
||
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
|
||
поведения, который в этой ветке чинить нельзя) заведи отдельным
|
||
issue со ссылкой на #${{ github.event.issue.number }} и метками:
|
||
тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не
|
||
считается и прямо запрещено §12.
|
||
|
||
Напиши полный документ ревью в файл, путь которого лежит в
|
||
переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория).
|
||
|
||
Почему не в docs/reviews: документ там был некоммитнутым файлом того
|
||
же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На
|
||
#220 три раунда подряд документ исчезал — восстановление дерева
|
||
(`git checkout -- .`, `git clean -fd`) сносит собственный артефакт
|
||
ревью, потому что он untracked. В репозиторий его положит шаг
|
||
публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно.
|
||
|
||
В самом репозитории не создавай файлов вообще: любые изменения в
|
||
рабочей копии будут отброшены. Имя документа в docs/reviews шаг
|
||
публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для
|
||
code, с номером issue и заходом.
|
||
|
||
Содержание документа: скоуп, как проверялось, находки с
|
||
воспроизведением, что проверено и корректно, чего не проверял. Для
|
||
r2 и дальше добавь два раздела: «Закрытие раунда r<N-1>» — таблица
|
||
«находка | чем закрыта | где это видно», и «Унаследовано из r<N-1>» —
|
||
что принято без повторной проверки, с документом и SHA.
|
||
|
||
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
|
||
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
|
||
`Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
|
||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
|
||
|
||
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
|
||
без него метка не переставится и конвейер встанет.
|
||
claude_args: |
|
||
--max-turns 150
|
||
--allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read
|
||
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}'
|
||
|
||
- name: Запечатать результат модели
|
||
id: result
|
||
env:
|
||
SOURCE: ${{ runner.temp }}/review-document.md
|
||
OUT: ${{ steps.review.outputs.structured_output }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
STAGE: ${{ needs.guard.outputs.stage }}
|
||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||
run: |
|
||
marker=CODE-REVIEW
|
||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||
legacy="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
|
||
if [ ! -f "$SOURCE" ] && [ -f "$legacy" ]; then cp "$legacy" "$SOURCE"; fi
|
||
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
|
||
printf '%s' "$OUT" > "$RUNNER_TEMP/verdict.json"
|
||
jq -e '
|
||
(.verdict == "green" or .verdict == "yellow" or .verdict == "red")
|
||
and (.high | type == "number") and (.medium | type == "number")
|
||
and (.summary | type == "string")' "$RUNNER_TEMP/verdict.json" >/dev/null
|
||
dir="$RUNNER_TEMP/review-result"
|
||
mkdir -p "$dir"
|
||
cp "$RUNNER_TEMP/review-prepared/prepared.json" "$dir/prepared.json"
|
||
cp "$SOURCE" "$dir/review-document.md"
|
||
cp "$RUNNER_TEMP/verdict.json" "$dir/verdict.json"
|
||
(cd "$dir" && sha256sum prepared.json review-document.md verdict.json > manifest.sha256)
|
||
echo "artifact=review-result-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Передать результат интеграции
|
||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||
with:
|
||
name: ${{ steps.result.outputs.artifact }}
|
||
path: ${{ runner.temp }}/review-result
|
||
if-no-files-found: error
|
||
retention-days: 1
|
||
|
||
- name: Зафиксировать длительность модели
|
||
id: duration
|
||
if: always()
|
||
env:
|
||
STARTED: ${{ steps.clock.outputs.started }}
|
||
run: |
|
||
seconds=$(( $(date +%s) - STARTED ))
|
||
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
|
||
echo "- model review: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY"
|
||
|
||
integrate:
|
||
name: "Ревью: публикация и интеграция"
|
||
# Публикует разбор, переставляет метку, сливает проверенный материал.
|
||
permissions:
|
||
contents: read
|
||
issues: write
|
||
needs: [guard, prepare, model_review]
|
||
if: always() && needs.guard.outputs.stage != ''
|
||
runs-on: ubuntu-latest
|
||
concurrency:
|
||
group: process-issue-${{ github.event.issue.number }}
|
||
cancel-in-progress: false
|
||
# Слияние кандидата при ушедшем dev может само ждать Validate до 45 минут;
|
||
# оно не должно обрывать уже оплаченный model review (#551).
|
||
timeout-minutes: 55
|
||
steps:
|
||
- name: Начать измерение стадии
|
||
id: clock
|
||
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Проверить исходы предыдущих стадий
|
||
id: ready
|
||
env:
|
||
PREPARE_RESULT: ${{ needs.prepare.result }}
|
||
MODEL_RESULT: ${{ needs.model_review.result }}
|
||
PROCEED: ${{ needs.prepare.outputs.proceed }}
|
||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||
run: |
|
||
if [ "$PREPARE_RESULT" != "success" ]; then
|
||
echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT"
|
||
exit 1
|
||
fi
|
||
if [ "$PROCEED" = "pending" ]; then
|
||
echo "Validate на материале ещё идёт — раунд продолжит событие завершения (#636); интегрировать нечего"
|
||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
if [ "$PROCEED" != "true" ]; then
|
||
echo "подготовка уже вернула задачу автору; интегрировать нечего"
|
||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||
echo "::error::стадия model review завершилась: $MODEL_RESULT"
|
||
exit 1
|
||
fi
|
||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||
if: steps.ready.outputs.proceed == 'true'
|
||
with:
|
||
fetch-depth: 0
|
||
ref: dev
|
||
persist-credentials: false
|
||
|
||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||
if: steps.ready.outputs.proceed == 'true'
|
||
with:
|
||
node-version: 22
|
||
|
||
- name: Получить результат модели
|
||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||
with:
|
||
name: review-result-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||
path: ${{ runner.temp }}/review-result
|
||
|
||
- name: Проверить полноту и происхождение результата
|
||
id: result
|
||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||
# #556: единственное, что недоверенная стадия модели может передать
|
||
# дальше, — этот artifact, и принимается он как ввод противника: полный
|
||
# набор файлов, сходящиеся суммы, совпадение КАЖДОГО поля паспорта с
|
||
# тем, что посчитала детерминированная `prepare`. Проверка вынесена из
|
||
# inline-shell в `scripts/review-result-gate.mjs` ради враждебных
|
||
# фикстур — в YAML её нельзя прогнать ни одним отрицательным случаем.
|
||
env:
|
||
ISSUE: ${{ github.event.issue.number }}
|
||
STAGE: ${{ needs.guard.outputs.stage }}
|
||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
|
||
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
|
||
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
|
||
VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }}
|
||
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
|
||
REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }}
|
||
VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }}
|
||
SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }}
|
||
SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }}
|
||
SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }}
|
||
run: |
|
||
set -euo pipefail
|
||
dir="$RUNNER_TEMP/review-result"
|
||
node scripts/review-result-gate.mjs --dir="$dir"
|
||
printf 'structured_output<<EOF_RESULT\n' >> "$GITHUB_OUTPUT"
|
||
cat "$dir/verdict.json" >> "$GITHUB_OUTPUT"
|
||
# structured_output не обязан оканчиваться LF: delimiter команды
|
||
# GitHub должен начинаться с отдельной строки.
|
||
printf '\nEOF_RESULT\n' >> "$GITHUB_OUTPUT"
|
||
|
||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||
- name: Опубликовать документ ревью
|
||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||
env:
|
||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
STAGE: ${{ needs.guard.outputs.stage }}
|
||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||
SOURCE: ${{ runner.temp }}/review-result/review-document.md
|
||
# После ребейза конвейером — якоря приведённого материала (#515).
|
||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
|
||
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
|
||
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
|
||
# Вердикт из structured_output попадает в блок якорей (#499): по нему
|
||
# следующий заход решает, можно ли применить зелёный вердикт повторно.
|
||
OUT: ${{ steps.result.outputs.structured_output }}
|
||
run: |
|
||
verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true)
|
||
high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true)
|
||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
|
||
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
|
||
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
|
||
target="${BRANCH:-dev}"
|
||
if [ -z "$BRANCH" ]; then
|
||
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
|
||
fi
|
||
marker=CODE-REVIEW
|
||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
|
||
# Документ спасается ПЕРВЫМ делом. Ревьюер мог написать его по старому
|
||
# пути прямо в рабочую копию, а дальше эта копия будет отброшена
|
||
# целиком — и вместе с ней пропал бы артефакт (#220).
|
||
if [ ! -f "$SOURCE" ] && [ -f "$doc" ]; then
|
||
cp "$doc" "$SOURCE"
|
||
echo "документ найден в рабочей копии и сохранён в $SOURCE"
|
||
fi
|
||
# Reset, а не checkout+clean, и вот почему (#365).
|
||
#
|
||
# 28.08 коммит bb2919f уехал в dev с тридцатью файлами вместо одного
|
||
# markdown: откатил отревьюженную реализацию #359, вернул старые чанки
|
||
# и держал dev откаченным три часа. Механизм воспроизведён:
|
||
# `git checkout -- .` восстанавливает рабочее дерево ИЗ ИНДЕКСА, а
|
||
# `git clean -fd` убирает неотслеживаемое — ни то, ни другое индекс не
|
||
# трогает. Ревьюер работает с Bash и в ходе проверки «умеет ли тест
|
||
# падать» вполне может сделать `git add`; всё, что осталось у него в
|
||
# индексе, прежняя уборка сохраняла, и следующий же `git commit`
|
||
# забирал это вместе с документом. Сообщение при этом невинное, и от
|
||
# рутины инцидент отличается только диффом.
|
||
#
|
||
# `reset --hard` снимает и индекс, и дерево разом. Терять нечего:
|
||
# документ приезжает извне репозитория, из RUNNER_TEMP.
|
||
git fetch -q origin "$target"
|
||
git reset -q --hard "origin/$target"
|
||
git clean -fdq -e node_modules >/dev/null 2>&1 || true
|
||
# Документ приезжает извне репозитория (#220). Три раунда подряд он
|
||
# терялся, пока лежал некоммитнутым файлом в том же дереве, которое
|
||
# ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс
|
||
# `git clean -fd` сносят собственный артефакт ревью, потому что он
|
||
# untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не
|
||
# страшна.
|
||
if [ -f "$SOURCE" ]; then
|
||
mkdir -p docs/reviews
|
||
cp "$SOURCE" "$doc"
|
||
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
|
||
# Якоря дописывает конвейер, а не ревьюер (#414). Дисциплина здесь
|
||
# уже подводила: на #403 SHA сняли до ребейза и не сверили перед
|
||
# выводом — через раунд команда из §2.10 не работала. Машина же
|
||
# снимает якоря в момент чтения материала и ошибиться в них не
|
||
# может; блок помечен как машинный, чтобы никто не правил его руками.
|
||
node scripts/review-doc-guard.mjs --anchor="$doc" \
|
||
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
|
||
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \
|
||
--issue-body="$MATERIAL_ISSUE_BODY" \
|
||
--verdict="$verdict" --high="$high"
|
||
else
|
||
echo "::warning::$SOURCE не найден — документа для публикации нет"
|
||
fi
|
||
# Индексируется ровно один путь, а не каталог: `git add docs/reviews`
|
||
# забрал бы всё, что там окажется, а после reset там не должно быть
|
||
# ничего постороннего — но полагаться на «не должно» здесь нельзя.
|
||
git add -- "$doc" 2>/dev/null || true
|
||
# #635: индекс ревью пересобирается тем же коммитом, что и документ —
|
||
# иначе он устаревает на первом же раунде. Генерируемый файл, класс C.
|
||
if [ -f "$doc" ]; then
|
||
node scripts/reviews-index.mjs --dir=docs/reviews
|
||
git add -- docs/reviews/INDEX.md
|
||
fi
|
||
if git diff --cached --quiet; then
|
||
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
|
||
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
|
||
# коммиттер GitHub). Провал — когда файла нет и на ветке.
|
||
git fetch -q origin "$target"
|
||
if git cat-file -e "origin/$target:$doc" 2>/dev/null; then
|
||
echo "документ уже опубликован ревьюером: $doc"
|
||
exit 0
|
||
fi
|
||
# Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше
|
||
# здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ
|
||
# остались только комментариями, метки переставились, и пропажу
|
||
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
|
||
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
|
||
echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
|
||
exit 1
|
||
fi
|
||
# Первый рубеж: что вообще проиндексировано.
|
||
git diff --cached --name-only | node scripts/review-doc-guard.mjs
|
||
git -c user.name="claude[bot]" \
|
||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||
commit -q -F - <<EOF
|
||
docs: review document for #$NUM
|
||
|
||
Issue: #$NUM
|
||
User-Visible: no
|
||
EOF
|
||
# Второй рубеж, и он главный: что пуш ДОБАВИТ в целевую ветку. Первый
|
||
# судит намерение шага, этот — результат, а расходились они именно
|
||
# тогда, когда база оказывалась не той.
|
||
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
|
||
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
|
||
# либо dev, поэтому push нужен с явным перебазированием при гонке:
|
||
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
|
||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||
"HEAD:$target"; then
|
||
git fetch -q origin "$target"
|
||
if ! git -c user.name="claude[bot]" \
|
||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||
rebase "origin/$target"; then
|
||
git rebase --abort || true
|
||
# Тоже вердикт без артефакта: раньше exit 0 переставил бы метку.
|
||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
|
||
exit 1
|
||
fi
|
||
# После ребейза набор путей другой — проверяется заново. Форс здесь
|
||
# запрещён и не появляется: ветка двигается только вперёд.
|
||
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
|
||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||
"HEAD:$target"
|
||
fi
|
||
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
|
||
# документом, названным не по формату, — тот же вердикт без
|
||
# артефакта, только дороже в обнаружении.
|
||
git fetch -q origin "$target"
|
||
if ! git cat-file -e "origin/$target:$doc" 2>/dev/null; then
|
||
echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)"
|
||
exit 1
|
||
fi
|
||
echo "документ опубликован в $target: $doc"
|
||
|
||
# Материал раунда обязан быть достижим с origin (#413).
|
||
#
|
||
# SPEC-REVIEW-403-r2 объявил материал на `HEAD = 83005c3c`, и тот же SHA
|
||
# независимо назвал автор ТЗ в комментарии issue. Коммит существовал, но
|
||
# к моменту публикации был осиротевшим: ветку перебазировали за 15 минут
|
||
# ДО публикации документа, спец-коммит переехал в 94502d3d с тем же
|
||
# сообщением и тем же содержимым. Через раунд команда `git diff
|
||
# 83005c3c..HEAD` из §2.10 буквально не работала, и r3 восстанавливал
|
||
# реальный коммит по содержимому диффа руками.
|
||
#
|
||
# Проверка стоит ПОСЛЕ публикации намеренно. Артефакт ревью терялся здесь
|
||
# трижды (#171, #220), и «вердикт без документа» в этом репозитории
|
||
# дороже мёртвой ссылки: документ сначала спасается, потом судится. Шаг
|
||
# при этом идёт ДО «Переставить метку», поэтому инвариант «метка не
|
||
# сменилась = прогон упал» сохраняется.
|
||
#
|
||
# Достижимость считается от `refs/remotes/origin/*`, а не от локальных
|
||
# ссылок: осиротевший 83005c3c до сих пор лежит в клоне автора и
|
||
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
|
||
# пользы нет — он достанет только то, что есть на origin.
|
||
- name: "Материал раунда воспроизводим (#413)"
|
||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||
env:
|
||
NUM: ${{ github.event.issue.number }}
|
||
STAGE: ${{ needs.guard.outputs.stage }}
|
||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||
run: |
|
||
marker=CODE-REVIEW
|
||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
|
||
target="${BRANCH:-dev}"
|
||
git fetch -q origin "$target"
|
||
# Судится опубликованная версия, а не рабочая копия: именно её прочтёт
|
||
# следующий раунд.
|
||
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
|
||
- name: Решение по вердикту
|
||
id: decide
|
||
if: steps.ready.outputs.proceed == 'true'
|
||
env:
|
||
OUT: ${{ steps.result.outputs.structured_output }}
|
||
STAGE: ${{ needs.guard.outputs.stage }}
|
||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||
REUSE_DOC: ${{ needs.prepare.outputs.reuse_doc }}
|
||
REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }}
|
||
REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }}
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
run: |
|
||
if [ "$REUSE" = "true" ]; then
|
||
# Модель не вызывалась: вердикт — записанный конвейером зелёный
|
||
# прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499).
|
||
verdict=green; high=0
|
||
short_tree=$(printf '%s' "$REUSE_TREE" | cut -c1-12)
|
||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||
"Вердикт: зелёный · заход r${{ needs.guard.outputs.cycle }} · применён повторно из r${REUSE_ROUND} без вызова модели (#499) · High: 0 · Medium: 0 · Документ: docs/reviews/${REUSE_DOC}
|
||
|
||
Дерево материала \`${short_tree}\` с захода r${REUSE_ROUND} не изменилось ни в одном файле вне \`docs/reviews/\` (проверено \`git diff\` по содержимому). Новый документ не публикуется: разбирать нечего. Любое отличие дерева — ребейз, тест, фикстура, ТЗ — запустило бы полный разбор."
|
||
else
|
||
verdict=$(echo "$OUT" | jq -r '.verdict')
|
||
high=$(echo "$OUT" | jq -r '.high')
|
||
fi
|
||
echo "вердикт: $verdict, High: $high"
|
||
|
||
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
|
||
# автору: на прогоне #111 жёлтый означал, что AC описывает неверное
|
||
# изменение контракта — реализовать такое ТЗ значит сделать ошибку
|
||
# по инструкции. Оба считаются циклом.
|
||
if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then
|
||
green=true
|
||
case "$STAGE" in
|
||
spec) from=S4-spec-review; to=S5-ready ;;
|
||
code) from=S7-code-review; to=S8-merged ;;
|
||
esac
|
||
else
|
||
green=false
|
||
case "$STAGE" in
|
||
spec) from=S4-spec-review; to=S3-spec ;;
|
||
code) from=S7-code-review; to=S6-in-progress ;;
|
||
esac
|
||
fi
|
||
echo "green=$green" >> "$GITHUB_OUTPUT"
|
||
echo "from=$from" >> "$GITHUB_OUTPUT"
|
||
echo "to=$to" >> "$GITHUB_OUTPUT"
|
||
|
||
# Ревью идёт десятки минут, а dev за это время двигается (28 августа —
|
||
# четыре раза за день). Вердикт при этом вынесен по дереву, которое уже не
|
||
# совпадает с вершиной линии, и слияние приведёт ветку к dev — то есть в
|
||
# dev уедет код, отличный от прочитанного (§7.2). Молчать об этом нельзя,
|
||
# но и шуметь на каждом прогоне ни к чему: строка появляется только когда
|
||
# dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот
|
||
# случится (#364).
|
||
- name: dev ушёл вперёд, пока шло ревью
|
||
if: steps.ready.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code'
|
||
env:
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
MATERIAL: ${{ needs.prepare.outputs.material_sha }}
|
||
GREEN: ${{ steps.decide.outputs.green }}
|
||
run: |
|
||
git fetch -q origin dev
|
||
moved=$(git rev-list --count "$MATERIAL..origin/dev")
|
||
echo "dev продвинулся на $moved коммит(ов) с момента фиксации материала"
|
||
echo "- dev продвинулся на **$moved** коммит(ов) во время ревью" >> "$GITHUB_STEP_SUMMARY"
|
||
if [ "$moved" -eq 0 ] || [ "$GREEN" != "true" ]; then exit 0; fi
|
||
short=$(git rev-parse --short "$MATERIAL")
|
||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||
"Пока шло ревью, \`dev\` продвинулся на $moved коммит(ов). Материал ревью — \`$short\`. Вердикт вынесен по дереву, которое уже не совпадает с вершиной линии: слияние приведёт ветку к dev, и это другой код (§7.2)."
|
||
|
||
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
|
||
# ДО метки, иначе она врёт в промежутке.
|
||
#
|
||
# Слияние — точный кандидат (#492 §4, scripts/merge-candidate.mjs):
|
||
# ветка сверяется с материалом (#312); если dev не двигался — push с
|
||
# lease на текущую вершину; если двигался — ребейз, сравнение patch-id
|
||
# с проверенным диффом, публикация кандидата в ветку, ожидание
|
||
# Validate на этом SHA и только потом push в dev с lease. Повторное
|
||
# движение dev — новая попытка, не более трёх. Каждый исход, кроме
|
||
# успеха, ведёт в S6-in-progress/S7-code-review с комментарием, ПОСЛЕ
|
||
# ПРОГОНА МЕТКА МЕНЯЕТСЯ ВСЕГДА — инвариант тот же, что и раньше.
|
||
- name: Слить ветку в dev
|
||
id: merge
|
||
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
|
||
env:
|
||
HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||
run: |
|
||
if [ -z "$BRANCH" ]; then
|
||
echo "::error::ветки задачи нет — сливать нечего"
|
||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \
|
||
--issue="$NUM" --repo="${{ github.repository }}"
|
||
|
||
- name: Переставить метку
|
||
if: steps.ready.outputs.proceed == 'true'
|
||
env:
|
||
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
FROM: ${{ steps.decide.outputs.from }}
|
||
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
|
||
# автору: метка утверждала бы, что код в dev, а его там нет.
|
||
# Точный кандидат (#492) сам называет исход: S8 после push, S6/S7 —
|
||
# когда кандидат не слит (конфликт, красный Validate, изменившийся
|
||
# patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6.
|
||
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }}
|
||
run: |
|
||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||
--add-label "$TO" --remove-label "$FROM"
|
||
echo "$FROM -> $TO"
|
||
|
||
- name: Сводка длительности стадий
|
||
if: always()
|
||
env:
|
||
STARTED: ${{ steps.clock.outputs.started }}
|
||
PREPARE_SECONDS: ${{ needs.prepare.outputs.duration_seconds }}
|
||
MODEL_SECONDS: ${{ needs.model_review.outputs.duration_seconds }}
|
||
run: |
|
||
integration=$(( $(date +%s) - STARTED ))
|
||
{
|
||
echo "## Бюджеты стадий (#551)"
|
||
echo ""
|
||
echo "| Стадия | Длительность | Лимит |"
|
||
echo "|---|---:|---:|"
|
||
echo "| deterministic prerequisites | ${PREPARE_SECONDS:-нет полного измерения}s | 55 min |"
|
||
echo "| model review | ${MODEL_SECONDS:-не запускалась}s | 45 min |"
|
||
echo "| publication/integration | ${integration}s | 55 min |"
|
||
} >> "$GITHUB_STEP_SUMMARY"
|
||
|
||
- name: Позвать владельца, если стадия упала
|
||
if: failure()
|
||
env:
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||
PREPARE_RESULT: ${{ needs.prepare.result }}
|
||
MODEL_RESULT: ${{ needs.model_review.result }}
|
||
PROCEED: ${{ needs.prepare.outputs.proceed }}
|
||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||
run: |
|
||
stage="публикация/интеграция"
|
||
detail="Модель уже завершила работу; её запечатанный результат сохранён artifact-ом этого run, но не был применён."
|
||
if [ "$PREPARE_RESULT" != "success" ]; then
|
||
stage="deterministic prerequisites"
|
||
detail="Модель не запускалась, цикл ревью не израсходован."
|
||
elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||
stage="model review"
|
||
detail="Полного валидного результата модели нет; метка не менялась."
|
||
fi
|
||
# Тело через heredoc, а не многострочный --body: строка с нулевым
|
||
# отступом обрывает блок YAML и оставляет незакрытую кавычку.
|
||
cat > /tmp/failure.md <<EOF
|
||
Автоматическое ревью не отработало: стадия **$stage** остановилась. [Прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
|
||
|
||
$detail
|
||
|
||
Если вердикт выше всё же опубликован — перестановку метки выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
|
||
EOF
|
||
gh issue comment "${{ github.event.issue.number }}" \
|
||
--repo "${{ github.repository }}" --body-file /tmp/failure.md
|