Skip to content

Latest commit

 

History

History
146 lines (123 loc) · 7.26 KB

File metadata and controls

146 lines (123 loc) · 7.26 KB

Module Interface Contract (build against this exactly)

CommonJS (require/module.exports). Node >=18 (global fetch, node:test available). Do not change any signature below — other modules depend on them. Do not run npm install.

Already implemented (do not modify; import and use)

src/config.js

const { cfg, QUEUE_NAME, DLQ_NAME } = require('./config');
// cfg: { role, port, redisUrl, ollamaUrl, model, webhookSecret, githubToken,
//        concurrency, llmTimeoutMs, maxAttempts }  (frozen)

src/logger.js

const { log } = require('./logger');
log(level, deliveryId, msg, extra = {});   // level: 'info'|'warn'|'error'

src/queue.js (importing this opens Redis — keep out of unit tests)

const { connection, reviewQueue, deadQueue, processedKey } = require('./queue');
// connection: ioredis client (use .exists(key) -> 0|1, .set(key,'1','EX',secs))
// reviewQueue, deadQueue: BullMQ Queue instances
// processedKey(repo, pr, sha) -> string

src/signature.js

const { verifySignature } = require('./signature');
verifySignature(rawBody /* Buffer */, signatureHeader /* string */, secret) -> boolean

To build

src/chunk.js — pure, no deps

module.exports = { chunkDiff, chunkHasLine };
// chunkDiff(rawDiff: string) -> Array<{ file: string, diff: string }>
//   Split a unified git diff per file, then per @@ hunk. Cap each hunk's `diff` to 4000 chars.
//   file name comes from the "+++ b/<name>" line; fall back to 'unknown'.
// chunkHasLine(chunk: {diff}, line: number) -> boolean
//   Parse the new-file range from the hunk header "@@ -a,b +c,d @@".
//   Return true if `line` is within [c, c+d]. If no header found, return true (don't over-reject).

src/reviewer.js — depends on config, logger, chunk (chunkHasLine), zod

module.exports = { reviewChunk, buildPrompt, callModel, withTimeout, FindingSchema };

// FindingSchema (zod):
//   { severity: 'info'|'warning'|'critical', line: int>=0, comment: string(1..500), confidence: number(0..1) }

// buildPrompt(chunk: {file, diff}) -> string
//   Instruct the model to return ONE JSON object matching FindingSchema and nothing else.

// callModel(prompt: string, signal: AbortSignal) -> Promise<string>
//   POST `${cfg.ollamaUrl}/api/generate` with
//     { model: cfg.model, prompt, stream: false, format: 'json', options: { temperature: 0.1 } }
//   Pass { signal }. Throw on !res.ok. Return the `.response` field (a JSON string).
//   Use global fetch.

// withTimeout(fn: (signal) => Promise<T>, ms: number) -> Promise<T>
//   Create an AbortController, abort after ms (Error 'LLM timeout'), clearTimeout in finally.

// reviewChunk(chunk: {file, diff}, deliveryId: string) -> Promise<finding | null>
//   Up to 3 attempts: withTimeout(signal => callModel(buildPrompt(chunk), signal), cfg.llmTimeoutMs)
//   -> JSON.parse -> FindingSchema.parse.
//   Guardrail 1: if !chunkHasLine(chunk, finding.line) -> throw (retryable).
//   Guardrail 2: if finding.confidence < 0.6 -> log + return null.
//   On success return { ...finding, file: chunk.file }.
//   On any thrown error: log('warn', ...); after attempt 3 return null (never throw — keep job alive).

src/github.js — depends on config, @octokit/rest

module.exports = { getPrHeadSha, fetchDiff, upsertReviewComment, renderReview };

// @octokit/rest v20.1.2 is CommonJS-requireable (verified): const { Octokit } = require('@octokit/rest');
// Create one cached instance from cfg.githubToken:
//   const { Octokit } = require('@octokit/rest');
//   const octokit = new Octokit({ auth: cfg.githubToken });

// getPrHeadSha({ repoOwner, repoName, prNumber }) -> Promise<string>
//   octokit.pulls.get({ owner, repo, pull_number }) -> data.head.sha

// fetchDiff({ repoOwner, repoName, prNumber }) -> Promise<string>
//   octokit.pulls.get({ owner, repo, pull_number, mediaType: { format: 'diff' } })
//   -> data is the raw diff string.

// upsertReviewComment({ repoOwner, repoName, prNumber, headSha, findings }) -> Promise<void>
//   body = `<!-- turtlecode:${headSha} -->\n` + renderReview(findings)
//   List issue comments; find one whose body startsWith '<!-- turtlecode:'.
//   If found -> issues.updateComment({ comment_id, body }); else issues.createComment({ issue_number, body }).
//   (owner=repoOwner, repo=repoName, issue_number=prNumber)

// renderReview(findings: Array<{severity,file,line,comment}>) -> string   (pure; export for tests)
//   No findings -> 'TurtleCode: no issues found.'
//   Else header '**TurtleCode review**' + one bullet per finding, sorted critical>warning>info:
//   `- **${severity}** \`${file}:${line}\` — ${comment}`

src/web.js — depends on config, logger, queue (reviewQueue), signature, express

module.exports = { startWeb };
// startWeb() -> http.Server
//   express app. Use express.json({ verify: (req,_res,buf) => { req.rawBody = buf; } }).
//   POST /webhook:
//     deliveryId = req.get('X-GitHub-Delivery') || ''; event = req.get('X-GitHub-Event').
//     if !verifySignature(req.rawBody, req.get('X-Hub-Signature-256'), cfg.webhookSecret) -> 401.
//     actionable = event==='pull_request' && ['opened','synchronize','reopened'].includes(body.action).
//       if not actionable -> 204.
//     job = { deliveryId, repoOwner: body.repository.owner.login, repoName: body.repository.name,
//             prNumber: body.pull_request.number, headSha: body.pull_request.head.sha }.
//     await reviewQueue.add('review', job, { jobId: deliveryId, attempts: cfg.maxAttempts,
//             backoff: { type:'exponential', delay:2000 }, removeOnComplete:1000, removeOnFail:false }).
//     res.status(202).json({ status:'queued', deliveryId }).
//   GET /healthz -> { ok: true }.
//   return app.listen(cfg.port, () => log('info','-',`web listening on :${cfg.port}`)).

src/worker.js — depends on config, logger, queue, github, chunk, reviewer, bullmq

module.exports = { startWorker };
// startWorker() -> BullMQ Worker
//   new Worker(QUEUE_NAME, processReview, { connection, concurrency: cfg.concurrency }).
//   worker.on('failed', (job, err) => { log('error',...); if attemptsMade >= opts.attempts ->
//     deadQueue.add('dead', {...job.data, error: err.message}, { removeOnComplete:false }); log DLQ }).
//   return worker.
//
// processReview(job): job.data = { deliveryId, repoOwner, repoName, prNumber, headSha }
//   1. if await connection.exists(processedKey(repoName, prNumber, headSha)) -> log + return (idempotent).
//   2. sha = await getPrHeadSha(...); if sha !== headSha -> log 'superseded' + return (staleness).
//   3. diff = await fetchDiff(...); chunks = chunkDiff(diff).
//   4. findings = []; for each chunk: f = await reviewChunk(chunk, deliveryId); if (f) findings.push(f).
//   5. await upsertReviewComment({ repoOwner, repoName, prNumber, headSha, findings }).
//   6. await connection.set(processedKey(repoName, prNumber, headSha), '1', 'EX', 60*60*24*7).
//   7. log('info', deliveryId, 'review posted', { findings: findings.length }).
//   Let errors throw so BullMQ retries; the 'failed' handler dead-letters after max attempts.

Tests

Use node:test + node:assert. Only unit-test modules that don't import queue.js (no Redis in tests): chunk, reviewer (mock global fetch), github (test renderReview). Put tests in test/<name>.test.js.