Skip to content

csbrasil-bot-pr-classify #9565

csbrasil-bot-pr-classify

csbrasil-bot-pr-classify #9565

name: csbrasil-bot-pr-classify
on:
# `pull_request_target` dispara no PUSH — e o CodeRabbit comenta DEPOIS do push. Com
# só esses gatilhos o rotulador rodava antes de existir comentário, não achava nada
# e nunca mais rodava: era estruturalmente cego para o que devia detectar. Medido na
# PR #240, que fechou com 4 fios do revisor abertos e NENHUMA etiqueta.
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
pull_request_review:
types: [submitted, dismissed]
pull_request_review_comment:
types: [created]
# Varredura: em PR vindo de FORK, os dois gatilhos de review acima rodam SEM os
# secrets do repositório (regra do GitHub), e o job abaixo não pode nem se
# identificar. O sweep roda no contexto base, com token, e reclassifica os PRs
# abertos pelo `workflow_dispatch` — é o que devolve a cobertura pós-CodeRabbit
# que os gatilhos de review davam nos PRs de casa.
schedule:
- cron: '7,37 * * * *'
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to classify
required: true
type: number
permissions:
contents: read
pull-requests: write
issues: write
# BUG-68: um review com N comentários dispara N gatilhos NO MESMO SEGUNDO; os
# runs paralelos leem a lista antes de qualquer um postar e cada um publica o
# seu (PR #348: 5 idênticos mesmo com dedupe). Serializa por PR: 1 run vivo.
concurrency:
group: pr-classify-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: true
jobs:
# PORTÃO QUE NÃO PODE MEDIR NÃO VOTA (mesma regra do #402 para o portão de autoria em
# clone raso). `pull_request_review` e `pull_request_review_comment` num PR de FORK não
# recebem secret nenhum: o job não tem como se identificar, falhava no guard e pintava
# de vermelho um PR cujo autor não tem o que consertar. Medido em 11 dos 14 PRs de fork
# dos últimos 29 - e em 0 dos 15 de casa. O run de `pull_request_target` do MESMO commit
# passa e é ele que vale; para o resto, o sweep de meia em meia hora cobre.
classify:
if: >-
github.event_name == 'pull_request_target' ||
github.event_name == 'workflow_dispatch' ||
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 10
env:
# Sem fallback: comentário nascido como github-actions[bot] é o defeito
# da PR #348 (5 idênticos) — sem o secret o job falha alto na cara.
GH_TOKEN: ${{ secrets.CSBRASIL_BOT_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
DESIRED_BASE_BRANCH: ${{ vars.PR_DEFAULT_BASE_BRANCH || 'staging' }}
steps:
- name: Guard de identidade
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::CSBRASIL_BOT_TOKEN ausente — crie o secret (PAT da conta csbrasil-bot, scopes: repo/workflow). Sem ele o classify posta como github-actions[bot] ou não posta."
exit 1
fi
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.13'
- name: Ensure labels exist
run: python3 scripts/ci/ensure_labels.py safe-automerge needs-staging needs-human-gameplay needs-human-backend target:main target:staging target:release needs-coderabbit-resolution coderabbit-resolved
- name: Fetch PR metadata
run: gh pr view "$PR_NUMBER" --json files,additions,deletions,changedFiles,baseRefName,author,assignees > /tmp/pr.json
- name: Route PR and set assignee
run: |
python3 - <<'PY'
import json, pathlib
pr = json.load(open('/tmp/pr.json'))
payload = {'pr': pr, 'desired_base': '${{ env.DESIRED_BASE_BRANCH }}'}
pathlib.Path('/tmp/pr-route-input.json').write_text(json.dumps(payload))
PY
python3 scripts/ci/pr_route.py < /tmp/pr-route-input.json > /tmp/pr-route.json
python3 - <<'PY'
import json, os, subprocess
route = json.load(open('/tmp/pr-route.json'))
pr_number = os.environ['PR_NUMBER']
repo = os.environ['GITHUB_REPOSITORY']
# REST puro: `gh pr edit` resolve nó de user/org via GraphQL e exige
# scope read:org — PAT com repo/workflow morre aqui (vermelho da #350).
if route.get('retarget_to'):
exists = subprocess.run(
['git', 'ls-remote', '--exit-code', '--heads', 'origin', route['retarget_to']],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if exists.returncode == 0:
subprocess.run(['gh', 'api', '-X', 'PATCH', f'repos/{repo}/pulls/{pr_number}', '--input', '-'],
input=json.dumps({'base': route['retarget_to']}), text=True, check=True)
else:
route['retarget_to'] = None
open('/tmp/pr-route.json', 'w').write(json.dumps(route))
if route.get('add_assignee'):
subprocess.run(['gh', 'api', '-X', 'POST', f'repos/{repo}/issues/{pr_number}/assignees', '--input', '-'],
input=json.dumps({'assignees': [route['add_assignee']]}), text=True, check=True)
PY
- name: Refresh PR metadata after routing
run: gh pr view "$PR_NUMBER" --json files,additions,deletions,changedFiles,baseRefName,author,assignees > /tmp/pr.json
- name: Classify PR
run: python3 scripts/ci/pr_classify.py < /tmp/pr.json > /tmp/pr-labels.json
- name: Gate on CodeRabbit presence
run: |
gh pr view "$PR_NUMBER" --json comments,statusCheckRollup > /tmp/pr-coderabbit.json
python3 scripts/ci/coderabbit_gate.py --selftest
python3 scripts/ci/coderabbit_gate.py < /tmp/pr-coderabbit.json > /tmp/pr-coderabbit-labels.json
- name: Apply labels
run: |
python3 - <<'PY'
import json, os, subprocess
data = json.load(open('/tmp/pr-labels.json'))
coderabbit = json.load(open('/tmp/pr-coderabbit-labels.json'))
add = sorted(set(data.get('labels_add', []) + coderabbit.get('labels_add', [])))
remove = sorted(set(data.get('labels_remove', []) + coderabbit.get('labels_remove', [])))
repo = os.environ['GITHUB_REPOSITORY']
n = os.environ['PR_NUMBER']
if add:
subprocess.run(['gh', 'api', '-X', 'POST', f'repos/{repo}/issues/{n}/labels', '--input', '-'],
input=json.dumps({'labels': add}), text=True, check=True)
for lab in remove:
import urllib.parse
subprocess.run(['gh', 'api', '-X', 'DELETE', f'repos/{repo}/issues/{n}/labels/{urllib.parse.quote(lab)}'], check=False)
PY
- name: Update bot classification comment
run: |
python3 - <<'PY'
import json, subprocess, pathlib
pr = json.load(open('/tmp/pr.json'))
labels = json.load(open('/tmp/pr-labels.json'))
route = json.load(open('/tmp/pr-route.json'))
coderabbit = json.load(open('/tmp/pr-coderabbit-labels.json'))
payload = {
'files': pr.get('files', []),
'changedFiles': pr.get('changedFiles', 0),
'labels_add': sorted(set(labels.get('labels_add', []) + coderabbit.get('labels_add', []))),
'baseRefName': pr.get('baseRefName'),
'author_login': ((pr.get('author') or {}).get('login')),
'add_assignee': route.get('add_assignee'),
'retarget_to': route.get('retarget_to'),
}
pathlib.Path('/tmp/pr-comment-payload.json').write_text(json.dumps(payload))
PY
python3 scripts/ci/pr_comment.py < /tmp/pr-comment-payload.json > /tmp/pr-comment.md
python3 - <<'PY'
import json, os, subprocess
marker = '## csbrasil-bot classification'
# BUG-56: casar por AUTOR não vale — sem CSBRASIL_BOT_TOKEN o comentário
# sai como github-actions[bot], o filtro nunca casa e cada gatilho posta
# um NOVO (PR #348 chegou a 5 idênticos). O marcador no corpo é a
# identidade; extras são deletados e o primeiro é atualizado.
comments = json.loads(subprocess.check_output([
'gh', 'api', f"repos/${{ github.repository }}/issues/{os.environ['PR_NUMBER']}/comments?per_page=100"
], text=True))
body = open('/tmp/pr-comment.md').read()
meus = [c for c in comments if marker in c.get('body', '')]
for c in meus[1:]:
subprocess.run(['gh', 'api', '-X', 'DELETE', f"repos/${{ github.repository }}/issues/comments/{c['id']}"], check=False)
if meus:
subprocess.run(['gh', 'api', f"repos/${{ github.repository }}/issues/comments/{meus[0]['id']}", '-X', 'PATCH', '-f', f'body={body}'], check=True)
raise SystemExit(0)
subprocess.run(['gh', 'api', '-X', 'POST', f"repos/${{ github.repository }}/issues/{os.environ['PR_NUMBER']}/comments", '--input', '-'],
input=json.dumps({'body': body}), text=True, check=True)
PY
# Reclassifica os PRs abertos pelo caminho que TEM secret. Idempotente: o classify
# dedupe rótulo e comentário, então rodar de novo não polui o PR.
sweep:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GH_TOKEN: ${{ secrets.CSBRASIL_BOT_TOKEN }}
REPO: ${{ github.repository }}
steps:
- name: Guard de identidade
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::CSBRASIL_BOT_TOKEN ausente — o sweep precisa do PAT para disparar o classify."
exit 1
fi
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Dispara o classify de cada PR aberto
run: |
for pr in $(gh pr list --repo "$REPO" --state open --limit 50 --json number --jq '.[].number'); do
gh workflow run csbrasil-bot-pr-classify.yml --repo "$REPO" -f pr_number="$pr" \
|| echo "::warning::não deu para reclassificar o PR #$pr"
done