csbrasil-bot-pr-classify #9565
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |