mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
A review label promises work. When the guard declined it wrote the reason to the run log and nothing else, so the issue sat in a status nobody was acting on and nobody could tell. #123 showed it: an outside reporter's issue was walked up to S4-spec-review, the guard refused in nine seconds because only the owner's issues enter the process, and the issue itself said not a word. Refusals that a human can act on now become a comment: wrong author, blocked, review-4. Only when a stage was actually recognised, so an unrelated label change stays silent. This is the same defect as the merge conflict that left the label untouched, seen from the other side. The pattern is worth naming: doing nothing quietly is the most expensive thing a pipeline can do. Issue: #114 User-Visible: no
379 lines
25 KiB
YAML
379 lines
25 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, а не только в логе прогона.
|
||
# Ревьюшная метка обещает работу; если конвейер её не начал и промолчал,
|
||
# задача стоит в этом статусе бесконечно и никто об этом не узнаёт.
|
||
# Так и вышло на #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, созданные владельцем: репозиторий
|
||
# публичный, чужие отчёты бывают невалидны и статусов не несут.
|
||
if [ -z "$stage" ]; then
|
||
:
|
||
elif [ "$AUTHOR" != "Matysh" ]; then
|
||
refuse "issue от $AUTHOR, не от владельца — пропуск" \
|
||
"issue создан пользователем \`$AUTHOR\`, а в процесс идут только issue владельца (PROCESS.md §9). Чтобы взять задачу в работу, владельцу нужно завести свой issue со ссылкой на этот."
|
||
elif [ "$BLOCKED" = "true" ]; then
|
||
refuse "стоит blocked — конвейер не запускается" \
|
||
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
|
||
elif [ "$EXHAUSTED" = "true" ]; then
|
||
refuse "стоит review-4 — решение за владельцем" \
|
||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
|
||
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. Значит слияние обязано произойти
|
||
# ДО метки, иначе она врёт в промежутке.
|
||
#
|
||
# При конфликте шаг НЕ падает и метку не оставляет на месте. Первая
|
||
# редакция делала именно так, и это оказалось тупиком: автор ждёт смену
|
||
# метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы
|
||
# затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант
|
||
# теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА.
|
||
- name: Слить ветку в dev
|
||
id: merge
|
||
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::ветки задачи нет — сливать нечего"
|
||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
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
|
||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||
echo "::warning::ветка $BRANCH не сливается в dev без конфликта"
|
||
cat > /tmp/conflict.md <<EOF
|
||
**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`$BRANCH\` конфликтует с \`dev\`.
|
||
|
||
Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:
|
||
|
||
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
|
||
2. запушить ветку;
|
||
3. вернуть метку \`S7-code-review\`.
|
||
|
||
Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.
|
||
EOF
|
||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
|
||
exit 0
|
||
fi
|
||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
|
||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||
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 }}
|
||
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
|
||
# автору: метка утверждала бы, что код в dev, а его там нет.
|
||
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: 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
|