mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
The guard counted every verdict comment on the issue, so a spec-review verdict consumed a cycle from the code-review budget. On #89 the first code review came out as r2/4. With two spec cycles the second code review would have hit review-4 after a single fix — the limit would have fired on a task nobody had reviewed twice. The stage is now resolved first and only its own verdicts are counted, recognised by the review document named in the comment. If the document is missing the verdict is not counted: undercounting grants an extra cycle, overcounting would stop the work early, and of the two mistakes the recoverable one wins. Issue: #114 User-Visible: no
342 lines
21 KiB
YAML
342 lines
21 KiB
YAML
name: Process
|
||
|
||
# Событийный конвейер процесса (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:
|
||
# Два события по одному issue не должны запускать два прогона.
|
||
group: process-issue-${{ github.event.issue.number }}
|
||
cancel-in-progress: false
|
||
|
||
permissions:
|
||
contents: read
|
||
issues: write
|
||
# Обязательно: claude-code-action получает OIDC-токен для авторизации
|
||
# GitHub App. Без этого прогон падает с «Could not fetch an OIDC token».
|
||
id-token: write
|
||
|
||
jobs:
|
||
guard:
|
||
runs-on: ubuntu-latest
|
||
outputs:
|
||
stage: ${{ steps.decide.outputs.stage }}
|
||
cycle: ${{ steps.decide.outputs.cycle }}
|
||
limit: ${{ steps.decide.outputs.limit }}
|
||
steps:
|
||
- id: decide
|
||
env:
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
LABEL: ${{ github.event.label.name }}
|
||
AUTHOR: ${{ github.event.issue.user.login }}
|
||
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
|
||
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
|
||
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
run: |
|
||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||
stage=""; marker=""
|
||
case "$LABEL" in
|
||
S4-spec-review) stage="spec"; marker="SPEC-REVIEW" ;;
|
||
S7-code-review) stage="code"; marker="CODE-REVIEW" ;;
|
||
*) echo "метка $LABEL конвейер не запускает" ;;
|
||
esac
|
||
|
||
# Лимит циклов: 4 обычный, 2 на лёгком треке (PROCESS.md §4).
|
||
limit=4; [ "$SMALL" = "true" ] && limit=2
|
||
|
||
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
|
||
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
|
||
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
|
||
# код-ревью упиралось бы в review-4 после одной правки.
|
||
#
|
||
# Этап опознаётся по имени документа в теле комментария. Если документа
|
||
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
|
||
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
|
||
done_cycles=0
|
||
if [ -n "$stage" ]; then
|
||
done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \
|
||
--json comments \
|
||
-q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length")
|
||
fi
|
||
|
||
# В процесс идут только issue, созданные владельцем: репозиторий
|
||
# публичный, чужие отчёты бывают невалидны и статусов не несут.
|
||
if [ -z "$stage" ]; then
|
||
:
|
||
elif [ "$AUTHOR" != "Matysh" ]; then
|
||
echo "issue от $AUTHOR, не от владельца — пропуск"
|
||
stage=""
|
||
elif [ "$BLOCKED" = "true" ]; then
|
||
echo "стоит blocked — конвейер не запускается"
|
||
stage=""
|
||
elif [ "$EXHAUSTED" = "true" ]; then
|
||
echo "стоит review-4 — решение за владельцем"
|
||
stage=""
|
||
elif [ "$done_cycles" -ge "$limit" ]; then
|
||
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
|
||
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
|
||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||
"Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
|
||
stage=""
|
||
else
|
||
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
|
||
fi
|
||
echo "stage=$stage" >> "$GITHUB_OUTPUT"
|
||
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
|
||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||
|
||
review:
|
||
needs: guard
|
||
if: needs.guard.outputs.stage != ''
|
||
runs-on: ubuntu-latest
|
||
# Время — единственный настоящий ограничитель зациклившегося прогона.
|
||
timeout-minutes: 45
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
with:
|
||
fetch-depth: 0
|
||
ref: dev
|
||
|
||
- uses: actions/setup-node@v4
|
||
with: { node-version: 22 }
|
||
|
||
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
|
||
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
|
||
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
|
||
- name: Перейти на ветку задачи
|
||
id: branch
|
||
env:
|
||
NUM: ${{ github.event.issue.number }}
|
||
run: |
|
||
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
|
||
| head -1 | sed 's|.*refs/heads/||')
|
||
if [ -n "$branch" ]; then
|
||
git checkout -q "origin/$branch"
|
||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||
echo "name=$branch" >> "$GITHUB_OUTPUT"
|
||
else
|
||
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
|
||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||
fi
|
||
|
||
- name: Review
|
||
id: review
|
||
uses: anthropics/claude-code-action@v1
|
||
with:
|
||
# Подписка, а не отдельный счёт API: токен выпускается через
|
||
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
|
||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||
prompt: |
|
||
Ты ревьюер проекта House Plan. Язык ответа — русский.
|
||
|
||
Issue: #${{ github.event.issue.number }}
|
||
Репозиторий: ${{ github.repository }}
|
||
Этап: ${{ needs.guard.outputs.stage }}
|
||
spec — ревью ТЗ (PROCESS.md §2.4)
|
||
code — код-ревью (PROCESS.md §2.7)
|
||
|
||
Прочитай в этом порядке, прежде чем судить:
|
||
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 помечен small, ТЗ живёт в теле issue и
|
||
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
|
||
Проверь обязательные разделы §7.1, однозначность каждого AC и
|
||
указание способа доказательства. Отдельно проверь, что автор не
|
||
выдал догадку за решение: утверждение о поведении, которого нет ни
|
||
в одном документе и которое не помечено как предположение, —
|
||
замечание. Не бывает сложной задачи без единого открытого вопроса.
|
||
|
||
Владельцу задаются только продуктовые вопросы: что человек видит или
|
||
делает и каков объём видимых изменений в этом issue. Технический
|
||
вопрос, вынесенный владельцу, — тоже замечание: ты его снимаешь и
|
||
решаешь по существу в своём вердикте.
|
||
|
||
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
|
||
и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет,
|
||
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
|
||
По каждому AC: либо он доказан автотестом и ты убедился, что тест
|
||
умеет падать, либо разобран по коду с явной записью «проверено
|
||
чтением, не исполнением». «Verified» без названной команды и её
|
||
результата доказательством не является. Зависимостей в рабочей
|
||
копии нет: перед гейтами выполни `npm ci`. Проверь трейлеры Issue и
|
||
User-Visible, при User-Visible: yes — правки в оба changelog в том же
|
||
коммите.
|
||
|
||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||
|
||
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
|
||
Low либо правится, либо снимается с записью. Жёлтый вердикт
|
||
допустим при полностью выполненных AC, если изменение не решает
|
||
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
|
||
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
|
||
|
||
Каждую Medium-находку заведи отдельным issue со ссылкой на
|
||
#${{ github.event.issue.number }} и метками: тип, приоритет,
|
||
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
|
||
запрещено §12.
|
||
|
||
Напиши полный документ ревью в файл
|
||
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
|
||
(SPEC для этапа spec, CODE для code): скоуп, как проверялось,
|
||
находки с воспроизведением, что проверено и корректно, чего не
|
||
проверял. Каталог docs/reviews/ создай, если его нет. Больше не
|
||
пиши ничего: любой файл вне docs/reviews/ опубликован не будет.
|
||
|
||
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
|
||
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
|
||
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
|
||
|
||
Затем верни 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"]}'
|
||
|
||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||
- name: Опубликовать документ ревью
|
||
env:
|
||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
BRANCH: ${{ steps.branch.outputs.name }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
run: |
|
||
if [ -z "$BRANCH" ]; then
|
||
echo "ветки задачи нет — документ некуда класть"; exit 0
|
||
fi
|
||
git checkout -- . 2>/dev/null || true
|
||
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
|
||
git add docs/reviews 2>/dev/null || true
|
||
if git diff --cached --quiet; then
|
||
echo "документ ревью не создан"; exit 0
|
||
fi
|
||
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 push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||
"HEAD:$BRANCH"
|
||
echo "документ опубликован в $BRANCH"
|
||
|
||
- name: Решение по вердикту
|
||
id: decide
|
||
env:
|
||
OUT: ${{ steps.review.outputs.structured_output }}
|
||
STAGE: ${{ needs.guard.outputs.stage }}
|
||
run: |
|
||
verdict=$(echo "$OUT" | jq -r '.verdict')
|
||
high=$(echo "$OUT" | jq -r '.high')
|
||
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"
|
||
|
||
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
|
||
# ДО метки, иначе она врờt в промежутке. Конфликт — метка не двигается.
|
||
- name: Слить ветку в dev
|
||
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
|
||
env:
|
||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
BRANCH: ${{ steps.branch.outputs.name }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
run: |
|
||
if [ -z "$BRANCH" ]; then
|
||
echo "::error::ветки задачи нет — сливать нечего"; exit 1
|
||
fi
|
||
git fetch -q origin dev
|
||
git checkout -q -B merge-into-dev "origin/$BRANCH"
|
||
if ! git -c user.name="claude[bot]" \
|
||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||
rebase origin/dev; then
|
||
git rebase --abort || true
|
||
cat > /tmp/conflict.md <<EOF
|
||
Код-ревью зелёное, но ветка $BRANCH не сливается в dev без конфликта. Статусная метка не менялась: задача осталась в S7-code-review.
|
||
|
||
Разрешить конфликт и запушить dev — на стороне автора, затем снять и вернуть метку S7-code-review для повторного прогона.
|
||
EOF
|
||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
|
||
exit 1
|
||
fi
|
||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
|
||
echo "слито в dev: $(git rev-parse --short HEAD)"
|
||
|
||
- name: Переставить метку
|
||
env:
|
||
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
NUM: ${{ github.event.issue.number }}
|
||
FROM: ${{ steps.decide.outputs.from }}
|
||
TO: ${{ steps.decide.outputs.to }}
|
||
run: |
|
||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||
--add-label "$TO" --remove-label "$FROM"
|
||
echo "$FROM -> $TO"
|
||
|
||
- name: Позвать владельца, если ревью упало
|
||
if: failure()
|
||
env:
|
||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||
run: |
|
||
# Тело через heredoc, а не многострочный --body: строка с нулевым
|
||
# отступом обрывает блок YAML и оставляет незакрытую кавычку.
|
||
cat > /tmp/failure.md <<EOF
|
||
Автоматическое ревью не отработало: [прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
|
||
|
||
Если вердикт выше всё же опубликован — сбой произошёл после него. Перестановку метки в этом случае выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
|
||
EOF
|
||
gh issue comment "${{ github.event.issue.number }}" \\
|
||
--repo "${{ github.repository }}" --body-file /tmp/failure.md
|