Pular para o conteúdo
Rogerio Lima

· IA, AWS · 3 min de leitura

Um bot de code review com Lambda e LiteLLM

Um webhook do Bitbucket chama uma Lambda, que manda o diff pro modelo via LiteLLM. O que deu trabalho foi não revisar o mesmo commit duas vezes e um timeout de 300 segundos que vinha do fetch.

Computer screen displaying HTML code for a web development project
Foto: Mohammad Rahmani · Unsplash

No time, PR ficava parado esperando revisor, e quem revisava gastava boa parte do tempo com coisa que uma máquina pegaria. Então montei um bot que revisa cada PR assim que ele abre. Um webhook do Bitbucket chama uma Lambda, a Lambda manda o diff pro modelo através de um proxy LiteLLM, e o resultado volta como comentários no PR, aprovando ou pedindo mudança.

Montar esse fluxo é fácil. O trabalho todo foi nos detalhes abaixo.

1. Conferir que o webhook veio mesmo do Bitbucket

O Bitbucket assina o corpo com HMAC-SHA256 no header X-Hub-Signature. Comparo com timingSafeEqual pra não vazar a assinatura pelo tempo de resposta:

function isValidSignature(rawBody, signature) {
  const secret = process.env.WEBHOOK_SECRET || "";
  if (!signature || !secret) return false;
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(String(signature));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Atrás do API Gateway, o corpo pode chegar em base64 (isBase64Encoded). A assinatura é calculada sobre o texto original, então decodifique antes de validar.

O handler só reage a pullrequest:created e pullrequest:updated, e ignora PR que não está aberto ou cujo título começa com WIP: ou draft.

2. Não revisar o mesmo commit duas vezes

O Bitbucket pode mandar o mesmo evento mais de uma vez, e todo push dispara um updated. Pra deduplicar, uso o SHA do commit de origem em vez do hash do diff: commit novo sempre tem SHA novo, mas o diff pode sair igual (num amend que só muda a mensagem, por exemplo).

A trava é um PutItem condicional no DynamoDB:

await ddb.send(new PutCommand({
  TableName: table,
  Item: { pk, commitHash, status: "running", startedAt: now.toISOString() },
  ConditionExpression:
    "attribute_not_exists(pk) OR commitHash <> :hash" +
    " OR (#s = :running AND startedAt < :cutoff)",
  ExpressionAttributeNames: { "#s": "status" },
  ExpressionAttributeValues: { ":hash": commitHash, ":running": "running", ":cutoff": fifteenMinutesAgo },
}));

Se a condição falha, é porque outro webhook já está cuidando desse commit. A última cláusula cobre a Lambda que morreu no meio do caminho: uma trava running com mais de 15 minutos pode ser assumida por outra. E se a revisão falha, a trava é liberada pro próximo evento tentar de novo.

3. Não gastar modelo com o óbvio

Antes de chamar qualquer LLM, o bot procura marcador de conflito (<<<<<<<, =======, >>>>>>>) no diff. Se achar, já pede mudança, sem chamar modelo nenhum.

4. Diff grande em lotes

Um diff enorme num prompt só estoura o tempo do modelo. Então o diff é cortado por arquivo e empacotado em lotes de até 20 mil caracteres, com no máximo 6 lotes revisados em paralelo:

export function splitDiffByFile(diff) {
  return diff
    .split(/^(?=diff --git )/m)
    .filter((p) => p.trim())
    .map((text) => ({ file: text.match(/^diff --git a\/.+? b\/(.+)$/m)?.[1] ?? "?", text }));
}

Arquivo maior que o lote vai sozinho e truncado, e o que não coube nos 6 lotes aparece no resumo como não revisado. Na hora de juntar, se um lote pedir mudança, o PR inteiro recebe pedido de mudança.

5. Duas revisões ao mesmo tempo

O modelo com raciocínio estendido acha mais bug: numa amostra de PRs reais, ele pegou 3 de 3, e o rápido pegou 1 de 4. Só que às vezes ele estoura o tempo. Por isso peço as duas revisões juntas, e a rápida (uns 25 segundos) só entra se a outra falhar:

export async function reviewWithFallback(review) {
  const fast = review(false);
  fast.catch(() => {}); // só é aguardada se a outra falhar
  try {
    return { ...(await review(true)), fast: false };
  } catch {
    return { ...(await fast), fast: true };
  }
}

O fast.catch(() => {}) está ali pra promise rápida não virar "unhandled rejection" quando ninguém espera por ela.

6. O timeout que não era meu

A primeira versão usava fetch, e as revisões longas morriam nos 300 segundos. O fetch do Node (undici) desiste se os headers da resposta não chegam em 300 s, e um modelo lendo um diff de 60 mil caracteres às vezes demora mais que isso. Troquei por node:https, que não tem esse limite, e o único timeout passou a ser o meu: 420 s, abaixo dos 900 s da Lambda.

Retentativa é uma só, e só pra 429, 502, 503 e 504 que acontecem nos primeiros 60 segundos. Uma falha lenta não ganha segunda chance, porque não sobraria tempo de Lambda pra ela.

E o LiteLLM?

A Lambda não sabe qual modelo está do outro lado. Ela chama /chat/completions num proxy LiteLLM, com um nome de modelo configurado no proxy, pedindo response_format: { type: "json_object" }. Trocar de modelo, de provedor ou de versão é mudança de configuração no proxy, sem deploy da Lambda.

Travou em alguma etapa? Chamar no WhatsApp

← Todos os posts