|
| 1 | +import { Octokit } from "@octokit/rest"; |
| 2 | + |
| 3 | +const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); |
| 4 | + |
| 5 | +const EXPLAIN_TRIGGER = "@gitagent explain"; |
| 6 | +const TEST_TRIGGER = "@gitagent test"; |
| 7 | + |
| 8 | +async function fetchFileContent(owner, repo, filePath, ref) { |
| 9 | + try { |
| 10 | + const { data } = await octokit.repos.getContent({ owner, repo, path: filePath, ref }); |
| 11 | + return Buffer.from(data.content, "base64").toString("utf-8"); |
| 12 | + } catch { |
| 13 | + return null; |
| 14 | + } |
| 15 | +} |
| 16 | + |
| 17 | +function parseComment(body) { |
| 18 | + const lineMatch = body.match(/([^\s#]+)(?:#L(\d+)(?:-L?(\d+))?)?/); |
| 19 | + const filePath = lineMatch?.[1]?.replace(/@gitagent\s+(explain|test)\s*/i, "").trim() || null; |
| 20 | + const startLine = lineMatch?.[2] ? parseInt(lineMatch[2]) : null; |
| 21 | + const endLine = lineMatch?.[3] ? parseInt(lineMatch[3]) : startLine; |
| 22 | + return { filePath, startLine, endLine }; |
| 23 | +} |
| 24 | + |
| 25 | +function sliceLines(content, start, end) { |
| 26 | + if (!start) return content; |
| 27 | + return content.split("\n").slice(start - 1, end).join("\n"); |
| 28 | +} |
| 29 | + |
| 30 | +async function callLLM(prompt) { |
| 31 | + const res = await fetch("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" + process.env.GEMINI_API_KEY, { |
| 32 | + method: "POST", |
| 33 | + headers: { "Content-Type": "application/json" }, |
| 34 | + body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }), |
| 35 | + }); |
| 36 | + const data = await res.json(); |
| 37 | + return data.candidates?.[0]?.content?.parts?.[0]?.text || "Sorry, I couldn't generate a response."; |
| 38 | +} |
| 39 | + |
| 40 | +async function postComment(owner, repo, issueNumber, body) { |
| 41 | + await octokit.issues.createComment({ owner, repo, issue_number: issueNumber, body }); |
| 42 | +} |
| 43 | + |
| 44 | +async function handleExplain({ owner, repo, issueNumber, commentBody, prRef }) { |
| 45 | + const { filePath, startLine, endLine } = parseComment(commentBody); |
| 46 | + if (!filePath) { |
| 47 | + return postComment(owner, repo, issueNumber, "Please specify a file path, e.g. `@gitagent explain src/auth.js#L10-L30`"); |
| 48 | + } |
| 49 | + |
| 50 | + const raw = await fetchFileContent(owner, repo, filePath, prRef); |
| 51 | + if (!raw) { |
| 52 | + return postComment(owner, repo, issueNumber, `Could not read \`${filePath}\`. Make sure the path is correct.`); |
| 53 | + } |
| 54 | + |
| 55 | + const snippet = sliceLines(raw, startLine, endLine); |
| 56 | + const lineInfo = startLine ? ` (lines ${startLine}–${endLine || startLine})` : ""; |
| 57 | + |
| 58 | + const prompt = `You are a senior engineer explaining code to a teammate. |
| 59 | +Explain what the following code does in plain English. Be concise. |
| 60 | +Format your response exactly like this: |
| 61 | +
|
| 62 | +### What this code does |
| 63 | +
|
| 64 | +[2-4 sentence summary] |
| 65 | +
|
| 66 | +**Key points:** |
| 67 | +- [point] |
| 68 | +- [point] |
| 69 | +
|
| 70 | +**Watch out for:** [one gotcha or edge case if relevant, otherwise omit this line] |
| 71 | +
|
| 72 | +File: ${filePath}${lineInfo} |
| 73 | +
|
| 74 | +\`\`\` |
| 75 | +${snippet} |
| 76 | +\`\`\``; |
| 77 | + |
| 78 | + const explanation = await callLLM(prompt); |
| 79 | + await postComment(owner, repo, issueNumber, explanation); |
| 80 | +} |
| 81 | + |
| 82 | +async function handleTest({ owner, repo, issueNumber, commentBody, prRef }) { |
| 83 | + const { filePath } = parseComment(commentBody); |
| 84 | + if (!filePath) { |
| 85 | + return postComment(owner, repo, issueNumber, "Please specify a file path, e.g. `@gitagent test src/utils.js`"); |
| 86 | + } |
| 87 | + |
| 88 | + const raw = await fetchFileContent(owner, repo, filePath, prRef); |
| 89 | + if (!raw) { |
| 90 | + return postComment(owner, repo, issueNumber, `Could not read \`${filePath}\`. Make sure the path is correct.`); |
| 91 | + } |
| 92 | + |
| 93 | + const ext = filePath.split(".").pop(); |
| 94 | + const lang = { js: "JavaScript/Jest", ts: "TypeScript/Jest", py: "Python/pytest", rb: "Ruby/RSpec" }[ext] || ext; |
| 95 | + |
| 96 | + const prompt = `You are a senior engineer writing unit tests. |
| 97 | +Generate minimal, runnable unit tests for the code below. |
| 98 | +Use ${lang}. Cover: happy path, edge cases, and error cases. |
| 99 | +Format your response exactly like this: |
| 100 | +
|
| 101 | +### Generated tests for \`${filePath}\` |
| 102 | +
|
| 103 | +Framework: ${lang} |
| 104 | +
|
| 105 | +\`\`\`${ext} |
| 106 | +[test code] |
| 107 | +\`\`\` |
| 108 | +
|
| 109 | +> These are stubs — add mocks for any external dependencies. |
| 110 | +
|
| 111 | +\`\`\` |
| 112 | +${raw} |
| 113 | +\`\`\``; |
| 114 | + |
| 115 | + const tests = await callLLM(prompt); |
| 116 | + await postComment(owner, repo, issueNumber, tests); |
| 117 | +} |
| 118 | + |
| 119 | +// --- Main entry point (called by GitHub Actions webhook) --- |
| 120 | +export async function run(payload) { |
| 121 | + const body = payload.comment?.body || ""; |
| 122 | + const owner = payload.repository.owner.login; |
| 123 | + const repo = payload.repository.name; |
| 124 | + const issueNumber = payload.issue?.number || payload.pull_request?.number; |
| 125 | + const prRef = payload.pull_request?.head?.sha || payload.repository.default_branch; |
| 126 | + |
| 127 | + if (body.includes(EXPLAIN_TRIGGER)) { |
| 128 | + await handleExplain({ owner, repo, issueNumber, commentBody: body, prRef }); |
| 129 | + } else if (body.includes(TEST_TRIGGER)) { |
| 130 | + await handleTest({ owner, repo, issueNumber, commentBody: body, prRef }); |
| 131 | + } |
| 132 | +} |
0 commit comments