LLM per code review automatica in pipeline GitHub e GitLab: qualità senza rallentamenti

LLM per code review automatica in pipeline GitHub e GitLab: qualità senza rallentamenti

Il bot di code review che gira sulle Pull Request del mio monorepo personale di sperimentazione è operativo dal 9 gennaio 2026. La codebase di riferimento è sempre la stessa che uso come banco di prova - 200.000 righe di Symfony 7.2 su PHP 8.3, 1.400 classi, un assortimento deliberato di moduli ben scritti e moduli volutamente sporchi dove testo regressioni. L'infrastruttura del bot è minimalista: un Hetzner CX22 (2 vCPU Intel, 4 GB RAM DDR4, 40 GB NVMe) su Debian 12, che ospita un runner self-hosted GitHub Actions e un piccolo servizio Node.js 22 che fa da middleware fra il runner e Claude Sonnet 4.6. Il costo fisso è di 4,51 euro al mese per il VPS più una spesa variabile di 8-15 dollari al mese per API Anthropic sulle review automatiche. Il mindset con cui ho costruito il sistema è rigoroso: il bot non approva mai, non blocca mai il merge, non dice "LGTM". Pubblica commenti strutturati, tassonomizzati per gravità e fondati sul diff specifico della PR, con link precisi a riga e colonna. La decisione finale resta umana. Se questo sembra una limitazione, è perché lo è - ed è esattamente la limitazione che rende il sistema utile invece che dannoso.

Perché un LLM non può sostituire la code review di un reviewer senior?

La risposta in una frase è che un LLM fa benissimo pattern matching locale sul diff - SQL senza parametri, input non sanitizzato, query Eloquent che producono N+1 evidente - ma è sistematicamente debole sul ragionamento architetturale che un reviewer senior porta in review. Ci sono quattro classi di problemi che un LLM non riesce a vedere e che un senior umano vede quasi subito. Primo: l'incongruenza fra la PR e la architectural intent del modulo ("questo metodo sta nel posto sbagliato, la responsabilità è del service X"). Secondo: l'interazione con flag feature, config per ambiente, migrazioni schema che il diff non mostra ma di cui il reviewer si ricorda ("questo codice romperà il job batch notturno X"). Terzo: le race condition e i locking pattern che richiedono esperienza accumulata su incident passati - cosa che non è nel prompt. Quarto: il gusto - la percezione di quando un design è oggettivamente migliore di un altro a parità di correttezza. L'LLM non ha gusto, ha plausibilità.

Il punto è che questi quattro aspetti occupano il 20% del tempo di una review senior e portano il 60% del valore. L'altro 80% del tempo va nel cercare mismatch banali - un || al posto di un &&, una variabile non inizializzata, un try/catch che inghiotte l'eccezione. È esattamente il 80% che l'LLM fa bene. Il calcolo di valore è quindi: se il bot toglie il 80% del tempo al reviewer umano sulla parte meccanica, il reviewer usa quel tempo liberato per la parte architetturale - dove davvero serve. Non è sostituzione, è amplification: lo stesso principio che ho descritto nell'articolo sui limiti reali di LLM su sviluppo PHP in produzione e che vedo confermato in ogni audit AI-tooling che faccio. Un reviewer senior affiancato da un LLM produce review di qualità più alta in tempo più breve - un reviewer senior sostituito da un LLM produce review plausibile e superficiale.

Se vuoi vedere come progetto AI dev tools che accelerano il lavoro dei team di sviluppo senza sostituire il ragionamento senior, nel mio hub sullo sviluppo assistito da AI per aziende trovo articoli sui pattern che uso - Claude Code in produzione, MCP server custom, automazione di pipeline con cost governance - con criterio comune di mantenere l'umano nel loop di approvazione finale.

Step 1: il workflow GitHub Actions che chiama il bot

Il workflow YAML che uso è deliberatamente magro. Il grosso della logica vive nel servizio Node.js, il runner fa solo plumbing: reperisce il diff, lo passa al bot, attende la risposta, posta il commento sulla PR. Il trigger è pull_request su eventi opened, synchronize, reopened.

name: LLM Code Review

on:
  pull_request:
    types: [opened, synchronize, reopened]
    paths-ignore:
      - '.env*'
      - 'config/credentials/**'
      - 'secrets/**'
      - 'vendor/**'

jobs:
  review:
    runs-on: [self-hosted, review-bot]
    timeout-minutes: 8
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Collect PR diff
        id: diff
        run: |
          git diff origin/${{ github.base_ref }}...HEAD \
            --unified=3 --no-color \
            -- '*.php' ':!tests/Fixtures/**' ':!database/migrations/**' \
            > /tmp/pr.diff
          echo "size=$(wc -c </tmp/pr.diff)" >> "$GITHUB_OUTPUT"

      - name: Skip if diff too large
        if: steps.diff.outputs.size > 120000
        run: |
          echo "diff > 120KB - skipping LLM review"
          exit 0

      - name: Run LLM review
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: node /opt/review-bot/dist/run.js --diff /tmp/pr.diff

Il paths-ignore esclude deliberatamente tutto ciò che potrebbe contenere segreti (.env*, config/credentials, secrets) e dipendenze (vendor/). L':!tests/Fixtures/** esclude fixture di test - file con contenuto intenzionalmente brutto che scatenerebbero centinaia di falsi positivi. Il size > 120000 è una guardia di costo: una PR che modifica 120 KB di codice è fuori scopo per un LLM - dovrebbe essere spaccata in più PR comunque. Il bot salta, il reviewer umano fa il lavoro integrale.

Step 2: prompt strategy per ridurre i falsi positivi

Il prompt al modello è il pezzo che, più di ogni altro, determina la qualità delle review. La versione che uso oggi è il risultato di 8 settimane di iterazione e di almeno tre retraining del system prompt dopo che il bot aveva prodotto segnalazioni stupide. Tre regole guidano la struttura.

Regola uno: mostra il diff strutturato, non il file intero. Passo al modello solo le hunk modificate con 3 righe di contesto a monte e a valle. Il modello guarda le modifiche, non il codice nel suo insieme. Questo riduce il context size da 40k-80k token per PR a 4k-8k token medi, taglia il costo di un ordine di grandezza, e concentra l'attenzione sul cambiamento reale.

Regola due: chiedi severity tassonomizzata. Il bot deve classificare ogni finding in quattro livelli: blocker (bug critico o vulnerabilità evidente), major (problema serio di qualità che influenza manutenibilità), minor (suggerimento di miglioramento), nitpick (stylistic). Senza tassonomia, il modello tende a uniformare tutto a "major" e il reviewer umano non sa cosa guardare per primo.

Regola tre: chiedi confidence per ogni finding. Il modello indica quanto è sicuro (0.0-1.0) di ogni segnalazione. Finding con confidence sotto 0.6 vengono soppressi dal commento finale. Empiricamente, il filtro di confidence riduce i falsi positivi del 60-70%.

SYSTEM PROMPT (estratto):
Sei un reviewer di codice PHP/Laravel/Symfony. Analizza SOLO il diff fornito.
NON commentare codice non modificato. NON dare approvazioni, NON dire "LGTM".

Per ogni problema emetti un oggetto:
{
  "file": "<path>",
  "line": <numero riga nella nuova versione>,
  "severity": "blocker" | "major" | "minor" | "nitpick",
  "category": "sql-injection" | "n-plus-one" | "error-handling" | ...,
  "message": "<descrizione in italiano, max 240 caratteri>",
  "confidence": <0.0-1.0>,
  "suggestion": "<opzionale: snippet suggerito, max 10 righe>"
}

Se non trovi problemi, emetti lista vuota. NON inventare problemi.
NON ripetere problemi già segnalati in un commento precedente (te li passo in context).

La frase "NON inventare problemi" sembra banale e non lo è. I modelli hanno un bias a produrre output: se gli chiedi di cercare bug e non ce ne sono, alcuni producono bug fittizi pur di riempire la risposta. Una riga esplicita contro questo comportamento, unita alla regola "se non trovi problemi emetti lista vuota", stabilizza il comportamento.

Step 3: filter pre-LLM con PHPStan per tagliare il rumore

Prima di spendere token su ogni PR, lancio PHPStan livello 8 sul diff e filtro tutto quello che lo static analyzer già rileva. I problemi che PHPStan rileva deterministicamente - tipi inconsistenti, metodi inesistenti, null non gestiti - non hanno bisogno di un LLM: sono certainty, non probabilistic. Passandoli anche all'LLM otterrei doppi commenti o, peggio, LLM che tenta di riverificarli male.

# dentro il job GitHub Actions, prima di chiamare l'LLM
vendor/bin/phpstan analyse --level=8 --error-format=json \
  $(git diff origin/main...HEAD --name-only --diff-filter=AM | grep '\.php$') \
  > /tmp/phpstan.json

# il bot Node.js legge /tmp/phpstan.json e lo passa come "known issues"
# al prompt - con istruzione "NON ripetere questi problemi"

Questo dual pipeline (PHPStan prima, LLM dopo) ha due effetti virtuosi. Taglia il rumore del LLM perché PHPStan già copre la parte deterministica. Usa il LLM dove serve davvero - pattern semantici che PHPStan non può vedere (SQL injection tramite concatenation dinamica, N+1 Eloquent sotto forme non ovvie, error handling che nasconde eccezioni invece di gestirle). La divisione di lavoro è esattamente: PHPStan dice "questo è sbagliato secondo il type system", LLM dice "questo è pericoloso secondo il common sense accumulato su milioni di esempi di codice reale".

Step 4: cost cap per pull request e hard limit giornaliero

Il bot non deve poter bruciare budget. Nel servizio Node.js impongo due limit separati. Il primo: ogni PR ha un cost cap di 0,50 dollari - se la chiamata Claude supera quel costo, il bot si ferma, posta un commento "review parziale per cost cap" e chiude. Il secondo: daily hard limit di 5 dollari complessivi - se la somma dei costi del giorno supera 5 dollari, il bot entra in modalità emergency off per le prossime 24 ore, pubblicando un commento standardizzato su ogni nuova PR "bot temporaneamente sospeso per daily budget esaurito, review umana richiesta".

interface BudgetState {
  pr_cost_usd: Record<number, number>;
  today_total_usd: number;
  day_key: string;
}

async function canRun(prNumber: number, estimatedCost: number): Promise<{ allowed: boolean; reason?: string }> {
    const state = await loadState();
    const today = new Date().toISOString().slice(0, 10);

    if (state.day_key !== today) {
        state.today_total_usd = 0;
        state.pr_cost_usd = {};
        state.day_key = today;
    }
    if (state.today_total_usd + estimatedCost > DAILY_CAP_USD) {
        return { allowed: false, reason: 'daily_cap_exceeded' };
    }
    const prSoFar = state.pr_cost_usd[prNumber] ?? 0;
    if (prSoFar + estimatedCost > PR_CAP_USD) {
        return { allowed: false, reason: 'pr_cap_exceeded' };
    }
    return { allowed: true };
}

Questi cap esistono perché un bot che gira da solo su un runner è un perfetto caso di studio per OWASP LLM10 Unbounded Consumption - ogni PR aperta a ripetizione consuma token, e un attaccante che sa come triggerare il bot può creare centinaia di PR fittizie per drenarti il budget in una notte. Il daily cap è il kill switch che rende questo scenario inoffensivo anche nello worst case.

Step 5: posting del commento strutturato come review GitHub

Il bot NON commenta con un comment generico sulla PR. Usa l'API Pull Request Review di GitHub e ogni finding diventa un inline comment sulla riga specifica, con severity come emoji-free tag e suggestion come code block suggerito se presente. Questo ha due vantaggi: il reviewer umano vede i commenti esattamente dove servono nel diff, e GitHub marca automaticamente i comment come risolti quando il codice viene modificato, pulendo la UI.

import { Octokit } from '@octokit/rest';

async function postReview(findings: Finding[]): Promise<void> {
    const comments = findings
        .filter(f => f.confidence >= 0.6)
        .map(f => ({
            path: f.file,
            line: f.line,
            side: 'RIGHT' as const,
            body: `**[${f.severity}]** ${f.category}\n\n${f.message}${
                f.suggestion ? `\n\n\`\`\`suggestion\n${f.suggestion}\n\`\`\`` : ''
            }\n\n*Confidence: ${f.confidence.toFixed(2)} - this is an automated suggestion, human review required.*`
        }));

    await octokit.pulls.createReview({
        owner, repo, pull_number,
        event: 'COMMENT', // MAI 'APPROVE' o 'REQUEST_CHANGES' dal bot
        comments,
    });
}

Il event: 'COMMENT' (mai APPROVEREQUEST_CHANGES) è non negoziabile. Un bot che può approvare una PR è un bot che può - volontariamente o meno - portare codice vulnerabile in produzione. Un bot che può bloccare una PR è un bot che può DoS-are il team. Il comment-only mode fa esattamente quello per cui il bot è utile - evidenziare potenziali problemi - e nient'altro.

Step 6: esclusione di file sensibili e secret hygiene

Anche con il paths-ignore nel workflow, un scan di sicurezza pre-LLM è prudenza minima. Se un developer committa per errore un secret dentro un file di test, il paths-ignore non lo cattura, ma il diff finisce in un prompt al modello esterno. La regola è: prima di spedire il diff a Claude, un secret scanner locale (uso detect-secrets di Yelp) lo setaccia. Se trova match, il job fallisce con un errore esplicito "possible secret detected - human review required". Un falso positivo è trattabile con allowlist sul file; un vero positivo blocca la PR finché il secret non è rimosso.

Questa disciplina è la stessa che descrivo nell'articolo sul red team di RAG systems per prompt injection via documenti indicizzati: ogni dato che esce dal perimetro verso un LLM esterno deve passare da un filtro applicativo che tu controlli, non da una promise del vendor.

Step 7: la review del review-bot (osservabilità e tuning)

Ogni commento del bot viene persistito in un piccolo database SQLite con il finding originale, la PR, l'autore, e il feedback che il reviewer umano dà alla fine. Il feedback è una di quattro etichette cliccabili sul commento: correct (il bot ha ragione, ho applicato la suggestion), correct-but-not-applicable (il bot ha identificato un vero pattern ma in questo contesto è intenzionale), false-positive (il bot sbaglia), unclear (non capisco cosa dice il bot). Il reviewer umano mette questi tag una volta ogni 2-3 review medie, è un costo sostenibile.

Settimanalmente guardo le statistiche aggregate. Se false-positive sale sopra il 20% su una category, il system prompt viene tunato per ridurre sensibilità su quella categoria. Se correct-but-not-applicable è frequente, aggiungo esempi nel prompt del tipo di codice intenzionale nel contesto aziendale. Se unclear è alto, riscrivo la descrizione dei finding con meno jargon. Questo feedback loop è la differenza tra un bot statico che peggiora nel tempo e un bot che impara (senza fine-tune del modello - solo tramite prompt engineering iterativo sui dati di feedback).

Quando non usare un bot di code review LLM

Se il tuo team è composto da 2-3 senior che fanno review approfondita e le PR sono naturalmente piccole (<200 righe mediane), un bot aggiunge rumore senza togliere lavoro. Se lavori su codebase altamente regolamentate (banking, sanità, infrastrutture critiche) dove ogni finding di review deve essere tracciato con audit formale, il LLM con confidence probabilistico non soddisfa il requisito - serve static analyzer deterministico. Se il costo di un falso negativo è alto (bug in produzione con impatto sulla salute umana), NON delegare neanche il 20% al bot - quel 20% è esattamente dove il bot non vede.

Il pattern GitHub Actions + Claude API + PHPStan prefilter + cost cap + feedback loop si giustifica quando hai simultaneamente: team di 5-15 developer con ratio reviewer insufficiente, PR mediamente grandi (300-1.500 righe), mix di junior e senior dove i junior scrivono codice che beneficia di suggerimenti immediati, e budget mensile per AI-tooling nell'ordine di 20-50 dollari al mese. In quel punto, il bot è una leva vera: libera 30-50% del tempo di review senior, accelera il feedback ai junior, cattura pattern ricorrenti prima che diventino abitudine. Senza questi requisiti, è gadget, non strumento.

La differenza tra un bot review LLM utile e uno dannoso non sta nel modello scelto né nella lunghezza del prompt. Sta nella disciplina con cui hai progettato i guardrail attorno al modello: chi può triggerarlo, su quali file, con quale budget, con quale potere di azione, con quale feedback loop di qualità. Senza questi guardrail il bot diventa rapidamente un generatore di rumore plausibile che i developer imparano a ignorare - e quando lo ignorano, l'utilità del bot è zero. Con i guardrail, il bot smette di essere l'ennesimo esperimento AI morto nel cassetto del CTO e diventa una parte infrastrutturale del flusso di sviluppo - invisibile quando funziona, sostituibile quando non funziona, mai indispensabile e mai pericoloso.

Se stai valutando l'introduzione di un bot di code review LLM sul tuo team di sviluppo, per amplificare i reviewer senior senza sostituirli, e vuoi capire come dimensionare il costo, il prompt engineering, e i guardrail rispetto al tuo volume di PR, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.

Ultima modifica: