diff --git a/README.md b/README.md index e647057..be59e40 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,5 @@ path instead of the host-port URL.) ``` npm test # node --test: JSON extractor, verdict derivation, trailer parser ``` + + diff --git a/src/config.js b/src/config.js index b8bb879..9454c7b 100644 --- a/src/config.js +++ b/src/config.js @@ -11,6 +11,8 @@ const DEFAULTS = { webhookSecret: '', routerUrl: 'http://localhost:5497', model: 'anthropic/claude-sonnet-4-6', + agentId: 'code-reviewer', + fallbackAgentId: 'architect', tenantId: 'nirlab', publicBaseUrl: 'http://localhost:5498', dbPath: join(CONFIG_DIR, 'reviews.db'), diff --git a/src/lib/gitea.js b/src/lib/gitea.js index 0e69b5c..7a8fcd1 100644 --- a/src/lib/gitea.js +++ b/src/lib/gitea.js @@ -65,6 +65,10 @@ export class GiteaClient { }); } + getPrCommits(owner, repo, index) { + return this.req('GET', `/api/v1/repos/${owner}/${repo}/pulls/${index}/commits?limit=50`); + } + getCommit(owner, repo, sha) { return this.req('GET', `/api/v1/repos/${owner}/${repo}/git/commits/${sha}?stat=false&verification=false&files=false`); } diff --git a/src/lib/review.js b/src/lib/review.js index 3c5139a..b8cc669 100644 --- a/src/lib/review.js +++ b/src/lib/review.js @@ -19,6 +19,18 @@ export async function runReview({ cfg, gitea, db, job, withGlobal, log }) { const dashboardUrl = `${cfg.publicBaseUrl}/review/${owner}/${repo}`; const t0 = Date.now(); + // For PR events the webhook only carries title/body; pull the PR's commits + // so Co-Authored-By trailer attribution sees real commit messages. + if (job.prIndex) { + const commits = await safe(log, 'pr-commits', () => gitea.getPrCommits(owner, repo, job.prIndex)); + if (Array.isArray(commits)) { + job.commitMessages = job.commitMessages.concat( + commits.map(c => c?.commit?.message || '').filter(Boolean)); + const names = commits.map(c => c?.commit?.author?.name || c?.author?.login).filter(Boolean); + job.authors = [...new Set(job.authors.concat(names))]; + } + } + // 0. pending status (best-effort) await safe(log, 'pending-status', () => gitea.postStatus(owner, repo, sha, { @@ -55,18 +67,26 @@ export async function runReview({ cfg, gitea, db, job, withGlobal, log }) { const messages = buildReviewPrompt({ repo: fullRepo, ref: job.ref, sha, prTitle: job.prTitle, conventions, diff, truncated }); - let review = null, llmError = null; + let review = null, llmError = null, servedModel = null; await withGlobal(async () => { for (let attempt = 1; attempt <= 2; attempt++) { try { - const content = await chat({ + // Attempt 1 uses the review agent. Attempt 2 falls back to the + // c-suite agent (bypasses the router's domain preflight), covering + // classifier false-positives on diff content: agent_mismatch 400s + // and tool fast-path hijacks that return non-JSON. + const out = await chat({ routerUrl: cfg.routerUrl, model: cfg.model, messages, - agentId: 'code-reviewer', - sessionId: `review-${owner}-${repo}-${sha.slice(0, 12)}`, + agentId: attempt === 1 ? cfg.agentId : cfg.fallbackAgentId, + sessionId: `review-${owner}-${repo}-${sha.slice(0, 12)}-a${attempt}`, tenantId: cfg.tenantId, timeoutMs: cfg.routerTimeoutMs }); - const raw = extractJson(content); - if (!raw) throw new Error(`no JSON object in model output (attempt ${attempt})`); + servedModel = out.servedModel; + const raw = extractJson(out.content); + if (!raw) { + log(`[${fullRepo}@${sha.slice(0, 8)}] unparseable model output (served=${servedModel}): ${JSON.stringify(out.content.slice(0, 400))}`); + throw new Error(`no JSON object in model output (attempt ${attempt})`); + } review = normalizeReview(raw); if (!review) throw new Error('normalization failed'); llmError = null; @@ -111,7 +131,7 @@ export async function runReview({ cfg, gitea, db, job, withGlobal, log }) { axes: review.axes, overall: review.overall, grade: review.grade, verdict: review.verdict, findings: review.findings, statusState: state, error: null, diffBytes, truncated, - model: cfg.model, durationMs: Date.now() - t0 + model: servedModel || cfg.model, durationMs: Date.now() - t0 }); log(`[${fullRepo}@${sha.slice(0, 8)}] reviewed: ${review.verdict} grade=${review.grade} overall=${review.overall} findings=${review.findings.length} in ${Date.now() - t0}ms`); return { ok: true, verdict: review.verdict }; diff --git a/src/lib/router.js b/src/lib/router.js index f9d0ee8..bb65144 100644 --- a/src/lib/router.js +++ b/src/lib/router.js @@ -6,7 +6,14 @@ export async function chat({ routerUrl, model, messages, agentId, sessionId, ten const res = await fetch(`${routerUrl.replace(/\/$/, '')}/v1/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model, messages, agentId, sessionId, tenantId, stream: false }), + // tools:false + raw:true — reviews need pure model reasoning. raw:true is + // the router's sanctioned _bypassToolRouting flag: without it, keyword + // fast-paths (current-info briefing, store-resolver, retail dispatch) + // hijack prompts whose DIFF CONTENT mentions github/sales/store-ish terms + // and return non-review text. The domain preflight (agent_mismatch 400) + // still applies per agent tier; the pipeline's attempt-2 fallback agent + // covers that. + body: JSON.stringify({ model, messages, agentId, sessionId, tenantId, stream: false, tools: false, raw: true }), signal: AbortSignal.timeout(timeoutMs) }); if (!res.ok) { @@ -14,9 +21,14 @@ export async function chat({ routerUrl, model, messages, agentId, sessionId, ten throw new Error(`router -> ${res.status}: ${text.slice(0, 300)}`); } const data = await res.json(); - const content = data?.message?.content ?? data?.content; + // Response shape varies by upstream: prefer .message.content, then + // OpenAI-style choices, then top-level .content (which can carry an + // upstream-down apology — hence last). + const content = data?.message?.content + ?? data?.choices?.[0]?.message?.content + ?? data?.content; if (typeof content !== 'string' || !content.trim()) { throw new Error('router returned empty content'); } - return content; + return { content, servedModel: data?._shre?.model ?? null }; }