Files
houseplan-card/.github/workflows/process.yml
T
Matysh 8a3f6efa0a fix: the review document is published even without a task branch
Issues labelled before the pipeline existed keep their spec straight in dev and
have no issue/NN branch. The publish step quietly exited zero for them, so the
verdict would arrive as a comment and the analysis behind it would be thrown
away — the fifth instance today of a step reporting success by doing nothing.

The document now goes wherever the spec itself lives: the task branch when there
is one, dev otherwise. Publishing also survives dev moving on while the review
ran, which takes up to forty-five minutes, by rebasing once before it gives up.

Four issues are waiting on this — #12, #30, #44 and #52 — each with a spec in dev,
a status label applied during the bulk pass in August and a review that never ran
because nothing was there to raise the event.

Issue: #114
User-Visible: no
2026-08-13 21:11:41 +03:00

402 lines
27 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
target="${BRANCH:-dev}"
if [ -z "$BRANCH" ]; then
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
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 "::warning::документ ревью не создан"
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
# Публикация в 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
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi
echo "документ опубликован в $target"
- 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