mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
The guard refused to review any issue the owner had not filed himself. The rule was meant to keep malformed outside reports out of the pipeline, but it checked at every step instead of at the entrance, and it duplicated a guarantee the platform already gives: only someone with write access can apply a label. Applying the first status label is the owner's explicit decision, and it is the only place the question belongs. So the author check is gone. While an issue carries no status label it sits outside the process and the invariants do not apply; once labelled, the task is in flight and who filed it stops mattering. The old rule also cost real work. On #123 an outside bug report had been analysed and specified before the guard turned it away in nine seconds, and the remedy on offer was to refile the same thing as the owner's own issue. Issue: #114 User-Visible: no
381 lines
25 KiB
YAML
381 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 }}
|
||
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 здесь не проверяется (решение владельца 2026-08-13).
|
||
# Проверка стоит на входе в процесс, а не на каждом шаге: как только
|
||
# задача получила статусную метку, она в работе, и кто её завёл — не
|
||
# имеет значения. Само присвоение метки и есть явное подтверждение
|
||
# владельца, причём проверенное платформой: метки может ставить только
|
||
# тот, у кого есть право записи в репозиторий. Прежняя проверка здесь
|
||
# дублировала эту гарантию и заставляла переоформлять чужие отчёты
|
||
# своими issue — чистая работа впустую, как на #123.
|
||
if [ -z "$stage" ]; then
|
||
:
|
||
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
|