From 50bb8407ae0760cf78d545d63f30b88715cf7f58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 13:12:27 +0000 Subject: [PATCH] refactor: extract shared action helpers and deduplicate cross-action boilerplate - Add `runAction` wrapper in shared to eliminate identical try/catch error handling duplicated across all 6 actions - Add `getActionContext` to consolidate repeated initialization of octokit, owner/repo, and Gemini model from action inputs - Add `parseJsonResponse` to replace 4 copies of JSON.parse with markdown fence stripping - Extract `collectVersionChanges` helper in parsers.ts to deduplicate the version-diff collection pattern repeated for npm, composer, pip, and go ecosystems - Remove dead code in pr-from-issue (unused `existingSha` ternary that always evaluated to undefined) https://claude.ai/code/session_017fwXR9qdd7YhM5CSQoY3e3 --- datadog-responder/dist/index.js | 293 +++++++++++-------- datadog-responder/src/index.ts | 226 +++++++-------- dependency-impact/dist/index.js | 404 +++++++++++++++------------ dependency-impact/src/index.ts | 262 ++++++++--------- dependency-impact/src/parsers.ts | 142 +++++----- pr-from-issue/dist/index.js | 280 ++++++++++++------- pr-from-issue/src/index.ts | 218 +++++++-------- pr-review/dist/index.js | 229 +++++++++------ pr-review/src/index.ts | 195 ++++++------- repo-qa/dist/index.js | 293 +++++++++++-------- repo-qa/src/index.ts | 271 +++++++++--------- shared/dist/action.d.ts | 21 ++ shared/dist/action.js | 71 +++++ shared/dist/gemini.d.ts | 4 + shared/dist/gemini.js | 7 + shared/dist/index.d.ts | 4 +- shared/dist/index.js | 6 +- shared/src/action.ts | 45 +++ shared/src/gemini.ts | 7 + shared/src/index.ts | 3 + test-failure-diagnosis/dist/index.js | 211 +++++++++----- test-failure-diagnosis/src/index.ts | 142 +++++----- 22 files changed, 1885 insertions(+), 1449 deletions(-) create mode 100644 shared/dist/action.d.ts create mode 100644 shared/dist/action.js create mode 100644 shared/src/action.ts diff --git a/datadog-responder/dist/index.js b/datadog-responder/dist/index.js index 83150d2..8793e36 100644 --- a/datadog-responder/dist/index.js +++ b/datadog-responder/dist/index.js @@ -31393,6 +31393,84 @@ function wrappy (fn, cb) { } +/***/ }), + +/***/ 6941: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getActionContext = getActionContext; +exports.runAction = runAction; +const core = __importStar(__nccwpck_require__(6618)); +const gemini_1 = __nccwpck_require__(9700); +const github_1 = __nccwpck_require__(8284); +/** + * Read the standard action inputs (gemini_api_key, github_token, model) + * and return an initialised ActionContext. + */ +function getActionContext() { + const geminiApiKey = core.getInput("gemini_api_key", { required: true }); + const githubToken = core.getInput("github_token", { required: true }); + const modelName = core.getInput("model") || "gemini-2.0-flash"; + const octokit = (0, github_1.getOctokitClient)(githubToken); + const { owner, repo } = (0, github_1.getRepoContext)(); + const model = (0, gemini_1.createGeminiModel)(geminiApiKey, modelName); + return { octokit, owner, repo, model }; +} +/** + * Wrap an action's main logic with consistent error handling. + * Catches errors and calls `core.setFailed` so every action doesn't have to. + */ +async function runAction(fn) { + try { + await fn(); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + else { + core.setFailed("An unexpected error occurred"); + } + } +} +//# sourceMappingURL=action.js.map + /***/ }), /***/ 9700: @@ -31438,6 +31516,7 @@ exports.createGeminiModel = createGeminiModel; exports.countTokens = countTokens; exports.generateContent = generateContent; exports.truncateText = truncateText; +exports.parseJsonResponse = parseJsonResponse; const core = __importStar(__nccwpck_require__(6618)); const generative_ai_1 = __nccwpck_require__(4274); const DEFAULT_MODEL = "gemini-2.0-flash"; @@ -31501,6 +31580,12 @@ function truncateText(text, maxChars, label = "content") { const truncated = text.slice(0, maxChars); return `${truncated}\n\n... [${label} truncated: ${(text.length - maxChars).toLocaleString()} characters omitted]`; } +/** + * Parse a JSON response from Gemini, stripping markdown code fences if present. + */ +function parseJsonResponse(response) { + return JSON.parse(response.replace(/```json?\n?|\n?```/g, "").trim()); +} //# sourceMappingURL=gemini.js.map /***/ }), @@ -31743,12 +31828,13 @@ async function listReleaseNotesBetween(octokit, owner, repo, fromVersion, toVers "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; +exports.runAction = exports.getActionContext = exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.parseJsonResponse = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; var gemini_1 = __nccwpck_require__(9700); Object.defineProperty(exports, "createGeminiModel", ({ enumerable: true, get: function () { return gemini_1.createGeminiModel; } })); Object.defineProperty(exports, "generateContent", ({ enumerable: true, get: function () { return gemini_1.generateContent; } })); Object.defineProperty(exports, "countTokens", ({ enumerable: true, get: function () { return gemini_1.countTokens; } })); Object.defineProperty(exports, "truncateText", ({ enumerable: true, get: function () { return gemini_1.truncateText; } })); +Object.defineProperty(exports, "parseJsonResponse", ({ enumerable: true, get: function () { return gemini_1.parseJsonResponse; } })); var github_1 = __nccwpck_require__(8284); Object.defineProperty(exports, "getOctokitClient", ({ enumerable: true, get: function () { return github_1.getOctokitClient; } })); Object.defineProperty(exports, "getRepoContext", ({ enumerable: true, get: function () { return github_1.getRepoContext; } })); @@ -31763,6 +31849,9 @@ Object.defineProperty(exports, "createBranch", ({ enumerable: true, get: functio Object.defineProperty(exports, "getDefaultBranch", ({ enumerable: true, get: function () { return github_1.getDefaultBranch; } })); Object.defineProperty(exports, "getRepoTree", ({ enumerable: true, get: function () { return github_1.getRepoTree; } })); Object.defineProperty(exports, "listReleaseNotesBetween", ({ enumerable: true, get: function () { return github_1.listReleaseNotesBetween; } })); +var action_1 = __nccwpck_require__(6941); +Object.defineProperty(exports, "getActionContext", ({ enumerable: true, get: function () { return action_1.getActionContext; } })); +Object.defineProperty(exports, "runAction", ({ enumerable: true, get: function () { return action_1.runAction; } })); //# sourceMappingURL=index.js.map /***/ }), @@ -31850,54 +31939,48 @@ async function queryMetrics(apiKey, appKey, query) { async function getMonitor(apiKey, appKey, monitorId) { return (await datadogRequest(apiKey, appKey, `/api/v1/monitor/${monitorId}`)); } -async function run() { - try { - const ddApiKey = core.getInput("datadog_api_key", { required: true }); - const ddAppKey = core.getInput("datadog_app_key", { required: true }); - const query = core.getInput("query", { required: true }); - const action = core.getInput("action", { required: true }); - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - const validActions = [ - "open_issue", - "comment_on_pr", - "trigger_workflow", - ]; - if (!validActions.includes(action)) { - throw new Error(`Invalid action: ${action}. Must be one of: ${validActions.join(", ")}`); - } - const octokit = (0, shared_1.getOctokitClient)(githubToken); - const { owner, repo } = (0, shared_1.getRepoContext)(); - const model = (0, shared_1.createGeminiModel)(geminiApiKey, modelName); - core.info(`Querying Datadog: ${query}`); - // 1. Query Datadog - determine if it's a monitor ID or a metrics query - let datadogData; - const isMonitorId = /^\d+$/.test(query.trim()); - if (isMonitorId) { - const monitor = await getMonitor(ddApiKey, ddAppKey, query.trim()); - datadogData = JSON.stringify(monitor, null, 2); - core.info(`Monitor "${monitor.name}" state: ${monitor.overall_state}`); - } - else { - const metrics = await queryMetrics(ddApiKey, ddAppKey, query); - datadogData = JSON.stringify(metrics, null, 2); - core.info(`Metrics query returned ${metrics.series?.length ?? 0} series`); - } - // 2. Get recent commits for correlation - const { data: commits } = await octokit.rest.repos.listCommits({ - owner, - repo, - per_page: 10, - }); - const recentCommits = commits.map((c) => ({ - sha: c.sha.slice(0, 7), - message: c.commit.message.split("\n")[0], - date: c.commit.author?.date, - author: c.commit.author?.name, - })); - // 3. Ask Gemini to interpret the data - const prompt = `You are a site reliability engineer analyzing monitoring data from Datadog in the context of a GitHub repository. +(0, shared_1.runAction)(async () => { + const ddApiKey = core.getInput("datadog_api_key", { required: true }); + const ddAppKey = core.getInput("datadog_app_key", { required: true }); + const query = core.getInput("query", { required: true }); + const action = core.getInput("action", { required: true }); + const validActions = [ + "open_issue", + "comment_on_pr", + "trigger_workflow", + ]; + if (!validActions.includes(action)) { + throw new Error(`Invalid action: ${action}. Must be one of: ${validActions.join(", ")}`); + } + const { octokit, owner, repo, model } = (0, shared_1.getActionContext)(); + core.info(`Querying Datadog: ${query}`); + // 1. Query Datadog - determine if it's a monitor ID or a metrics query + let datadogData; + const isMonitorId = /^\d+$/.test(query.trim()); + if (isMonitorId) { + const monitor = await getMonitor(ddApiKey, ddAppKey, query.trim()); + datadogData = JSON.stringify(monitor, null, 2); + core.info(`Monitor "${monitor.name}" state: ${monitor.overall_state}`); + } + else { + const metrics = await queryMetrics(ddApiKey, ddAppKey, query); + datadogData = JSON.stringify(metrics, null, 2); + core.info(`Metrics query returned ${metrics.series?.length ?? 0} series`); + } + // 2. Get recent commits for correlation + const { data: commits } = await octokit.rest.repos.listCommits({ + owner, + repo, + per_page: 10, + }); + const recentCommits = commits.map((c) => ({ + sha: c.sha.slice(0, 7), + message: c.commit.message.split("\n")[0], + date: c.commit.author?.date, + author: c.commit.author?.name, + })); + // 3. Ask Gemini to interpret the data + const prompt = `You are a site reliability engineer analyzing monitoring data from Datadog in the context of a GitHub repository. **Datadog ${isMonitorId ? "Monitor" : "Metrics"} Data:** \`\`\`json @@ -31916,70 +31999,60 @@ Analyze the monitoring data and provide: 4. **Recommended Action**: What should be done next? Format your response as structured markdown suitable for a GitHub ${action === "open_issue" ? "issue body" : "comment"}.`; - const analysis = await (0, shared_1.generateContent)(model, prompt); - // 4. Take the specified action - let resultId; - switch (action) { - case "open_issue": { - const { data: issue } = await octokit.rest.issues.create({ - owner, - repo, - title: `[Datadog Alert] ${isMonitorId ? `Monitor ${query}` : "Metrics anomaly detected"}`, - body: `## Datadog Alert Analysis\n\n${analysis}\n\n---\n*Generated by [gemini-datadog-responder](https://github.com/dortort/gemini-actions)*`, - labels: ["datadog", "automated"], - }); - resultId = issue.number.toString(); - core.info(`Created issue #${resultId}`); - break; - } - case "comment_on_pr": { - // Find the most recent open PR - const { data: prs } = await octokit.rest.pulls.list({ - owner, - repo, - state: "open", - sort: "updated", - direction: "desc", - per_page: 1, - }); - if (prs.length === 0) { - throw new Error("No open pull requests found to comment on"); - } - const prNumber = prs[0].number; - await (0, shared_1.postComment)(octokit, owner, repo, prNumber, `## Datadog Alert Analysis\n\n${analysis}\n\n---\n*Generated by [gemini-datadog-responder](https://github.com/dortort/gemini-actions)*`); - resultId = prNumber.toString(); - core.info(`Commented on PR #${resultId}`); - break; - } - case "trigger_workflow": { - // Trigger the repository_dispatch event so users can listen for it - await octokit.rest.repos.createDispatchEvent({ - owner, - repo, - event_type: "datadog-alert", - client_payload: { - query, - analysis, - is_monitor: isMonitorId, - }, - }); - resultId = "dispatch-sent"; - core.info("Triggered repository_dispatch event: datadog-alert"); - break; + const analysis = await (0, shared_1.generateContent)(model, prompt); + // 4. Take the specified action + let resultId; + switch (action) { + case "open_issue": { + const { data: issue } = await octokit.rest.issues.create({ + owner, + repo, + title: `[Datadog Alert] ${isMonitorId ? `Monitor ${query}` : "Metrics anomaly detected"}`, + body: `## Datadog Alert Analysis\n\n${analysis}\n\n---\n*Generated by [gemini-datadog-responder](https://github.com/dortort/gemini-actions)*`, + labels: ["datadog", "automated"], + }); + resultId = issue.number.toString(); + core.info(`Created issue #${resultId}`); + break; + } + case "comment_on_pr": { + // Find the most recent open PR + const { data: prs } = await octokit.rest.pulls.list({ + owner, + repo, + state: "open", + sort: "updated", + direction: "desc", + per_page: 1, + }); + if (prs.length === 0) { + throw new Error("No open pull requests found to comment on"); } - } - core.setOutput("result", resultId); - } - catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } - else { - core.setFailed("An unexpected error occurred"); + const prNumber = prs[0].number; + await (0, shared_1.postComment)(octokit, owner, repo, prNumber, `## Datadog Alert Analysis\n\n${analysis}\n\n---\n*Generated by [gemini-datadog-responder](https://github.com/dortort/gemini-actions)*`); + resultId = prNumber.toString(); + core.info(`Commented on PR #${resultId}`); + break; + } + case "trigger_workflow": { + // Trigger the repository_dispatch event so users can listen for it + await octokit.rest.repos.createDispatchEvent({ + owner, + repo, + event_type: "datadog-alert", + client_payload: { + query, + analysis, + is_monitor: isMonitorId, + }, + }); + resultId = "dispatch-sent"; + core.info("Triggered repository_dispatch event: datadog-alert"); + break; } } -} -run(); + core.setOutput("result", resultId); +}); /***/ }), diff --git a/datadog-responder/src/index.ts b/datadog-responder/src/index.ts index e80d352..aef4a76 100644 --- a/datadog-responder/src/index.ts +++ b/datadog-responder/src/index.ts @@ -1,12 +1,11 @@ import * as core from "@actions/core"; import * as https from "https"; import { - createGeminiModel, generateContent, truncateText, - getOctokitClient, - getRepoContext, postComment, + runAction, + getActionContext, } from "@gemini-actions/shared"; type ActionType = "open_issue" | "comment_on_pr" | "trigger_workflow"; @@ -96,63 +95,57 @@ async function getMonitor( )) as DatadogMonitorResult; } -async function run(): Promise { - try { - const ddApiKey = core.getInput("datadog_api_key", { required: true }); - const ddAppKey = core.getInput("datadog_app_key", { required: true }); - const query = core.getInput("query", { required: true }); - const action = core.getInput("action", { required: true }) as ActionType; - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - - const validActions: ActionType[] = [ - "open_issue", - "comment_on_pr", - "trigger_workflow", - ]; - if (!validActions.includes(action)) { - throw new Error( - `Invalid action: ${action}. Must be one of: ${validActions.join(", ")}`, - ); - } +runAction(async () => { + const ddApiKey = core.getInput("datadog_api_key", { required: true }); + const ddAppKey = core.getInput("datadog_app_key", { required: true }); + const query = core.getInput("query", { required: true }); + const action = core.getInput("action", { required: true }) as ActionType; + + const validActions: ActionType[] = [ + "open_issue", + "comment_on_pr", + "trigger_workflow", + ]; + if (!validActions.includes(action)) { + throw new Error( + `Invalid action: ${action}. Must be one of: ${validActions.join(", ")}`, + ); + } - const octokit = getOctokitClient(githubToken); - const { owner, repo } = getRepoContext(); - const model = createGeminiModel(geminiApiKey, modelName); + const { octokit, owner, repo, model } = getActionContext(); - core.info(`Querying Datadog: ${query}`); + core.info(`Querying Datadog: ${query}`); - // 1. Query Datadog - determine if it's a monitor ID or a metrics query - let datadogData: string; - const isMonitorId = /^\d+$/.test(query.trim()); + // 1. Query Datadog - determine if it's a monitor ID or a metrics query + let datadogData: string; + const isMonitorId = /^\d+$/.test(query.trim()); - if (isMonitorId) { - const monitor = await getMonitor(ddApiKey, ddAppKey, query.trim()); - datadogData = JSON.stringify(monitor, null, 2); - core.info(`Monitor "${monitor.name}" state: ${monitor.overall_state}`); - } else { - const metrics = await queryMetrics(ddApiKey, ddAppKey, query); - datadogData = JSON.stringify(metrics, null, 2); - core.info(`Metrics query returned ${metrics.series?.length ?? 0} series`); - } + if (isMonitorId) { + const monitor = await getMonitor(ddApiKey, ddAppKey, query.trim()); + datadogData = JSON.stringify(monitor, null, 2); + core.info(`Monitor "${monitor.name}" state: ${monitor.overall_state}`); + } else { + const metrics = await queryMetrics(ddApiKey, ddAppKey, query); + datadogData = JSON.stringify(metrics, null, 2); + core.info(`Metrics query returned ${metrics.series?.length ?? 0} series`); + } - // 2. Get recent commits for correlation - const { data: commits } = await octokit.rest.repos.listCommits({ - owner, - repo, - per_page: 10, - }); + // 2. Get recent commits for correlation + const { data: commits } = await octokit.rest.repos.listCommits({ + owner, + repo, + per_page: 10, + }); - const recentCommits = commits.map((c) => ({ - sha: c.sha.slice(0, 7), - message: c.commit.message.split("\n")[0], - date: c.commit.author?.date, - author: c.commit.author?.name, - })); + const recentCommits = commits.map((c) => ({ + sha: c.sha.slice(0, 7), + message: c.commit.message.split("\n")[0], + date: c.commit.author?.date, + author: c.commit.author?.name, + })); - // 3. Ask Gemini to interpret the data - const prompt = `You are a site reliability engineer analyzing monitoring data from Datadog in the context of a GitHub repository. + // 3. Ask Gemini to interpret the data + const prompt = `You are a site reliability engineer analyzing monitoring data from Datadog in the context of a GitHub repository. **Datadog ${isMonitorId ? "Monitor" : "Metrics"} Data:** \`\`\`json @@ -172,79 +165,70 @@ Analyze the monitoring data and provide: Format your response as structured markdown suitable for a GitHub ${action === "open_issue" ? "issue body" : "comment"}.`; - const analysis = await generateContent(model, prompt); - - // 4. Take the specified action - let resultId: string; - - switch (action) { - case "open_issue": { - const { data: issue } = await octokit.rest.issues.create({ - owner, - repo, - title: `[Datadog Alert] ${isMonitorId ? `Monitor ${query}` : "Metrics anomaly detected"}`, - body: `## Datadog Alert Analysis\n\n${analysis}\n\n---\n*Generated by [gemini-datadog-responder](https://github.com/dortort/gemini-actions)*`, - labels: ["datadog", "automated"], - }); - resultId = issue.number.toString(); - core.info(`Created issue #${resultId}`); - break; - } - - case "comment_on_pr": { - // Find the most recent open PR - const { data: prs } = await octokit.rest.pulls.list({ - owner, - repo, - state: "open", - sort: "updated", - direction: "desc", - per_page: 1, - }); + const analysis = await generateContent(model, prompt); + + // 4. Take the specified action + let resultId: string; + + switch (action) { + case "open_issue": { + const { data: issue } = await octokit.rest.issues.create({ + owner, + repo, + title: `[Datadog Alert] ${isMonitorId ? `Monitor ${query}` : "Metrics anomaly detected"}`, + body: `## Datadog Alert Analysis\n\n${analysis}\n\n---\n*Generated by [gemini-datadog-responder](https://github.com/dortort/gemini-actions)*`, + labels: ["datadog", "automated"], + }); + resultId = issue.number.toString(); + core.info(`Created issue #${resultId}`); + break; + } - if (prs.length === 0) { - throw new Error("No open pull requests found to comment on"); - } - - const prNumber = prs[0].number; - await postComment( - octokit, - owner, - repo, - prNumber, - `## Datadog Alert Analysis\n\n${analysis}\n\n---\n*Generated by [gemini-datadog-responder](https://github.com/dortort/gemini-actions)*`, - ); - resultId = prNumber.toString(); - core.info(`Commented on PR #${resultId}`); - break; + case "comment_on_pr": { + // Find the most recent open PR + const { data: prs } = await octokit.rest.pulls.list({ + owner, + repo, + state: "open", + sort: "updated", + direction: "desc", + per_page: 1, + }); + + if (prs.length === 0) { + throw new Error("No open pull requests found to comment on"); } - case "trigger_workflow": { - // Trigger the repository_dispatch event so users can listen for it - await octokit.rest.repos.createDispatchEvent({ - owner, - repo, - event_type: "datadog-alert", - client_payload: { - query, - analysis, - is_monitor: isMonitorId, - }, - }); - resultId = "dispatch-sent"; - core.info("Triggered repository_dispatch event: datadog-alert"); - break; - } + const prNumber = prs[0].number; + await postComment( + octokit, + owner, + repo, + prNumber, + `## Datadog Alert Analysis\n\n${analysis}\n\n---\n*Generated by [gemini-datadog-responder](https://github.com/dortort/gemini-actions)*`, + ); + resultId = prNumber.toString(); + core.info(`Commented on PR #${resultId}`); + break; } - core.setOutput("result", resultId); - } catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } else { - core.setFailed("An unexpected error occurred"); + case "trigger_workflow": { + // Trigger the repository_dispatch event so users can listen for it + await octokit.rest.repos.createDispatchEvent({ + owner, + repo, + event_type: "datadog-alert", + client_payload: { + query, + analysis, + is_monitor: isMonitorId, + }, + }); + resultId = "dispatch-sent"; + core.info("Triggered repository_dispatch event: datadog-alert"); + break; } } -} -run(); + core.setOutput("result", resultId); +}); diff --git a/dependency-impact/dist/index.js b/dependency-impact/dist/index.js index 4895f8e..59a2fa0 100644 --- a/dependency-impact/dist/index.js +++ b/dependency-impact/dist/index.js @@ -31393,6 +31393,84 @@ function wrappy (fn, cb) { } +/***/ }), + +/***/ 6941: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getActionContext = getActionContext; +exports.runAction = runAction; +const core = __importStar(__nccwpck_require__(6618)); +const gemini_1 = __nccwpck_require__(9700); +const github_1 = __nccwpck_require__(8284); +/** + * Read the standard action inputs (gemini_api_key, github_token, model) + * and return an initialised ActionContext. + */ +function getActionContext() { + const geminiApiKey = core.getInput("gemini_api_key", { required: true }); + const githubToken = core.getInput("github_token", { required: true }); + const modelName = core.getInput("model") || "gemini-2.0-flash"; + const octokit = (0, github_1.getOctokitClient)(githubToken); + const { owner, repo } = (0, github_1.getRepoContext)(); + const model = (0, gemini_1.createGeminiModel)(geminiApiKey, modelName); + return { octokit, owner, repo, model }; +} +/** + * Wrap an action's main logic with consistent error handling. + * Catches errors and calls `core.setFailed` so every action doesn't have to. + */ +async function runAction(fn) { + try { + await fn(); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + else { + core.setFailed("An unexpected error occurred"); + } + } +} +//# sourceMappingURL=action.js.map + /***/ }), /***/ 9700: @@ -31438,6 +31516,7 @@ exports.createGeminiModel = createGeminiModel; exports.countTokens = countTokens; exports.generateContent = generateContent; exports.truncateText = truncateText; +exports.parseJsonResponse = parseJsonResponse; const core = __importStar(__nccwpck_require__(6618)); const generative_ai_1 = __nccwpck_require__(4274); const DEFAULT_MODEL = "gemini-2.0-flash"; @@ -31501,6 +31580,12 @@ function truncateText(text, maxChars, label = "content") { const truncated = text.slice(0, maxChars); return `${truncated}\n\n... [${label} truncated: ${(text.length - maxChars).toLocaleString()} characters omitted]`; } +/** + * Parse a JSON response from Gemini, stripping markdown code fences if present. + */ +function parseJsonResponse(response) { + return JSON.parse(response.replace(/```json?\n?|\n?```/g, "").trim()); +} //# sourceMappingURL=gemini.js.map /***/ }), @@ -31743,12 +31828,13 @@ async function listReleaseNotesBetween(octokit, owner, repo, fromVersion, toVers "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; +exports.runAction = exports.getActionContext = exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.parseJsonResponse = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; var gemini_1 = __nccwpck_require__(9700); Object.defineProperty(exports, "createGeminiModel", ({ enumerable: true, get: function () { return gemini_1.createGeminiModel; } })); Object.defineProperty(exports, "generateContent", ({ enumerable: true, get: function () { return gemini_1.generateContent; } })); Object.defineProperty(exports, "countTokens", ({ enumerable: true, get: function () { return gemini_1.countTokens; } })); Object.defineProperty(exports, "truncateText", ({ enumerable: true, get: function () { return gemini_1.truncateText; } })); +Object.defineProperty(exports, "parseJsonResponse", ({ enumerable: true, get: function () { return gemini_1.parseJsonResponse; } })); var github_1 = __nccwpck_require__(8284); Object.defineProperty(exports, "getOctokitClient", ({ enumerable: true, get: function () { return github_1.getOctokitClient; } })); Object.defineProperty(exports, "getRepoContext", ({ enumerable: true, get: function () { return github_1.getRepoContext; } })); @@ -31763,6 +31849,9 @@ Object.defineProperty(exports, "createBranch", ({ enumerable: true, get: functio Object.defineProperty(exports, "getDefaultBranch", ({ enumerable: true, get: function () { return github_1.getDefaultBranch; } })); Object.defineProperty(exports, "getRepoTree", ({ enumerable: true, get: function () { return github_1.getRepoTree; } })); Object.defineProperty(exports, "listReleaseNotesBetween", ({ enumerable: true, get: function () { return github_1.listReleaseNotesBetween; } })); +var action_1 = __nccwpck_require__(6941); +Object.defineProperty(exports, "getActionContext", ({ enumerable: true, get: function () { return action_1.getActionContext; } })); +Object.defineProperty(exports, "runAction", ({ enumerable: true, get: function () { return action_1.runAction; } })); //# sourceMappingURL=index.js.map /***/ }), @@ -31862,101 +31951,95 @@ async function resolveGitHubRepo(dep) { } return null; } -async function run() { - try { - const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - const octokit = (0, shared_1.getOctokitClient)(githubToken); - const { owner, repo } = (0, shared_1.getRepoContext)(); - const model = (0, shared_1.createGeminiModel)(geminiApiKey, modelName); - core.info(`Analyzing dependency impact for PR #${prNumber}...`); - // 1. Get PR details - const pr = await (0, shared_1.getPullRequest)(octokit, owner, repo, prNumber); - core.info(`PR: ${pr.title}`); - // 2. Parse dependency changes from the diff - const depChanges = (0, parsers_1.parseDependencyChanges)(pr.diff, pr.files); - if (depChanges.length === 0) { - core.info("No dependency version changes detected in this PR"); - await (0, shared_1.postComment)(octokit, owner, repo, prNumber, "## Gemini Dependency Impact Analysis\n\nNo dependency version changes detected in this PR."); - return; - } - core.info(`Found ${depChanges.length} dependency change(s): ${depChanges.map((d) => d.name).join(", ")}`); - // 3. Get repository file tree to find usage - const defaultBranch = await (0, shared_1.getDefaultBranch)(octokit, owner, repo); - const tree = await (0, shared_1.getRepoTree)(octokit, owner, repo, defaultBranch.sha); - const sourceFiles = tree - .filter((item) => item.type === "blob") - .filter((item) => /\.(ts|js|tsx|jsx|py|go|java|rb|rs|tf|php)$/.test(item.path)) - .filter((item) => !item.path.includes("node_modules")) - .map((item) => item.path); - // 4. Sample source files to find usage of changed dependencies - const usageContext = {}; - for (const dep of depChanges) { - usageContext[dep.name] = []; - const importPatterns = (0, parsers_1.getImportPatterns)(dep.name, dep.ecosystem); - // Read a subset of source files to find imports - for (const filePath of sourceFiles.slice(0, 100)) { - try { - const content = await (0, shared_1.getFileContent)(octokit, owner, repo, filePath, defaultBranch.name); - if (importPatterns.some((pattern) => content.includes(pattern))) { - // Include the relevant lines, not the whole file - const relevantLines = content - .split("\n") - .filter((line) => importPatterns.some((p) => line.includes(p)) || - line.includes(dep.name)) - .slice(0, 20); - if (relevantLines.length > 0) { - usageContext[dep.name].push(`**${filePath}:**\n${relevantLines.join("\n")}`); - } +(0, shared_1.runAction)(async () => { + const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); + const { octokit, owner, repo, model } = (0, shared_1.getActionContext)(); + core.info(`Analyzing dependency impact for PR #${prNumber}...`); + // 1. Get PR details + const pr = await (0, shared_1.getPullRequest)(octokit, owner, repo, prNumber); + core.info(`PR: ${pr.title}`); + // 2. Parse dependency changes from the diff + const depChanges = (0, parsers_1.parseDependencyChanges)(pr.diff, pr.files); + if (depChanges.length === 0) { + core.info("No dependency version changes detected in this PR"); + await (0, shared_1.postComment)(octokit, owner, repo, prNumber, "## Gemini Dependency Impact Analysis\n\nNo dependency version changes detected in this PR."); + return; + } + core.info(`Found ${depChanges.length} dependency change(s): ${depChanges.map((d) => d.name).join(", ")}`); + // 3. Get repository file tree to find usage + const defaultBranch = await (0, shared_1.getDefaultBranch)(octokit, owner, repo); + const tree = await (0, shared_1.getRepoTree)(octokit, owner, repo, defaultBranch.sha); + const sourceFiles = tree + .filter((item) => item.type === "blob") + .filter((item) => /\.(ts|js|tsx|jsx|py|go|java|rb|rs|tf|php)$/.test(item.path)) + .filter((item) => !item.path.includes("node_modules")) + .map((item) => item.path); + // 4. Sample source files to find usage of changed dependencies + const usageContext = {}; + for (const dep of depChanges) { + usageContext[dep.name] = []; + const importPatterns = (0, parsers_1.getImportPatterns)(dep.name, dep.ecosystem); + // Read a subset of source files to find imports + for (const filePath of sourceFiles.slice(0, 100)) { + try { + const content = await (0, shared_1.getFileContent)(octokit, owner, repo, filePath, defaultBranch.name); + if (importPatterns.some((pattern) => content.includes(pattern))) { + // Include the relevant lines, not the whole file + const relevantLines = content + .split("\n") + .filter((line) => importPatterns.some((p) => line.includes(p)) || + line.includes(dep.name)) + .slice(0, 20); + if (relevantLines.length > 0) { + usageContext[dep.name].push(`**${filePath}:**\n${relevantLines.join("\n")}`); } } - catch { - // Skip files we can't read - } + } + catch { + // Skip files we can't read } } - // 5. Send to Gemini for analysis - const maxUsageCharsPerDep = 5000; - const usageSections = Object.entries(usageContext) - .map(([name, usages]) => { - if (usages.length === 0) - return `### ${name}\nNo direct imports found in source files.`; - const joined = usages.join("\n\n"); - return `### ${name}\n${(0, shared_1.truncateText)(joined, maxUsageCharsPerDep, `${name} usage`)}`; - }) - .join("\n\n"); - const isDependabot = /\[bot\]$/.test(pr.author); - const hasBody = pr.body != null && pr.body.trim().length > 50; - let releaseNotes = null; - if (isDependabot && hasBody) { - releaseNotes = pr.body; - } - else { - for (const dep of depChanges) { - const ghRepo = await resolveGitHubRepo(dep); - if (ghRepo) { - const notes = await (0, shared_1.listReleaseNotesBetween)(octokit, ghRepo.owner, ghRepo.repo, dep.fromVersion, dep.toVersion); - if (notes) { - releaseNotes = (releaseNotes ?? "") + `\n\n## ${dep.name}\n${notes}`; - } + } + // 5. Send to Gemini for analysis + const maxUsageCharsPerDep = 5000; + const usageSections = Object.entries(usageContext) + .map(([name, usages]) => { + if (usages.length === 0) + return `### ${name}\nNo direct imports found in source files.`; + const joined = usages.join("\n\n"); + return `### ${name}\n${(0, shared_1.truncateText)(joined, maxUsageCharsPerDep, `${name} usage`)}`; + }) + .join("\n\n"); + const isDependabot = /\[bot\]$/.test(pr.author); + const hasBody = pr.body != null && pr.body.trim().length > 50; + let releaseNotes = null; + if (isDependabot && hasBody) { + releaseNotes = pr.body; + } + else { + for (const dep of depChanges) { + const ghRepo = await resolveGitHubRepo(dep); + if (ghRepo) { + const notes = await (0, shared_1.listReleaseNotesBetween)(octokit, ghRepo.owner, ghRepo.repo, dep.fromVersion, dep.toVersion); + if (notes) { + releaseNotes = (releaseNotes ?? "") + `\n\n## ${dep.name}\n${notes}`; } } - if (!releaseNotes && hasBody) { - releaseNotes = pr.body; - } } - const prBodySection = releaseNotes - ? `**Release Notes:**\n${(0, shared_1.truncateText)(releaseNotes.trim(), 15000, "release notes")}` - : "**Release Notes:** No release notes available."; - const hasUsage = Object.values(usageContext).some(usages => usages.length > 0); - const depChangesList = depChanges - .map((d) => `- **${d.name}**: ${d.fromVersion} → ${d.toVersion} (${d.ecosystem})`) - .join("\n"); - let prompt; - if (hasUsage) { - prompt = `You are a dependency upgrade analyst. A pull request updates the following dependencies. + if (!releaseNotes && hasBody) { + releaseNotes = pr.body; + } + } + const prBodySection = releaseNotes + ? `**Release Notes:**\n${(0, shared_1.truncateText)(releaseNotes.trim(), 15000, "release notes")}` + : "**Release Notes:** No release notes available."; + const hasUsage = Object.values(usageContext).some(usages => usages.length > 0); + const depChangesList = depChanges + .map((d) => `- **${d.name}**: ${d.fromVersion} → ${d.toVersion} (${d.ecosystem})`) + .join("\n"); + let prompt; + if (hasUsage) { + prompt = `You are a dependency upgrade analyst. A pull request updates the following dependencies. Cross-reference the release notes with actual usage sites in this codebase. **Dependency Changes:** @@ -31981,9 +32064,9 @@ RULES: - Do NOT include generic advice like "review the changelog", "test in staging", "run terraform init", or "pin versions". - Do NOT fabricate examples, hypothetical scenarios, or breaking changes not confirmed by the release notes. - If the release notes do not mention breaking changes relevant to the detected usage, say "No breaking changes detected for current usage" and give a risk assessment.`; - } - else { - prompt = `You are a dependency upgrade analyst. A pull request updates the following dependencies. + } + else { + prompt = `You are a dependency upgrade analyst. A pull request updates the following dependencies. No usage of these dependencies was found in the source files. **Dependency Changes:** @@ -31999,28 +32082,18 @@ RULES: - Do NOT reference files or APIs since no usage was found. - Do NOT include generic advice like "review the changelog", "test in staging", or "pin versions". - If no release notes are available, say "No release notes available and no usage detected — no action needed." and stop.`; - } - const analysis = await (0, shared_1.generateContent)(model, prompt); - // 6. Post the analysis as a comment - const comment = `## Gemini Dependency Impact Analysis + } + const analysis = await (0, shared_1.generateContent)(model, prompt); + // 6. Post the analysis as a comment + const comment = `## Gemini Dependency Impact Analysis ${analysis} --- *${depChanges.length} dependency change(s) · ${Object.values(usageContext).flat().length} usage site(s) found — Generated by [gemini-dependency-impact](https://github.com/dortort/gemini-actions)*`; - await (0, shared_1.postComment)(octokit, owner, repo, prNumber, comment); - core.info("Dependency impact analysis posted"); - } - catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } - else { - core.setFailed("An unexpected error occurred"); - } - } -} -run(); + await (0, shared_1.postComment)(octokit, owner, repo, prNumber, comment); + core.info("Dependency impact analysis posted"); +}); /***/ }), @@ -32033,53 +32106,46 @@ run(); Object.defineProperty(exports, "__esModule", ({ value: true })); exports.parseDependencyChanges = parseDependencyChanges; exports.getImportPatterns = getImportPatterns; +/** + * Parse diff lines to collect added/removed values, then emit changes where the + * version actually changed. This pattern was previously duplicated for every + * ecosystem — now it lives in one place. + */ +function collectVersionChanges(patch, regex, ecosystem) { + const removed = new Map(); + const added = new Map(); + for (const line of patch.split("\n")) { + const match = line.match(regex); + if (match) { + if (match[1] === "-") + removed.set(match[2], match[3]); + else + added.set(match[2], match[3]); + } + } + const changes = []; + for (const [name, toVersion] of added) { + const fromVersion = removed.get(name); + if (fromVersion && fromVersion !== toVersion) { + changes.push({ name, fromVersion, toVersion, ecosystem }); + } + } + return changes; +} function parseDependencyChanges(diff, files) { const changes = []; for (const file of files) { if (!file.patch) continue; - // Parse package.json changes (npm) + // npm: package.json / package-lock.json if (file.filename.endsWith("package.json") || file.filename.endsWith("package-lock.json")) { - const depRegex = /^[-+]\s*"([^"]+)":\s*"[^]*?(\d+\.\d+\.\d+[^"]*)"/gm; - const removed = new Map(); - const added = new Map(); - for (const line of file.patch.split("\n")) { - const match = line.match(/^([-+])\s*"([^"]+)":\s*"[~^]?(\d+[^"]*)"/); - if (match) { - if (match[1] === "-") - removed.set(match[2], match[3]); - else - added.set(match[2], match[3]); - } - } - for (const [name, toVersion] of added) { - const fromVersion = removed.get(name); - if (fromVersion && fromVersion !== toVersion) { - changes.push({ name, fromVersion, toVersion, ecosystem: "npm" }); - } - } + changes.push(...collectVersionChanges(file.patch, /^([-+])\s*"([^"]+)":\s*"[~^]?(\d+[^"]*)"/, "npm")); } - // Parse composer.json changes (Composer) + // Composer: composer.json if (file.filename.endsWith("composer.json")) { - const removed = new Map(); - const added = new Map(); - for (const line of file.patch.split("\n")) { - const match = line.match(/^([-+])\s*"([^"]+\/[^"]+)":\s*"[~^]?(\d+[^"]*)"/); - if (match) { - if (match[1] === "-") - removed.set(match[2], match[3]); - else - added.set(match[2], match[3]); - } - } - for (const [name, toVersion] of added) { - const fromVersion = removed.get(name); - if (fromVersion && fromVersion !== toVersion) { - changes.push({ name, fromVersion, toVersion, ecosystem: "composer" }); - } - } + changes.push(...collectVersionChanges(file.patch, /^([-+])\s*"([^"]+\/[^"]+)":\s*"[~^]?(\d+[^"]*)"/, "composer")); } - // Parse composer.lock changes (Composer) + // Composer: composer.lock (name and version on separate lines) if (file.filename.endsWith("composer.lock")) { const removed = new Map(); const added = new Map(); @@ -32110,58 +32176,24 @@ function parseDependencyChanges(diff, files) { } } } - // Parse requirements.txt changes (Python) + // Python: requirements.txt / Pipfile if (file.filename.endsWith("requirements.txt") || file.filename.endsWith("Pipfile")) { - const removed = new Map(); - const added = new Map(); - for (const line of file.patch.split("\n")) { - const match = line.match(/^([-+])([a-zA-Z0-9_-]+)[=<>~!]+(\d+\S*)/); - if (match) { - if (match[1] === "-") - removed.set(match[2], match[3]); - else - added.set(match[2], match[3]); - } - } - for (const [name, toVersion] of added) { - const fromVersion = removed.get(name); - if (fromVersion && fromVersion !== toVersion) { - changes.push({ name, fromVersion, toVersion, ecosystem: "pip" }); - } - } + changes.push(...collectVersionChanges(file.patch, /^([-+])([a-zA-Z0-9_-]+)[=<>~!]+(\d+\S*)/, "pip")); } - // Parse go.mod changes (Go) + // Go: go.mod if (file.filename === "go.mod") { - const removed = new Map(); - const added = new Map(); - for (const line of file.patch.split("\n")) { - const match = line.match(/^([-+])\s*(\S+)\s+v(\S+)/); - if (match) { - if (match[1] === "-") - removed.set(match[2], match[3]); - else - added.set(match[2], match[3]); - } - } - for (const [name, toVersion] of added) { - const fromVersion = removed.get(name); - if (fromVersion && fromVersion !== toVersion) { - changes.push({ name, fromVersion, toVersion, ecosystem: "go" }); - } - } + changes.push(...collectVersionChanges(file.patch, /^([-+])\s*(\S+)\s+v(\S+)/, "go")); } - // Parse .terraform.lock.hcl changes (Terraform) + // Terraform: .terraform.lock.hcl (provider + version on separate lines) if (file.filename.endsWith(".terraform.lock.hcl")) { let currentProvider = ""; const removed = new Map(); const added = new Map(); for (const line of file.patch.split("\n")) { - // Track current provider from context, added, or removed lines const providerMatch = line.match(/^[ +-]\s*provider\s+"([^"]+)"/); if (providerMatch) { currentProvider = providerMatch[1]; } - // Extract pinned version const versionMatch = line.match(/^([-+])\s*version\s*=\s*"(\d+\S*)"/); if (versionMatch && currentProvider) { if (versionMatch[1] === "-") diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index 6e6ecb8..67b5b0a 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -1,16 +1,15 @@ import * as core from "@actions/core"; import { - createGeminiModel, generateContent, truncateText, - getOctokitClient, - getRepoContext, getPullRequest, getFileContent, postComment, getDefaultBranch, getRepoTree, listReleaseNotesBetween, + runAction, + getActionContext, } from "@gemini-actions/shared"; import { parseDependencyChanges, getImportPatterns } from "./parsers"; @@ -62,144 +61,138 @@ async function resolveGitHubRepo(dep: { name: string; ecosystem: string }): Prom return null; } -async function run(): Promise { - try { - const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - - const octokit = getOctokitClient(githubToken); - const { owner, repo } = getRepoContext(); - const model = createGeminiModel(geminiApiKey, modelName); - - core.info(`Analyzing dependency impact for PR #${prNumber}...`); - - // 1. Get PR details - const pr = await getPullRequest(octokit, owner, repo, prNumber); - core.info(`PR: ${pr.title}`); - - // 2. Parse dependency changes from the diff - const depChanges = parseDependencyChanges(pr.diff, pr.files); - - if (depChanges.length === 0) { - core.info("No dependency version changes detected in this PR"); - await postComment( - octokit, - owner, - repo, - prNumber, - "## Gemini Dependency Impact Analysis\n\nNo dependency version changes detected in this PR.", - ); - return; - } +runAction(async () => { + const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); - core.info(`Found ${depChanges.length} dependency change(s): ${depChanges.map((d) => d.name).join(", ")}`); + const { octokit, owner, repo, model } = getActionContext(); - // 3. Get repository file tree to find usage - const defaultBranch = await getDefaultBranch(octokit, owner, repo); - const tree = await getRepoTree(octokit, owner, repo, defaultBranch.sha); - const sourceFiles = tree - .filter((item) => item.type === "blob") - .filter((item) => /\.(ts|js|tsx|jsx|py|go|java|rb|rs|tf|php)$/.test(item.path)) - .filter((item) => !item.path.includes("node_modules")) - .map((item) => item.path); + core.info(`Analyzing dependency impact for PR #${prNumber}...`); - // 4. Sample source files to find usage of changed dependencies - const usageContext: Record = {}; + // 1. Get PR details + const pr = await getPullRequest(octokit, owner, repo, prNumber); + core.info(`PR: ${pr.title}`); - for (const dep of depChanges) { - usageContext[dep.name] = []; - const importPatterns = getImportPatterns(dep.name, dep.ecosystem); - - // Read a subset of source files to find imports - for (const filePath of sourceFiles.slice(0, 100)) { - try { - const content = await getFileContent( - octokit, - owner, - repo, - filePath, - defaultBranch.name, - ); - - if (importPatterns.some((pattern) => content.includes(pattern))) { - // Include the relevant lines, not the whole file - const relevantLines = content - .split("\n") - .filter((line) => - importPatterns.some((p) => line.includes(p)) || - line.includes(dep.name), - ) - .slice(0, 20); - - if (relevantLines.length > 0) { - usageContext[dep.name].push( - `**${filePath}:**\n${relevantLines.join("\n")}`, - ); - } + // 2. Parse dependency changes from the diff + const depChanges = parseDependencyChanges(pr.diff, pr.files); + + if (depChanges.length === 0) { + core.info("No dependency version changes detected in this PR"); + await postComment( + octokit, + owner, + repo, + prNumber, + "## Gemini Dependency Impact Analysis\n\nNo dependency version changes detected in this PR.", + ); + return; + } + + core.info(`Found ${depChanges.length} dependency change(s): ${depChanges.map((d) => d.name).join(", ")}`); + + // 3. Get repository file tree to find usage + const defaultBranch = await getDefaultBranch(octokit, owner, repo); + const tree = await getRepoTree(octokit, owner, repo, defaultBranch.sha); + const sourceFiles = tree + .filter((item) => item.type === "blob") + .filter((item) => /\.(ts|js|tsx|jsx|py|go|java|rb|rs|tf|php)$/.test(item.path)) + .filter((item) => !item.path.includes("node_modules")) + .map((item) => item.path); + + // 4. Sample source files to find usage of changed dependencies + const usageContext: Record = {}; + + for (const dep of depChanges) { + usageContext[dep.name] = []; + const importPatterns = getImportPatterns(dep.name, dep.ecosystem); + + // Read a subset of source files to find imports + for (const filePath of sourceFiles.slice(0, 100)) { + try { + const content = await getFileContent( + octokit, + owner, + repo, + filePath, + defaultBranch.name, + ); + + if (importPatterns.some((pattern) => content.includes(pattern))) { + // Include the relevant lines, not the whole file + const relevantLines = content + .split("\n") + .filter((line) => + importPatterns.some((p) => line.includes(p)) || + line.includes(dep.name), + ) + .slice(0, 20); + + if (relevantLines.length > 0) { + usageContext[dep.name].push( + `**${filePath}:**\n${relevantLines.join("\n")}`, + ); } - } catch { - // Skip files we can't read } + } catch { + // Skip files we can't read } } + } - // 5. Send to Gemini for analysis - const maxUsageCharsPerDep = 5000; - const usageSections = Object.entries(usageContext) - .map(([name, usages]) => { - if (usages.length === 0) return `### ${name}\nNo direct imports found in source files.`; - const joined = usages.join("\n\n"); - return `### ${name}\n${truncateText(joined, maxUsageCharsPerDep, `${name} usage`)}`; - }) - .join("\n\n"); + // 5. Send to Gemini for analysis + const maxUsageCharsPerDep = 5000; + const usageSections = Object.entries(usageContext) + .map(([name, usages]) => { + if (usages.length === 0) return `### ${name}\nNo direct imports found in source files.`; + const joined = usages.join("\n\n"); + return `### ${name}\n${truncateText(joined, maxUsageCharsPerDep, `${name} usage`)}`; + }) + .join("\n\n"); - const isDependabot = /\[bot\]$/.test(pr.author); - const hasBody = pr.body != null && pr.body.trim().length > 50; + const isDependabot = /\[bot\]$/.test(pr.author); + const hasBody = pr.body != null && pr.body.trim().length > 50; - let releaseNotes: string | null = null; + let releaseNotes: string | null = null; - if (isDependabot && hasBody) { - releaseNotes = pr.body!; - } else { - for (const dep of depChanges) { - const ghRepo = await resolveGitHubRepo(dep); - if (ghRepo) { - const notes = await listReleaseNotesBetween( - octokit, - ghRepo.owner, - ghRepo.repo, - dep.fromVersion, - dep.toVersion, - ); - if (notes) { - releaseNotes = (releaseNotes ?? "") + `\n\n## ${dep.name}\n${notes}`; - } + if (isDependabot && hasBody) { + releaseNotes = pr.body!; + } else { + for (const dep of depChanges) { + const ghRepo = await resolveGitHubRepo(dep); + if (ghRepo) { + const notes = await listReleaseNotesBetween( + octokit, + ghRepo.owner, + ghRepo.repo, + dep.fromVersion, + dep.toVersion, + ); + if (notes) { + releaseNotes = (releaseNotes ?? "") + `\n\n## ${dep.name}\n${notes}`; } } - if (!releaseNotes && hasBody) { - releaseNotes = pr.body!; - } } + if (!releaseNotes && hasBody) { + releaseNotes = pr.body!; + } + } - const prBodySection = releaseNotes - ? `**Release Notes:**\n${truncateText(releaseNotes.trim(), 15000, "release notes")}` - : "**Release Notes:** No release notes available."; + const prBodySection = releaseNotes + ? `**Release Notes:**\n${truncateText(releaseNotes.trim(), 15000, "release notes")}` + : "**Release Notes:** No release notes available."; - const hasUsage = Object.values(usageContext).some(usages => usages.length > 0); + const hasUsage = Object.values(usageContext).some(usages => usages.length > 0); - const depChangesList = depChanges - .map( - (d) => - `- **${d.name}**: ${d.fromVersion} → ${d.toVersion} (${d.ecosystem})`, - ) - .join("\n"); + const depChangesList = depChanges + .map( + (d) => + `- **${d.name}**: ${d.fromVersion} → ${d.toVersion} (${d.ecosystem})`, + ) + .join("\n"); - let prompt: string; + let prompt: string; - if (hasUsage) { - prompt = `You are a dependency upgrade analyst. A pull request updates the following dependencies. + if (hasUsage) { + prompt = `You are a dependency upgrade analyst. A pull request updates the following dependencies. Cross-reference the release notes with actual usage sites in this codebase. **Dependency Changes:** @@ -224,8 +217,8 @@ RULES: - Do NOT include generic advice like "review the changelog", "test in staging", "run terraform init", or "pin versions". - Do NOT fabricate examples, hypothetical scenarios, or breaking changes not confirmed by the release notes. - If the release notes do not mention breaking changes relevant to the detected usage, say "No breaking changes detected for current usage" and give a risk assessment.`; - } else { - prompt = `You are a dependency upgrade analyst. A pull request updates the following dependencies. + } else { + prompt = `You are a dependency upgrade analyst. A pull request updates the following dependencies. No usage of these dependencies was found in the source files. **Dependency Changes:** @@ -241,27 +234,18 @@ RULES: - Do NOT reference files or APIs since no usage was found. - Do NOT include generic advice like "review the changelog", "test in staging", or "pin versions". - If no release notes are available, say "No release notes available and no usage detected — no action needed." and stop.`; - } + } - const analysis = await generateContent(model, prompt); + const analysis = await generateContent(model, prompt); - // 6. Post the analysis as a comment - const comment = `## Gemini Dependency Impact Analysis + // 6. Post the analysis as a comment + const comment = `## Gemini Dependency Impact Analysis ${analysis} --- *${depChanges.length} dependency change(s) · ${Object.values(usageContext).flat().length} usage site(s) found — Generated by [gemini-dependency-impact](https://github.com/dortort/gemini-actions)*`; - await postComment(octokit, owner, repo, prNumber, comment); - core.info("Dependency impact analysis posted"); - } catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } else { - core.setFailed("An unexpected error occurred"); - } - } -} - -run(); + await postComment(octokit, owner, repo, prNumber, comment); + core.info("Dependency impact analysis posted"); +}); diff --git a/dependency-impact/src/parsers.ts b/dependency-impact/src/parsers.ts index 05eaba5..a39a007 100644 --- a/dependency-impact/src/parsers.ts +++ b/dependency-impact/src/parsers.ts @@ -5,56 +5,66 @@ export interface DependencyChange { ecosystem: string; } +/** + * Parse diff lines to collect added/removed values, then emit changes where the + * version actually changed. This pattern was previously duplicated for every + * ecosystem — now it lives in one place. + */ +function collectVersionChanges( + patch: string, + regex: RegExp, + ecosystem: string, +): DependencyChange[] { + const removed = new Map(); + const added = new Map(); + + for (const line of patch.split("\n")) { + const match = line.match(regex); + if (match) { + if (match[1] === "-") removed.set(match[2], match[3]); + else added.set(match[2], match[3]); + } + } + + const changes: DependencyChange[] = []; + for (const [name, toVersion] of added) { + const fromVersion = removed.get(name); + if (fromVersion && fromVersion !== toVersion) { + changes.push({ name, fromVersion, toVersion, ecosystem }); + } + } + return changes; +} + export function parseDependencyChanges(diff: string, files: { filename: string; patch?: string }[]): DependencyChange[] { const changes: DependencyChange[] = []; for (const file of files) { if (!file.patch) continue; - // Parse package.json changes (npm) + // npm: package.json / package-lock.json if (file.filename.endsWith("package.json") || file.filename.endsWith("package-lock.json")) { - const depRegex = /^[-+]\s*"([^"]+)":\s*"[^]*?(\d+\.\d+\.\d+[^"]*)"/gm; - const removed = new Map(); - const added = new Map(); - - for (const line of file.patch.split("\n")) { - const match = line.match(/^([-+])\s*"([^"]+)":\s*"[~^]?(\d+[^"]*)"/) - if (match) { - if (match[1] === "-") removed.set(match[2], match[3]); - else added.set(match[2], match[3]); - } - } - - for (const [name, toVersion] of added) { - const fromVersion = removed.get(name); - if (fromVersion && fromVersion !== toVersion) { - changes.push({ name, fromVersion, toVersion, ecosystem: "npm" }); - } - } + changes.push( + ...collectVersionChanges( + file.patch, + /^([-+])\s*"([^"]+)":\s*"[~^]?(\d+[^"]*)"/, + "npm", + ), + ); } - // Parse composer.json changes (Composer) + // Composer: composer.json if (file.filename.endsWith("composer.json")) { - const removed = new Map(); - const added = new Map(); - - for (const line of file.patch.split("\n")) { - const match = line.match(/^([-+])\s*"([^"]+\/[^"]+)":\s*"[~^]?(\d+[^"]*)"/) - if (match) { - if (match[1] === "-") removed.set(match[2], match[3]); - else added.set(match[2], match[3]); - } - } - - for (const [name, toVersion] of added) { - const fromVersion = removed.get(name); - if (fromVersion && fromVersion !== toVersion) { - changes.push({ name, fromVersion, toVersion, ecosystem: "composer" }); - } - } + changes.push( + ...collectVersionChanges( + file.patch, + /^([-+])\s*"([^"]+\/[^"]+)":\s*"[~^]?(\d+[^"]*)"/, + "composer", + ), + ); } - // Parse composer.lock changes (Composer) + // Composer: composer.lock (name and version on separate lines) if (file.filename.endsWith("composer.lock")) { const removed = new Map(); const added = new Map(); @@ -86,62 +96,40 @@ export function parseDependencyChanges(diff: string, files: { filename: string; } } - // Parse requirements.txt changes (Python) + // Python: requirements.txt / Pipfile if (file.filename.endsWith("requirements.txt") || file.filename.endsWith("Pipfile")) { - const removed = new Map(); - const added = new Map(); - - for (const line of file.patch.split("\n")) { - const match = line.match(/^([-+])([a-zA-Z0-9_-]+)[=<>~!]+(\d+\S*)/); - if (match) { - if (match[1] === "-") removed.set(match[2], match[3]); - else added.set(match[2], match[3]); - } - } - - for (const [name, toVersion] of added) { - const fromVersion = removed.get(name); - if (fromVersion && fromVersion !== toVersion) { - changes.push({ name, fromVersion, toVersion, ecosystem: "pip" }); - } - } + changes.push( + ...collectVersionChanges( + file.patch, + /^([-+])([a-zA-Z0-9_-]+)[=<>~!]+(\d+\S*)/, + "pip", + ), + ); } - // Parse go.mod changes (Go) + // Go: go.mod if (file.filename === "go.mod") { - const removed = new Map(); - const added = new Map(); - - for (const line of file.patch.split("\n")) { - const match = line.match(/^([-+])\s*(\S+)\s+v(\S+)/); - if (match) { - if (match[1] === "-") removed.set(match[2], match[3]); - else added.set(match[2], match[3]); - } - } - - for (const [name, toVersion] of added) { - const fromVersion = removed.get(name); - if (fromVersion && fromVersion !== toVersion) { - changes.push({ name, fromVersion, toVersion, ecosystem: "go" }); - } - } + changes.push( + ...collectVersionChanges( + file.patch, + /^([-+])\s*(\S+)\s+v(\S+)/, + "go", + ), + ); } - // Parse .terraform.lock.hcl changes (Terraform) + // Terraform: .terraform.lock.hcl (provider + version on separate lines) if (file.filename.endsWith(".terraform.lock.hcl")) { let currentProvider = ""; const removed = new Map(); const added = new Map(); for (const line of file.patch.split("\n")) { - // Track current provider from context, added, or removed lines const providerMatch = line.match(/^[ +-]\s*provider\s+"([^"]+)"/); if (providerMatch) { currentProvider = providerMatch[1]; } - // Extract pinned version const versionMatch = line.match(/^([-+])\s*version\s*=\s*"(\d+\S*)"/); if (versionMatch && currentProvider) { if (versionMatch[1] === "-") removed.set(currentProvider, versionMatch[2]); diff --git a/pr-from-issue/dist/index.js b/pr-from-issue/dist/index.js index 2b6fc5f..828de76 100644 --- a/pr-from-issue/dist/index.js +++ b/pr-from-issue/dist/index.js @@ -31393,6 +31393,84 @@ function wrappy (fn, cb) { } +/***/ }), + +/***/ 6941: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getActionContext = getActionContext; +exports.runAction = runAction; +const core = __importStar(__nccwpck_require__(6618)); +const gemini_1 = __nccwpck_require__(9700); +const github_1 = __nccwpck_require__(8284); +/** + * Read the standard action inputs (gemini_api_key, github_token, model) + * and return an initialised ActionContext. + */ +function getActionContext() { + const geminiApiKey = core.getInput("gemini_api_key", { required: true }); + const githubToken = core.getInput("github_token", { required: true }); + const modelName = core.getInput("model") || "gemini-2.0-flash"; + const octokit = (0, github_1.getOctokitClient)(githubToken); + const { owner, repo } = (0, github_1.getRepoContext)(); + const model = (0, gemini_1.createGeminiModel)(geminiApiKey, modelName); + return { octokit, owner, repo, model }; +} +/** + * Wrap an action's main logic with consistent error handling. + * Catches errors and calls `core.setFailed` so every action doesn't have to. + */ +async function runAction(fn) { + try { + await fn(); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + else { + core.setFailed("An unexpected error occurred"); + } + } +} +//# sourceMappingURL=action.js.map + /***/ }), /***/ 9700: @@ -31438,6 +31516,7 @@ exports.createGeminiModel = createGeminiModel; exports.countTokens = countTokens; exports.generateContent = generateContent; exports.truncateText = truncateText; +exports.parseJsonResponse = parseJsonResponse; const core = __importStar(__nccwpck_require__(6618)); const generative_ai_1 = __nccwpck_require__(4274); const DEFAULT_MODEL = "gemini-2.0-flash"; @@ -31501,6 +31580,12 @@ function truncateText(text, maxChars, label = "content") { const truncated = text.slice(0, maxChars); return `${truncated}\n\n... [${label} truncated: ${(text.length - maxChars).toLocaleString()} characters omitted]`; } +/** + * Parse a JSON response from Gemini, stripping markdown code fences if present. + */ +function parseJsonResponse(response) { + return JSON.parse(response.replace(/```json?\n?|\n?```/g, "").trim()); +} //# sourceMappingURL=gemini.js.map /***/ }), @@ -31743,12 +31828,13 @@ async function listReleaseNotesBetween(octokit, owner, repo, fromVersion, toVers "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; +exports.runAction = exports.getActionContext = exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.parseJsonResponse = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; var gemini_1 = __nccwpck_require__(9700); Object.defineProperty(exports, "createGeminiModel", ({ enumerable: true, get: function () { return gemini_1.createGeminiModel; } })); Object.defineProperty(exports, "generateContent", ({ enumerable: true, get: function () { return gemini_1.generateContent; } })); Object.defineProperty(exports, "countTokens", ({ enumerable: true, get: function () { return gemini_1.countTokens; } })); Object.defineProperty(exports, "truncateText", ({ enumerable: true, get: function () { return gemini_1.truncateText; } })); +Object.defineProperty(exports, "parseJsonResponse", ({ enumerable: true, get: function () { return gemini_1.parseJsonResponse; } })); var github_1 = __nccwpck_require__(8284); Object.defineProperty(exports, "getOctokitClient", ({ enumerable: true, get: function () { return github_1.getOctokitClient; } })); Object.defineProperty(exports, "getRepoContext", ({ enumerable: true, get: function () { return github_1.getRepoContext; } })); @@ -31763,6 +31849,9 @@ Object.defineProperty(exports, "createBranch", ({ enumerable: true, get: functio Object.defineProperty(exports, "getDefaultBranch", ({ enumerable: true, get: function () { return github_1.getDefaultBranch; } })); Object.defineProperty(exports, "getRepoTree", ({ enumerable: true, get: function () { return github_1.getRepoTree; } })); Object.defineProperty(exports, "listReleaseNotesBetween", ({ enumerable: true, get: function () { return github_1.listReleaseNotesBetween; } })); +var action_1 = __nccwpck_require__(6941); +Object.defineProperty(exports, "getActionContext", ({ enumerable: true, get: function () { return action_1.getActionContext; } })); +Object.defineProperty(exports, "runAction", ({ enumerable: true, get: function () { return action_1.runAction; } })); //# sourceMappingURL=index.js.map /***/ }), @@ -31808,28 +31897,22 @@ var __importStar = (this && this.__importStar) || (function () { Object.defineProperty(exports, "__esModule", ({ value: true })); const core = __importStar(__nccwpck_require__(6618)); const shared_1 = __nccwpck_require__(7451); -async function run() { - try { - const issueNumber = parseInt(core.getInput("issue_number", { required: true }), 10); - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - const octokit = (0, shared_1.getOctokitClient)(githubToken); - const { owner, repo } = (0, shared_1.getRepoContext)(); - const model = (0, shared_1.createGeminiModel)(geminiApiKey, modelName); - core.info(`Processing issue #${issueNumber}...`); - // 1. Get issue details - const issue = await (0, shared_1.getIssue)(octokit, owner, repo, issueNumber); - core.info(`Issue: ${issue.title}`); - // 2. Get repository structure for context - const defaultBranch = await (0, shared_1.getDefaultBranch)(octokit, owner, repo); - const tree = await (0, shared_1.getRepoTree)(octokit, owner, repo, defaultBranch.sha); - const fileList = tree - .filter((item) => item.type === "blob") - .map((item) => item.path); - // 3. Ask Gemini to identify which files are relevant and what changes to make - const fileListText = (0, shared_1.truncateText)(fileList.join("\n"), 50000, "file list"); - const planPrompt = `You are a software engineer. A GitHub issue has been filed requesting a change to the repository. +(0, shared_1.runAction)(async () => { + const issueNumber = parseInt(core.getInput("issue_number", { required: true }), 10); + const { octokit, owner, repo, model } = (0, shared_1.getActionContext)(); + core.info(`Processing issue #${issueNumber}...`); + // 1. Get issue details + const issue = await (0, shared_1.getIssue)(octokit, owner, repo, issueNumber); + core.info(`Issue: ${issue.title}`); + // 2. Get repository structure for context + const defaultBranch = await (0, shared_1.getDefaultBranch)(octokit, owner, repo); + const tree = await (0, shared_1.getRepoTree)(octokit, owner, repo, defaultBranch.sha); + const fileList = tree + .filter((item) => item.type === "blob") + .map((item) => item.path); + // 3. Ask Gemini to identify which files are relevant and what changes to make + const fileListText = (0, shared_1.truncateText)(fileList.join("\n"), 50000, "file list"); + const planPrompt = `You are a software engineer. A GitHub issue has been filed requesting a change to the repository. **Issue #${issue.number}: ${issue.title}** ${issue.body ?? "No description provided."} @@ -31842,40 +31925,40 @@ Respond with a JSON array of file paths that are relevant. Only include files th If new files need to be created, include them too. Respond ONLY with a JSON array of strings, e.g.: ["src/config.ts", "README.md"]`; - const planResponse = await (0, shared_1.generateContent)(model, planPrompt); - let relevantFiles; - try { - relevantFiles = JSON.parse(planResponse.replace(/```json?\n?|\n?```/g, "").trim()); - } - catch { - core.warning("Could not parse file plan from Gemini, using issue body heuristics"); - relevantFiles = fileList.slice(0, 10); - } - // 4. Fetch content of relevant existing files (capped at 20 files, 10K chars each) - const maxFilesForContext = 20; - const maxFileChars = 10000; - const fileContents = {}; - for (const filePath of relevantFiles.slice(0, maxFilesForContext)) { - if (fileList.includes(filePath)) { - try { - const raw = await (0, shared_1.getFileContent)(octokit, owner, repo, filePath, defaultBranch.name); - fileContents[filePath] = (0, shared_1.truncateText)(raw, maxFileChars, filePath); - } - catch { - core.debug(`Could not read ${filePath}, may be a new file`); - } + const planResponse = await (0, shared_1.generateContent)(model, planPrompt); + let relevantFiles; + try { + relevantFiles = (0, shared_1.parseJsonResponse)(planResponse); + } + catch { + core.warning("Could not parse file plan from Gemini, using issue body heuristics"); + relevantFiles = fileList.slice(0, 10); + } + // 4. Fetch content of relevant existing files (capped at 20 files, 10K chars each) + const maxFilesForContext = 20; + const maxFileChars = 10000; + const fileContents = {}; + for (const filePath of relevantFiles.slice(0, maxFilesForContext)) { + if (fileList.includes(filePath)) { + try { + const raw = await (0, shared_1.getFileContent)(octokit, owner, repo, filePath, defaultBranch.name); + fileContents[filePath] = (0, shared_1.truncateText)(raw, maxFileChars, filePath); + } + catch { + core.debug(`Could not read ${filePath}, may be a new file`); } } - // 5. Ask Gemini to generate the actual code changes - const changePrompt = `You are a software engineer implementing a change based on a GitHub issue. + } + // 5. Ask Gemini to generate the actual code changes + const changePrompt = `You are a software engineer implementing a change based on a GitHub issue. **Issue #${issue.number}: ${issue.title}** ${issue.body ?? "No description provided."} **Current file contents:** ${Object.entries(fileContents) - .map(([path, content]) => `--- ${path} ---\n${content}`) - .join("\n\n")} + .map(([path, content]) => `--- ${path} ---\n${content}`) + .join("\n\n")} Generate the complete updated file contents for each file that needs to change. If a file needs to be created, provide its full content. @@ -31887,49 +31970,40 @@ Important: - Provide the COMPLETE file content, not just the diff - Make minimal changes needed to address the issue - Follow existing code style and conventions`; - const changeResponse = await (0, shared_1.generateContent)(model, changePrompt); - let changes; - try { - changes = JSON.parse(changeResponse.replace(/```json?\n?|\n?```/g, "").trim()); - } - catch { - throw new Error("Failed to parse code changes from Gemini response"); - } - if (changes.length === 0) { - core.info("Gemini determined no changes are needed"); - return; - } - // 6. Create a new branch and apply changes - const branchName = `gemini/issue-${issueNumber}`; - await (0, shared_1.createBranch)(octokit, owner, repo, branchName, defaultBranch.sha); - core.info(`Created branch: ${branchName}`); - for (const change of changes) { - const existingSha = fileList.includes(change.path) - ? undefined - : undefined; - // Check if file exists to get its SHA for updates - let sha; - if (fileList.includes(change.path)) { - try { - const { data } = await octokit.rest.repos.getContent({ - owner, - repo, - path: change.path, - ref: branchName, - }); - if ("sha" in data) { - sha = data.sha; - } - } - catch { - // File doesn't exist yet, that's fine + const changeResponse = await (0, shared_1.generateContent)(model, changePrompt); + const changes = (0, shared_1.parseJsonResponse)(changeResponse); + if (changes.length === 0) { + core.info("Gemini determined no changes are needed"); + return; + } + // 6. Create a new branch and apply changes + const branchName = `gemini/issue-${issueNumber}`; + await (0, shared_1.createBranch)(octokit, owner, repo, branchName, defaultBranch.sha); + core.info(`Created branch: ${branchName}`); + for (const change of changes) { + // Get SHA for existing files so the API can update them + let sha; + if (fileList.includes(change.path)) { + try { + const { data } = await octokit.rest.repos.getContent({ + owner, + repo, + path: change.path, + ref: branchName, + }); + if ("sha" in data) { + sha = data.sha; } } - await (0, shared_1.createOrUpdateFile)(octokit, owner, repo, change.path, change.content, `feat: update ${change.path} for issue #${issueNumber}`, branchName, sha); - core.info(`Updated: ${change.path}`); + catch { + // File doesn't exist yet, that's fine + } } - // 7. Create the pull request - const prBody = `## Summary + await (0, shared_1.createOrUpdateFile)(octokit, owner, repo, change.path, change.content, `feat: update ${change.path} for issue #${issueNumber}`, branchName, sha); + core.info(`Updated: ${change.path}`); + } + // 7. Create the pull request + const prBody = `## Summary This PR was automatically generated by Gemini to address #${issueNumber}. @@ -31941,25 +32015,15 @@ Closes #${issueNumber} --- *Generated by [gemini-pr-from-issue](https://github.com/dortort/gemini-actions)*`; - const prNumber = await (0, shared_1.createPullRequest)(octokit, owner, repo, { - title: `feat: ${issue.title}`, - body: prBody, - head: branchName, - base: defaultBranch.name, - }); - core.info(`Created PR #${prNumber}`); - core.setOutput("pr_number", prNumber.toString()); - } - catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } - else { - core.setFailed("An unexpected error occurred"); - } - } -} -run(); + const prNumber = await (0, shared_1.createPullRequest)(octokit, owner, repo, { + title: `feat: ${issue.title}`, + body: prBody, + head: branchName, + base: defaultBranch.name, + }); + core.info(`Created PR #${prNumber}`); + core.setOutput("pr_number", prNumber.toString()); +}); /***/ }), diff --git a/pr-from-issue/src/index.ts b/pr-from-issue/src/index.ts index f6a3203..f15dec1 100644 --- a/pr-from-issue/src/index.ts +++ b/pr-from-issue/src/index.ts @@ -1,10 +1,8 @@ import * as core from "@actions/core"; import { - createGeminiModel, generateContent, truncateText, - getOctokitClient, - getRepoContext, + parseJsonResponse, getIssue, getFileContent, createPullRequest, @@ -12,6 +10,8 @@ import { createOrUpdateFile, getDefaultBranch, getRepoTree, + runAction, + getActionContext, } from "@gemini-actions/shared"; interface FileChange { @@ -19,33 +19,27 @@ interface FileChange { content: string; } -async function run(): Promise { - try { - const issueNumber = parseInt(core.getInput("issue_number", { required: true }), 10); - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; +runAction(async () => { + const issueNumber = parseInt(core.getInput("issue_number", { required: true }), 10); - const octokit = getOctokitClient(githubToken); - const { owner, repo } = getRepoContext(); - const model = createGeminiModel(geminiApiKey, modelName); + const { octokit, owner, repo, model } = getActionContext(); - core.info(`Processing issue #${issueNumber}...`); + core.info(`Processing issue #${issueNumber}...`); - // 1. Get issue details - const issue = await getIssue(octokit, owner, repo, issueNumber); - core.info(`Issue: ${issue.title}`); + // 1. Get issue details + const issue = await getIssue(octokit, owner, repo, issueNumber); + core.info(`Issue: ${issue.title}`); - // 2. Get repository structure for context - const defaultBranch = await getDefaultBranch(octokit, owner, repo); - const tree = await getRepoTree(octokit, owner, repo, defaultBranch.sha); - const fileList = tree - .filter((item) => item.type === "blob") - .map((item) => item.path); + // 2. Get repository structure for context + const defaultBranch = await getDefaultBranch(octokit, owner, repo); + const tree = await getRepoTree(octokit, owner, repo, defaultBranch.sha); + const fileList = tree + .filter((item) => item.type === "blob") + .map((item) => item.path); - // 3. Ask Gemini to identify which files are relevant and what changes to make - const fileListText = truncateText(fileList.join("\n"), 50000, "file list"); - const planPrompt = `You are a software engineer. A GitHub issue has been filed requesting a change to the repository. + // 3. Ask Gemini to identify which files are relevant and what changes to make + const fileListText = truncateText(fileList.join("\n"), 50000, "file list"); + const planPrompt = `You are a software engineer. A GitHub issue has been filed requesting a change to the repository. **Issue #${issue.number}: ${issue.title}** ${issue.body ?? "No description provided."} @@ -59,38 +53,38 @@ If new files need to be created, include them too. Respond ONLY with a JSON array of strings, e.g.: ["src/config.ts", "README.md"]`; - const planResponse = await generateContent(model, planPrompt); - let relevantFiles: string[]; - try { - relevantFiles = JSON.parse(planResponse.replace(/```json?\n?|\n?```/g, "").trim()); - } catch { - core.warning("Could not parse file plan from Gemini, using issue body heuristics"); - relevantFiles = fileList.slice(0, 10); - } + const planResponse = await generateContent(model, planPrompt); + let relevantFiles: string[]; + try { + relevantFiles = parseJsonResponse(planResponse); + } catch { + core.warning("Could not parse file plan from Gemini, using issue body heuristics"); + relevantFiles = fileList.slice(0, 10); + } - // 4. Fetch content of relevant existing files (capped at 20 files, 10K chars each) - const maxFilesForContext = 20; - const maxFileChars = 10000; - const fileContents: Record = {}; - for (const filePath of relevantFiles.slice(0, maxFilesForContext)) { - if (fileList.includes(filePath)) { - try { - const raw = await getFileContent( - octokit, - owner, - repo, - filePath, - defaultBranch.name, - ); - fileContents[filePath] = truncateText(raw, maxFileChars, filePath); - } catch { - core.debug(`Could not read ${filePath}, may be a new file`); - } + // 4. Fetch content of relevant existing files (capped at 20 files, 10K chars each) + const maxFilesForContext = 20; + const maxFileChars = 10000; + const fileContents: Record = {}; + for (const filePath of relevantFiles.slice(0, maxFilesForContext)) { + if (fileList.includes(filePath)) { + try { + const raw = await getFileContent( + octokit, + owner, + repo, + filePath, + defaultBranch.name, + ); + fileContents[filePath] = truncateText(raw, maxFileChars, filePath); + } catch { + core.debug(`Could not read ${filePath}, may be a new file`); } } + } - // 5. Ask Gemini to generate the actual code changes - const changePrompt = `You are a software engineer implementing a change based on a GitHub issue. + // 5. Ask Gemini to generate the actual code changes + const changePrompt = `You are a software engineer implementing a change based on a GitHub issue. **Issue #${issue.number}: ${issue.title}** ${issue.body ?? "No description provided."} @@ -111,62 +105,53 @@ Important: - Make minimal changes needed to address the issue - Follow existing code style and conventions`; - const changeResponse = await generateContent(model, changePrompt); - let changes: FileChange[]; - try { - changes = JSON.parse(changeResponse.replace(/```json?\n?|\n?```/g, "").trim()); - } catch { - throw new Error("Failed to parse code changes from Gemini response"); - } + const changeResponse = await generateContent(model, changePrompt); + const changes = parseJsonResponse(changeResponse); - if (changes.length === 0) { - core.info("Gemini determined no changes are needed"); - return; - } + if (changes.length === 0) { + core.info("Gemini determined no changes are needed"); + return; + } - // 6. Create a new branch and apply changes - const branchName = `gemini/issue-${issueNumber}`; - await createBranch(octokit, owner, repo, branchName, defaultBranch.sha); - core.info(`Created branch: ${branchName}`); - - for (const change of changes) { - const existingSha = fileList.includes(change.path) - ? undefined - : undefined; - - // Check if file exists to get its SHA for updates - let sha: string | undefined; - if (fileList.includes(change.path)) { - try { - const { data } = await octokit.rest.repos.getContent({ - owner, - repo, - path: change.path, - ref: branchName, - }); - if ("sha" in data) { - sha = data.sha; - } - } catch { - // File doesn't exist yet, that's fine + // 6. Create a new branch and apply changes + const branchName = `gemini/issue-${issueNumber}`; + await createBranch(octokit, owner, repo, branchName, defaultBranch.sha); + core.info(`Created branch: ${branchName}`); + + for (const change of changes) { + // Get SHA for existing files so the API can update them + let sha: string | undefined; + if (fileList.includes(change.path)) { + try { + const { data } = await octokit.rest.repos.getContent({ + owner, + repo, + path: change.path, + ref: branchName, + }); + if ("sha" in data) { + sha = data.sha; } + } catch { + // File doesn't exist yet, that's fine } - - await createOrUpdateFile( - octokit, - owner, - repo, - change.path, - change.content, - `feat: update ${change.path} for issue #${issueNumber}`, - branchName, - sha, - ); - core.info(`Updated: ${change.path}`); } - // 7. Create the pull request - const prBody = `## Summary + await createOrUpdateFile( + octokit, + owner, + repo, + change.path, + change.content, + `feat: update ${change.path} for issue #${issueNumber}`, + branchName, + sha, + ); + core.info(`Updated: ${change.path}`); + } + + // 7. Create the pull request + const prBody = `## Summary This PR was automatically generated by Gemini to address #${issueNumber}. @@ -179,22 +164,13 @@ Closes #${issueNumber} --- *Generated by [gemini-pr-from-issue](https://github.com/dortort/gemini-actions)*`; - const prNumber = await createPullRequest(octokit, owner, repo, { - title: `feat: ${issue.title}`, - body: prBody, - head: branchName, - base: defaultBranch.name, - }); - - core.info(`Created PR #${prNumber}`); - core.setOutput("pr_number", prNumber.toString()); - } catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } else { - core.setFailed("An unexpected error occurred"); - } - } -} + const prNumber = await createPullRequest(octokit, owner, repo, { + title: `feat: ${issue.title}`, + body: prBody, + head: branchName, + base: defaultBranch.name, + }); -run(); + core.info(`Created PR #${prNumber}`); + core.setOutput("pr_number", prNumber.toString()); +}); diff --git a/pr-review/dist/index.js b/pr-review/dist/index.js index c0a72fe..17df2c4 100644 --- a/pr-review/dist/index.js +++ b/pr-review/dist/index.js @@ -31393,6 +31393,84 @@ function wrappy (fn, cb) { } +/***/ }), + +/***/ 6941: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getActionContext = getActionContext; +exports.runAction = runAction; +const core = __importStar(__nccwpck_require__(6618)); +const gemini_1 = __nccwpck_require__(9700); +const github_1 = __nccwpck_require__(8284); +/** + * Read the standard action inputs (gemini_api_key, github_token, model) + * and return an initialised ActionContext. + */ +function getActionContext() { + const geminiApiKey = core.getInput("gemini_api_key", { required: true }); + const githubToken = core.getInput("github_token", { required: true }); + const modelName = core.getInput("model") || "gemini-2.0-flash"; + const octokit = (0, github_1.getOctokitClient)(githubToken); + const { owner, repo } = (0, github_1.getRepoContext)(); + const model = (0, gemini_1.createGeminiModel)(geminiApiKey, modelName); + return { octokit, owner, repo, model }; +} +/** + * Wrap an action's main logic with consistent error handling. + * Catches errors and calls `core.setFailed` so every action doesn't have to. + */ +async function runAction(fn) { + try { + await fn(); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + else { + core.setFailed("An unexpected error occurred"); + } + } +} +//# sourceMappingURL=action.js.map + /***/ }), /***/ 9700: @@ -31438,6 +31516,7 @@ exports.createGeminiModel = createGeminiModel; exports.countTokens = countTokens; exports.generateContent = generateContent; exports.truncateText = truncateText; +exports.parseJsonResponse = parseJsonResponse; const core = __importStar(__nccwpck_require__(6618)); const generative_ai_1 = __nccwpck_require__(4274); const DEFAULT_MODEL = "gemini-2.0-flash"; @@ -31501,6 +31580,12 @@ function truncateText(text, maxChars, label = "content") { const truncated = text.slice(0, maxChars); return `${truncated}\n\n... [${label} truncated: ${(text.length - maxChars).toLocaleString()} characters omitted]`; } +/** + * Parse a JSON response from Gemini, stripping markdown code fences if present. + */ +function parseJsonResponse(response) { + return JSON.parse(response.replace(/```json?\n?|\n?```/g, "").trim()); +} //# sourceMappingURL=gemini.js.map /***/ }), @@ -31743,12 +31828,13 @@ async function listReleaseNotesBetween(octokit, owner, repo, fromVersion, toVers "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; +exports.runAction = exports.getActionContext = exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.parseJsonResponse = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; var gemini_1 = __nccwpck_require__(9700); Object.defineProperty(exports, "createGeminiModel", ({ enumerable: true, get: function () { return gemini_1.createGeminiModel; } })); Object.defineProperty(exports, "generateContent", ({ enumerable: true, get: function () { return gemini_1.generateContent; } })); Object.defineProperty(exports, "countTokens", ({ enumerable: true, get: function () { return gemini_1.countTokens; } })); Object.defineProperty(exports, "truncateText", ({ enumerable: true, get: function () { return gemini_1.truncateText; } })); +Object.defineProperty(exports, "parseJsonResponse", ({ enumerable: true, get: function () { return gemini_1.parseJsonResponse; } })); var github_1 = __nccwpck_require__(8284); Object.defineProperty(exports, "getOctokitClient", ({ enumerable: true, get: function () { return github_1.getOctokitClient; } })); Object.defineProperty(exports, "getRepoContext", ({ enumerable: true, get: function () { return github_1.getRepoContext; } })); @@ -31763,6 +31849,9 @@ Object.defineProperty(exports, "createBranch", ({ enumerable: true, get: functio Object.defineProperty(exports, "getDefaultBranch", ({ enumerable: true, get: function () { return github_1.getDefaultBranch; } })); Object.defineProperty(exports, "getRepoTree", ({ enumerable: true, get: function () { return github_1.getRepoTree; } })); Object.defineProperty(exports, "listReleaseNotesBetween", ({ enumerable: true, get: function () { return github_1.listReleaseNotesBetween; } })); +var action_1 = __nccwpck_require__(6941); +Object.defineProperty(exports, "getActionContext", ({ enumerable: true, get: function () { return action_1.getActionContext; } })); +Object.defineProperty(exports, "runAction", ({ enumerable: true, get: function () { return action_1.runAction; } })); //# sourceMappingURL=index.js.map /***/ }), @@ -31813,40 +31902,34 @@ const STRICTNESS_PROMPTS = { medium: "Review for bugs, security issues, performance problems, and significant design concerns. Note style issues only if they hurt readability.", high: "Perform a thorough review covering bugs, security, performance, design, error handling, edge cases, naming conventions, and code style.", }; -async function run() { - try { - const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); - const strictness = core.getInput("review_strictness") || "medium"; - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - if (!STRICTNESS_PROMPTS[strictness]) { - throw new Error(`Invalid review_strictness: ${strictness}. Must be low, medium, or high.`); - } - const octokit = (0, shared_1.getOctokitClient)(githubToken); - const { owner, repo } = (0, shared_1.getRepoContext)(); - const model = (0, shared_1.createGeminiModel)(geminiApiKey, modelName); - core.info(`Reviewing PR #${prNumber} with ${strictness} strictness...`); - // 1. Get PR details and diff - const pr = await (0, shared_1.getPullRequest)(octokit, owner, repo, prNumber); - core.info(`PR: ${pr.title} (${pr.files.length} files changed)`); - // 2. Build the review prompt with truncated diffs - const maxPatchPerFile = 10000; - const maxTotalDiff = 200000; - let totalDiffChars = 0; - const fileSections = []; - for (const f of pr.files) { - const patch = f.patch ?? "Binary file or no diff available"; - const truncatedPatch = (0, shared_1.truncateText)(patch, maxPatchPerFile, `${f.filename} diff`); - if (totalDiffChars + truncatedPatch.length > maxTotalDiff) { - fileSections.push(`### ${f.filename} (${f.status}: +${f.additions} -${f.deletions})\n` + - `*Diff omitted — total diff budget (${(maxTotalDiff / 1000).toFixed(0)}K chars) reached*`); - continue; - } - totalDiffChars += truncatedPatch.length; - fileSections.push(`### ${f.filename} (${f.status}: +${f.additions} -${f.deletions})\n\`\`\`diff\n${truncatedPatch}\n\`\`\``); +(0, shared_1.runAction)(async () => { + const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); + const strictness = core.getInput("review_strictness") || "medium"; + if (!STRICTNESS_PROMPTS[strictness]) { + throw new Error(`Invalid review_strictness: ${strictness}. Must be low, medium, or high.`); + } + const { octokit, owner, repo, model } = (0, shared_1.getActionContext)(); + core.info(`Reviewing PR #${prNumber} with ${strictness} strictness...`); + // 1. Get PR details and diff + const pr = await (0, shared_1.getPullRequest)(octokit, owner, repo, prNumber); + core.info(`PR: ${pr.title} (${pr.files.length} files changed)`); + // 2. Build the review prompt with truncated diffs + const maxPatchPerFile = 10000; + const maxTotalDiff = 200000; + let totalDiffChars = 0; + const fileSections = []; + for (const f of pr.files) { + const patch = f.patch ?? "Binary file or no diff available"; + const truncatedPatch = (0, shared_1.truncateText)(patch, maxPatchPerFile, `${f.filename} diff`); + if (totalDiffChars + truncatedPatch.length > maxTotalDiff) { + fileSections.push(`### ${f.filename} (${f.status}: +${f.additions} -${f.deletions})\n` + + `*Diff omitted — total diff budget (${(maxTotalDiff / 1000).toFixed(0)}K chars) reached*`); + continue; } - const prompt = `You are an expert code reviewer. Review the following pull request. + totalDiffChars += truncatedPatch.length; + fileSections.push(`### ${f.filename} (${f.status}: +${f.additions} -${f.deletions})\n\`\`\`diff\n${truncatedPatch}\n\`\`\``); + } + const prompt = `You are an expert code reviewer. Review the following pull request. **Review Strictness:** ${strictness} ${STRICTNESS_PROMPTS[strictness]} @@ -31878,56 +31961,46 @@ Guidelines: - The summary should give an overall assessment and highlight the most important findings Respond ONLY with the JSON object.`; - const response = await (0, shared_1.generateContent)(model, prompt); - let review; - try { - review = JSON.parse(response.replace(/```json?\n?|\n?```/g, "").trim()); - } - catch { - // If parsing fails, post the raw response as a comment - core.warning("Could not parse structured review, posting as plain comment"); - await (0, shared_1.createReview)(octokit, owner, repo, prNumber, `## Gemini Code Review (${strictness} strictness)\n\n${response}`); - return; + const response = await (0, shared_1.generateContent)(model, prompt); + let review; + try { + review = (0, shared_1.parseJsonResponse)(response); + } + catch { + // If parsing fails, post the raw response as a comment + core.warning("Could not parse structured review, posting as plain comment"); + await (0, shared_1.createReview)(octokit, owner, repo, prNumber, `## Gemini Code Review (${strictness} strictness)\n\n${response}`); + return; + } + // 3. Format and post the review + const severityEmoji = { + critical: "[CRITICAL]", + warning: "[WARNING]", + suggestion: "[SUGGESTION]", + nitpick: "[NITPICK]", + }; + const reviewComments = review.comments + .filter((c) => { + const fileInPR = pr.files.some((f) => f.filename === c.path); + if (!fileInPR) { + core.warning(`Skipping comment for ${c.path}: file not in PR diff`); } - // 3. Format and post the review - const severityEmoji = { - critical: "[CRITICAL]", - warning: "[WARNING]", - suggestion: "[SUGGESTION]", - nitpick: "[NITPICK]", - }; - const reviewComments = review.comments - .filter((c) => { - const fileInPR = pr.files.some((f) => f.filename === c.path); - if (!fileInPR) { - core.warning(`Skipping comment for ${c.path}: file not in PR diff`); - } - return fileInPR && c.line > 0; - }) - .map((c) => ({ - path: c.path, - line: c.line, - body: `${severityEmoji[c.severity] || ""} ${c.comment}`, - })); - const summaryBody = `## Gemini Code Review (${strictness} strictness) + return fileInPR && c.line > 0; + }) + .map((c) => ({ + path: c.path, + line: c.line, + body: `${severityEmoji[c.severity] || ""} ${c.comment}`, + })); + const summaryBody = `## Gemini Code Review (${strictness} strictness) ${review.summary} --- *${review.comments.length} comment(s) across ${new Set(review.comments.map((c) => c.path)).size} file(s) — Generated by [gemini-pr-review](https://github.com/dortort/gemini-actions)*`; - await (0, shared_1.createReview)(octokit, owner, repo, prNumber, summaryBody, reviewComments); - core.info(`Review posted: ${review.comments.length} comments, ${reviewComments.length} inline`); - } - catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } - else { - core.setFailed("An unexpected error occurred"); - } - } -} -run(); + await (0, shared_1.createReview)(octokit, owner, repo, prNumber, summaryBody, reviewComments); + core.info(`Review posted: ${review.comments.length} comments, ${reviewComments.length} inline`); +}); /***/ }), diff --git a/pr-review/src/index.ts b/pr-review/src/index.ts index e54fcb0..ba97515 100644 --- a/pr-review/src/index.ts +++ b/pr-review/src/index.ts @@ -1,12 +1,12 @@ import * as core from "@actions/core"; import { - createGeminiModel, generateContent, truncateText, - getOctokitClient, - getRepoContext, + parseJsonResponse, getPullRequest, createReview, + runAction, + getActionContext, ReviewComment, } from "@gemini-actions/shared"; @@ -29,53 +29,47 @@ const STRICTNESS_PROMPTS: Record = { high: "Perform a thorough review covering bugs, security, performance, design, error handling, edge cases, naming conventions, and code style.", }; -async function run(): Promise { - try { - const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); - const strictness = core.getInput("review_strictness") || "medium"; - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - - if (!STRICTNESS_PROMPTS[strictness]) { - throw new Error( - `Invalid review_strictness: ${strictness}. Must be low, medium, or high.`, - ); - } +runAction(async () => { + const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); + const strictness = core.getInput("review_strictness") || "medium"; - const octokit = getOctokitClient(githubToken); - const { owner, repo } = getRepoContext(); - const model = createGeminiModel(geminiApiKey, modelName); - - core.info(`Reviewing PR #${prNumber} with ${strictness} strictness...`); - - // 1. Get PR details and diff - const pr = await getPullRequest(octokit, owner, repo, prNumber); - core.info(`PR: ${pr.title} (${pr.files.length} files changed)`); - - // 2. Build the review prompt with truncated diffs - const maxPatchPerFile = 10000; - const maxTotalDiff = 200000; - - let totalDiffChars = 0; - const fileSections: string[] = []; - for (const f of pr.files) { - const patch = f.patch ?? "Binary file or no diff available"; - const truncatedPatch = truncateText(patch, maxPatchPerFile, `${f.filename} diff`); - if (totalDiffChars + truncatedPatch.length > maxTotalDiff) { - fileSections.push( - `### ${f.filename} (${f.status}: +${f.additions} -${f.deletions})\n` + - `*Diff omitted — total diff budget (${(maxTotalDiff / 1000).toFixed(0)}K chars) reached*`, - ); - continue; - } - totalDiffChars += truncatedPatch.length; + if (!STRICTNESS_PROMPTS[strictness]) { + throw new Error( + `Invalid review_strictness: ${strictness}. Must be low, medium, or high.`, + ); + } + + const { octokit, owner, repo, model } = getActionContext(); + + core.info(`Reviewing PR #${prNumber} with ${strictness} strictness...`); + + // 1. Get PR details and diff + const pr = await getPullRequest(octokit, owner, repo, prNumber); + core.info(`PR: ${pr.title} (${pr.files.length} files changed)`); + + // 2. Build the review prompt with truncated diffs + const maxPatchPerFile = 10000; + const maxTotalDiff = 200000; + + let totalDiffChars = 0; + const fileSections: string[] = []; + for (const f of pr.files) { + const patch = f.patch ?? "Binary file or no diff available"; + const truncatedPatch = truncateText(patch, maxPatchPerFile, `${f.filename} diff`); + if (totalDiffChars + truncatedPatch.length > maxTotalDiff) { fileSections.push( - `### ${f.filename} (${f.status}: +${f.additions} -${f.deletions})\n\`\`\`diff\n${truncatedPatch}\n\`\`\``, + `### ${f.filename} (${f.status}: +${f.additions} -${f.deletions})\n` + + `*Diff omitted — total diff budget (${(maxTotalDiff / 1000).toFixed(0)}K chars) reached*`, ); + continue; } + totalDiffChars += truncatedPatch.length; + fileSections.push( + `### ${f.filename} (${f.status}: +${f.additions} -${f.deletions})\n\`\`\`diff\n${truncatedPatch}\n\`\`\``, + ); + } - const prompt = `You are an expert code reviewer. Review the following pull request. + const prompt = `You are an expert code reviewer. Review the following pull request. **Review Strictness:** ${strictness} ${STRICTNESS_PROMPTS[strictness]} @@ -108,71 +102,62 @@ Guidelines: Respond ONLY with the JSON object.`; - const response = await generateContent(model, prompt); - let review: GeminiReview; - try { - review = JSON.parse(response.replace(/```json?\n?|\n?```/g, "").trim()); - } catch { - // If parsing fails, post the raw response as a comment - core.warning("Could not parse structured review, posting as plain comment"); - await createReview( - octokit, - owner, - repo, - prNumber, - `## Gemini Code Review (${strictness} strictness)\n\n${response}`, - ); - return; - } - - // 3. Format and post the review - const severityEmoji: Record = { - critical: "[CRITICAL]", - warning: "[WARNING]", - suggestion: "[SUGGESTION]", - nitpick: "[NITPICK]", - }; - - const reviewComments: ReviewComment[] = review.comments - .filter((c) => { - const fileInPR = pr.files.some((f) => f.filename === c.path); - if (!fileInPR) { - core.warning(`Skipping comment for ${c.path}: file not in PR diff`); - } - return fileInPR && c.line > 0; - }) - .map((c) => ({ - path: c.path, - line: c.line, - body: `${severityEmoji[c.severity] || ""} ${c.comment}`, - })); - - const summaryBody = `## Gemini Code Review (${strictness} strictness) - -${review.summary} - ---- -*${review.comments.length} comment(s) across ${new Set(review.comments.map((c) => c.path)).size} file(s) — Generated by [gemini-pr-review](https://github.com/dortort/gemini-actions)*`; - + const response = await generateContent(model, prompt); + let review: GeminiReview; + try { + review = parseJsonResponse(response); + } catch { + // If parsing fails, post the raw response as a comment + core.warning("Could not parse structured review, posting as plain comment"); await createReview( octokit, owner, repo, prNumber, - summaryBody, - reviewComments, + `## Gemini Code Review (${strictness} strictness)\n\n${response}`, ); - - core.info( - `Review posted: ${review.comments.length} comments, ${reviewComments.length} inline`, - ); - } catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } else { - core.setFailed("An unexpected error occurred"); - } + return; } -} -run(); + // 3. Format and post the review + const severityEmoji: Record = { + critical: "[CRITICAL]", + warning: "[WARNING]", + suggestion: "[SUGGESTION]", + nitpick: "[NITPICK]", + }; + + const reviewComments: ReviewComment[] = review.comments + .filter((c) => { + const fileInPR = pr.files.some((f) => f.filename === c.path); + if (!fileInPR) { + core.warning(`Skipping comment for ${c.path}: file not in PR diff`); + } + return fileInPR && c.line > 0; + }) + .map((c) => ({ + path: c.path, + line: c.line, + body: `${severityEmoji[c.severity] || ""} ${c.comment}`, + })); + + const summaryBody = `## Gemini Code Review (${strictness} strictness) + +${review.summary} + +--- +*${review.comments.length} comment(s) across ${new Set(review.comments.map((c) => c.path)).size} file(s) — Generated by [gemini-pr-review](https://github.com/dortort/gemini-actions)*`; + + await createReview( + octokit, + owner, + repo, + prNumber, + summaryBody, + reviewComments, + ); + + core.info( + `Review posted: ${review.comments.length} comments, ${reviewComments.length} inline`, + ); +}); diff --git a/repo-qa/dist/index.js b/repo-qa/dist/index.js index e96b9ad..61894cf 100644 --- a/repo-qa/dist/index.js +++ b/repo-qa/dist/index.js @@ -31393,6 +31393,84 @@ function wrappy (fn, cb) { } +/***/ }), + +/***/ 6941: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getActionContext = getActionContext; +exports.runAction = runAction; +const core = __importStar(__nccwpck_require__(6618)); +const gemini_1 = __nccwpck_require__(9700); +const github_1 = __nccwpck_require__(8284); +/** + * Read the standard action inputs (gemini_api_key, github_token, model) + * and return an initialised ActionContext. + */ +function getActionContext() { + const geminiApiKey = core.getInput("gemini_api_key", { required: true }); + const githubToken = core.getInput("github_token", { required: true }); + const modelName = core.getInput("model") || "gemini-2.0-flash"; + const octokit = (0, github_1.getOctokitClient)(githubToken); + const { owner, repo } = (0, github_1.getRepoContext)(); + const model = (0, gemini_1.createGeminiModel)(geminiApiKey, modelName); + return { octokit, owner, repo, model }; +} +/** + * Wrap an action's main logic with consistent error handling. + * Catches errors and calls `core.setFailed` so every action doesn't have to. + */ +async function runAction(fn) { + try { + await fn(); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + else { + core.setFailed("An unexpected error occurred"); + } + } +} +//# sourceMappingURL=action.js.map + /***/ }), /***/ 9700: @@ -31438,6 +31516,7 @@ exports.createGeminiModel = createGeminiModel; exports.countTokens = countTokens; exports.generateContent = generateContent; exports.truncateText = truncateText; +exports.parseJsonResponse = parseJsonResponse; const core = __importStar(__nccwpck_require__(6618)); const generative_ai_1 = __nccwpck_require__(4274); const DEFAULT_MODEL = "gemini-2.0-flash"; @@ -31501,6 +31580,12 @@ function truncateText(text, maxChars, label = "content") { const truncated = text.slice(0, maxChars); return `${truncated}\n\n... [${label} truncated: ${(text.length - maxChars).toLocaleString()} characters omitted]`; } +/** + * Parse a JSON response from Gemini, stripping markdown code fences if present. + */ +function parseJsonResponse(response) { + return JSON.parse(response.replace(/```json?\n?|\n?```/g, "").trim()); +} //# sourceMappingURL=gemini.js.map /***/ }), @@ -31743,12 +31828,13 @@ async function listReleaseNotesBetween(octokit, owner, repo, fromVersion, toVers "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; +exports.runAction = exports.getActionContext = exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.parseJsonResponse = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; var gemini_1 = __nccwpck_require__(9700); Object.defineProperty(exports, "createGeminiModel", ({ enumerable: true, get: function () { return gemini_1.createGeminiModel; } })); Object.defineProperty(exports, "generateContent", ({ enumerable: true, get: function () { return gemini_1.generateContent; } })); Object.defineProperty(exports, "countTokens", ({ enumerable: true, get: function () { return gemini_1.countTokens; } })); Object.defineProperty(exports, "truncateText", ({ enumerable: true, get: function () { return gemini_1.truncateText; } })); +Object.defineProperty(exports, "parseJsonResponse", ({ enumerable: true, get: function () { return gemini_1.parseJsonResponse; } })); var github_1 = __nccwpck_require__(8284); Object.defineProperty(exports, "getOctokitClient", ({ enumerable: true, get: function () { return github_1.getOctokitClient; } })); Object.defineProperty(exports, "getRepoContext", ({ enumerable: true, get: function () { return github_1.getRepoContext; } })); @@ -31763,6 +31849,9 @@ Object.defineProperty(exports, "createBranch", ({ enumerable: true, get: functio Object.defineProperty(exports, "getDefaultBranch", ({ enumerable: true, get: function () { return github_1.getDefaultBranch; } })); Object.defineProperty(exports, "getRepoTree", ({ enumerable: true, get: function () { return github_1.getRepoTree; } })); Object.defineProperty(exports, "listReleaseNotesBetween", ({ enumerable: true, get: function () { return github_1.listReleaseNotesBetween; } })); +var action_1 = __nccwpck_require__(6941); +Object.defineProperty(exports, "getActionContext", ({ enumerable: true, get: function () { return action_1.getActionContext; } })); +Object.defineProperty(exports, "runAction", ({ enumerable: true, get: function () { return action_1.runAction; } })); //# sourceMappingURL=index.js.map /***/ }), @@ -31820,60 +31909,54 @@ function matchGlob(filePath, pattern) { function matchesAnyGlob(filePath, patterns) { return patterns.some((pattern) => matchGlob(filePath, pattern)); } -async function run() { - try { - const issueNumberStr = core.getInput("issue_number"); - const discussionIdStr = core.getInput("discussion_id"); - const sourcePaths = core.getInput("source_paths") || "src/**"; - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - if (!issueNumberStr && !discussionIdStr) { - throw new Error("Either issue_number or discussion_id must be provided"); - } - const octokit = (0, shared_1.getOctokitClient)(githubToken); - const { owner, repo } = (0, shared_1.getRepoContext)(); - const model = (0, shared_1.createGeminiModel)(geminiApiKey, modelName); - // 1. Get the question - let question; - let questionTitle; - let responseTarget; - if (issueNumberStr) { - const issueNumber = parseInt(issueNumberStr, 10); - const issue = await (0, shared_1.getIssue)(octokit, owner, repo, issueNumber); - questionTitle = issue.title; - question = `${issue.title}\n\n${issue.body ?? ""}`; - responseTarget = { type: "issue", number: issueNumber }; - core.info(`Question from issue #${issueNumber}: ${issue.title}`); - } - else { - const discussionId = discussionIdStr; - // Fetch discussion via GraphQL - const { repository } = await octokit.graphql(`query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $number) { - title - body - number - } +(0, shared_1.runAction)(async () => { + const issueNumberStr = core.getInput("issue_number"); + const discussionIdStr = core.getInput("discussion_id"); + const sourcePaths = core.getInput("source_paths") || "src/**"; + if (!issueNumberStr && !discussionIdStr) { + throw new Error("Either issue_number or discussion_id must be provided"); + } + const { octokit, owner, repo, model } = (0, shared_1.getActionContext)(); + // 1. Get the question + let question; + let questionTitle; + let responseTarget; + if (issueNumberStr) { + const issueNumber = parseInt(issueNumberStr, 10); + const issue = await (0, shared_1.getIssue)(octokit, owner, repo, issueNumber); + questionTitle = issue.title; + question = `${issue.title}\n\n${issue.body ?? ""}`; + responseTarget = { type: "issue", number: issueNumber }; + core.info(`Question from issue #${issueNumber}: ${issue.title}`); + } + else { + const discussionId = discussionIdStr; + // Fetch discussion via GraphQL + const { repository } = await octokit.graphql(`query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + title + body + number } - }`, { owner, repo, number: parseInt(discussionId, 10) }); - questionTitle = repository.discussion.title; - question = `${repository.discussion.title}\n\n${repository.discussion.body}`; - responseTarget = { type: "discussion", id: discussionId }; - core.info(`Question from discussion #${discussionId}: ${questionTitle}`); - } - // 2. Get repository file tree and filter by source_paths - const defaultBranch = await (0, shared_1.getDefaultBranch)(octokit, owner, repo); - const tree = await (0, shared_1.getRepoTree)(octokit, owner, repo, defaultBranch.sha); - const globs = sourcePaths.split(",").map((s) => s.trim()); - const sourceFiles = tree - .filter((item) => item.type === "blob") - .filter((item) => matchesAnyGlob(item.path, globs)) - .map((item) => item.path); - core.info(`Found ${sourceFiles.length} source files matching: ${sourcePaths}`); - // 3. Ask Gemini to identify relevant files based on the question - const fileSelectionPrompt = `A user asked a question about a codebase. Which files are most likely relevant to answering it? + } + }`, { owner, repo, number: parseInt(discussionId, 10) }); + questionTitle = repository.discussion.title; + question = `${repository.discussion.title}\n\n${repository.discussion.body}`; + responseTarget = { type: "discussion", id: discussionId }; + core.info(`Question from discussion #${discussionId}: ${questionTitle}`); + } + // 2. Get repository file tree and filter by source_paths + const defaultBranch = await (0, shared_1.getDefaultBranch)(octokit, owner, repo); + const tree = await (0, shared_1.getRepoTree)(octokit, owner, repo, defaultBranch.sha); + const globs = sourcePaths.split(",").map((s) => s.trim()); + const sourceFiles = tree + .filter((item) => item.type === "blob") + .filter((item) => matchesAnyGlob(item.path, globs)) + .map((item) => item.path); + core.info(`Found ${sourceFiles.length} source files matching: ${sourcePaths}`); + // 3. Ask Gemini to identify relevant files based on the question + const fileSelectionPrompt = `A user asked a question about a codebase. Which files are most likely relevant to answering it? **Question:** ${question} @@ -31882,38 +31965,38 @@ ${sourceFiles.join("\n")} Return a JSON array of the most relevant file paths (max 20 files). Consider the question topic and select files that would contain the answer. Respond ONLY with a JSON array of strings.`; - const fileSelectionResponse = await (0, shared_1.generateContent)(model, fileSelectionPrompt); - let relevantFiles; + const fileSelectionResponse = await (0, shared_1.generateContent)(model, fileSelectionPrompt); + let relevantFiles; + try { + relevantFiles = (0, shared_1.parseJsonResponse)(fileSelectionResponse); + // Validate that selected files actually exist in our tree + relevantFiles = relevantFiles.filter((f) => sourceFiles.includes(f)); + } + catch { + core.warning("Could not parse file selection, using first 15 source files"); + relevantFiles = sourceFiles.slice(0, 15); + } + core.info(`Reading ${relevantFiles.length} relevant files...`); + // 4. Fetch content of relevant files + const fileContents = {}; + for (const filePath of relevantFiles) { try { - relevantFiles = JSON.parse(fileSelectionResponse.replace(/```json?\n?|\n?```/g, "").trim()); - // Validate that selected files actually exist in our tree - relevantFiles = relevantFiles.filter((f) => sourceFiles.includes(f)); + const content = await (0, shared_1.getFileContent)(octokit, owner, repo, filePath, defaultBranch.name); + fileContents[filePath] = (0, shared_1.truncateText)(content, 5000, filePath); } catch { - core.warning("Could not parse file selection, using first 15 source files"); - relevantFiles = sourceFiles.slice(0, 15); - } - core.info(`Reading ${relevantFiles.length} relevant files...`); - // 4. Fetch content of relevant files - const fileContents = {}; - for (const filePath of relevantFiles) { - try { - const content = await (0, shared_1.getFileContent)(octokit, owner, repo, filePath, defaultBranch.name); - fileContents[filePath] = (0, shared_1.truncateText)(content, 5000, filePath); - } - catch { - core.debug(`Could not read ${filePath}`); - } + core.debug(`Could not read ${filePath}`); } - // 5. Generate answer - const answerPrompt = `You are a knowledgeable assistant for the ${owner}/${repo} repository. A user has asked a question, and you have access to relevant source files. Answer the question with specific references to the code. + } + // 5. Generate answer + const answerPrompt = `You are a knowledgeable assistant for the ${owner}/${repo} repository. A user has asked a question, and you have access to relevant source files. Answer the question with specific references to the code. **Question:** ${question} **Source Files:** ${Object.entries(fileContents) - .map(([path, content]) => `### ${path}\n\`\`\`\n${content}\n\`\`\``) - .join("\n\n")} + .map(([path, content]) => `### ${path}\n\`\`\`\n${content}\n\`\`\``) + .join("\n\n")} Guidelines: - Reference specific files and line numbers when explaining concepts @@ -31921,48 +32004,38 @@ Guidelines: - If the source files don't contain enough information to fully answer the question, say so - Structure your answer clearly with headers if needed - Be concise but thorough`; - const answer = await (0, shared_1.generateContent)(model, answerPrompt); - // 6. Post the answer - const responseBody = `## Answer + const answer = await (0, shared_1.generateContent)(model, answerPrompt); + // 6. Post the answer + const responseBody = `## Answer ${answer} --- *Based on ${Object.keys(fileContents).length} source file(s) — Generated by [gemini-repo-qa](https://github.com/dortort/gemini-actions)*`; - if (responseTarget.type === "issue") { - await (0, shared_1.postComment)(octokit, owner, repo, responseTarget.number, responseBody); - core.info(`Answer posted on issue #${responseTarget.number}`); - } - else { - // Post discussion comment via GraphQL - // First get the discussion node ID - const { repository } = await octokit.graphql(`query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $number) { - id - } - } - }`, { owner, repo, number: parseInt(responseTarget.id, 10) }); - await octokit.graphql(`mutation($discussionId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { - comment { - id - } - } - }`, { discussionId: repository.discussion.id, body: responseBody }); - core.info(`Answer posted on discussion #${responseTarget.id}`); - } + if (responseTarget.type === "issue") { + await (0, shared_1.postComment)(octokit, owner, repo, responseTarget.number, responseBody); + core.info(`Answer posted on issue #${responseTarget.number}`); } - catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); + else { + // Post discussion comment via GraphQL + // First get the discussion node ID + const { repository } = await octokit.graphql(`query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + id + } } - else { - core.setFailed("An unexpected error occurred"); + }`, { owner, repo, number: parseInt(responseTarget.id, 10) }); + await octokit.graphql(`mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { + comment { + id + } } + }`, { discussionId: repository.discussion.id, body: responseBody }); + core.info(`Answer posted on discussion #${responseTarget.id}`); } -} -run(); +}); /***/ }), diff --git a/repo-qa/src/index.ts b/repo-qa/src/index.ts index 14ceccb..5bb47b1 100644 --- a/repo-qa/src/index.ts +++ b/repo-qa/src/index.ts @@ -1,15 +1,15 @@ import * as core from "@actions/core"; import { - createGeminiModel, generateContent, truncateText, - getOctokitClient, - getRepoContext, + parseJsonResponse, getIssue, getFileContent, postComment, getDefaultBranch, getRepoTree, + runAction, + getActionContext, } from "@gemini-actions/shared"; function matchGlob(filePath: string, pattern: string): boolean { @@ -26,81 +26,75 @@ function matchesAnyGlob(filePath: string, patterns: string[]): boolean { return patterns.some((pattern) => matchGlob(filePath, pattern)); } -async function run(): Promise { - try { - const issueNumberStr = core.getInput("issue_number"); - const discussionIdStr = core.getInput("discussion_id"); - const sourcePaths = core.getInput("source_paths") || "src/**"; - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - - if (!issueNumberStr && !discussionIdStr) { - throw new Error( - "Either issue_number or discussion_id must be provided", - ); - } +runAction(async () => { + const issueNumberStr = core.getInput("issue_number"); + const discussionIdStr = core.getInput("discussion_id"); + const sourcePaths = core.getInput("source_paths") || "src/**"; - const octokit = getOctokitClient(githubToken); - const { owner, repo } = getRepoContext(); - const model = createGeminiModel(geminiApiKey, modelName); - - // 1. Get the question - let question: string; - let questionTitle: string; - let responseTarget: { type: "issue"; number: number } | { type: "discussion"; id: string }; - - if (issueNumberStr) { - const issueNumber = parseInt(issueNumberStr, 10); - const issue = await getIssue(octokit, owner, repo, issueNumber); - questionTitle = issue.title; - question = `${issue.title}\n\n${issue.body ?? ""}`; - responseTarget = { type: "issue", number: issueNumber }; - core.info(`Question from issue #${issueNumber}: ${issue.title}`); - } else { - const discussionId = discussionIdStr!; - // Fetch discussion via GraphQL - const { repository } = await octokit.graphql<{ - repository: { - discussion: { - title: string; - body: string; - number: number; - }; + if (!issueNumberStr && !discussionIdStr) { + throw new Error( + "Either issue_number or discussion_id must be provided", + ); + } + + const { octokit, owner, repo, model } = getActionContext(); + + // 1. Get the question + let question: string; + let questionTitle: string; + let responseTarget: { type: "issue"; number: number } | { type: "discussion"; id: string }; + + if (issueNumberStr) { + const issueNumber = parseInt(issueNumberStr, 10); + const issue = await getIssue(octokit, owner, repo, issueNumber); + questionTitle = issue.title; + question = `${issue.title}\n\n${issue.body ?? ""}`; + responseTarget = { type: "issue", number: issueNumber }; + core.info(`Question from issue #${issueNumber}: ${issue.title}`); + } else { + const discussionId = discussionIdStr!; + // Fetch discussion via GraphQL + const { repository } = await octokit.graphql<{ + repository: { + discussion: { + title: string; + body: string; + number: number; }; - }>( - `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $number) { - title - body - number - } + }; + }>( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + title + body + number } - }`, - { owner, repo, number: parseInt(discussionId, 10) }, - ); - - questionTitle = repository.discussion.title; - question = `${repository.discussion.title}\n\n${repository.discussion.body}`; - responseTarget = { type: "discussion", id: discussionId }; - core.info(`Question from discussion #${discussionId}: ${questionTitle}`); - } + } + }`, + { owner, repo, number: parseInt(discussionId, 10) }, + ); + + questionTitle = repository.discussion.title; + question = `${repository.discussion.title}\n\n${repository.discussion.body}`; + responseTarget = { type: "discussion", id: discussionId }; + core.info(`Question from discussion #${discussionId}: ${questionTitle}`); + } - // 2. Get repository file tree and filter by source_paths - const defaultBranch = await getDefaultBranch(octokit, owner, repo); - const tree = await getRepoTree(octokit, owner, repo, defaultBranch.sha); + // 2. Get repository file tree and filter by source_paths + const defaultBranch = await getDefaultBranch(octokit, owner, repo); + const tree = await getRepoTree(octokit, owner, repo, defaultBranch.sha); - const globs = sourcePaths.split(",").map((s) => s.trim()); - const sourceFiles = tree - .filter((item) => item.type === "blob") - .filter((item) => matchesAnyGlob(item.path, globs)) - .map((item) => item.path); + const globs = sourcePaths.split(",").map((s) => s.trim()); + const sourceFiles = tree + .filter((item) => item.type === "blob") + .filter((item) => matchesAnyGlob(item.path, globs)) + .map((item) => item.path); - core.info(`Found ${sourceFiles.length} source files matching: ${sourcePaths}`); + core.info(`Found ${sourceFiles.length} source files matching: ${sourcePaths}`); - // 3. Ask Gemini to identify relevant files based on the question - const fileSelectionPrompt = `A user asked a question about a codebase. Which files are most likely relevant to answering it? + // 3. Ask Gemini to identify relevant files based on the question + const fileSelectionPrompt = `A user asked a question about a codebase. Which files are most likely relevant to answering it? **Question:** ${question} @@ -110,40 +104,38 @@ ${sourceFiles.join("\n")} Return a JSON array of the most relevant file paths (max 20 files). Consider the question topic and select files that would contain the answer. Respond ONLY with a JSON array of strings.`; - const fileSelectionResponse = await generateContent(model, fileSelectionPrompt); - let relevantFiles: string[]; + const fileSelectionResponse = await generateContent(model, fileSelectionPrompt); + let relevantFiles: string[]; + try { + relevantFiles = parseJsonResponse(fileSelectionResponse); + // Validate that selected files actually exist in our tree + relevantFiles = relevantFiles.filter((f) => sourceFiles.includes(f)); + } catch { + core.warning("Could not parse file selection, using first 15 source files"); + relevantFiles = sourceFiles.slice(0, 15); + } + + core.info(`Reading ${relevantFiles.length} relevant files...`); + + // 4. Fetch content of relevant files + const fileContents: Record = {}; + for (const filePath of relevantFiles) { try { - relevantFiles = JSON.parse( - fileSelectionResponse.replace(/```json?\n?|\n?```/g, "").trim(), + const content = await getFileContent( + octokit, + owner, + repo, + filePath, + defaultBranch.name, ); - // Validate that selected files actually exist in our tree - relevantFiles = relevantFiles.filter((f) => sourceFiles.includes(f)); + fileContents[filePath] = truncateText(content, 5000, filePath); } catch { - core.warning("Could not parse file selection, using first 15 source files"); - relevantFiles = sourceFiles.slice(0, 15); - } - - core.info(`Reading ${relevantFiles.length} relevant files...`); - - // 4. Fetch content of relevant files - const fileContents: Record = {}; - for (const filePath of relevantFiles) { - try { - const content = await getFileContent( - octokit, - owner, - repo, - filePath, - defaultBranch.name, - ); - fileContents[filePath] = truncateText(content, 5000, filePath); - } catch { - core.debug(`Could not read ${filePath}`); - } + core.debug(`Could not read ${filePath}`); } + } - // 5. Generate answer - const answerPrompt = `You are a knowledgeable assistant for the ${owner}/${repo} repository. A user has asked a question, and you have access to relevant source files. Answer the question with specific references to the code. + // 5. Generate answer + const answerPrompt = `You are a knowledgeable assistant for the ${owner}/${repo} repository. A user has asked a question, and you have access to relevant source files. Answer the question with specific references to the code. **Question:** ${question} @@ -159,58 +151,49 @@ Guidelines: - Structure your answer clearly with headers if needed - Be concise but thorough`; - const answer = await generateContent(model, answerPrompt); + const answer = await generateContent(model, answerPrompt); - // 6. Post the answer - const responseBody = `## Answer + // 6. Post the answer + const responseBody = `## Answer ${answer} --- *Based on ${Object.keys(fileContents).length} source file(s) — Generated by [gemini-repo-qa](https://github.com/dortort/gemini-actions)*`; - if (responseTarget.type === "issue") { - await postComment(octokit, owner, repo, responseTarget.number, responseBody); - core.info(`Answer posted on issue #${responseTarget.number}`); - } else { - // Post discussion comment via GraphQL - // First get the discussion node ID - const { repository } = await octokit.graphql<{ - repository: { - discussion: { - id: string; - }; + if (responseTarget.type === "issue") { + await postComment(octokit, owner, repo, responseTarget.number, responseBody); + core.info(`Answer posted on issue #${responseTarget.number}`); + } else { + // Post discussion comment via GraphQL + // First get the discussion node ID + const { repository } = await octokit.graphql<{ + repository: { + discussion: { + id: string; }; - }>( - `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $number) { - id - } + }; + }>( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { + id } - }`, - { owner, repo, number: parseInt(responseTarget.id, 10) }, - ); - - await octokit.graphql( - `mutation($discussionId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { - comment { - id - } + } + }`, + { owner, repo, number: parseInt(responseTarget.id, 10) }, + ); + + await octokit.graphql( + `mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { + comment { + id } - }`, - { discussionId: repository.discussion.id, body: responseBody }, - ); - core.info(`Answer posted on discussion #${responseTarget.id}`); - } - } catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } else { - core.setFailed("An unexpected error occurred"); - } + } + }`, + { discussionId: repository.discussion.id, body: responseBody }, + ); + core.info(`Answer posted on discussion #${responseTarget.id}`); } -} - -run(); +}); diff --git a/shared/dist/action.d.ts b/shared/dist/action.d.ts new file mode 100644 index 0000000..fcf8e4c --- /dev/null +++ b/shared/dist/action.d.ts @@ -0,0 +1,21 @@ +import { GenerativeModel } from "@google/generative-ai"; +import { getOctokitClient } from "./github"; +type Octokit = ReturnType; +export interface ActionContext { + octokit: Octokit; + owner: string; + repo: string; + model: GenerativeModel; +} +/** + * Read the standard action inputs (gemini_api_key, github_token, model) + * and return an initialised ActionContext. + */ +export declare function getActionContext(): ActionContext; +/** + * Wrap an action's main logic with consistent error handling. + * Catches errors and calls `core.setFailed` so every action doesn't have to. + */ +export declare function runAction(fn: () => Promise): Promise; +export {}; +//# sourceMappingURL=action.d.ts.map \ No newline at end of file diff --git a/shared/dist/action.js b/shared/dist/action.js new file mode 100644 index 0000000..fa410d8 --- /dev/null +++ b/shared/dist/action.js @@ -0,0 +1,71 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getActionContext = getActionContext; +exports.runAction = runAction; +const core = __importStar(require("@actions/core")); +const gemini_1 = require("./gemini"); +const github_1 = require("./github"); +/** + * Read the standard action inputs (gemini_api_key, github_token, model) + * and return an initialised ActionContext. + */ +function getActionContext() { + const geminiApiKey = core.getInput("gemini_api_key", { required: true }); + const githubToken = core.getInput("github_token", { required: true }); + const modelName = core.getInput("model") || "gemini-2.0-flash"; + const octokit = (0, github_1.getOctokitClient)(githubToken); + const { owner, repo } = (0, github_1.getRepoContext)(); + const model = (0, gemini_1.createGeminiModel)(geminiApiKey, modelName); + return { octokit, owner, repo, model }; +} +/** + * Wrap an action's main logic with consistent error handling. + * Catches errors and calls `core.setFailed` so every action doesn't have to. + */ +async function runAction(fn) { + try { + await fn(); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + else { + core.setFailed("An unexpected error occurred"); + } + } +} +//# sourceMappingURL=action.js.map \ No newline at end of file diff --git a/shared/dist/gemini.d.ts b/shared/dist/gemini.d.ts index c76cf27..d4c367b 100644 --- a/shared/dist/gemini.d.ts +++ b/shared/dist/gemini.d.ts @@ -16,4 +16,8 @@ export declare function generateContent(model: GenerativeModel, prompt: string, * Truncate text to a character budget, appending a notice when truncated. */ export declare function truncateText(text: string, maxChars: number, label?: string): string; +/** + * Parse a JSON response from Gemini, stripping markdown code fences if present. + */ +export declare function parseJsonResponse(response: string): T; //# sourceMappingURL=gemini.d.ts.map \ No newline at end of file diff --git a/shared/dist/gemini.js b/shared/dist/gemini.js index 140e5b4..1dcd24d 100644 --- a/shared/dist/gemini.js +++ b/shared/dist/gemini.js @@ -37,6 +37,7 @@ exports.createGeminiModel = createGeminiModel; exports.countTokens = countTokens; exports.generateContent = generateContent; exports.truncateText = truncateText; +exports.parseJsonResponse = parseJsonResponse; const core = __importStar(require("@actions/core")); const generative_ai_1 = require("@google/generative-ai"); const DEFAULT_MODEL = "gemini-2.0-flash"; @@ -100,4 +101,10 @@ function truncateText(text, maxChars, label = "content") { const truncated = text.slice(0, maxChars); return `${truncated}\n\n... [${label} truncated: ${(text.length - maxChars).toLocaleString()} characters omitted]`; } +/** + * Parse a JSON response from Gemini, stripping markdown code fences if present. + */ +function parseJsonResponse(response) { + return JSON.parse(response.replace(/```json?\n?|\n?```/g, "").trim()); +} //# sourceMappingURL=gemini.js.map \ No newline at end of file diff --git a/shared/dist/index.d.ts b/shared/dist/index.d.ts index 69b1292..7d38471 100644 --- a/shared/dist/index.d.ts +++ b/shared/dist/index.d.ts @@ -1,4 +1,6 @@ -export { createGeminiModel, generateContent, countTokens, truncateText, } from "./gemini"; +export { createGeminiModel, generateContent, countTokens, truncateText, parseJsonResponse, } from "./gemini"; export { getOctokitClient, getRepoContext, getIssue, getPullRequest, getFileContent, postComment, createPullRequest, createReview, createOrUpdateFile, createBranch, getDefaultBranch, getRepoTree, listReleaseNotesBetween, } from "./github"; export type { PullRequestInfo, PullRequestFile, IssueInfo, ReviewComment, } from "./github"; +export { getActionContext, runAction } from "./action"; +export type { ActionContext } from "./action"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/shared/dist/index.js b/shared/dist/index.js index b2b2f72..281d1e8 100644 --- a/shared/dist/index.js +++ b/shared/dist/index.js @@ -1,11 +1,12 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; +exports.runAction = exports.getActionContext = exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.parseJsonResponse = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; var gemini_1 = require("./gemini"); Object.defineProperty(exports, "createGeminiModel", { enumerable: true, get: function () { return gemini_1.createGeminiModel; } }); Object.defineProperty(exports, "generateContent", { enumerable: true, get: function () { return gemini_1.generateContent; } }); Object.defineProperty(exports, "countTokens", { enumerable: true, get: function () { return gemini_1.countTokens; } }); Object.defineProperty(exports, "truncateText", { enumerable: true, get: function () { return gemini_1.truncateText; } }); +Object.defineProperty(exports, "parseJsonResponse", { enumerable: true, get: function () { return gemini_1.parseJsonResponse; } }); var github_1 = require("./github"); Object.defineProperty(exports, "getOctokitClient", { enumerable: true, get: function () { return github_1.getOctokitClient; } }); Object.defineProperty(exports, "getRepoContext", { enumerable: true, get: function () { return github_1.getRepoContext; } }); @@ -20,4 +21,7 @@ Object.defineProperty(exports, "createBranch", { enumerable: true, get: function Object.defineProperty(exports, "getDefaultBranch", { enumerable: true, get: function () { return github_1.getDefaultBranch; } }); Object.defineProperty(exports, "getRepoTree", { enumerable: true, get: function () { return github_1.getRepoTree; } }); Object.defineProperty(exports, "listReleaseNotesBetween", { enumerable: true, get: function () { return github_1.listReleaseNotesBetween; } }); +var action_1 = require("./action"); +Object.defineProperty(exports, "getActionContext", { enumerable: true, get: function () { return action_1.getActionContext; } }); +Object.defineProperty(exports, "runAction", { enumerable: true, get: function () { return action_1.runAction; } }); //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/shared/src/action.ts b/shared/src/action.ts new file mode 100644 index 0000000..2556951 --- /dev/null +++ b/shared/src/action.ts @@ -0,0 +1,45 @@ +import * as core from "@actions/core"; +import { GenerativeModel } from "@google/generative-ai"; +import { createGeminiModel } from "./gemini"; +import { getOctokitClient, getRepoContext } from "./github"; + +type Octokit = ReturnType; + +export interface ActionContext { + octokit: Octokit; + owner: string; + repo: string; + model: GenerativeModel; +} + +/** + * Read the standard action inputs (gemini_api_key, github_token, model) + * and return an initialised ActionContext. + */ +export function getActionContext(): ActionContext { + const geminiApiKey = core.getInput("gemini_api_key", { required: true }); + const githubToken = core.getInput("github_token", { required: true }); + const modelName = core.getInput("model") || "gemini-2.0-flash"; + + const octokit = getOctokitClient(githubToken); + const { owner, repo } = getRepoContext(); + const model = createGeminiModel(geminiApiKey, modelName); + + return { octokit, owner, repo, model }; +} + +/** + * Wrap an action's main logic with consistent error handling. + * Catches errors and calls `core.setFailed` so every action doesn't have to. + */ +export async function runAction(fn: () => Promise): Promise { + try { + await fn(); + } catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } else { + core.setFailed("An unexpected error occurred"); + } + } +} diff --git a/shared/src/gemini.ts b/shared/src/gemini.ts index 86b36fa..fca77e0 100644 --- a/shared/src/gemini.ts +++ b/shared/src/gemini.ts @@ -94,3 +94,10 @@ export function truncateText( const truncated = text.slice(0, maxChars); return `${truncated}\n\n... [${label} truncated: ${(text.length - maxChars).toLocaleString()} characters omitted]`; } + +/** + * Parse a JSON response from Gemini, stripping markdown code fences if present. + */ +export function parseJsonResponse(response: string): T { + return JSON.parse(response.replace(/```json?\n?|\n?```/g, "").trim()) as T; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index d37e28f..cf7ede2 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -3,6 +3,7 @@ export { generateContent, countTokens, truncateText, + parseJsonResponse, } from "./gemini"; export { getOctokitClient, @@ -25,3 +26,5 @@ export type { IssueInfo, ReviewComment, } from "./github"; +export { getActionContext, runAction } from "./action"; +export type { ActionContext } from "./action"; diff --git a/test-failure-diagnosis/dist/index.js b/test-failure-diagnosis/dist/index.js index 6002521..05b696a 100644 --- a/test-failure-diagnosis/dist/index.js +++ b/test-failure-diagnosis/dist/index.js @@ -31393,6 +31393,84 @@ function wrappy (fn, cb) { } +/***/ }), + +/***/ 6941: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getActionContext = getActionContext; +exports.runAction = runAction; +const core = __importStar(__nccwpck_require__(6618)); +const gemini_1 = __nccwpck_require__(9700); +const github_1 = __nccwpck_require__(8284); +/** + * Read the standard action inputs (gemini_api_key, github_token, model) + * and return an initialised ActionContext. + */ +function getActionContext() { + const geminiApiKey = core.getInput("gemini_api_key", { required: true }); + const githubToken = core.getInput("github_token", { required: true }); + const modelName = core.getInput("model") || "gemini-2.0-flash"; + const octokit = (0, github_1.getOctokitClient)(githubToken); + const { owner, repo } = (0, github_1.getRepoContext)(); + const model = (0, gemini_1.createGeminiModel)(geminiApiKey, modelName); + return { octokit, owner, repo, model }; +} +/** + * Wrap an action's main logic with consistent error handling. + * Catches errors and calls `core.setFailed` so every action doesn't have to. + */ +async function runAction(fn) { + try { + await fn(); + } + catch (error) { + if (error instanceof Error) { + core.setFailed(error.message); + } + else { + core.setFailed("An unexpected error occurred"); + } + } +} +//# sourceMappingURL=action.js.map + /***/ }), /***/ 9700: @@ -31438,6 +31516,7 @@ exports.createGeminiModel = createGeminiModel; exports.countTokens = countTokens; exports.generateContent = generateContent; exports.truncateText = truncateText; +exports.parseJsonResponse = parseJsonResponse; const core = __importStar(__nccwpck_require__(6618)); const generative_ai_1 = __nccwpck_require__(4274); const DEFAULT_MODEL = "gemini-2.0-flash"; @@ -31501,6 +31580,12 @@ function truncateText(text, maxChars, label = "content") { const truncated = text.slice(0, maxChars); return `${truncated}\n\n... [${label} truncated: ${(text.length - maxChars).toLocaleString()} characters omitted]`; } +/** + * Parse a JSON response from Gemini, stripping markdown code fences if present. + */ +function parseJsonResponse(response) { + return JSON.parse(response.replace(/```json?\n?|\n?```/g, "").trim()); +} //# sourceMappingURL=gemini.js.map /***/ }), @@ -31743,12 +31828,13 @@ async function listReleaseNotesBetween(octokit, owner, repo, fromVersion, toVers "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; +exports.runAction = exports.getActionContext = exports.listReleaseNotesBetween = exports.getRepoTree = exports.getDefaultBranch = exports.createBranch = exports.createOrUpdateFile = exports.createReview = exports.createPullRequest = exports.postComment = exports.getFileContent = exports.getPullRequest = exports.getIssue = exports.getRepoContext = exports.getOctokitClient = exports.parseJsonResponse = exports.truncateText = exports.countTokens = exports.generateContent = exports.createGeminiModel = void 0; var gemini_1 = __nccwpck_require__(9700); Object.defineProperty(exports, "createGeminiModel", ({ enumerable: true, get: function () { return gemini_1.createGeminiModel; } })); Object.defineProperty(exports, "generateContent", ({ enumerable: true, get: function () { return gemini_1.generateContent; } })); Object.defineProperty(exports, "countTokens", ({ enumerable: true, get: function () { return gemini_1.countTokens; } })); Object.defineProperty(exports, "truncateText", ({ enumerable: true, get: function () { return gemini_1.truncateText; } })); +Object.defineProperty(exports, "parseJsonResponse", ({ enumerable: true, get: function () { return gemini_1.parseJsonResponse; } })); var github_1 = __nccwpck_require__(8284); Object.defineProperty(exports, "getOctokitClient", ({ enumerable: true, get: function () { return github_1.getOctokitClient; } })); Object.defineProperty(exports, "getRepoContext", ({ enumerable: true, get: function () { return github_1.getRepoContext; } })); @@ -31763,6 +31849,9 @@ Object.defineProperty(exports, "createBranch", ({ enumerable: true, get: functio Object.defineProperty(exports, "getDefaultBranch", ({ enumerable: true, get: function () { return github_1.getDefaultBranch; } })); Object.defineProperty(exports, "getRepoTree", ({ enumerable: true, get: function () { return github_1.getRepoTree; } })); Object.defineProperty(exports, "listReleaseNotesBetween", ({ enumerable: true, get: function () { return github_1.listReleaseNotesBetween; } })); +var action_1 = __nccwpck_require__(6941); +Object.defineProperty(exports, "getActionContext", ({ enumerable: true, get: function () { return action_1.getActionContext; } })); +Object.defineProperty(exports, "runAction", ({ enumerable: true, get: function () { return action_1.runAction; } })); //# sourceMappingURL=index.js.map /***/ }), @@ -31810,56 +31899,50 @@ const core = __importStar(__nccwpck_require__(6618)); const fs = __importStar(__nccwpck_require__(9896)); const path = __importStar(__nccwpck_require__(6928)); const shared_1 = __nccwpck_require__(7451); -async function run() { - try { - const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); - const testOutputPath = core.getInput("test_output", { required: true }); - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - const octokit = (0, shared_1.getOctokitClient)(githubToken); - const { owner, repo } = (0, shared_1.getRepoContext)(); - const model = (0, shared_1.createGeminiModel)(geminiApiKey, modelName); - core.info(`Diagnosing test failures for PR #${prNumber}...`); - // 1. Get PR details and diff - const pr = await (0, shared_1.getPullRequest)(octokit, owner, repo, prNumber); - core.info(`PR: ${pr.title} (${pr.files.length} files changed)`); - // 2. Read test output - let testOutput; - const resolvedPath = path.resolve(testOutputPath); - if (fs.existsSync(resolvedPath)) { - testOutput = fs.readFileSync(resolvedPath, "utf-8"); - core.info(`Read test output from ${resolvedPath} (${testOutput.length} chars)`); +(0, shared_1.runAction)(async () => { + const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); + const testOutputPath = core.getInput("test_output", { required: true }); + const { octokit, owner, repo, model } = (0, shared_1.getActionContext)(); + core.info(`Diagnosing test failures for PR #${prNumber}...`); + // 1. Get PR details and diff + const pr = await (0, shared_1.getPullRequest)(octokit, owner, repo, prNumber); + core.info(`PR: ${pr.title} (${pr.files.length} files changed)`); + // 2. Read test output + let testOutput; + const resolvedPath = path.resolve(testOutputPath); + if (fs.existsSync(resolvedPath)) { + testOutput = fs.readFileSync(resolvedPath, "utf-8"); + core.info(`Read test output from ${resolvedPath} (${testOutput.length} chars)`); + } + else { + // Try to find it as a workspace artifact + const workspacePath = path.join(process.env.GITHUB_WORKSPACE || ".", testOutputPath); + if (fs.existsSync(workspacePath)) { + testOutput = fs.readFileSync(workspacePath, "utf-8"); + core.info(`Read test output from ${workspacePath}`); } else { - // Try to find it as a workspace artifact - const workspacePath = path.join(process.env.GITHUB_WORKSPACE || ".", testOutputPath); - if (fs.existsSync(workspacePath)) { - testOutput = fs.readFileSync(workspacePath, "utf-8"); - core.info(`Read test output from ${workspacePath}`); - } - else { - throw new Error(`Test output not found at ${resolvedPath} or ${workspacePath}`); - } + throw new Error(`Test output not found at ${resolvedPath} or ${workspacePath}`); } - // Truncate very long test output to fit in the prompt - testOutput = (0, shared_1.truncateText)(testOutput, 15000, "test output"); - // 3. Extract failing test file names from the output - const testFilePatterns = extractTestFilePaths(testOutput); - core.info(`Detected test files in output: ${testFilePatterns.join(", ") || "none"}`); - // 4. Fetch failing test source code (if identifiable) - const testSources = {}; - for (const testFile of testFilePatterns.slice(0, 5)) { - try { - const content = await (0, shared_1.getFileContent)(octokit, owner, repo, testFile, pr.head.ref); - testSources[testFile] = content; - } - catch { - core.debug(`Could not fetch test file: ${testFile}`); - } + } + // Truncate very long test output to fit in the prompt + testOutput = (0, shared_1.truncateText)(testOutput, 15000, "test output"); + // 3. Extract failing test file names from the output + const testFilePatterns = extractTestFilePaths(testOutput); + core.info(`Detected test files in output: ${testFilePatterns.join(", ") || "none"}`); + // 4. Fetch failing test source code (if identifiable) + const testSources = {}; + for (const testFile of testFilePatterns.slice(0, 5)) { + try { + const content = await (0, shared_1.getFileContent)(octokit, owner, repo, testFile, pr.head.ref); + testSources[testFile] = content; + } + catch { + core.debug(`Could not fetch test file: ${testFile}`); } - // 5. Send to Gemini for diagnosis - const prompt = `You are a senior software engineer diagnosing test failures on a pull request. + } + // 5. Send to Gemini for diagnosis + const prompt = `You are a senior software engineer diagnosing test failures on a pull request. **PR Title:** ${pr.title} **PR Description:** ${pr.body ?? "No description."} @@ -31867,8 +31950,8 @@ async function run() { **PR Diff (changes made):** \`\`\`diff ${(0, shared_1.truncateText)(pr.files - .map((f) => `--- ${f.filename} ---\n${f.patch ?? "(binary or no diff)"}`) - .join("\n\n"), 15000, "PR diff")} + .map((f) => `--- ${f.filename} ---\n${f.patch ?? "(binary or no diff)"}`) + .join("\n\n"), 15000, "PR diff")} \`\`\` **Test Output (failures):** @@ -31877,11 +31960,11 @@ ${testOutput} \`\`\` ${Object.keys(testSources).length > 0 - ? `**Failing Test Source Code:** + ? `**Failing Test Source Code:** ${Object.entries(testSources) - .map(([path, content]) => `--- ${path} ---\n\`\`\`\n${(0, shared_1.truncateText)(content, 5000, path)}\n\`\`\``) - .join("\n\n")}` - : ""} + .map(([path, content]) => `--- ${path} ---\n\`\`\`\n${(0, shared_1.truncateText)(content, 5000, path)}\n\`\`\``) + .join("\n\n")}` + : ""} Provide a diagnosis that includes: @@ -31890,26 +31973,17 @@ Provide a diagnosis that includes: 3. **Suggested Fix**: Provide specific, actionable fix suggestions. Include code snippets where helpful. Indicate whether the fix should be in the source code or the tests. Format your response as clear, structured markdown.`; - const diagnosis = await (0, shared_1.generateContent)(model, prompt); - // 6. Post the diagnosis as a comment - const comment = `## Gemini Test Failure Diagnosis + const diagnosis = await (0, shared_1.generateContent)(model, prompt); + // 6. Post the diagnosis as a comment + const comment = `## Gemini Test Failure Diagnosis ${diagnosis} --- *Analyzed ${pr.files.length} changed file(s) and ${testFilePatterns.length} test file(s) — Generated by [gemini-test-failure-diagnosis](https://github.com/dortort/gemini-actions)*`; - await (0, shared_1.postComment)(octokit, owner, repo, prNumber, comment); - core.info("Test failure diagnosis posted"); - } - catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } - else { - core.setFailed("An unexpected error occurred"); - } - } -} + await (0, shared_1.postComment)(octokit, owner, repo, prNumber, comment); + core.info("Test failure diagnosis posted"); +}); function extractTestFilePaths(testOutput) { const paths = new Set(); // Common patterns for test file paths in output @@ -31934,7 +32008,6 @@ function extractTestFilePaths(testOutput) { } return [...paths]; } -run(); /***/ }), diff --git a/test-failure-diagnosis/src/index.ts b/test-failure-diagnosis/src/index.ts index ef2215e..5712acd 100644 --- a/test-failure-diagnosis/src/index.ts +++ b/test-failure-diagnosis/src/index.ts @@ -2,83 +2,76 @@ import * as core from "@actions/core"; import * as fs from "fs"; import * as path from "path"; import { - createGeminiModel, generateContent, truncateText, - getOctokitClient, - getRepoContext, getPullRequest, getFileContent, postComment, + runAction, + getActionContext, } from "@gemini-actions/shared"; -async function run(): Promise { - try { - const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); - const testOutputPath = core.getInput("test_output", { required: true }); - const geminiApiKey = core.getInput("gemini_api_key", { required: true }); - const githubToken = core.getInput("github_token", { required: true }); - const modelName = core.getInput("model") || "gemini-2.0-flash"; - - const octokit = getOctokitClient(githubToken); - const { owner, repo } = getRepoContext(); - const model = createGeminiModel(geminiApiKey, modelName); - - core.info(`Diagnosing test failures for PR #${prNumber}...`); - - // 1. Get PR details and diff - const pr = await getPullRequest(octokit, owner, repo, prNumber); - core.info(`PR: ${pr.title} (${pr.files.length} files changed)`); - - // 2. Read test output - let testOutput: string; - const resolvedPath = path.resolve(testOutputPath); - - if (fs.existsSync(resolvedPath)) { - testOutput = fs.readFileSync(resolvedPath, "utf-8"); - core.info(`Read test output from ${resolvedPath} (${testOutput.length} chars)`); +runAction(async () => { + const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); + const testOutputPath = core.getInput("test_output", { required: true }); + + const { octokit, owner, repo, model } = getActionContext(); + + core.info(`Diagnosing test failures for PR #${prNumber}...`); + + // 1. Get PR details and diff + const pr = await getPullRequest(octokit, owner, repo, prNumber); + core.info(`PR: ${pr.title} (${pr.files.length} files changed)`); + + // 2. Read test output + let testOutput: string; + const resolvedPath = path.resolve(testOutputPath); + + if (fs.existsSync(resolvedPath)) { + testOutput = fs.readFileSync(resolvedPath, "utf-8"); + core.info(`Read test output from ${resolvedPath} (${testOutput.length} chars)`); + } else { + // Try to find it as a workspace artifact + const workspacePath = path.join( + process.env.GITHUB_WORKSPACE || ".", + testOutputPath, + ); + if (fs.existsSync(workspacePath)) { + testOutput = fs.readFileSync(workspacePath, "utf-8"); + core.info(`Read test output from ${workspacePath}`); } else { - // Try to find it as a workspace artifact - const workspacePath = path.join( - process.env.GITHUB_WORKSPACE || ".", - testOutputPath, + throw new Error( + `Test output not found at ${resolvedPath} or ${workspacePath}`, ); - if (fs.existsSync(workspacePath)) { - testOutput = fs.readFileSync(workspacePath, "utf-8"); - core.info(`Read test output from ${workspacePath}`); - } else { - throw new Error( - `Test output not found at ${resolvedPath} or ${workspacePath}`, - ); - } } + } - // Truncate very long test output to fit in the prompt - testOutput = truncateText(testOutput, 15000, "test output"); - - // 3. Extract failing test file names from the output - const testFilePatterns = extractTestFilePaths(testOutput); - core.info(`Detected test files in output: ${testFilePatterns.join(", ") || "none"}`); - - // 4. Fetch failing test source code (if identifiable) - const testSources: Record = {}; - for (const testFile of testFilePatterns.slice(0, 5)) { - try { - const content = await getFileContent( - octokit, - owner, - repo, - testFile, - pr.head.ref, - ); - testSources[testFile] = content; - } catch { - core.debug(`Could not fetch test file: ${testFile}`); - } + // Truncate very long test output to fit in the prompt + testOutput = truncateText(testOutput, 15000, "test output"); + + // 3. Extract failing test file names from the output + const testFilePatterns = extractTestFilePaths(testOutput); + core.info(`Detected test files in output: ${testFilePatterns.join(", ") || "none"}`); + + // 4. Fetch failing test source code (if identifiable) + const testSources: Record = {}; + for (const testFile of testFilePatterns.slice(0, 5)) { + try { + const content = await getFileContent( + octokit, + owner, + repo, + testFile, + pr.head.ref, + ); + testSources[testFile] = content; + } catch { + core.debug(`Could not fetch test file: ${testFile}`); } + } - // 5. Send to Gemini for diagnosis - const prompt = `You are a senior software engineer diagnosing test failures on a pull request. + // 5. Send to Gemini for diagnosis + const prompt = `You are a senior software engineer diagnosing test failures on a pull request. **PR Title:** ${pr.title} **PR Description:** ${pr.body ?? "No description."} @@ -119,26 +112,19 @@ Provide a diagnosis that includes: Format your response as clear, structured markdown.`; - const diagnosis = await generateContent(model, prompt); + const diagnosis = await generateContent(model, prompt); - // 6. Post the diagnosis as a comment - const comment = `## Gemini Test Failure Diagnosis + // 6. Post the diagnosis as a comment + const comment = `## Gemini Test Failure Diagnosis ${diagnosis} --- *Analyzed ${pr.files.length} changed file(s) and ${testFilePatterns.length} test file(s) — Generated by [gemini-test-failure-diagnosis](https://github.com/dortort/gemini-actions)*`; - await postComment(octokit, owner, repo, prNumber, comment); - core.info("Test failure diagnosis posted"); - } catch (error) { - if (error instanceof Error) { - core.setFailed(error.message); - } else { - core.setFailed("An unexpected error occurred"); - } - } -} + await postComment(octokit, owner, repo, prNumber, comment); + core.info("Test failure diagnosis posted"); +}); function extractTestFilePaths(testOutput: string): string[] { const paths = new Set(); @@ -167,5 +153,3 @@ function extractTestFilePaths(testOutput: string): string[] { return [...paths]; } - -run();