CI/CD 20 min de lecture

Construire un reviewer de PR GitHub avec Claude 3.5 Sonnet

#craftcms#claude.ai

On met en place un reviewer automatique de pull requests avec Claude 3.5 Sonnet via l’API Anthropic, intégré à GitHub Actions. Objectif: des commentaires concrets et actionnables, publiés directement sur la PR.

Construire un reviewer de PR GitHub avec Claude 3.5 Sonnet

Dans ce tutoriel, vous allez mettre en place un reviewer automatique pour vos pull requests GitHub propulsé par Claude 3.5 Sonnet via l’API Anthropic. Le bot analysera les diffs, proposera des commentaires actionnables, et publiera ses retours directement sur la PR. L’objectif est d’obtenir des revues cohérentes, reproductibles, et plus rapides, tout en gardant la main sur le budget et la sécurité.

Objectif

Nous allons déployer un workflow GitHub Actions qui appelle un script Node. Ce script récupère le contexte de la PR et le diff des fichiers modifiés, prépare des prompts structurés et appelle Claude 3.5 Sonnet avec une consigne précise pour obtenir un JSON normalisé de commentaires. Les retours sont validés, dédupliqués, puis transformés en commentaires GitHub, avec un message de synthèse pour les points critiques et des suggestions de code quand c’est pertinent. À la fin, vous obtenez une review automatisée lisible et exploitable sans intervention manuelle.

Objectif, architecture et prérequis

Ce que nous allons construire est un bot reviewer qui lit les diffs de la PR et commente les fichiers concernés. Il signale les bugs probables, les problèmes de sécurité, de performance, de lisibilité, ainsi que des améliorations DX. Lorsque c’est possible, il fournit des suggestions de patch en Markdown de type suggestion pour un “apply” en un clic. Le flux est simple à visualiser: un contributeur ouvre ou met à jour une pull request, ce qui déclenche une GitHub Action; celle-ci exécute un script Node.js; le script prépare un lot de prompts par fichier et appelle l’API Anthropic (modèle Claude 3.5 Sonnet); les réponses sont validées et publiées sous forme de commentaires sur la PR.

Côté prérequis, vous avez besoin d’un environnement Node 18 ou supérieur, d’un dépôt GitHub avec GitHub Actions activé, d’une clé API Anthropic, et du GITHUB_TOKEN fourni automatiquement dans les workflows. Il faut également garder en tête les contraintes de budget de tokens et de latence pour ne pas allonger le temps de build. Les quotas d’API et la politique de sécurité exigent de ne jamais exposer la clé et de tracer les erreurs sans fuite d’informations sensibles. Vous adapterez le nombre de fichiers, la longueur des patches et le degré de parallélisme pour garder un temps de feedback raisonnable.

Créer le repo de démo et configurer la clé API

Commencez par créer un nouveau dépôt GitHub, qu’il soit public ou privé. Dans l’interface du dépôt, ouvrez Settings, puis Secrets and variables, puis Actions. Ajoutez un nouveau secret de dépôt nommé ANTHROPIC_API_KEY qui contiendra votre clé API Anthropic. Vous pouvez également définir des variables d’environnement au niveau du dépôt pour paramétrer le workflow sans modifier le code, par exemple MODEL pour la version du modèle, MAX_TOKENS pour contrôler la taille de réponse et ENABLE_DEBUG pour activer plus de journaux. Cette configuration vous permettra d’ajuster le comportement du bot sans redéployer.

Initialiser le projet Node pour la review

Initialisez un projet Node et préparez l’arborescence des scripts. Placez le script principal dans un répertoire scripts afin d’éviter les collisions avec les sources de votre application. Installez ensuite les dépendances nécessaires: le SDK Anthropic pour l’appel au modèle, Octokit pour parler à l’API GitHub, zod pour la validation stricte du JSON de sortie, p-limit pour limiter le parallélisme des appels au modèle, minimatch pour filtrer des chemins de fichiers, et dotenv pour gérer des configurations locales.

Exemple:

mkdir -p scripts
npm init -y

npm i -E @anthropic-ai/sdk @octokit/rest zod p-limit minimatch dotenv

Ajoutez un script npm pour tester localement le pipeline sur un diff simulé. Cette commande lancera le script avec un mode local qui lit un diff de votre repo courant et évite de publier des commentaires sur GitHub.

{
  "name": "pr-reviewer",
  "type": "module",
  "scripts": {
    "review:local": "REVIEW_LOCAL=1 node scripts/review.mjs",
    "lint": "node -e \"console.log('OK')\""
  }
}

Créez un fichier .env.local pour vos tests en dehors de GitHub Actions, en veillant à ne jamais commiter ce fichier. Par exemple:

echo "ANTHROPIC_API_KEY=sk-ant-xxxxxxxx" > .env.local

Définir le workflow GitHub Actions

Créez le fichier .github/workflows/pr-review.yml. Déclenchez le workflow sur les événements de pull request importants: ouverture, mise à jour et réouverture. Donnez seulement les permissions minimales dont le bot a besoin: lecture du contenu et écriture sur les pull requests pour publier des commentaires. Limitez le workflow aux branches cibles si nécessaire pour réduire le bruit, par exemple uniquement les PRs vers main.

Voici un workflow prêt à l’emploi, comprenant un cache pour un petit fichier local de cache fonctionnel et un artefact pour les logs:

name: PR Review (Claude)

on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches:
      - main
      - develop

permissions:
  contents: read
  pull-requests: write

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '18'

      - name: Cache reviewer state
        uses: actions/cache@v4
        with:
          path: .pr-review-cache.json
          key: pr-review-cache-${{ github.event.pull_request.number }}-v1

      - name: Install deps
        run: npm ci

      - name: Run reviewer
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          MODEL: ${{ vars.MODEL || 'claude-3-5-sonnet-20240620' }}
          MAX_TOKENS: ${{ vars.MAX_TOKENS || '1000' }}
          ENABLE_DEBUG: ${{ vars.ENABLE_DEBUG || '0' }}
          INCLUDE_GLOBS: "**/*.js,**/*.ts,**/*.tsx,**/*.jsx,**/*.py,**/*.rb,**/*.go"
          EXCLUDE_GLOBS: "**/dist/**,**/build/**,**/*.min.*"
          MAX_PATCH_CHARS: "20000"
          MAX_COMMENTS_PER_FILE: "6"
          PARALLEL_LIMIT: "3"
        run: node scripts/review.mjs

      - name: Upload logs
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: pr-review-logs-${{ github.run_id }}
          path: .review-logs

Dans cet exemple, INCLUDE_GLOBS et EXCLUDE_GLOBS contrôlent le périmètre d’analyse. MAX_PATCH_CHARS impose un tronquage agressif des diffs pour contenir les coûts. PARALLEL_LIMIT limite la pression sur l’API Anthropic et réduit les risques de dépassement de quota.

Récupérer le contexte de la PR et le diff

Le script Node va lire l’événement GitHub pour extraire le numéro de PR, l’auteur, la branche cible et la branche source. Avec Octokit, vous récupérerez ensuite la liste des fichiers modifiés et, pour chacun, le patch unifié (champ patch). Vous filtrerez ces fichiers selon vos glob patterns et vous ignorerez les binaires, ainsi que les fichiers trop volumineux, en vous basant sur size ou en détectant l’absence de patch (GitHub n’inclut pas de patch pour certains types de fichiers).

Créez scripts/review.mjs avec un squelette robuste qui gère la configuration, le contexte GitHub et la collecte des diffs:

// scripts/review.mjs
import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { Octokit } from '@octokit/rest';
import Anthropic from '@anthropic-ai/sdk';
import { z } from 'zod';
import pLimit from 'p-limit';
import { minimatch } from 'minimatch';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// --- Config & helpers ---
const CONFIG = {
  model: process.env.MODEL || 'claude-3-5-sonnet-20240620',
  maxTokens: Number(process.env.MAX_TOKENS || 1000),
  temperature: 0.2,
  includeGlobs: (process.env.INCLUDE_GLOBS || '**/*').split(','),
  excludeGlobs: (process.env.EXCLUDE_GLOBS || '').split(',').filter(Boolean),
  maxPatchChars: Number(process.env.MAX_PATCH_CHARS || 20000),
  maxCommentsPerFile: Number(process.env.MAX_COMMENTS_PER_FILE || 6),
  parallelLimit: Number(process.env.PARALLEL_LIMIT || 3),
  enableDebug: process.env.ENABLE_DEBUG === '1',
  localMode: process.env.REVIEW_LOCAL === '1',
  logsDir: path.resolve(process.cwd(), '.review-logs')
};

if (!fs.existsSync(CONFIG.logsDir)) {
  fs.mkdirSync(CONFIG.logsDir, { recursive: true });
}

const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

function debugLog(name, data) {
  if (!CONFIG.enableDebug) return;
  const file = path.join(CONFIG.logsDir, `${Date.now()}-${name}.json`);
  fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
}

function hash(text) {
  return crypto.createHash('sha256').update(text).digest('hex').slice(0, 16);
}

// Simple local cache persisted in workspace
const CACHE_FILE = path.resolve(process.cwd(), '.pr-review-cache.json');
function readCache() {
  try {
    return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
  } catch {
    return {};
  }
}
function writeCache(cache) {
  fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), 'utf8');
}

// --- Unified diff utilities ---
function truncatePatch(patch, limit = CONFIG.maxPatchChars) {
  if (!patch) return '';
  if (patch.length <= limit) return patch;
  // Keep head/tail and all hunk headers to help the model keep context
  const hunks = patch.split('\n').filter(l => l.startsWith('@@ '));
  const head = patch.slice(0, Math.floor(limit * 0.6));
  const tail = patch.slice(-Math.floor(limit * 0.35));
  const header = `\n\n--- PATCH TRUNCATED (${patch.length} chars > ${limit}) ---\n`;
  return head + header + hunks.join('\n') + '\n' + tail;
}

// Build a set of valid "new file" line numbers present in the diff hunks
function buildNewLineMap(patch) {
  const map = new Set();
  if (!patch) return map;
  const lines = patch.split('\n');
  let newLine = 0;
  for (const line of lines) {
    if (line.startsWith('@@')) {
      // Example: @@ -12,7 +12,9 @@
      const m = line.match(/\+(\d+)(?:,(\d+))?/);
      if (m) newLine = Number(m[1]);
      continue;
    }
    if (line.startsWith('+')) {
      map.add(newLine);
      newLine += 1;
    } else if (line.startsWith('-')) {
      // removal does not advance new line counter
      continue;
    } else {
      // context line
      map.add(newLine);
      newLine += 1;
    }
  }
  return map;
}

function matchesGlobs(file, includes, excludes) {
  const inc = includes.length === 0 || includes.some(g => minimatch(file, g));
  const exc = excludes.some(g => minimatch(file, g));
  return inc && !exc;
}

// --- Zod schema for model output ---
const CommentSchema = z.object({
  line: z.number().int().positive(),
  severity: z.enum(['nit', 'info', 'warn', 'error']),
  message: z.string().min(1),
  suggestion: z.string().optional()
});
const FileReviewSchema = z.object({
  file: z.string(),
  comments: z.array(CommentSchema).max(CONFIG.maxCommentsPerFile)
});
const ResponseSchema = FileReviewSchema;

// --- Prompt builders ---
function systemPrompt() {
  return [
    'Tu es un reviewer de code senior.',
    'Objectif: produire des retours concis, actionnables et priorisés.',
    'Priorités: 1) Bugs et sécurité, 2) Perf et complexité, 3) Lisibilité et DX.',
    'Quand c’est pertinent, propose un diff minimaliste sous forme de suggestion.',
    `Réponds STRICTEMENT en JSON valide selon le schéma:`,
    `{"file": "path", "comments": [{"line": 0, "severity": "nit|info|warn|error", "message":"...", "suggestion":"..."}]}`,
    `Ne dépasse pas ${CONFIG.maxCommentsPerFile} commentaires par fichier et évite les doublons.`,
    'N’invente pas: si tu n’es pas certain, classe en "info" et explique tes hypothèses.'
  ].join('\n');
}

function userPromptPerFile({ prTitle, prBody, repoRules, filePath, patch }) {
  const rules = repoRules || 'Suivre ESLint/Prettier si applicable. Respecter conventions des tests et de sécurité du repo.';
  return [
    `PR: ${prTitle}`,
    `Description: ${prBody || '(pas de description)'}`,
    `Règles du repo: ${rules}`,
    `Fichier: ${filePath}`,
    `Patch (unified diff):`,
    '```diff',
    patch || '(pas de patch)',
    '```',
    'Contraintes:',
    `- Utilise les numéros de lignes du nouveau fichier (côté head) dans "line".`,
    `- Retourne strictement un JSON (pas de texte hors JSON).`,
    `- Max commentaires: ${CONFIG.maxCommentsPerFile}.`,
  ].join('\n');
}

// --- Anthropic call w/ retries ---
async function callClaude({ filePath, prTitle, prBody, repoRules, patch }) {
  const SYS = systemPrompt();
  const USR = userPromptPerFile({ prTitle, prBody, repoRules, filePath, patch });
  debugLog(`prompt-${hash(filePath)}`, { system: SYS, user: USR });

  let attempt = 0;
  const maxAttempts = 3;
  const baseDelay = 1000;

  while (attempt < maxAttempts) {
    attempt += 1;
    try {
      const msg = await anthropic.messages.create({
        model: CONFIG.model,
        max_tokens: CONFIG.maxTokens,
        temperature: CONFIG.temperature,
        system: SYS,
        messages: [{ role: 'user', content: USR }]
      });
      const textBlocks = msg.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
      const json = extractJson(textBlocks);
      debugLog(`response-${hash(filePath)}`, { raw: textBlocks, json });
      const parsed = ResponseSchema.safeParse(json);
      if (!parsed.success) {
        throw new Error(`Invalid JSON from model: ${parsed.error.message}`);
      }
      return parsed.data;
    } catch (err) {
      if (attempt >= maxAttempts) throw err;
      const delay = baseDelay * Math.pow(2, attempt - 1);
      await new Promise(res => setTimeout(res, delay));
    }
  }
}

function extractJson(text) {
  // Try direct JSON
  try { return JSON.parse(text); } catch {}
  // Try fenced code blocks
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (fenced) {
    try { return JSON.parse(fenced[1]); } catch {}
  }
  // Last resort: naive braces slice
  const start = text.indexOf('{');
  const end = text.lastIndexOf('}');
  if (start !== -1 && end !== -1 && end > start) {
    const slice = text.slice(start, end + 1);
    return JSON.parse(slice);
  }
  throw new Error('Unable to extract JSON from model response');
}

// --- GitHub context ---
function getGhContext() {
  if (CONFIG.localMode) {
    return {
      owner: 'local',
      repo: 'local',
      pull_number: 0,
      isLocal: true
    };
  }
  const repoFull = process.env.GITHUB_REPOSITORY;
  const [owner, repo] = repoFull.split('/');
  const eventPath = process.env.GITHUB_EVENT_PATH;
  const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
  const pr = event.pull_request;
  return {
    owner,
    repo,
    pull_number: pr.number,
    pr
  };
}

// --- Main ---
async function main() {
  const cache = readCache();
  const ctx = getGhContext();

  // Collect PR info and files
  let prData;
  if (!ctx.isLocal) {
    prData = await octokit.pulls.get({
      owner: ctx.owner, repo: ctx.repo, pull_number: ctx.pull_number
    });
  } else {
    prData = {
      data: {
        number: 0,
        title: 'Local review',
        body: 'Test local du reviewer',
        head: { sha: (await exec('git rev-parse HEAD')).trim() },
        base: { ref: 'local' }
      }
    };
  }

  let files;
  if (!ctx.isLocal) {
    const all = await octokit.paginate(octokit.pulls.listFiles, {
      owner: ctx.owner, repo: ctx.repo, pull_number: ctx.pull_number, per_page: 100
    });
    files = all;
  } else {
    const diff = await exec('git diff --unified=3 --patch HEAD~1');
    files = parseLocalDiffToFiles(diff);
  }

  // Filter files
  const filtered = files.filter(f => {
    const patch = f.patch || '';
    const tooBig = (f.changes || 0) > 2000 || patch.length > CONFIG.maxPatchChars * 4;
    const hasPatch = !!f.patch;
    const pathOk = matchesGlobs(f.filename, CONFIG.includeGlobs, CONFIG.excludeGlobs);
    const notBinary = hasPatch; // heuristic: binary often lacks patch
    return pathOk && notBinary && !tooBig;
  });

  // Prepare requests
  const limit = pLimit(CONFIG.parallelLimit);
  const prTitle = prData.data.title || '';
  const prBody = prData.data.body || '';
  const headSha = prData.data.head.sha;
  const repoRules = 'Code style du repo, sécurité de base, tests si modifs logiques, éviter régressions de performances.';

  const results = await Promise.all(filtered.map(f => limit(async () => {
    const filePath = f.filename;
    const patchTrunc = truncatePatch(f.patch || '');
    const id = `${ctx.pull_number}:${filePath}:${hash(patchTrunc)}`;
    const cached = cache[id];
    if (cached) return { filePath, review: cached, map: buildNewLineMap(f.patch || '') };

    const review = await callClaude({
      filePath, prTitle, prBody, repoRules, patch: patchTrunc
    });

    // Enforce file path in response
    review.file = filePath;

    // Deduplicate and cap comments
    const seen = new Set();
    review.comments = review.comments
      .slice(0, CONFIG.maxCommentsPerFile)
      .filter(c => {
        const h = hash(`${review.file}:${c.line}:${c.severity}:${c.message}`);
        if (seen.has(h)) return false;
        seen.add(h);
        return true;
      });

    cache[id] = review;
    return { filePath, review, map: buildNewLineMap(f.patch || '') };
  })));

  writeCache(cache);

  // Publish results
  if (CONFIG.localMode) {
    console.log('Local run - results:');
    console.log(JSON.stringify(results.map(r => r.review), null, 2));
    return;
  }

  await publishToGitHub({
    owner: ctx.owner,
    repo: ctx.repo,
    pull_number: ctx.pull_number,
    headSha,
    results
  });
}

async function publishToGitHub({ owner, repo, pull_number, headSha, results }) {
  const allComments = [];
  for (const r of results) {
    for (const c of r.review.comments) {
      allComments.push({ file: r.review.file, map: r.map, ...c });
    }
  }

  // Top-level summary
  const critical = allComments.filter(c => c.severity === 'error' || c.severity === 'warn');
  if (critical.length > 0) {
    const bodyLines = [
      'Résumé du reviewer (points prioritaires):',
      '',
      ...critical.slice(0, 10).map(c => `- [${c.severity.toUpperCase()}] ${c.file}:${c.line} — ${c.message}`)
    ];
    await octokit.issues.createComment({
      owner, repo, issue_number: pull_number, body: bodyLines.join('\n')
    });
  }

  // Inline comments
  for (const c of allComments) {
    const nearestLine = nearestAvailableLine(c.line, c.map);
    if (!nearestLine) {
      // Fallback: attach to PR top-level if we can’t place inline
      await octokit.issues.createComment({
        owner, repo, issue_number: pull_number,
        body: `Commentaire sur ${c.file} (impossible de placer en ligne):\n\n[${c.severity.toUpperCase()}] ${c.message}`
      });
      continue;
    }

    const pieces = [];
    pieces.push(`[${
      c.severity.toUpperCase()
    }] ${c.message}`);
    if (c.suggestion) {
      pieces.push('');
      pieces.push('```suggestion');
      pieces.push(c.suggestion);
      pieces.push('```');
    }
    const body = pieces.join('\n');

    await octokit.pulls.createReviewComment({
      owner, repo, pull_number,
      commit_id: headSha,
      path: c.file,
      side: 'RIGHT',
      line: nearestLine,
      body
    });
  }
}

function nearestAvailableLine(target, map) {
  if (!map || map.size === 0) return null;
  if (map.has(target)) return target;
  // find nearest line in map
  let best = null;
  let bestDist = Infinity;
  for (const l of map) {
    const d = Math.abs(l - target);
    if (d < bestDist) {
      bestDist = d;
      best = l;
    }
  }
  return best;
}

// --- Utilities ---
function parseLocalDiffToFiles(diff) {
  // Minimal parser splitting by "diff --git a/... b/..."
  const files = [];
  const chunks = diff.split('\ndiff --git ').slice(1);
  for (const ch of chunks) {
    const headerLine = ch.split('\n')[0];
    const match = headerLine.match(/a\/(.+?) b\/(.+)$/);
    const filename = match ? match[2] : 'unknown';
    const patch = ch.includes('@@') ? 'diff --git ' + ch : null;
    files.push({ filename, patch, changes: (patch || '').split('\n').length });
  }
  return files;
}

async function exec(cmd) {
  const { exec } = await import('node:child_process');
  return await new Promise((resolve, reject) => {
    exec(cmd, { maxBuffer: 1024 * 1024 * 10 }, (err, stdout, stderr) => {
      if (err) reject(err);
      else resolve(stdout);
    });
  });
}

main().catch(err => {
  console.error('[reviewer] fatal:', err);
  process.exitCode = 1;
});

Ce script gère le mode local et le mode CI, filtre les fichiers, tronque les patches, appelle Claude avec un prompt strict, valide la réponse via zod, puis publie un résumé et des commentaires inline. Les lignes sont ancrées au côté “RIGHT” (nouvelle version) de la PR, en utilisant la meilleure ligne disponible issue du parsing des hunks. Un petit cache local évite des appels redondants pour des patches identiques dans un même run ou entre runs si vous conservez l’artefact du cache.

Concevoir le prompting: consignes et format de sortie JSON

Le prompt système définit la posture de reviewer senior, les priorités et surtout le format de sortie strict. Cette approche stabilise énormément la qualité et la parseabilité des réponses. Pour chaque fichier, le prompt utilisateur inclut le titre et la description de la PR, un rappel de règles du repo et le patch unifié du fichier.

Voici un exemple de prompt système et utilisateur tels qu’envoyés par le script:

Prompt système:

  • Tu es un reviewer de code senior, tu donnes des retours concis et actionnables.
  • Tu priorises bugs/sécurité, puis performance/complexité, puis lisibilité/DX.
  • Tu proposes des suggestions de patch minimales quand c’est utile.
  • Tu réponds strictement en JSON valide conforme au schéma attendu.

Prompt utilisateur:

  • PR: “Refactor du module d’auth + ajout de logs”
  • Description: “Suppression d’un util static, extraction login flow, ajout d’un retry sur 5xx.”
  • Règles: “Respect ESLint/Prettier, pas de secrets en clair dans les logs.”
  • Fichier: src/auth/login.ts
  • Patch unifié au format diff, éventuellement tronqué avec les en-têtes de hunk.

Le modèle doit retourner un JSON strict comme:

{
  "file": "src/auth/login.ts",
  "comments": [
    {
      "line": 42,
      "severity": "warn",
      "message": "Le retry manque un backoff exponentiel, risque de surcharge en cas d'incident.",
      "suggestion": "Utiliser une fonction de retry avec jitter, ex: attendre 200ms, 400ms, 800ms, puis abandonner."
    }
  ]
}

Le schéma zod intégré au script valide ces contraintes et rejette toute réponse non conforme, avec une stratégie de retry côté client.

Appeler Claude 3.5 Sonnet via l’API Anthropic

L’appel au modèle se fait via @anthropic-ai/sdk en ciblant le modèle claude-3-5-sonnet-20240620 et un budget de tokens adapté. Pour une review par fichier, un max_tokens de 800 à 1200 suffit généralement, surtout avec un patch tronqué. Une température basse (0.2 à 0.4) favorise la précision et limite les variations. Le script exécute ces appels en parallèle avec p-limit pour garder un débit stable et limiter les erreurs 429. En cas de format JSON invalide, la fonction extractJson essaie plusieurs stratégies (bloc de code JSON, extraction par accolades) avant d’échouer proprement, ce qui déclenche un retry exponentiel.

Si vous devez traiter de très grosses PRs, pensez à réduire encore plus les patches, à augmenter PARALLEL_LIMIT avec prudence ou à activer un “summary mode” conditionné par le nombre de lignes modifiées. Par exemple, vous pouvez ignorer les fichiers au-delà d’un certain nombre de lignes et n’en produire qu’un résumé dans le commentaire principal.

Assembler et publier les commentaires sur la PR

Une fois toutes les réponses reçues et validées, il faut agréger les commentaires, dédupliquer ceux qui sont identiques, et décider de la meilleure façon de les publier. Le script propose un commentaire de synthèse sur la PR listant les points en warn et error, puis poste les commentaires inline par fichier, ancrés sur le commit head de la PR. Lorsqu’un commentaire contient une suggestion, le corps inclut un bloc Markdown de type suggestion pour une application directe si possible:

Exemple de corps d’un commentaire inline:

[WARN] Le handler n’est pas idempotent; en cas de retry il peut doubler l’effet.

```suggestion
if (processedIds.has(id)) return;
processedIds.add(id);

Si la ligne proposée par le modèle ne correspond pas exactement à une ligne du côté head, le script choisit la ligne la plus proche disponible dans la hunk. En dernier recours, il publie le commentaire au niveau de la PR avec une mention claire du fichier concerné. Cette stratégie évite d’échouer silencieusement et garantit que le feedback arrive aux développeurs.

## Gérer la taille, les erreurs et l’observabilité

La taille des patches est gérée par une heuristique de tronquage qui conserve le début et la fin du diff ainsi que les en-têtes de hunk @@, ce qui préserve les informations de position et le contexte structurel. Cela limite fortement le coût en tokens sans perdre l’alignement nécessaire aux lignes. Les appels au modèle sont protégés par des retries exponentiels sur les erreurs transitoires, avec journalisation dans un répertoire .review-logs téléversé en artefact de run, accessible uniquement au sein de la CI.

Côté robustesse, le script valide strictement le JSON, évite les doublons, et ne bloque pas une publication complète pour un fichier en erreur: un fichier qui échoue sera simplement ignoré avec un log d’échec. Un petit cache local basé sur un hash du patch évite de rappeler le modèle pour un fichier identique au sein d’un run ou d’un run ultérieur si vous conservez l’artefact/cache. Voici un exemple minimaliste d’écriture des logs et du cache, déjà intégré plus haut:

```js
// écritures de logs debug
debugLog(`prompt-${hash(filePath)}`, { system: SYS, user: USR });
debugLog(`response-${hash(filePath)}`, { raw: textBlocks, json });

// lecture/écriture du cache
const cache = readCache();
cache[id] = review;
writeCache(cache);

Dans le workflow, l’étape “Upload logs” conserve ces éléments pour l’inspection en cas de souci. Vous pouvez compléter par des métriques simples (nombre de fichiers analysés, temps moyen de réponse, coûts estimés) si vous avez besoin de pilotage budgétaire fin.

Tests locaux et évolutions

Pour tester localement sans GitHub Actions, lancez npm run review:local. Le script récupère un diff via git diff HEAD~1 et passe dans le même pipeline, mais n’essaie pas de publier des commentaires. Vous pouvez valider que le JSON généré par le modèle respecte bien le schéma zod et comparer les résultats avec des snapshots pour éviter des régressions. Par exemple, stockez le JSON renvoyé par le modèle et suivez-le avec un test de snapshot Jest.

Un bon axe d’évolution consiste à proposer des reviewers spécialisés. Par exemple, un mode “sécurité” resserre la consigne sur la validation d’entrées, la gestion de secrets et les vulnérabilités OWASP. Un mode “perfs front” met l’accent sur la taille du bundle, l’utilisation de memoization en React ou la mise en cache. Dans un monorepo, vous pouvez moduler les seuils par répertoire, par exemple en augmentant MAX_TOKENS pour un service critique. Pour des PRs gigantesques, un “summary mode” peut produire d’abord une synthèse des risques majeurs et n’examiner en détail que les fichiers prioritaires selon des règles métiers.

Voici un exemple simple pour lancer une review locale et voir le JSON produit:

# simulateur local (ne publie rien)
ANTHROPIC_API_KEY=sk-ant-xxxxxxxx ENABLE_DEBUG=1 npm run review:local

Si vous souhaitez forcer un mode “summary”, vous pouvez ajouter une variable d’environnement et adapter le code:

REVIEW_SUMMARY_ONLY=1 npm run review:local

Et dans le script, décidez de ne traiter que les 5 premiers fichiers et de poster uniquement un résumé.

Checklist

Avant d’activer définitivement le reviewer sur vos dépôts, prenez quelques minutes pour relire la configuration et tester les chemins critiques. Vérifiez que le secret ANTHROPIC_API_KEY est bien présent, que le workflow a les permissions pull-requests: write et qu’il ne se déclenche que sur les branches attendues. Exécutez un test local avec une petite modification de fichier et assurez-vous que le JSON de sortie passe la validation zod. Lancez une PR de test sur votre dépôt et confirmez que le commentaire de synthèse et les commentaires inline apparaissent correctement, avec des suggestions applicables quand c’est pertinent. Une fois satisfait, publiez vos changements sur la branche par défaut et surveillez les premiers runs, en consultant l’artefact de logs si nécessaire pour ajuster la taille des patches ou le degré de parallélisme.

Conclusion

Vous disposez maintenant d’un reviewer automatique pragmatique et pilotable, qui commente vos PRs avec des retours priorisés et actionnables. En combinant GitHub Actions, Octokit et Claude 3.5 Sonnet, vous obtenez des revues plus régulières, plus rapides et plus faciles à faire évoluer. Le système proposé est sûr par défaut (clé API en secret, permissions minimales), respectueux de votre budget (tronquage, parallélisme maîtrisé) et extensible (modes spécialisés, summary mode, filtres par répertoire). À vous d’ajuster les prompts et les règles pour refléter vos standards d’ingénierie.

Ressources