← Voltar ao Blog

Conventional Comments: 9 rótulos, 1 trava merge

No capítulo 7 do meu livro em PT Revisão de Código com Harness Engineering eu propus duas coisas: adotar Conventional Comments como vocabulário de review, e colocar um workflow do GitHub Actions chamado review-gate.yml para travar o merge enquanto houver comentário issue: sem resolução. O capítulo apresenta a lista de rótulos como se fossem seis, e o workflow usa um filtro !c.line em cima da REST API para decidir se o comentário está resolvido.

Fui conferir essas duas afirmações contra a documentação oficial, antes de recomendar o padrão sem reservas em outros lugares. A spec real define nove rótulos, não seis. E o filtro !c.line que eu publiquei no YAML não detecta resolve — a REST API não devolve esse estado, e o campo line não desaparece quando alguém marca o thread como resolvido na UI.

Este post é a correção pública dessas duas peças, com link para as fontes.

Os 9 rótulos oficiais de Conventional Comments e como cada um se relaciona com "travar merge" na spec. Por padrão, só issue carrega essa semântica; os outros podem receber a decoração (blocking) se o time quiser.

A spec tem nove rótulos, não seis

conventionalcomments.org foi publicado em 2020 por Paul Slaughter (engenheiro na GitLab) como uma convenção leve para dar prefixo a comentários de code review. A lista oficial, hoje, tem nove rótulos principais:

  • praise — destacar algo positivo (a spec sugere pelo menos um por review).
  • nitpick — preferência trivial, marcada como não-bloqueante por natureza.
  • suggestion — proposta de melhoria com a razão explícita.
  • issue — problema concreto (bug, segurança, correção obrigatória).
  • todo — mudança pequena que precisa ser feita antes do merge.
  • question — dúvida que exige resposta, nem sempre mudança.
  • thought — ideia aberta, útil para mentoria e discussão.
  • chore — tarefa de processo (atualizar CHANGELOG, assinar CLA).
  • note — informação relevante; sempre não-bloqueante.

Mais três variantes expressivas opcionais: typo (como todo, só para erro de digitação), polish (como suggestion, para melhoria sem corrigir erro) e quibble (como nitpick, com outro tom).

No meu capítulo 7 eu listei seis — praise / issue / suggestion / nitpick / question / thought — e falei que “nada além de issue é blocking”. A simplificação não é totalmente errada: na prática, times que adotam Conventional Comments costumam escolher um subset. O que eu deveria ter escrito, no entanto, é que a spec tem nove, e que o subset é escolha de time, não da convenção. O leitor que foi para conventionalcomments.org depois de ler meu capítulo encontrou três rótulos a mais (todo, chore, note) que o capítulo não cita. É pouca coisa, mas é o tipo de simplificação que vira dívida: todo em particular carrega semântica de “obrigatório antes de merge” que issue não cobre bem (um todo: adicionar teste de regressão não é bug, mas é bloqueante).

Outra coisa que o capítulo omite são as decorações da spec: (blocking), (non-blocking) e (if-minor). Elas podem ser anexadas a qualquer rótulo:

suggestion (blocking): este endpoint precisa validar o input antes de persistir.
nitpick (if-minor): este nome de variável poderia ser mais curto.
question (non-blocking): essa escolha de ORM é por preferência pessoal?

A existência de (blocking) como decoração explícita significa que “travar merge” não é propriedade intrínseca de issue. É uma convenção que o time instaura, usando o rótulo e/ou a decoração. Qualquer gate que associe hard-coded “só issue: bloqueia” está pré-filtrando as outras oito combinações válidas. É uma escolha legítima — mas precisa ser documentada como escolha do time, não como regra do padrão.

O merge-gate do capítulo 7

Agora o pedaço mais embaraçoso. O capítulo 7 propõe um workflow do GitHub Actions para transformar o issue: em portão automático:

# .github/workflows/review-gate.yml (versão do capítulo 7 — com defeito)
name: Review Gate
on:
  pull_request:
    types: [review_requested, synchronize]

jobs:
  check-unresolved-issues:
    runs-on: ubuntu-latest
    steps:
      - name: Check unresolved CodeRabbit issues
        uses: actions/github-script@v7
        with:
          script: |
            const comments = await github.rest.pulls.listReviewComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.payload.pull_request.number
            });

            const unresolvedIssues = comments.data.filter(c =>
              c.body.startsWith('issue:') && !c.line  // resolved comments lose line
            );

A ideia é: pega os review comments do PR via REST (pulls.listReviewComments), filtra os que começam com issue:, e descarta os que foram “resolvidos”. O comentário na linha do filtro — resolved comments lose line — é a premissa que o workflow depende. Se ela cai, o gate inteiro vira teatro: ele sempre vê zero comentário resolvido, logo nunca falha, logo nunca bloqueia merge.

Fui conferir essa premissa.

O que a REST API de review comments realmente devolve

O endpoint que o workflow chama é GET /repos/{owner}/{repo}/pulls/{pull_number}/comments, documentado em docs.github.com/en/rest/pulls/comments. O schema de resposta de cada comentário tem os campos url, pull_request_review_id, id, node_id, diff_hunk, path, position, original_position, commit_id, original_commit_id, in_reply_to_id, user, body, created_at, updated_at, html_url, pull_request_url, author_association, _links, start_line, original_start_line, start_side, line, original_line, side, subject_type, reactions, body_html, body_text.

Nenhum campo que indica resolução. Nem resolved, nem isResolved, nem state. O line existe, aponta para a linha do diff, e não é anulado quando o thread é marcado como resolvido na UI. A resolução é uma propriedade do review thread, não do comment, e os dois endpoints estão em superfícies diferentes da API.

Para confirmar que isso é limitação conhecida e não algo que eu interpretei mal, procurei a discussão oficial. Em community/discussions/9175, o título é literalmente “[Github API] List comments for a pull request review github api — lack of status”. O autor abre pedindo exatamente o que meu workflow assume: “there is no way to find out which conversation is resolved/unresolved though the github api” via REST. A resposta mais votada aponta GraphQL como o único caminho atual, e o tópico segue aberto há anos como pedido de feature (sem resposta oficial marcada como “accepted”). Em community/discussions/24854 a pergunta é a mesma, com título “GraphQL resolved conversations”, e a solução é a mesma: reviewThreads { nodes { isResolved } }.

Na prática: o filtro c.body.startsWith('issue:') && !c.line sempre vai manter todos os comentários issue: como “não resolvidos”, porque c.line nunca é falsy por conta de resolução. O gate nunca vai bloquear merge. É um gate pintado: dá sensação de proteção, mas o fluxo passa por baixo dele.

O caminho correto: GraphQL isResolved

A API que de fato expõe resolução é o GraphQL, no tipo PullRequestReviewThread, com o campo isResolved: Boolean!. A query equivalente ao que o workflow queria fazer é:

query ($owner: String!, $repo: String!, $pr: Int!) {
  repository(owner: $owner, name: $repo) {
    pullRequest(number: $pr) {
      reviewThreads(first: 100) {
        nodes {
          isResolved
          comments(first: 1) {
            nodes { body }
          }
        }
      }
    }
  }
}

A estrutura muda: você sai do nível de comment e sobe para thread. Um thread tem isResolved: true | false e uma lista de comments. Para replicar a intenção do workflow — “existe algum thread aberto cujo primeiro comentário começa com issue:?” — a lógica fica assim, em JavaScript dentro de actions/github-script:

# .github/workflows/review-gate.yml (versão corrigida)
name: Review Gate
on:
  pull_request:
    types: [opened, synchronize, review_requested]

jobs:
  check-unresolved-issues:
    runs-on: ubuntu-latest
    steps:
      - name: Check unresolved issue: threads via GraphQL
        uses: actions/github-script@v7
        with:
          script: |
            const query = `
              query ($owner: String!, $repo: String!, $pr: Int!) {
                repository(owner: $owner, name: $repo) {
                  pullRequest(number: $pr) {
                    reviewThreads(first: 100) {
                      nodes {
                        isResolved
                        comments(first: 1) {
                          nodes { body }
                        }
                      }
                    }
                  }
                }
              }
            `;
            const data = await github.graphql(query, {
              owner: context.repo.owner,
              repo: context.repo.repo,
              pr: context.payload.pull_request.number,
            });
            const threads = data.repository.pullRequest.reviewThreads.nodes;
            const unresolved = threads.filter(t =>
              !t.isResolved &&
              t.comments.nodes[0]?.body.trimStart().startsWith('issue:')
            );
            if (unresolved.length > 0) {
              core.setFailed(
                `${unresolved.length} thread(s) com "issue:" ainda não resolvido(s)`
              );
            }

Três diferenças em relação à versão do capítulo:

  1. github.graphql em vez de github.rest.pulls.listReviewComments. É a mudança de superfície que resolve o problema.
  2. Nível de agregação sobe para thread. O filtro agora é “thread com isResolved === false”, não “comentário sem line”. A primeira é uma propriedade real; a segunda era imaginação minha.
  3. trimStart() antes do startsWith. Em testes rápidos, muitas respostas do CodeRabbit vêm com espaço ou quebra de linha no começo do body. Sem o trim, o gate perde comentários que começam com issue: ou \nissue:.

Também mudei o trigger de [review_requested, synchronize] para [opened, synchronize, review_requested], porque o padrão do capítulo deixava a primeira abertura do PR sem gate até alguém pedir review explicitamente. Pequeno detalhe que não tem a ver com o bug principal, mas aproveitei a correção.

Por que a versão quebrada parecia funcionar

Tem uma armadilha interessante aqui: um gate que “nunca falha” pode ficar meses no repositório sem que ninguém note. Diferente de um lint que deixa passar código ruim (visível no PR seguinte), um merge-gate falso-positivo passa de forma silenciosa. O time acredita que está protegido, só vê CI verde, e nunca descobre que o gate está vazio.

A única maneira de pegar esse tipo de bug é escrever um teste que espera o gate falhar — abrir um PR deliberado com um comentário issue: não resolvido e verificar que o workflow bloqueia. Esse teste nunca estava no capítulo 7. Era obrigação do leitor, e eu nem avisei que era obrigação do leitor. É o tipo de omissão que atrapalha mais do que a configuração em si: a configuração, uma vez corrigida, para de errar; a ausência do teste significa que a próxima configuração vai errar igual, só com outro filtro.

Esse padrão — gate pintado que passa silenciosamente — é a mesma classe do problema que descrevi em Escrevi ‘testes antes do PR’ no AGENTS.md por 3 meses. Só 12% seguiram. Regra sem portão não vira comportamento; portão sem teste de regressão não vira defesa.

O que isso muda no fluxo que o capítulo propõe

A arquitetura do capítulo 7 continua defensável: adotar Conventional Comments como vocabulário comum, forçar o uso via .coderabbit.yaml, e deixar que um gate automático traduza issue: em “merge bloqueado”. O que precisa ser trocado é só a implementação do gate:

  • O .coderabbit.yaml com reviews.path_instructions do capítulo continua correto. Esse schema está documentado hoje em docs.coderabbit.ai e tem path (glob) + instructions (string multi-linha), exatamente como no livro.
  • Os templates de Conventional Comments em AGENTS.md continuam úteis — só precisam incluir todo, chore e note como opções da spec, e mencionar as decorações (blocking) / (non-blocking) / (if-minor).
  • O workflow precisa ser trocado pela versão GraphQL. A versão REST do capítulo é um gate decorativo.

A versão PT do livro na Amazon está em B0H2DB9YXD com data de 2026-05-20. Vou subir uma errata no próximo update do manuscrito corrigindo o capítulo 7, e deixar este post como referência pública enquanto isso.

O que eu tiro desse exercício

Duas coisas práticas:

1. Comentário em código de livro técnico é afirmação, não decoração. A linha // resolved comments lose line era uma afirmação sobre como a REST API da GitHub se comporta. Eu nunca verifiquei. O código compilava, o YAML passava no CI do próprio projeto do livro (que não tinha issue: nenhum para testar), e o comentário parecia plausível. Nenhum desses sinais exige que a afirmação seja verdadeira.

2. “Resolve de review” é propriedade de thread, não de comment, em todas as APIs modernas da GitHub. Essa é a lição transferível para outros gates que alguém queira montar: se o seu filtro depende de “este comentário foi resolvido”, você precisa subir um nível para reviewThreads em GraphQL. REST só devolve comentários individuais, sem o contexto de resolução.

A proposta do capítulo não muda — gate automático que trata issue: como “obrigatório antes de merge” ainda é um padrão útil. O que muda é a tubulação. E, bem honestamente, o fato de eu ter publicado a tubulação quebrada e só ter percebido agora é um lembrete de que verificar a doc depois de o YAML parecer bonito já é tarde.

Referências


ken imoto · WebRTC & Voice AI engineer · kenimoto.dev · TabNews

Revisão de Código com Harness Engineering Livro relacionado Revisão de Código com Harness Engineering Revisão de código em três camadas | hooks + IA + humano · AGENTS.md · CodeRabbit · GitHub Actions Ver a página do livro →