From 2309b7d1f7bff48ad3cc914b763575d92b263089 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:09:45 +0000 Subject: [PATCH 01/27] feat(dependency-impact): add structured types for multi-step analysis pipeline Add types.ts with interfaces for the three-step analysis workflow: - Step1Result: breaking change extraction from release notes - Step2Result: cross-reference of breaking changes with codebase usage - DependencyAssessment: final synthesized output with risk table, action items, and inline annotations https://claude.ai/code/session_01DcQfYZFCR9zyLMot2crYdN --- dependency-impact/dist/types.d.ts | 62 +++++++++++++++++++++++++++ dependency-impact/src/types.ts | 70 +++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 dependency-impact/dist/types.d.ts create mode 100644 dependency-impact/src/types.ts diff --git a/dependency-impact/dist/types.d.ts b/dependency-impact/dist/types.d.ts new file mode 100644 index 0000000..8c65c13 --- /dev/null +++ b/dependency-impact/dist/types.d.ts @@ -0,0 +1,62 @@ +import { DependencyChange } from "./parsers"; +/** Semver upgrade classification */ +export type UpgradeType = "major" | "minor" | "patch" | "unknown"; +/** Risk level for impact assessment */ +export type RiskLevel = "low" | "medium" | "high" | "critical"; +/** A dependency change enriched with upgrade classification and release notes */ +export interface EnrichedDependencyChange extends DependencyChange { + upgradeType: UpgradeType; + releaseNotes: string | null; +} +/** Output of Step 1: breaking change extraction per dependency */ +export interface BreakingChangeEntry { + dependency: string; + upgradeType: UpgradeType; + breakingChanges: string[]; + deprecations: string[]; + notableChanges: string[]; + hasConfirmedBreakingChanges: boolean; +} +export interface Step1Result { + dependencies: BreakingChangeEntry[]; +} +/** Output of Step 2: cross-reference of breaking changes with codebase usage */ +export interface UsageImpact { + dependency: string; + change: string; + affectedFiles: string[]; + affectedCode: string[]; + requiredAction: string; + severity: RiskLevel; +} +export interface Step2Result { + impacts: UsageImpact[]; + unaffectedUsages: Array<{ + dependency: string; + fileCount: number; + }>; +} +/** Output of Step 3: final synthesized assessment */ +export interface DependencyAssessment { + overallRisk: RiskLevel; + riskJustification: string; + dependencySummaries: Array<{ + dependency: string; + fromVersion: string; + toVersion: string; + upgradeType: UpgradeType; + risk: RiskLevel; + oneLiner: string; + }>; + actionItems: Array<{ + severity: RiskLevel; + dependency: string; + file: string; + description: string; + }>; + inlineAnnotations: Array<{ + dependency: string; + annotation: string; + }>; + narrativeSummary: string; +} diff --git a/dependency-impact/src/types.ts b/dependency-impact/src/types.ts new file mode 100644 index 0000000..809e72f --- /dev/null +++ b/dependency-impact/src/types.ts @@ -0,0 +1,70 @@ +import { DependencyChange } from "./parsers"; + +/** Semver upgrade classification */ +export type UpgradeType = "major" | "minor" | "patch" | "unknown"; + +/** Risk level for impact assessment */ +export type RiskLevel = "low" | "medium" | "high" | "critical"; + +/** A dependency change enriched with upgrade classification and release notes */ +export interface EnrichedDependencyChange extends DependencyChange { + upgradeType: UpgradeType; + releaseNotes: string | null; +} + +/** Output of Step 1: breaking change extraction per dependency */ +export interface BreakingChangeEntry { + dependency: string; + upgradeType: UpgradeType; + breakingChanges: string[]; + deprecations: string[]; + notableChanges: string[]; + hasConfirmedBreakingChanges: boolean; +} + +export interface Step1Result { + dependencies: BreakingChangeEntry[]; +} + +/** Output of Step 2: cross-reference of breaking changes with codebase usage */ +export interface UsageImpact { + dependency: string; + change: string; + affectedFiles: string[]; + affectedCode: string[]; + requiredAction: string; + severity: RiskLevel; +} + +export interface Step2Result { + impacts: UsageImpact[]; + unaffectedUsages: Array<{ + dependency: string; + fileCount: number; + }>; +} + +/** Output of Step 3: final synthesized assessment */ +export interface DependencyAssessment { + overallRisk: RiskLevel; + riskJustification: string; + dependencySummaries: Array<{ + dependency: string; + fromVersion: string; + toVersion: string; + upgradeType: UpgradeType; + risk: RiskLevel; + oneLiner: string; + }>; + actionItems: Array<{ + severity: RiskLevel; + dependency: string; + file: string; + description: string; + }>; + inlineAnnotations: Array<{ + dependency: string; + annotation: string; + }>; + narrativeSummary: string; +} From 87a838c220ed3b6887523817cc72a6a0dd37266c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:10:02 +0000 Subject: [PATCH 02/27] feat(dependency-impact): add version classifier and diff line finder Add classifyUpgrade() to classify version bumps as major/minor/patch based on semver segments. Add findDepLineInPatch() to deterministically locate the line number of a dependency's version change in a diff patch, used for placing inline review comments. https://claude.ai/code/session_01DcQfYZFCR9zyLMot2crYdN --- dependency-impact/dist/parsers.d.ts | 10 +++++ dependency-impact/src/parsers.test.ts | 64 ++++++++++++++++++++++++++- dependency-impact/src/parsers.ts | 47 ++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/dependency-impact/dist/parsers.d.ts b/dependency-impact/dist/parsers.d.ts index a70bbff..d3277d5 100644 --- a/dependency-impact/dist/parsers.d.ts +++ b/dependency-impact/dist/parsers.d.ts @@ -8,4 +8,14 @@ export declare function parseDependencyChanges(diff: string, files: { filename: string; patch?: string; }[]): DependencyChange[]; +import { UpgradeType } from "./types"; +/** + * Classify a version change as major, minor, patch, or unknown. + */ +export declare function classifyUpgrade(from: string, to: string): UpgradeType; +/** + * Find the line number in the new file where a dependency's version appears as + * an added line in a diff patch. Returns null if not found. + */ +export declare function findDepLineInPatch(patch: string, depName: string): number | null; export declare function getImportPatterns(depName: string, ecosystem: string): string[]; diff --git a/dependency-impact/src/parsers.test.ts b/dependency-impact/src/parsers.test.ts index 8d25bd2..a5bf57d 100644 --- a/dependency-impact/src/parsers.test.ts +++ b/dependency-impact/src/parsers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseDependencyChanges, getImportPatterns } from "./parsers"; +import { parseDependencyChanges, getImportPatterns, classifyUpgrade, findDepLineInPatch } from "./parsers"; describe("parseDependencyChanges", () => { describe("terraform", () => { @@ -405,3 +405,65 @@ describe("getImportPatterns", () => { }); }); }); + +describe("classifyUpgrade", () => { + it("detects major upgrades", () => { + expect(classifyUpgrade("1.6.0", "2.0.0")).toBe("major"); + expect(classifyUpgrade("6.3", "7.0")).toBe("major"); + }); + + it("detects minor upgrades", () => { + expect(classifyUpgrade("5.31.0", "5.32.0")).toBe("minor"); + expect(classifyUpgrade("1.9.0", "1.10.0")).toBe("minor"); + }); + + it("detects patch upgrades", () => { + expect(classifyUpgrade("5.31.0", "5.31.1")).toBe("patch"); + expect(classifyUpgrade("7.5.0", "7.5.3")).toBe("patch"); + }); + + it("returns unknown for non-semver strings", () => { + expect(classifyUpgrade("abc", "def")).toBe("unknown"); + }); + + it("handles two-segment versions", () => { + expect(classifyUpgrade("1.0", "1.1")).toBe("minor"); + expect(classifyUpgrade("1.0", "2.0")).toBe("major"); + }); +}); + +describe("findDepLineInPatch", () => { + it("finds the line number for an added dependency", () => { + const patch = [ + `@@ -10,3 +10,3 @@`, + ` "lodash": "^4.17.0",`, + `- "axios": "^1.6.0"`, + `+ "axios": "^2.0.0"`, + ].join("\n"); + + expect(findDepLineInPatch(patch, "axios")).toBe(11); + }); + + it("returns null when dependency is not in an added line", () => { + const patch = [ + `@@ -10,3 +10,3 @@`, + ` "lodash": "^4.17.0",`, + ` "axios": "^1.6.0"`, + ].join("\n"); + + expect(findDepLineInPatch(patch, "axios")).toBeNull(); + }); + + it("handles multiple hunks", () => { + const patch = [ + `@@ -1,3 +1,3 @@`, + ` "a": "1.0.0",`, + `@@ -20,3 +20,3 @@`, + ` "b": "2.0.0",`, + `- "target": "^1.0.0"`, + `+ "target": "^2.0.0"`, + ].join("\n"); + + expect(findDepLineInPatch(patch, "target")).toBe(21); + }); +}); diff --git a/dependency-impact/src/parsers.ts b/dependency-impact/src/parsers.ts index a39a007..73e142c 100644 --- a/dependency-impact/src/parsers.ts +++ b/dependency-impact/src/parsers.ts @@ -149,6 +149,53 @@ export function parseDependencyChanges(diff: string, files: { filename: string; return changes; } +import { UpgradeType } from "./types"; + +/** + * Classify a version change as major, minor, patch, or unknown. + */ +export function classifyUpgrade(from: string, to: string): UpgradeType { + const parseSemver = (v: string): [number, number, number] | null => { + const match = v.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/); + if (!match) return null; + return [ + parseInt(match[1], 10), + parseInt(match[2] ?? "0", 10), + parseInt(match[3] ?? "0", 10), + ]; + }; + + const fromParts = parseSemver(from); + const toParts = parseSemver(to); + if (!fromParts || !toParts) return "unknown"; + + if (toParts[0] !== fromParts[0]) return "major"; + if (toParts[1] !== fromParts[1]) return "minor"; + return "patch"; +} + +/** + * Find the line number in the new file where a dependency's version appears as + * an added line in a diff patch. Returns null if not found. + */ +export function findDepLineInPatch(patch: string, depName: string): number | null { + let lineNum = 0; + + for (const raw of patch.split("\n")) { + const hunkMatch = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)/); + if (hunkMatch) { + lineNum = parseInt(hunkMatch[1], 10) - 1; + continue; + } + if (raw.startsWith("-")) continue; + lineNum++; + if (raw.startsWith("+") && raw.includes(depName)) { + return lineNum; + } + } + return null; +} + export function getImportPatterns(depName: string, ecosystem: string): string[] { switch (ecosystem) { case "npm": From 3982c9df714574a57f1ac079e4610f8a9b045dd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:11:23 +0000 Subject: [PATCH 03/27] feat(dependency-impact): extract prompt builders for multi-step workflow Add prompts.ts with focused prompt builders for each analysis step: - buildStep1Prompt: extract breaking changes from release notes - buildStep2Prompt: cross-reference breaking changes with codebase usage - buildStep3Prompt/buildStep3NoUsagePrompt: synthesize final assessment - buildLegacyPrompt: fallback preserving current single-prompt behavior Each prompt requests structured JSON output and has a single focused task instead of the current monolithic prompt. https://claude.ai/code/session_01DcQfYZFCR9zyLMot2crYdN --- dependency-impact/dist/prompts.d.ts | 9 + dependency-impact/src/prompts.ts | 297 ++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 dependency-impact/dist/prompts.d.ts create mode 100644 dependency-impact/src/prompts.ts diff --git a/dependency-impact/dist/prompts.d.ts b/dependency-impact/dist/prompts.d.ts new file mode 100644 index 0000000..d3bca1a --- /dev/null +++ b/dependency-impact/dist/prompts.d.ts @@ -0,0 +1,9 @@ +import type { EnrichedDependencyChange, Step1Result, Step2Result } from "./types"; +export declare function buildStep1Prompt(enrichedDeps: EnrichedDependencyChange[]): string; +export declare function buildStep2Prompt(step1Result: Step1Result, usageSections: string): string; +export declare function buildStep3Prompt(enrichedDeps: EnrichedDependencyChange[], step1Result: Step1Result, step2Result: Step2Result): string; +export declare function buildStep3NoUsagePrompt(enrichedDeps: EnrichedDependencyChange[], step1Result: Step1Result): string; +/** + * Legacy single-prompt fallback, used when structured pipeline JSON parsing fails. + */ +export declare function buildLegacyPrompt(depChangesList: string, prBodySection: string, hasUsage: boolean, usageSections: string, prDiff: string): string; diff --git a/dependency-impact/src/prompts.ts b/dependency-impact/src/prompts.ts new file mode 100644 index 0000000..da23474 --- /dev/null +++ b/dependency-impact/src/prompts.ts @@ -0,0 +1,297 @@ +import { truncateText } from "@gemini-actions/shared"; +import type { EnrichedDependencyChange, Step1Result, Step2Result } from "./types"; + +export function buildStep1Prompt(enrichedDeps: EnrichedDependencyChange[]): string { + const depSections = enrichedDeps + .map((dep) => { + const notes = dep.releaseNotes + ? truncateText(dep.releaseNotes, 8000, `${dep.name} release notes`) + : "No release notes available."; + + return `### ${dep.name} (${dep.fromVersion} → ${dep.toVersion}, ${dep.upgradeType} upgrade)\n${notes}`; + }) + .join("\n\n"); + + return `You are analyzing release notes for dependency upgrades to extract breaking changes. + +For each dependency below, extract ONLY information that is explicitly stated in the release notes. + +**Dependencies and their release notes:** +${depSections} + +Respond with a JSON object matching this schema: +{ + "dependencies": [ + { + "dependency": "package-name", + "upgradeType": "major|minor|patch|unknown", + "breakingChanges": ["list of breaking changes explicitly mentioned"], + "deprecations": ["list of deprecations explicitly mentioned"], + "notableChanges": ["behavioral changes, renamed APIs, new defaults"], + "hasConfirmedBreakingChanges": true|false + } + ] +} + +RULES: +- Only include breaking changes that are EXPLICITLY stated in the release notes (e.g., labeled "BREAKING", "Breaking Change", or in a "Migration" section). +- For major upgrades, be thorough — major versions typically contain breaking changes. +- For minor/patch upgrades, breaking changes are rare; do not fabricate them. +- If no release notes are available, set breakingChanges and deprecations to empty arrays and hasConfirmedBreakingChanges to false. +- Keep each entry concise — one sentence per breaking change. + +Respond ONLY with the JSON object.`; +} + +export function buildStep2Prompt( + step1Result: Step1Result, + usageSections: string, +): string { + const relevantDeps = step1Result.dependencies.filter( + (d) => + d.hasConfirmedBreakingChanges || + d.deprecations.length > 0 || + d.notableChanges.length > 0, + ); + + const changesSummary = relevantDeps + .map((d) => { + const items = [ + ...d.breakingChanges.map((c) => ` - [BREAKING] ${c}`), + ...d.deprecations.map((c) => ` - [DEPRECATED] ${c}`), + ...d.notableChanges.map((c) => ` - [CHANGED] ${c}`), + ].join("\n"); + return `### ${d.dependency}\n${items}`; + }) + .join("\n\n"); + + return `You are cross-referencing dependency breaking changes with actual codebase usage. + +**Breaking changes and deprecations to check:** +${changesSummary} + +**Codebase usage of these dependencies:** +${usageSections} + +For each breaking change or deprecation above, check whether the code patterns shown in "Codebase usage" are affected. Only report impacts that are CONFIRMED by both the change description AND the actual code shown. + +Respond with a JSON object: +{ + "impacts": [ + { + "dependency": "package-name", + "change": "the specific breaking change", + "affectedFiles": ["path/to/file.ts"], + "affectedCode": ["the specific import or usage line affected"], + "requiredAction": "what needs to change (be specific: rename X to Y, update argument from A to B, etc.)", + "severity": "low|medium|high|critical" + } + ], + "unaffectedUsages": [ + { + "dependency": "package-name", + "fileCount": 5 + } + ] +} + +RULES: +- Only report an impact if you can point to a SPECIFIC line from the usage context that is affected. +- If a dependency has usage in the codebase but none of its breaking changes affect the actual code shown, put it in unaffectedUsages. +- severity: critical = will break at runtime, high = likely to break, medium = may cause issues, low = cosmetic or deprecated but still functional. +- Do NOT speculate about usage patterns not shown in the codebase usage context. + +Respond ONLY with the JSON object.`; +} + +export function buildStep3Prompt( + enrichedDeps: EnrichedDependencyChange[], + step1Result: Step1Result, + step2Result: Step2Result, +): string { + const depList = enrichedDeps + .map( + (d) => + `- ${d.name}: ${d.fromVersion} → ${d.toVersion} (${d.upgradeType})`, + ) + .join("\n"); + + const step1Summary = truncateText( + JSON.stringify(step1Result.dependencies, null, 2), + 5000, + "step 1 results", + ); + + const step2Summary = truncateText( + JSON.stringify(step2Result, null, 2), + 5000, + "step 2 results", + ); + + return `You are writing the final dependency impact assessment for a pull request. + +**Dependencies updated:** +${depList} + +**Breaking changes extracted (from release notes):** +${step1Summary} + +**Codebase impact analysis:** +${step2Summary} + +Produce a JSON object with this structure: +{ + "overallRisk": "low|medium|high|critical", + "riskJustification": "one sentence explaining the overall risk", + "dependencySummaries": [ + { + "dependency": "name", + "fromVersion": "x.y.z", + "toVersion": "a.b.c", + "upgradeType": "major|minor|patch", + "risk": "low|medium|high|critical", + "oneLiner": "brief impact summary for this dep" + } + ], + "actionItems": [ + { + "severity": "low|medium|high|critical", + "dependency": "name", + "file": "path to the impacted source file", + "description": "what to check or do (be specific)" + } + ], + "inlineAnnotations": [ + { + "dependency": "name", + "annotation": "one-line summary for the version-change line in the manifest file" + } + ], + "narrativeSummary": "2-3 paragraph markdown narrative for the review body" +} + +RULES: +- overallRisk should reflect the HIGHEST severity across all impacts. If no impacts, default to "low". +- actionItems reference impacted SOURCE files from the codebase analysis, not the dependency manifest files. +- If there are no impacts from the codebase analysis, actionItems should be empty. +- inlineAnnotations provide a brief one-line summary per dependency, suitable for a comment on the manifest file's version-change line. +- Do NOT include generic advice. Every action item must be specific and tied to a concrete finding. +- The narrativeSummary should start with the most important finding. If no breaking changes were found, say "No breaking changes detected for current usage." + +Respond ONLY with the JSON object.`; +} + +export function buildStep3NoUsagePrompt( + enrichedDeps: EnrichedDependencyChange[], + step1Result: Step1Result, +): string { + const depList = enrichedDeps + .map( + (d) => + `- ${d.name}: ${d.fromVersion} → ${d.toVersion} (${d.upgradeType})`, + ) + .join("\n"); + + const step1Summary = truncateText( + JSON.stringify(step1Result.dependencies, null, 2), + 5000, + "step 1 results", + ); + + return `You are writing the final dependency impact assessment for a pull request. +No usage of these dependencies was found in source files. + +**Dependencies updated:** +${depList} + +**Breaking changes extracted (from release notes):** +${step1Summary} + +Produce a JSON object with this structure: +{ + "overallRisk": "low|medium|high|critical", + "riskJustification": "one sentence explaining the overall risk", + "dependencySummaries": [ + { + "dependency": "name", + "fromVersion": "x.y.z", + "toVersion": "a.b.c", + "upgradeType": "major|minor|patch", + "risk": "low|medium|high|critical", + "oneLiner": "brief impact summary for this dep" + } + ], + "actionItems": [], + "inlineAnnotations": [ + { + "dependency": "name", + "annotation": "one-line summary for the version-change line in the manifest file" + } + ], + "narrativeSummary": "1-2 paragraph markdown summary of key highlights from the release notes" +} + +RULES: +- Do NOT fabricate impact analysis or reference files since no usage was found. +- actionItems must be empty since there is no codebase usage to reference. +- If no release notes are available, say "No release notes available and no usage detected — no action needed." in narrativeSummary. +- Do NOT include generic advice like "review the changelog", "test in staging", or "pin versions". + +Respond ONLY with the JSON object.`; +} + +/** + * Legacy single-prompt fallback, used when structured pipeline JSON parsing fails. + */ +export function buildLegacyPrompt( + depChangesList: string, + prBodySection: string, + hasUsage: boolean, + usageSections: string, + prDiff: string, +): string { + if (hasUsage) { + return `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:** +${depChangesList} + +${prBodySection} + +**Usage in Codebase:** +${usageSections} + +**PR Diff:** +\`\`\`diff +${truncateText(prDiff, 10000, "PR diff")} +\`\`\` + +Respond with ONLY sections that have content. Skip empty sections entirely. +- **Breaking changes affecting this codebase**: Only mention breaking changes that are confirmed by the release notes AND affect files shown in "Usage in Codebase". Do not speculate. +- **Action required**: Specific code changes needed, referencing actual file paths and line content from the usage context. +- **Risk assessment**: Low / Medium / High with a one-line justification. + +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.`; + } + + return `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:** +${depChangesList} + +${prBodySection} + +Summarize the key highlights from the release notes as a concise bulleted list (max 10 bullets). +End with a one-line risk assessment (Low / Medium / High). + +RULES: +- Do NOT fabricate impact analysis, example scenarios, or migration steps. +- 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.`; +} From 6f6573e65f682618dd04240fcc21ef494ece665c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:11:41 +0000 Subject: [PATCH 04/27] feat(dependency-impact): wire multi-step pipeline and inline review comments Replace the single LLM call with a three-step pipeline: 1. Extract breaking changes from release notes 2. Cross-reference with codebase usage (skipped when unnecessary) 3. Synthesize structured assessment Switch PR output from postComment() to createReview() with: - Summary table with per-dependency risk ratings - Inline comments on manifest file version-change lines - Action items referencing impacted source files - Graceful fallback to legacy single-prompt mode on parse failure https://claude.ai/code/session_01DcQfYZFCR9zyLMot2crYdN --- dependency-impact/dist/index.js | 543 +++++++++++++++++++++++++++----- dependency-impact/src/index.ts | 282 ++++++++++++----- 2 files changed, 679 insertions(+), 146 deletions(-) diff --git a/dependency-impact/dist/index.js b/dependency-impact/dist/index.js index 59a2fa0..319c7ae 100644 --- a/dependency-impact/dist/index.js +++ b/dependency-impact/dist/index.js @@ -31898,6 +31898,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); const core = __importStar(__nccwpck_require__(6618)); const shared_1 = __nccwpck_require__(7451); const parsers_1 = __nccwpck_require__(2149); +const prompts_1 = __nccwpck_require__(8606); async function resolveGitHubRepo(dep) { if (dep.ecosystem === "go" && dep.name.startsWith("github.com/")) { const parts = dep.name.replace("github.com/", "").split("/"); @@ -31951,6 +31952,65 @@ async function resolveGitHubRepo(dep) { } return null; } +/** + * Build the review body with a summary table and narrative. + */ +function buildReviewBody(assessment) { + const riskLabel = { + low: "LOW RISK", + medium: "MEDIUM RISK", + high: "HIGH RISK", + critical: "CRITICAL RISK", + }; + const tableHeader = "| Dependency | Version | Type | Risk | Summary |\n|---|---|---|---|---|"; + const tableRows = assessment.dependencySummaries + .map((d) => `| ${d.dependency} | ${d.fromVersion} → ${d.toVersion} | ${d.upgradeType} | **${riskLabel[d.risk]}** | ${d.oneLiner} |`) + .join("\n"); + const actionSection = assessment.actionItems.length > 0 + ? `### Action Required\n\n${assessment.actionItems.map((a) => `- **[${a.severity.toUpperCase()}]** \`${a.file}\` — ${a.description}`).join("\n")}\n\n` + : ""; + return `## Gemini Dependency Impact Analysis + +**${riskLabel[assessment.overallRisk]}** — ${assessment.riskJustification} + +### Summary + +${tableHeader} +${tableRows} + +${actionSection}### Details + +${assessment.narrativeSummary} + +--- +*${assessment.dependencySummaries.length} dependency change(s) analyzed — Generated by [gemini-dependency-impact](https://github.com/dortort/gemini-actions)*`; +} +/** + * Build inline review comments for dependency version-change lines in manifest files. + */ +function buildInlineComments(assessment, enrichedDeps, prFiles) { + const comments = []; + for (const annotation of assessment.inlineAnnotations) { + const dep = enrichedDeps.find((d) => d.name === annotation.dependency); + if (!dep) + continue; + // Find the manifest file in the PR that contains this dependency's version change + for (const file of prFiles) { + if (!file.patch) + continue; + const line = (0, parsers_1.findDepLineInPatch)(file.patch, dep.name); + if (line != null && line > 0) { + comments.push({ + path: file.filename, + line, + body: annotation.annotation, + }); + break; // one comment per dependency + } + } + } + return comments; +} (0, shared_1.runAction)(async () => { const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); const { octokit, owner, repo, model } = (0, shared_1.getActionContext)(); @@ -31979,12 +32039,10 @@ async function resolveGitHubRepo(dep) { 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)) || @@ -32000,21 +32058,15 @@ async function resolveGitHubRepo(dep) { } } } - // 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"); + // 5. Fetch release notes const isDependabot = /\[bot\]$/.test(pr.author); const hasBody = pr.body != null && pr.body.trim().length > 50; - let releaseNotes = null; + const releaseNotesPerDep = new Map(); if (isDependabot && hasBody) { - releaseNotes = pr.body; + // Dependabot PRs include release notes in the body — use for all deps + for (const dep of depChanges) { + releaseNotesPerDep.set(dep.name, pr.body); + } } else { for (const dep of depChanges) { @@ -32022,77 +32074,116 @@ async function 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}`; + releaseNotesPerDep.set(dep.name, notes); } } } - if (!releaseNotes && hasBody) { - releaseNotes = pr.body; + // Fall back to PR body if no GitHub Releases found + if (releaseNotesPerDep.size === 0 && hasBody) { + for (const dep of depChanges) { + releaseNotesPerDep.set(dep.name, 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:** -${depChangesList} - -${prBodySection} - -**Usage in Codebase:** -${usageSections} - -**PR Diff:** -\`\`\`diff -${(0, shared_1.truncateText)(pr.diff, 10000, "PR diff")} -\`\`\` - -Respond with ONLY sections that have content. Skip empty sections entirely. -- **Breaking changes affecting this codebase**: Only mention breaking changes that are confirmed by the release notes AND affect files shown in "Usage in Codebase". Do not speculate. -- **Action required**: Specific code changes needed, referencing actual file paths and line content from the usage context. -- **Risk assessment**: Low / Medium / High with a one-line justification. - -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.`; + // 6. Enrich dependency changes with upgrade type and release notes + const enrichedDeps = depChanges.map((dep) => ({ + ...dep, + upgradeType: (0, parsers_1.classifyUpgrade)(dep.fromVersion, dep.toVersion), + releaseNotes: releaseNotesPerDep.get(dep.name) ?? null, + })); + 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 hasUsage = Object.values(usageContext).some((usages) => usages.length > 0); + // 7. Step 1: Extract breaking changes from release notes + core.info("Step 1: Extracting breaking changes from release notes..."); + let step1Result; + try { + const step1Response = await (0, shared_1.generateContent)(model, (0, prompts_1.buildStep1Prompt)(enrichedDeps), 200_000); + step1Result = (0, shared_1.parseJsonResponse)(step1Response); + core.info(`Step 1 complete: ${step1Result.dependencies.filter((d) => d.hasConfirmedBreakingChanges).length} dep(s) with breaking changes`); + } + catch (err) { + core.warning(`Step 1 failed (${err instanceof Error ? err.message : err}), falling back to legacy prompt`); + await runLegacyFallback(enrichedDeps, usageSections, hasUsage, pr.diff); + return; + } + // 8. Step 2: Cross-reference with codebase usage (conditional) + const hasBreakingChanges = step1Result.dependencies.some((d) => d.hasConfirmedBreakingChanges || + d.deprecations.length > 0 || + d.notableChanges.length > 0); + let step2Result = { impacts: [], unaffectedUsages: [] }; + if (hasUsage && hasBreakingChanges) { + core.info("Step 2: Cross-referencing breaking changes with codebase usage..."); + try { + const step2Response = await (0, shared_1.generateContent)(model, (0, prompts_1.buildStep2Prompt)(step1Result, usageSections), 300_000); + step2Result = (0, shared_1.parseJsonResponse)(step2Response); + core.info(`Step 2 complete: ${step2Result.impacts.length} impact(s) found`); + } + catch (err) { + core.warning(`Step 2 failed (${err instanceof Error ? err.message : err}), proceeding with empty impact analysis`); + } } 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:** -${depChangesList} - -${prBodySection} - -Summarize the key highlights from the release notes as a concise bulleted list (max 10 bullets). -End with a one-line risk assessment (Low / Medium / High). - -RULES: -- Do NOT fabricate impact analysis, example scenarios, or migration steps. -- 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.`; + core.info("Step 2: Skipped — no breaking changes or no codebase usage detected"); } - const analysis = await (0, shared_1.generateContent)(model, prompt); - // 6. Post the analysis as a comment - const comment = `## Gemini Dependency Impact Analysis + // 9. Step 3: Synthesize final assessment + core.info("Step 3: Synthesizing final assessment..."); + let assessment; + try { + const step3Prompt = hasUsage + ? (0, prompts_1.buildStep3Prompt)(enrichedDeps, step1Result, step2Result) + : (0, prompts_1.buildStep3NoUsagePrompt)(enrichedDeps, step1Result); + const step3Response = await (0, shared_1.generateContent)(model, step3Prompt, 100_000); + assessment = (0, shared_1.parseJsonResponse)(step3Response); + core.info(`Step 3 complete: overall risk = ${assessment.overallRisk}`); + } + catch (err) { + core.warning(`Step 3 failed (${err instanceof Error ? err.message : err}), falling back to legacy prompt`); + await runLegacyFallback(enrichedDeps, usageSections, hasUsage, pr.diff); + return; + } + // 10. Post the analysis as a review with inline comments + const body = buildReviewBody(assessment); + const inlineComments = buildInlineComments(assessment, enrichedDeps, pr.files); + if (inlineComments.length > 0) { + await (0, shared_1.createReview)(octokit, owner, repo, prNumber, body, inlineComments); + } + else { + await (0, shared_1.postComment)(octokit, owner, repo, prNumber, body); + } + core.info(`Dependency impact analysis posted (${inlineComments.length} inline comment(s))`); + // --- Legacy fallback for when structured pipeline fails --- + async function runLegacyFallback(deps, usageSects, usage, diff) { + const depChangesList = deps + .map((d) => `- **${d.name}**: ${d.fromVersion} → ${d.toVersion} (${d.ecosystem})`) + .join("\n"); + let combinedNotes = null; + for (const dep of deps) { + if (dep.releaseNotes) { + combinedNotes = (combinedNotes ?? "") + `\n\n## ${dep.name}\n${dep.releaseNotes}`; + } + } + const prBodySection = combinedNotes + ? `**Release Notes:**\n${(0, shared_1.truncateText)(combinedNotes.trim(), 15000, "release notes")}` + : "**Release Notes:** No release notes available."; + const prompt = (0, prompts_1.buildLegacyPrompt)(depChangesList, prBodySection, usage, usageSects, diff); + const analysis = await (0, shared_1.generateContent)(model, prompt); + 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"); +*${deps.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 (legacy fallback)"); + } }); @@ -32105,6 +32196,8 @@ ${analysis} Object.defineProperty(exports, "__esModule", ({ value: true })); exports.parseDependencyChanges = parseDependencyChanges; +exports.classifyUpgrade = classifyUpgrade; +exports.findDepLineInPatch = findDepLineInPatch; exports.getImportPatterns = getImportPatterns; /** * Parse diff lines to collect added/removed values, then emit changes where the @@ -32212,6 +32305,51 @@ function parseDependencyChanges(diff, files) { } return changes; } +/** + * Classify a version change as major, minor, patch, or unknown. + */ +function classifyUpgrade(from, to) { + const parseSemver = (v) => { + const match = v.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/); + if (!match) + return null; + return [ + parseInt(match[1], 10), + parseInt(match[2] ?? "0", 10), + parseInt(match[3] ?? "0", 10), + ]; + }; + const fromParts = parseSemver(from); + const toParts = parseSemver(to); + if (!fromParts || !toParts) + return "unknown"; + if (toParts[0] !== fromParts[0]) + return "major"; + if (toParts[1] !== fromParts[1]) + return "minor"; + return "patch"; +} +/** + * Find the line number in the new file where a dependency's version appears as + * an added line in a diff patch. Returns null if not found. + */ +function findDepLineInPatch(patch, depName) { + let lineNum = 0; + for (const raw of patch.split("\n")) { + const hunkMatch = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)/); + if (hunkMatch) { + lineNum = parseInt(hunkMatch[1], 10) - 1; + continue; + } + if (raw.startsWith("-")) + continue; + lineNum++; + if (raw.startsWith("+") && raw.includes(depName)) { + return lineNum; + } + } + return null; +} function getImportPatterns(depName, ecosystem) { switch (ecosystem) { case "npm": @@ -32252,6 +32390,265 @@ function getImportPatterns(depName, ecosystem) { } +/***/ }), + +/***/ 8606: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.buildStep1Prompt = buildStep1Prompt; +exports.buildStep2Prompt = buildStep2Prompt; +exports.buildStep3Prompt = buildStep3Prompt; +exports.buildStep3NoUsagePrompt = buildStep3NoUsagePrompt; +exports.buildLegacyPrompt = buildLegacyPrompt; +const shared_1 = __nccwpck_require__(7451); +function buildStep1Prompt(enrichedDeps) { + const depSections = enrichedDeps + .map((dep) => { + const notes = dep.releaseNotes + ? (0, shared_1.truncateText)(dep.releaseNotes, 8000, `${dep.name} release notes`) + : "No release notes available."; + return `### ${dep.name} (${dep.fromVersion} → ${dep.toVersion}, ${dep.upgradeType} upgrade)\n${notes}`; + }) + .join("\n\n"); + return `You are analyzing release notes for dependency upgrades to extract breaking changes. + +For each dependency below, extract ONLY information that is explicitly stated in the release notes. + +**Dependencies and their release notes:** +${depSections} + +Respond with a JSON object matching this schema: +{ + "dependencies": [ + { + "dependency": "package-name", + "upgradeType": "major|minor|patch|unknown", + "breakingChanges": ["list of breaking changes explicitly mentioned"], + "deprecations": ["list of deprecations explicitly mentioned"], + "notableChanges": ["behavioral changes, renamed APIs, new defaults"], + "hasConfirmedBreakingChanges": true|false + } + ] +} + +RULES: +- Only include breaking changes that are EXPLICITLY stated in the release notes (e.g., labeled "BREAKING", "Breaking Change", or in a "Migration" section). +- For major upgrades, be thorough — major versions typically contain breaking changes. +- For minor/patch upgrades, breaking changes are rare; do not fabricate them. +- If no release notes are available, set breakingChanges and deprecations to empty arrays and hasConfirmedBreakingChanges to false. +- Keep each entry concise — one sentence per breaking change. + +Respond ONLY with the JSON object.`; +} +function buildStep2Prompt(step1Result, usageSections) { + const relevantDeps = step1Result.dependencies.filter((d) => d.hasConfirmedBreakingChanges || + d.deprecations.length > 0 || + d.notableChanges.length > 0); + const changesSummary = relevantDeps + .map((d) => { + const items = [ + ...d.breakingChanges.map((c) => ` - [BREAKING] ${c}`), + ...d.deprecations.map((c) => ` - [DEPRECATED] ${c}`), + ...d.notableChanges.map((c) => ` - [CHANGED] ${c}`), + ].join("\n"); + return `### ${d.dependency}\n${items}`; + }) + .join("\n\n"); + return `You are cross-referencing dependency breaking changes with actual codebase usage. + +**Breaking changes and deprecations to check:** +${changesSummary} + +**Codebase usage of these dependencies:** +${usageSections} + +For each breaking change or deprecation above, check whether the code patterns shown in "Codebase usage" are affected. Only report impacts that are CONFIRMED by both the change description AND the actual code shown. + +Respond with a JSON object: +{ + "impacts": [ + { + "dependency": "package-name", + "change": "the specific breaking change", + "affectedFiles": ["path/to/file.ts"], + "affectedCode": ["the specific import or usage line affected"], + "requiredAction": "what needs to change (be specific: rename X to Y, update argument from A to B, etc.)", + "severity": "low|medium|high|critical" + } + ], + "unaffectedUsages": [ + { + "dependency": "package-name", + "fileCount": 5 + } + ] +} + +RULES: +- Only report an impact if you can point to a SPECIFIC line from the usage context that is affected. +- If a dependency has usage in the codebase but none of its breaking changes affect the actual code shown, put it in unaffectedUsages. +- severity: critical = will break at runtime, high = likely to break, medium = may cause issues, low = cosmetic or deprecated but still functional. +- Do NOT speculate about usage patterns not shown in the codebase usage context. + +Respond ONLY with the JSON object.`; +} +function buildStep3Prompt(enrichedDeps, step1Result, step2Result) { + const depList = enrichedDeps + .map((d) => `- ${d.name}: ${d.fromVersion} → ${d.toVersion} (${d.upgradeType})`) + .join("\n"); + const step1Summary = (0, shared_1.truncateText)(JSON.stringify(step1Result.dependencies, null, 2), 5000, "step 1 results"); + const step2Summary = (0, shared_1.truncateText)(JSON.stringify(step2Result, null, 2), 5000, "step 2 results"); + return `You are writing the final dependency impact assessment for a pull request. + +**Dependencies updated:** +${depList} + +**Breaking changes extracted (from release notes):** +${step1Summary} + +**Codebase impact analysis:** +${step2Summary} + +Produce a JSON object with this structure: +{ + "overallRisk": "low|medium|high|critical", + "riskJustification": "one sentence explaining the overall risk", + "dependencySummaries": [ + { + "dependency": "name", + "fromVersion": "x.y.z", + "toVersion": "a.b.c", + "upgradeType": "major|minor|patch", + "risk": "low|medium|high|critical", + "oneLiner": "brief impact summary for this dep" + } + ], + "actionItems": [ + { + "severity": "low|medium|high|critical", + "dependency": "name", + "file": "path to the impacted source file", + "description": "what to check or do (be specific)" + } + ], + "inlineAnnotations": [ + { + "dependency": "name", + "annotation": "one-line summary for the version-change line in the manifest file" + } + ], + "narrativeSummary": "2-3 paragraph markdown narrative for the review body" +} + +RULES: +- overallRisk should reflect the HIGHEST severity across all impacts. If no impacts, default to "low". +- actionItems reference impacted SOURCE files from the codebase analysis, not the dependency manifest files. +- If there are no impacts from the codebase analysis, actionItems should be empty. +- inlineAnnotations provide a brief one-line summary per dependency, suitable for a comment on the manifest file's version-change line. +- Do NOT include generic advice. Every action item must be specific and tied to a concrete finding. +- The narrativeSummary should start with the most important finding. If no breaking changes were found, say "No breaking changes detected for current usage." + +Respond ONLY with the JSON object.`; +} +function buildStep3NoUsagePrompt(enrichedDeps, step1Result) { + const depList = enrichedDeps + .map((d) => `- ${d.name}: ${d.fromVersion} → ${d.toVersion} (${d.upgradeType})`) + .join("\n"); + const step1Summary = (0, shared_1.truncateText)(JSON.stringify(step1Result.dependencies, null, 2), 5000, "step 1 results"); + return `You are writing the final dependency impact assessment for a pull request. +No usage of these dependencies was found in source files. + +**Dependencies updated:** +${depList} + +**Breaking changes extracted (from release notes):** +${step1Summary} + +Produce a JSON object with this structure: +{ + "overallRisk": "low|medium|high|critical", + "riskJustification": "one sentence explaining the overall risk", + "dependencySummaries": [ + { + "dependency": "name", + "fromVersion": "x.y.z", + "toVersion": "a.b.c", + "upgradeType": "major|minor|patch", + "risk": "low|medium|high|critical", + "oneLiner": "brief impact summary for this dep" + } + ], + "actionItems": [], + "inlineAnnotations": [ + { + "dependency": "name", + "annotation": "one-line summary for the version-change line in the manifest file" + } + ], + "narrativeSummary": "1-2 paragraph markdown summary of key highlights from the release notes" +} + +RULES: +- Do NOT fabricate impact analysis or reference files since no usage was found. +- actionItems must be empty since there is no codebase usage to reference. +- If no release notes are available, say "No release notes available and no usage detected — no action needed." in narrativeSummary. +- Do NOT include generic advice like "review the changelog", "test in staging", or "pin versions". + +Respond ONLY with the JSON object.`; +} +/** + * Legacy single-prompt fallback, used when structured pipeline JSON parsing fails. + */ +function buildLegacyPrompt(depChangesList, prBodySection, hasUsage, usageSections, prDiff) { + if (hasUsage) { + return `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:** +${depChangesList} + +${prBodySection} + +**Usage in Codebase:** +${usageSections} + +**PR Diff:** +\`\`\`diff +${(0, shared_1.truncateText)(prDiff, 10000, "PR diff")} +\`\`\` + +Respond with ONLY sections that have content. Skip empty sections entirely. +- **Breaking changes affecting this codebase**: Only mention breaking changes that are confirmed by the release notes AND affect files shown in "Usage in Codebase". Do not speculate. +- **Action required**: Specific code changes needed, referencing actual file paths and line content from the usage context. +- **Risk assessment**: Low / Medium / High with a one-line justification. + +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.`; + } + return `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:** +${depChangesList} + +${prBodySection} + +Summarize the key highlights from the release notes as a concise bulleted list (max 10 bullets). +End with a one-line risk assessment (Low / Medium / High). + +RULES: +- Do NOT fabricate impact analysis, example scenarios, or migration steps. +- 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.`; +} + + /***/ }), /***/ 2613: diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index 67b5b0a..2692c36 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -2,16 +2,33 @@ import * as core from "@actions/core"; import { generateContent, truncateText, + parseJsonResponse, getPullRequest, getFileContent, postComment, + createReview, getDefaultBranch, getRepoTree, listReleaseNotesBetween, runAction, getActionContext, } from "@gemini-actions/shared"; -import { parseDependencyChanges, getImportPatterns } from "./parsers"; +import type { ReviewComment, PullRequestFile } from "@gemini-actions/shared"; +import { parseDependencyChanges, getImportPatterns, classifyUpgrade, findDepLineInPatch } from "./parsers"; +import type { + EnrichedDependencyChange, + Step1Result, + Step2Result, + DependencyAssessment, + RiskLevel, +} from "./types"; +import { + buildStep1Prompt, + buildStep2Prompt, + buildStep3Prompt, + buildStep3NoUsagePrompt, + buildLegacyPrompt, +} from "./prompts"; async function resolveGitHubRepo(dep: { name: string; ecosystem: string }): Promise<{ owner: string; repo: string } | null> { if (dep.ecosystem === "go" && dep.name.startsWith("github.com/")) { @@ -61,6 +78,79 @@ async function resolveGitHubRepo(dep: { name: string; ecosystem: string }): Prom return null; } +/** + * Build the review body with a summary table and narrative. + */ +function buildReviewBody(assessment: DependencyAssessment): string { + const riskLabel: Record = { + low: "LOW RISK", + medium: "MEDIUM RISK", + high: "HIGH RISK", + critical: "CRITICAL RISK", + }; + + const tableHeader = "| Dependency | Version | Type | Risk | Summary |\n|---|---|---|---|---|"; + const tableRows = assessment.dependencySummaries + .map( + (d) => + `| ${d.dependency} | ${d.fromVersion} → ${d.toVersion} | ${d.upgradeType} | **${riskLabel[d.risk]}** | ${d.oneLiner} |`, + ) + .join("\n"); + + const actionSection = + assessment.actionItems.length > 0 + ? `### Action Required\n\n${assessment.actionItems.map((a) => `- **[${a.severity.toUpperCase()}]** \`${a.file}\` — ${a.description}`).join("\n")}\n\n` + : ""; + + return `## Gemini Dependency Impact Analysis + +**${riskLabel[assessment.overallRisk]}** — ${assessment.riskJustification} + +### Summary + +${tableHeader} +${tableRows} + +${actionSection}### Details + +${assessment.narrativeSummary} + +--- +*${assessment.dependencySummaries.length} dependency change(s) analyzed — Generated by [gemini-dependency-impact](https://github.com/dortort/gemini-actions)*`; +} + +/** + * Build inline review comments for dependency version-change lines in manifest files. + */ +function buildInlineComments( + assessment: DependencyAssessment, + enrichedDeps: EnrichedDependencyChange[], + prFiles: PullRequestFile[], +): ReviewComment[] { + const comments: ReviewComment[] = []; + + for (const annotation of assessment.inlineAnnotations) { + const dep = enrichedDeps.find((d) => d.name === annotation.dependency); + if (!dep) continue; + + // Find the manifest file in the PR that contains this dependency's version change + for (const file of prFiles) { + if (!file.patch) continue; + const line = findDepLineInPatch(file.patch, dep.name); + if (line != null && line > 0) { + comments.push({ + path: file.filename, + line, + body: annotation.annotation, + }); + break; // one comment per dependency + } + } + } + + return comments; +} + runAction(async () => { const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); @@ -105,7 +195,6 @@ runAction(async () => { 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( @@ -117,7 +206,6 @@ runAction(async () => { ); if (importPatterns.some((pattern) => content.includes(pattern))) { - // Include the relevant lines, not the whole file const relevantLines = content .split("\n") .filter((line) => @@ -138,23 +226,16 @@ runAction(async () => { } } - // 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. Fetch release notes const isDependabot = /\[bot\]$/.test(pr.author); const hasBody = pr.body != null && pr.body.trim().length > 50; - - let releaseNotes: string | null = null; + const releaseNotesPerDep = new Map(); if (isDependabot && hasBody) { - releaseNotes = pr.body!; + // Dependabot PRs include release notes in the body — use for all deps + for (const dep of depChanges) { + releaseNotesPerDep.set(dep.name, pr.body!); + } } else { for (const dep of depChanges) { const ghRepo = await resolveGitHubRepo(dep); @@ -167,85 +248,140 @@ runAction(async () => { dep.toVersion, ); if (notes) { - releaseNotes = (releaseNotes ?? "") + `\n\n## ${dep.name}\n${notes}`; + releaseNotesPerDep.set(dep.name, notes); } } } - if (!releaseNotes && hasBody) { - releaseNotes = pr.body!; + // Fall back to PR body if no GitHub Releases found + if (releaseNotesPerDep.size === 0 && hasBody) { + for (const dep of depChanges) { + releaseNotesPerDep.set(dep.name, pr.body!); + } } } - 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 depChangesList = depChanges - .map( - (d) => - `- **${d.name}**: ${d.fromVersion} → ${d.toVersion} (${d.ecosystem})`, - ) - .join("\n"); - - let prompt: string; - - 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. + // 6. Enrich dependency changes with upgrade type and release notes + const enrichedDeps: EnrichedDependencyChange[] = depChanges.map((dep) => ({ + ...dep, + upgradeType: classifyUpgrade(dep.fromVersion, dep.toVersion), + releaseNotes: releaseNotesPerDep.get(dep.name) ?? null, + })); -**Dependency Changes:** -${depChangesList} + 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"); -${prBodySection} + const hasUsage = Object.values(usageContext).some((usages) => usages.length > 0); -**Usage in Codebase:** -${usageSections} + // 7. Step 1: Extract breaking changes from release notes + core.info("Step 1: Extracting breaking changes from release notes..."); + let step1Result: Step1Result; + try { + const step1Response = await generateContent(model, buildStep1Prompt(enrichedDeps), 200_000); + step1Result = parseJsonResponse(step1Response); + core.info( + `Step 1 complete: ${step1Result.dependencies.filter((d) => d.hasConfirmedBreakingChanges).length} dep(s) with breaking changes`, + ); + } catch (err) { + core.warning(`Step 1 failed (${err instanceof Error ? err.message : err}), falling back to legacy prompt`); + await runLegacyFallback(enrichedDeps, usageSections, hasUsage, pr.diff); + return; + } -**PR Diff:** -\`\`\`diff -${truncateText(pr.diff, 10000, "PR diff")} -\`\`\` + // 8. Step 2: Cross-reference with codebase usage (conditional) + const hasBreakingChanges = step1Result.dependencies.some( + (d) => + d.hasConfirmedBreakingChanges || + d.deprecations.length > 0 || + d.notableChanges.length > 0, + ); -Respond with ONLY sections that have content. Skip empty sections entirely. -- **Breaking changes affecting this codebase**: Only mention breaking changes that are confirmed by the release notes AND affect files shown in "Usage in Codebase". Do not speculate. -- **Action required**: Specific code changes needed, referencing actual file paths and line content from the usage context. -- **Risk assessment**: Low / Medium / High with a one-line justification. + let step2Result: Step2Result = { impacts: [], unaffectedUsages: [] }; -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.`; + if (hasUsage && hasBreakingChanges) { + core.info("Step 2: Cross-referencing breaking changes with codebase usage..."); + try { + const step2Response = await generateContent(model, buildStep2Prompt(step1Result, usageSections), 300_000); + step2Result = parseJsonResponse(step2Response); + core.info(`Step 2 complete: ${step2Result.impacts.length} impact(s) found`); + } catch (err) { + core.warning(`Step 2 failed (${err instanceof Error ? err.message : err}), proceeding with empty impact analysis`); + } } 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:** -${depChangesList} + core.info("Step 2: Skipped — no breaking changes or no codebase usage detected"); + } -${prBodySection} + // 9. Step 3: Synthesize final assessment + core.info("Step 3: Synthesizing final assessment..."); + let assessment: DependencyAssessment; + try { + const step3Prompt = hasUsage + ? buildStep3Prompt(enrichedDeps, step1Result, step2Result) + : buildStep3NoUsagePrompt(enrichedDeps, step1Result); + + const step3Response = await generateContent(model, step3Prompt, 100_000); + assessment = parseJsonResponse(step3Response); + core.info(`Step 3 complete: overall risk = ${assessment.overallRisk}`); + } catch (err) { + core.warning(`Step 3 failed (${err instanceof Error ? err.message : err}), falling back to legacy prompt`); + await runLegacyFallback(enrichedDeps, usageSections, hasUsage, pr.diff); + return; + } -Summarize the key highlights from the release notes as a concise bulleted list (max 10 bullets). -End with a one-line risk assessment (Low / Medium / High). + // 10. Post the analysis as a review with inline comments + const body = buildReviewBody(assessment); + const inlineComments = buildInlineComments(assessment, enrichedDeps, pr.files); -RULES: -- Do NOT fabricate impact analysis, example scenarios, or migration steps. -- 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.`; + if (inlineComments.length > 0) { + await createReview(octokit, owner, repo, prNumber, body, inlineComments); + } else { + await postComment(octokit, owner, repo, prNumber, body); } - const analysis = await generateContent(model, prompt); + core.info( + `Dependency impact analysis posted (${inlineComments.length} inline comment(s))`, + ); + + // --- Legacy fallback for when structured pipeline fails --- + async function runLegacyFallback( + deps: EnrichedDependencyChange[], + usageSects: string, + usage: boolean, + diff: string, + ): Promise { + const depChangesList = deps + .map( + (d) => + `- **${d.name}**: ${d.fromVersion} → ${d.toVersion} (${d.ecosystem})`, + ) + .join("\n"); + + let combinedNotes: string | null = null; + for (const dep of deps) { + if (dep.releaseNotes) { + combinedNotes = (combinedNotes ?? "") + `\n\n## ${dep.name}\n${dep.releaseNotes}`; + } + } + const prBodySection = combinedNotes + ? `**Release Notes:**\n${truncateText(combinedNotes.trim(), 15000, "release notes")}` + : "**Release Notes:** No release notes available."; - // 6. Post the analysis as a comment - const comment = `## Gemini Dependency Impact Analysis + const prompt = buildLegacyPrompt(depChangesList, prBodySection, usage, usageSects, diff); + const analysis = await generateContent(model, prompt); + + 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)*`; +*${deps.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"); + await postComment(octokit, owner, repo, prNumber, comment); + core.info("Dependency impact analysis posted (legacy fallback)"); + } }); From e35aaa4b981f525afe8d417342a1e3b3098cb176 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:18:58 +0000 Subject: [PATCH 05/27] refactor(dependency-impact): extract review rendering into testable module Move buildReviewBody() and buildInlineComments() from index.ts into a dedicated review.ts module so they can be unit tested independently of the GitHub Actions runtime. https://claude.ai/code/session_01DcQfYZFCR9zyLMot2crYdN --- dependency-impact/dist/index.js | 135 +++++++++++++++++--------------- dependency-impact/src/index.ts | 78 +----------------- dependency-impact/src/review.ts | 75 ++++++++++++++++++ 3 files changed, 151 insertions(+), 137 deletions(-) create mode 100644 dependency-impact/src/review.ts diff --git a/dependency-impact/dist/index.js b/dependency-impact/dist/index.js index 319c7ae..78e4f5d 100644 --- a/dependency-impact/dist/index.js +++ b/dependency-impact/dist/index.js @@ -31899,6 +31899,7 @@ const core = __importStar(__nccwpck_require__(6618)); const shared_1 = __nccwpck_require__(7451); const parsers_1 = __nccwpck_require__(2149); const prompts_1 = __nccwpck_require__(8606); +const review_1 = __nccwpck_require__(6701); async function resolveGitHubRepo(dep) { if (dep.ecosystem === "go" && dep.name.startsWith("github.com/")) { const parts = dep.name.replace("github.com/", "").split("/"); @@ -31952,65 +31953,6 @@ async function resolveGitHubRepo(dep) { } return null; } -/** - * Build the review body with a summary table and narrative. - */ -function buildReviewBody(assessment) { - const riskLabel = { - low: "LOW RISK", - medium: "MEDIUM RISK", - high: "HIGH RISK", - critical: "CRITICAL RISK", - }; - const tableHeader = "| Dependency | Version | Type | Risk | Summary |\n|---|---|---|---|---|"; - const tableRows = assessment.dependencySummaries - .map((d) => `| ${d.dependency} | ${d.fromVersion} → ${d.toVersion} | ${d.upgradeType} | **${riskLabel[d.risk]}** | ${d.oneLiner} |`) - .join("\n"); - const actionSection = assessment.actionItems.length > 0 - ? `### Action Required\n\n${assessment.actionItems.map((a) => `- **[${a.severity.toUpperCase()}]** \`${a.file}\` — ${a.description}`).join("\n")}\n\n` - : ""; - return `## Gemini Dependency Impact Analysis - -**${riskLabel[assessment.overallRisk]}** — ${assessment.riskJustification} - -### Summary - -${tableHeader} -${tableRows} - -${actionSection}### Details - -${assessment.narrativeSummary} - ---- -*${assessment.dependencySummaries.length} dependency change(s) analyzed — Generated by [gemini-dependency-impact](https://github.com/dortort/gemini-actions)*`; -} -/** - * Build inline review comments for dependency version-change lines in manifest files. - */ -function buildInlineComments(assessment, enrichedDeps, prFiles) { - const comments = []; - for (const annotation of assessment.inlineAnnotations) { - const dep = enrichedDeps.find((d) => d.name === annotation.dependency); - if (!dep) - continue; - // Find the manifest file in the PR that contains this dependency's version change - for (const file of prFiles) { - if (!file.patch) - continue; - const line = (0, parsers_1.findDepLineInPatch)(file.patch, dep.name); - if (line != null && line > 0) { - comments.push({ - path: file.filename, - line, - body: annotation.annotation, - }); - break; // one comment per dependency - } - } - } - return comments; -} (0, shared_1.runAction)(async () => { const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); const { octokit, owner, repo, model } = (0, shared_1.getActionContext)(); @@ -32150,8 +32092,8 @@ function buildInlineComments(assessment, enrichedDeps, prFiles) { return; } // 10. Post the analysis as a review with inline comments - const body = buildReviewBody(assessment); - const inlineComments = buildInlineComments(assessment, enrichedDeps, pr.files); + const body = (0, review_1.buildReviewBody)(assessment); + const inlineComments = (0, review_1.buildInlineComments)(assessment, enrichedDeps, pr.files); if (inlineComments.length > 0) { await (0, shared_1.createReview)(octokit, owner, repo, prNumber, body, inlineComments); } @@ -32649,6 +32591,77 @@ RULES: } +/***/ }), + +/***/ 6701: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.buildReviewBody = buildReviewBody; +exports.buildInlineComments = buildInlineComments; +const parsers_1 = __nccwpck_require__(2149); +const riskLabel = { + low: "LOW RISK", + medium: "MEDIUM RISK", + high: "HIGH RISK", + critical: "CRITICAL RISK", +}; +/** + * Build the review body with a summary table and narrative. + */ +function buildReviewBody(assessment) { + const tableHeader = "| Dependency | Version | Type | Risk | Summary |\n|---|---|---|---|---|"; + const tableRows = assessment.dependencySummaries + .map((d) => `| ${d.dependency} | ${d.fromVersion} → ${d.toVersion} | ${d.upgradeType} | **${riskLabel[d.risk]}** | ${d.oneLiner} |`) + .join("\n"); + const actionSection = assessment.actionItems.length > 0 + ? `### Action Required\n\n${assessment.actionItems.map((a) => `- **[${a.severity.toUpperCase()}]** \`${a.file}\` — ${a.description}`).join("\n")}\n\n` + : ""; + return `## Gemini Dependency Impact Analysis + +**${riskLabel[assessment.overallRisk]}** — ${assessment.riskJustification} + +### Summary + +${tableHeader} +${tableRows} + +${actionSection}### Details + +${assessment.narrativeSummary} + +--- +*${assessment.dependencySummaries.length} dependency change(s) analyzed — Generated by [gemini-dependency-impact](https://github.com/dortort/gemini-actions)*`; +} +/** + * Build inline review comments for dependency version-change lines in manifest files. + */ +function buildInlineComments(assessment, enrichedDeps, prFiles) { + const comments = []; + for (const annotation of assessment.inlineAnnotations) { + const dep = enrichedDeps.find((d) => d.name === annotation.dependency); + if (!dep) + continue; + for (const file of prFiles) { + if (!file.patch) + continue; + const line = (0, parsers_1.findDepLineInPatch)(file.patch, dep.name); + if (line != null && line > 0) { + comments.push({ + path: file.filename, + line, + body: annotation.annotation, + }); + break; + } + } + } + return comments; +} + + /***/ }), /***/ 2613: diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index 2692c36..da811c0 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -13,14 +13,12 @@ import { runAction, getActionContext, } from "@gemini-actions/shared"; -import type { ReviewComment, PullRequestFile } from "@gemini-actions/shared"; -import { parseDependencyChanges, getImportPatterns, classifyUpgrade, findDepLineInPatch } from "./parsers"; +import { parseDependencyChanges, getImportPatterns, classifyUpgrade } from "./parsers"; import type { EnrichedDependencyChange, Step1Result, Step2Result, DependencyAssessment, - RiskLevel, } from "./types"; import { buildStep1Prompt, @@ -29,6 +27,7 @@ import { buildStep3NoUsagePrompt, buildLegacyPrompt, } from "./prompts"; +import { buildReviewBody, buildInlineComments } from "./review"; async function resolveGitHubRepo(dep: { name: string; ecosystem: string }): Promise<{ owner: string; repo: string } | null> { if (dep.ecosystem === "go" && dep.name.startsWith("github.com/")) { @@ -78,79 +77,6 @@ async function resolveGitHubRepo(dep: { name: string; ecosystem: string }): Prom return null; } -/** - * Build the review body with a summary table and narrative. - */ -function buildReviewBody(assessment: DependencyAssessment): string { - const riskLabel: Record = { - low: "LOW RISK", - medium: "MEDIUM RISK", - high: "HIGH RISK", - critical: "CRITICAL RISK", - }; - - const tableHeader = "| Dependency | Version | Type | Risk | Summary |\n|---|---|---|---|---|"; - const tableRows = assessment.dependencySummaries - .map( - (d) => - `| ${d.dependency} | ${d.fromVersion} → ${d.toVersion} | ${d.upgradeType} | **${riskLabel[d.risk]}** | ${d.oneLiner} |`, - ) - .join("\n"); - - const actionSection = - assessment.actionItems.length > 0 - ? `### Action Required\n\n${assessment.actionItems.map((a) => `- **[${a.severity.toUpperCase()}]** \`${a.file}\` — ${a.description}`).join("\n")}\n\n` - : ""; - - return `## Gemini Dependency Impact Analysis - -**${riskLabel[assessment.overallRisk]}** — ${assessment.riskJustification} - -### Summary - -${tableHeader} -${tableRows} - -${actionSection}### Details - -${assessment.narrativeSummary} - ---- -*${assessment.dependencySummaries.length} dependency change(s) analyzed — Generated by [gemini-dependency-impact](https://github.com/dortort/gemini-actions)*`; -} - -/** - * Build inline review comments for dependency version-change lines in manifest files. - */ -function buildInlineComments( - assessment: DependencyAssessment, - enrichedDeps: EnrichedDependencyChange[], - prFiles: PullRequestFile[], -): ReviewComment[] { - const comments: ReviewComment[] = []; - - for (const annotation of assessment.inlineAnnotations) { - const dep = enrichedDeps.find((d) => d.name === annotation.dependency); - if (!dep) continue; - - // Find the manifest file in the PR that contains this dependency's version change - for (const file of prFiles) { - if (!file.patch) continue; - const line = findDepLineInPatch(file.patch, dep.name); - if (line != null && line > 0) { - comments.push({ - path: file.filename, - line, - body: annotation.annotation, - }); - break; // one comment per dependency - } - } - } - - return comments; -} - runAction(async () => { const prNumber = parseInt(core.getInput("pr_number", { required: true }), 10); diff --git a/dependency-impact/src/review.ts b/dependency-impact/src/review.ts new file mode 100644 index 0000000..3f16027 --- /dev/null +++ b/dependency-impact/src/review.ts @@ -0,0 +1,75 @@ +import type { ReviewComment, PullRequestFile } from "@gemini-actions/shared"; +import { findDepLineInPatch } from "./parsers"; +import type { DependencyAssessment, EnrichedDependencyChange, RiskLevel } from "./types"; + +const riskLabel: Record = { + low: "LOW RISK", + medium: "MEDIUM RISK", + high: "HIGH RISK", + critical: "CRITICAL RISK", +}; + +/** + * Build the review body with a summary table and narrative. + */ +export function buildReviewBody(assessment: DependencyAssessment): string { + const tableHeader = "| Dependency | Version | Type | Risk | Summary |\n|---|---|---|---|---|"; + const tableRows = assessment.dependencySummaries + .map( + (d) => + `| ${d.dependency} | ${d.fromVersion} → ${d.toVersion} | ${d.upgradeType} | **${riskLabel[d.risk]}** | ${d.oneLiner} |`, + ) + .join("\n"); + + const actionSection = + assessment.actionItems.length > 0 + ? `### Action Required\n\n${assessment.actionItems.map((a) => `- **[${a.severity.toUpperCase()}]** \`${a.file}\` — ${a.description}`).join("\n")}\n\n` + : ""; + + return `## Gemini Dependency Impact Analysis + +**${riskLabel[assessment.overallRisk]}** — ${assessment.riskJustification} + +### Summary + +${tableHeader} +${tableRows} + +${actionSection}### Details + +${assessment.narrativeSummary} + +--- +*${assessment.dependencySummaries.length} dependency change(s) analyzed — Generated by [gemini-dependency-impact](https://github.com/dortort/gemini-actions)*`; +} + +/** + * Build inline review comments for dependency version-change lines in manifest files. + */ +export function buildInlineComments( + assessment: DependencyAssessment, + enrichedDeps: EnrichedDependencyChange[], + prFiles: PullRequestFile[], +): ReviewComment[] { + const comments: ReviewComment[] = []; + + for (const annotation of assessment.inlineAnnotations) { + const dep = enrichedDeps.find((d) => d.name === annotation.dependency); + if (!dep) continue; + + for (const file of prFiles) { + if (!file.patch) continue; + const line = findDepLineInPatch(file.patch, dep.name); + if (line != null && line > 0) { + comments.push({ + path: file.filename, + line, + body: annotation.annotation, + }); + break; + } + } + } + + return comments; +} From 6dd64207e920c8320bf3406e2ca1ec6998e4b355 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:19:17 +0000 Subject: [PATCH 06/27] test(dependency-impact): add tests for prompt builders and review rendering Add prompts.test.ts (21 tests) covering all 5 prompt builder functions: - buildStep1Prompt, buildStep2Prompt, buildStep3Prompt, buildStep3NoUsagePrompt, buildLegacyPrompt Add review.test.ts (13 tests) covering: - buildReviewBody: risk badge, summary table, action items, narrative - buildInlineComments: line mapping, missing deps, missing patches, one-comment-per-dep deduplication Total test count: 32 existing + 34 new = 66 https://claude.ai/code/session_01DcQfYZFCR9zyLMot2crYdN --- dependency-impact/src/prompts.test.ts | 213 +++++++++++++++++++++++++ dependency-impact/src/review.test.ts | 214 ++++++++++++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 dependency-impact/src/prompts.test.ts create mode 100644 dependency-impact/src/review.test.ts diff --git a/dependency-impact/src/prompts.test.ts b/dependency-impact/src/prompts.test.ts new file mode 100644 index 0000000..221e554 --- /dev/null +++ b/dependency-impact/src/prompts.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from "vitest"; +import { + buildStep1Prompt, + buildStep2Prompt, + buildStep3Prompt, + buildStep3NoUsagePrompt, + buildLegacyPrompt, +} from "./prompts"; +import type { EnrichedDependencyChange, Step1Result, Step2Result } from "./types"; + +const sampleDep: EnrichedDependencyChange = { + name: "axios", + fromVersion: "1.6.0", + toVersion: "2.0.0", + ecosystem: "npm", + upgradeType: "major", + releaseNotes: "## Breaking Changes\n- Removed `createInstance()` default export", +}; + +const sampleDepNoNotes: EnrichedDependencyChange = { + name: "lodash", + fromVersion: "4.17.20", + toVersion: "4.17.21", + ecosystem: "npm", + upgradeType: "patch", + releaseNotes: null, +}; + +const sampleStep1Result: Step1Result = { + dependencies: [ + { + dependency: "axios", + upgradeType: "major", + breakingChanges: ["Removed createInstance() default export"], + deprecations: [], + notableChanges: ["New fetch-based adapter"], + hasConfirmedBreakingChanges: true, + }, + ], +}; + +const sampleStep1NoBreaking: Step1Result = { + dependencies: [ + { + dependency: "lodash", + upgradeType: "patch", + breakingChanges: [], + deprecations: [], + notableChanges: [], + hasConfirmedBreakingChanges: false, + }, + ], +}; + +const sampleStep2Result: Step2Result = { + impacts: [ + { + dependency: "axios", + change: "Removed createInstance() default export", + affectedFiles: ["src/api/client.ts"], + affectedCode: ["import axios from 'axios'"], + requiredAction: "Use named import: import { axios } from 'axios'", + severity: "high", + }, + ], + unaffectedUsages: [], +}; + +const emptyStep2: Step2Result = { + impacts: [], + unaffectedUsages: [], +}; + +describe("buildStep1Prompt", () => { + it("includes dependency name and version info", () => { + const prompt = buildStep1Prompt([sampleDep]); + expect(prompt).toContain("axios"); + expect(prompt).toContain("1.6.0"); + expect(prompt).toContain("2.0.0"); + expect(prompt).toContain("major upgrade"); + }); + + it("includes release notes when available", () => { + const prompt = buildStep1Prompt([sampleDep]); + expect(prompt).toContain("Removed `createInstance()` default export"); + }); + + it("indicates when release notes are unavailable", () => { + const prompt = buildStep1Prompt([sampleDepNoNotes]); + expect(prompt).toContain("No release notes available."); + }); + + it("includes multiple dependencies", () => { + const prompt = buildStep1Prompt([sampleDep, sampleDepNoNotes]); + expect(prompt).toContain("axios"); + expect(prompt).toContain("lodash"); + }); + + it("requests JSON output", () => { + const prompt = buildStep1Prompt([sampleDep]); + expect(prompt).toContain('"dependencies"'); + expect(prompt).toContain("Respond ONLY with the JSON object"); + }); + + it("includes rules about not fabricating breaking changes", () => { + const prompt = buildStep1Prompt([sampleDep]); + expect(prompt).toContain("EXPLICITLY stated"); + }); +}); + +describe("buildStep2Prompt", () => { + it("includes breaking changes from step 1", () => { + const prompt = buildStep2Prompt(sampleStep1Result, "### axios\nimport axios from 'axios'"); + expect(prompt).toContain("[BREAKING] Removed createInstance() default export"); + }); + + it("includes notable changes from step 1", () => { + const prompt = buildStep2Prompt(sampleStep1Result, "### axios\nimport axios"); + expect(prompt).toContain("[CHANGED] New fetch-based adapter"); + }); + + it("filters out dependencies with no changes from the changes section", () => { + const prompt = buildStep2Prompt(sampleStep1NoBreaking, "### lodash\nimport lodash"); + // The breaking changes section should be empty since lodash has no breaking/deprecation/notable changes + expect(prompt).toContain("**Breaking changes and deprecations to check:**\n\n\n**Codebase usage"); + }); + + it("includes usage sections", () => { + const usage = "### axios\n**src/api/client.ts:**\nimport axios from 'axios'"; + const prompt = buildStep2Prompt(sampleStep1Result, usage); + expect(prompt).toContain("src/api/client.ts"); + }); + + it("requests JSON output with impacts schema", () => { + const prompt = buildStep2Prompt(sampleStep1Result, "usage"); + expect(prompt).toContain('"impacts"'); + expect(prompt).toContain('"unaffectedUsages"'); + expect(prompt).toContain("Respond ONLY with the JSON object"); + }); +}); + +describe("buildStep3Prompt", () => { + it("includes dependency list", () => { + const prompt = buildStep3Prompt([sampleDep], sampleStep1Result, sampleStep2Result); + expect(prompt).toContain("axios: 1.6.0"); + expect(prompt).toContain("2.0.0"); + }); + + it("includes step 1 and step 2 results", () => { + const prompt = buildStep3Prompt([sampleDep], sampleStep1Result, sampleStep2Result); + expect(prompt).toContain("Breaking changes extracted"); + expect(prompt).toContain("Codebase impact analysis"); + }); + + it("requests the full assessment schema", () => { + const prompt = buildStep3Prompt([sampleDep], sampleStep1Result, emptyStep2); + expect(prompt).toContain('"overallRisk"'); + expect(prompt).toContain('"dependencySummaries"'); + expect(prompt).toContain('"actionItems"'); + expect(prompt).toContain('"inlineAnnotations"'); + expect(prompt).toContain('"narrativeSummary"'); + }); +}); + +describe("buildStep3NoUsagePrompt", () => { + it("states no usage was found", () => { + const prompt = buildStep3NoUsagePrompt([sampleDep], sampleStep1Result); + expect(prompt).toContain("No usage of these dependencies was found"); + }); + + it("requires empty actionItems", () => { + const prompt = buildStep3NoUsagePrompt([sampleDep], sampleStep1Result); + expect(prompt).toContain('"actionItems": []'); + }); + + it("includes dependency list and step 1 results", () => { + const prompt = buildStep3NoUsagePrompt([sampleDep], sampleStep1Result); + expect(prompt).toContain("axios"); + expect(prompt).toContain("Breaking changes extracted"); + }); +}); + +describe("buildLegacyPrompt", () => { + const depList = "- **axios**: 1.6.0 → 2.0.0 (npm)"; + const prBody = "**Release Notes:**\nSome notes"; + + it("includes usage sections when hasUsage is true", () => { + const prompt = buildLegacyPrompt(depList, prBody, true, "### axios\nimport axios", "diff content"); + expect(prompt).toContain("Usage in Codebase"); + expect(prompt).toContain("import axios"); + expect(prompt).toContain("PR Diff"); + }); + + it("does not include usage when hasUsage is false", () => { + const prompt = buildLegacyPrompt(depList, prBody, false, "", ""); + expect(prompt).toContain("No usage of these dependencies was found"); + expect(prompt).not.toContain("Usage in Codebase"); + }); + + it("includes dependency changes list in both modes", () => { + const withUsage = buildLegacyPrompt(depList, prBody, true, "usage", "diff"); + const noUsage = buildLegacyPrompt(depList, prBody, false, "", ""); + expect(withUsage).toContain("axios"); + expect(noUsage).toContain("axios"); + }); + + it("includes release notes in both modes", () => { + const withUsage = buildLegacyPrompt(depList, prBody, true, "usage", "diff"); + const noUsage = buildLegacyPrompt(depList, prBody, false, "", ""); + expect(withUsage).toContain("Some notes"); + expect(noUsage).toContain("Some notes"); + }); +}); diff --git a/dependency-impact/src/review.test.ts b/dependency-impact/src/review.test.ts new file mode 100644 index 0000000..d7d6f03 --- /dev/null +++ b/dependency-impact/src/review.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect } from "vitest"; +import { buildReviewBody, buildInlineComments } from "./review"; +import type { DependencyAssessment, EnrichedDependencyChange } from "./types"; +import type { PullRequestFile } from "@gemini-actions/shared"; + +const baseAssessment: DependencyAssessment = { + overallRisk: "high", + riskJustification: "Major upgrade with confirmed breaking change", + dependencySummaries: [ + { + dependency: "axios", + fromVersion: "1.6.0", + toVersion: "2.0.0", + upgradeType: "major", + risk: "high", + oneLiner: "Breaking: createInstance removed", + }, + ], + actionItems: [ + { + severity: "high", + dependency: "axios", + file: "src/api/client.ts", + description: "Replace createInstance() with new Axios()", + }, + ], + inlineAnnotations: [ + { + dependency: "axios", + annotation: "**Major upgrade** — createInstance() removed, see review for details", + }, + ], + narrativeSummary: "The axios 2.0 upgrade removes the createInstance() export.", +}; + +const lowRiskAssessment: DependencyAssessment = { + overallRisk: "low", + riskJustification: "Patch upgrade with no breaking changes", + dependencySummaries: [ + { + dependency: "lodash", + fromVersion: "4.17.20", + toVersion: "4.17.21", + upgradeType: "patch", + risk: "low", + oneLiner: "Security fix only", + }, + ], + actionItems: [], + inlineAnnotations: [ + { + dependency: "lodash", + annotation: "Patch — security fix, no breaking changes", + }, + ], + narrativeSummary: "No breaking changes detected for current usage.", +}; + +describe("buildReviewBody", () => { + it("includes the risk badge", () => { + const body = buildReviewBody(baseAssessment); + expect(body).toContain("**HIGH RISK**"); + expect(body).toContain("Major upgrade with confirmed breaking change"); + }); + + it("includes the summary table", () => { + const body = buildReviewBody(baseAssessment); + expect(body).toContain("| Dependency | Version | Type | Risk | Summary |"); + expect(body).toContain("| axios |"); + expect(body).toContain("1.6.0 → 2.0.0"); + expect(body).toContain("major"); + expect(body).toContain("Breaking: createInstance removed"); + }); + + it("includes action items when present", () => { + const body = buildReviewBody(baseAssessment); + expect(body).toContain("### Action Required"); + expect(body).toContain("`src/api/client.ts`"); + expect(body).toContain("Replace createInstance() with new Axios()"); + }); + + it("omits action section when no action items", () => { + const body = buildReviewBody(lowRiskAssessment); + expect(body).not.toContain("### Action Required"); + }); + + it("includes narrative summary", () => { + const body = buildReviewBody(baseAssessment); + expect(body).toContain("createInstance() export"); + }); + + it("includes footer with dependency count", () => { + const body = buildReviewBody(baseAssessment); + expect(body).toContain("1 dependency change(s) analyzed"); + }); + + it("renders low risk correctly", () => { + const body = buildReviewBody(lowRiskAssessment); + expect(body).toContain("**LOW RISK**"); + }); +}); + +describe("buildInlineComments", () => { + const enrichedDeps: EnrichedDependencyChange[] = [ + { + name: "axios", + fromVersion: "1.6.0", + toVersion: "2.0.0", + ecosystem: "npm", + upgradeType: "major", + releaseNotes: null, + }, + { + name: "lodash", + fromVersion: "4.17.20", + toVersion: "4.17.21", + ecosystem: "npm", + upgradeType: "patch", + releaseNotes: null, + }, + ]; + + const prFiles: PullRequestFile[] = [ + { + filename: "package.json", + status: "modified", + additions: 2, + deletions: 2, + patch: [ + `@@ -10,4 +10,4 @@`, + ` "lodash": "^4.17.20",`, + `- "axios": "^1.6.0"`, + `+ "axios": "^2.0.0"`, + ].join("\n"), + }, + ]; + + it("maps annotations to the correct file and line", () => { + const comments = buildInlineComments(baseAssessment, enrichedDeps, prFiles); + expect(comments).toHaveLength(1); + expect(comments[0].path).toBe("package.json"); + expect(comments[0].line).toBe(11); + expect(comments[0].body).toContain("Major upgrade"); + }); + + it("skips annotations for dependencies not in enrichedDeps", () => { + const assessment: DependencyAssessment = { + ...baseAssessment, + inlineAnnotations: [ + { dependency: "nonexistent-package", annotation: "Should be skipped" }, + ], + }; + const comments = buildInlineComments(assessment, enrichedDeps, prFiles); + expect(comments).toHaveLength(0); + }); + + it("skips annotations when dependency is not in any PR file patch", () => { + const emptyFiles: PullRequestFile[] = [ + { + filename: "package.json", + status: "modified", + additions: 1, + deletions: 1, + patch: [ + `@@ -1,3 +1,3 @@`, + `- "name": "old"`, + `+ "name": "new"`, + ].join("\n"), + }, + ]; + const comments = buildInlineComments(baseAssessment, enrichedDeps, emptyFiles); + expect(comments).toHaveLength(0); + }); + + it("handles files without patches", () => { + const filesNoPatch: PullRequestFile[] = [ + { filename: "package.json", status: "modified", additions: 0, deletions: 0 }, + ]; + const comments = buildInlineComments(baseAssessment, enrichedDeps, filesNoPatch); + expect(comments).toHaveLength(0); + }); + + it("returns empty array when there are no annotations", () => { + const noAnnotations: DependencyAssessment = { + ...baseAssessment, + inlineAnnotations: [], + }; + const comments = buildInlineComments(noAnnotations, enrichedDeps, prFiles); + expect(comments).toHaveLength(0); + }); + + it("produces one comment per dependency even with multiple files", () => { + const multiFiles: PullRequestFile[] = [ + { + filename: "package.json", + status: "modified", + additions: 1, + deletions: 1, + patch: `@@ -10,3 +10,3 @@\n- "axios": "^1.6.0"\n+ "axios": "^2.0.0"`, + }, + { + filename: "package-lock.json", + status: "modified", + additions: 1, + deletions: 1, + patch: `@@ -100,3 +100,3 @@\n- "axios": "1.6.0"\n+ "axios": "2.0.0"`, + }, + ]; + const comments = buildInlineComments(baseAssessment, enrichedDeps, multiFiles); + // Should only match the first file + expect(comments).toHaveLength(1); + expect(comments[0].path).toBe("package.json"); + }); +}); From 9872bbae0ecb7e62393d31e18c6d75c65914c028 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:20:07 +0000 Subject: [PATCH 07/27] chore(dependency-impact): add missing dist declaration files https://claude.ai/code/session_01DcQfYZFCR9zyLMot2crYdN --- dependency-impact/dist/prompts.test.d.ts | 1 + dependency-impact/dist/review.d.ts | 10 ++++++++++ dependency-impact/dist/review.test.d.ts | 1 + 3 files changed, 12 insertions(+) create mode 100644 dependency-impact/dist/prompts.test.d.ts create mode 100644 dependency-impact/dist/review.d.ts create mode 100644 dependency-impact/dist/review.test.d.ts diff --git a/dependency-impact/dist/prompts.test.d.ts b/dependency-impact/dist/prompts.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dependency-impact/dist/prompts.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dependency-impact/dist/review.d.ts b/dependency-impact/dist/review.d.ts new file mode 100644 index 0000000..6f768ca --- /dev/null +++ b/dependency-impact/dist/review.d.ts @@ -0,0 +1,10 @@ +import type { ReviewComment, PullRequestFile } from "@gemini-actions/shared"; +import type { DependencyAssessment, EnrichedDependencyChange } from "./types"; +/** + * Build the review body with a summary table and narrative. + */ +export declare function buildReviewBody(assessment: DependencyAssessment): string; +/** + * Build inline review comments for dependency version-change lines in manifest files. + */ +export declare function buildInlineComments(assessment: DependencyAssessment, enrichedDeps: EnrichedDependencyChange[], prFiles: PullRequestFile[]): ReviewComment[]; diff --git a/dependency-impact/dist/review.test.d.ts b/dependency-impact/dist/review.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dependency-impact/dist/review.test.d.ts @@ -0,0 +1 @@ +export {}; From 7bf6709ebf2e438e3fdd4df9134ec1e860d3a624 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 14:34:22 +0000 Subject: [PATCH 08/27] fix(dependency-impact): extract per-dep sections from Dependabot PR body Instead of copying the entire bot PR body into every dependency's release notes (wasting tokens when multiple deps are bumped), parse the
blocks and extract only the sections that mention each dependency name. Falls back to the full body for single-dep PRs or when no matching block is found. https://claude.ai/code/session_01DcQfYZFCR9zyLMot2crYdN --- dependency-impact/dist/index.js | 26 ++++++++++- dependency-impact/dist/parsers.d.ts | 12 +++++ dependency-impact/src/index.ts | 6 +-- dependency-impact/src/parsers.test.ts | 63 ++++++++++++++++++++++++++- dependency-impact/src/parsers.ts | 22 ++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) diff --git a/dependency-impact/dist/index.js b/dependency-impact/dist/index.js index 78e4f5d..3f77dcf 100644 --- a/dependency-impact/dist/index.js +++ b/dependency-impact/dist/index.js @@ -32005,9 +32005,9 @@ async function resolveGitHubRepo(dep) { const hasBody = pr.body != null && pr.body.trim().length > 50; const releaseNotesPerDep = new Map(); if (isDependabot && hasBody) { - // Dependabot PRs include release notes in the body — use for all deps + // Dependabot PRs embed release notes in the body — extract per-dep sections for (const dep of depChanges) { - releaseNotesPerDep.set(dep.name, pr.body); + releaseNotesPerDep.set(dep.name, (0, parsers_1.extractDependabotSection)(pr.body, dep.name)); } } else { @@ -32141,6 +32141,7 @@ exports.parseDependencyChanges = parseDependencyChanges; exports.classifyUpgrade = classifyUpgrade; exports.findDepLineInPatch = findDepLineInPatch; exports.getImportPatterns = getImportPatterns; +exports.extractDependabotSection = extractDependabotSection; /** * Parse diff lines to collect added/removed values, then emit changes where the * version actually changed. This pattern was previously duplicated for every @@ -32330,6 +32331,27 @@ function getImportPatterns(depName, ecosystem) { return [depName]; } } +/** + * Extract the section of a Dependabot PR body relevant to a specific dependency. + * + * Dependabot group PRs embed per-dependency release notes inside `
` + * blocks whose content mentions the package name. This function returns only + * the matching block(s) instead of the full body, avoiding token duplication + * when the body is attached to every dependency. + * + * Falls back to the full body when no per-dep section can be isolated (e.g. + * single-dependency Dependabot PRs where the whole body is relevant). + */ +function extractDependabotSection(body, depName) { + // Match all
blocks (non-greedy, case-insensitive tags) + const detailsBlocks = body.match(//gi); + if (!detailsBlocks || detailsBlocks.length === 0) + return body; + const matching = detailsBlocks.filter((block) => block.includes(depName)); + if (matching.length === 0) + return body; + return matching.join("\n\n"); +} /***/ }), diff --git a/dependency-impact/dist/parsers.d.ts b/dependency-impact/dist/parsers.d.ts index d3277d5..aca062f 100644 --- a/dependency-impact/dist/parsers.d.ts +++ b/dependency-impact/dist/parsers.d.ts @@ -19,3 +19,15 @@ export declare function classifyUpgrade(from: string, to: string): UpgradeType; */ export declare function findDepLineInPatch(patch: string, depName: string): number | null; export declare function getImportPatterns(depName: string, ecosystem: string): string[]; +/** + * Extract the section of a Dependabot PR body relevant to a specific dependency. + * + * Dependabot group PRs embed per-dependency release notes inside `
` + * blocks whose content mentions the package name. This function returns only + * the matching block(s) instead of the full body, avoiding token duplication + * when the body is attached to every dependency. + * + * Falls back to the full body when no per-dep section can be isolated (e.g. + * single-dependency Dependabot PRs where the whole body is relevant). + */ +export declare function extractDependabotSection(body: string, depName: string): string; diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index da811c0..a0baf17 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -13,7 +13,7 @@ import { runAction, getActionContext, } from "@gemini-actions/shared"; -import { parseDependencyChanges, getImportPatterns, classifyUpgrade } from "./parsers"; +import { parseDependencyChanges, getImportPatterns, classifyUpgrade, extractDependabotSection } from "./parsers"; import type { EnrichedDependencyChange, Step1Result, @@ -158,9 +158,9 @@ runAction(async () => { const releaseNotesPerDep = new Map(); if (isDependabot && hasBody) { - // Dependabot PRs include release notes in the body — use for all deps + // Dependabot PRs embed release notes in the body — extract per-dep sections for (const dep of depChanges) { - releaseNotesPerDep.set(dep.name, pr.body!); + releaseNotesPerDep.set(dep.name, extractDependabotSection(pr.body!, dep.name)); } } else { for (const dep of depChanges) { diff --git a/dependency-impact/src/parsers.test.ts b/dependency-impact/src/parsers.test.ts index a5bf57d..20a754b 100644 --- a/dependency-impact/src/parsers.test.ts +++ b/dependency-impact/src/parsers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseDependencyChanges, getImportPatterns, classifyUpgrade, findDepLineInPatch } from "./parsers"; +import { parseDependencyChanges, getImportPatterns, classifyUpgrade, findDepLineInPatch, extractDependabotSection } from "./parsers"; describe("parseDependencyChanges", () => { describe("terraform", () => { @@ -467,3 +467,64 @@ describe("findDepLineInPatch", () => { expect(findDepLineInPatch(patch, "target")).toBe(21); }); }); + +describe("extractDependabotSection", () => { + const groupBody = [ + "Bumps the deps group with 2 updates.", + "", + '
Release notes for axios', + "

Release v2.0.0 - Breaking changes

", + "
", + "", + '
Release notes for lodash', + "

v4.17.21 - Security fix

", + "
", + ].join("\n"); + + it("extracts only the matching details block for a dependency", () => { + const section = extractDependabotSection(groupBody, "axios"); + expect(section).toContain("axios"); + expect(section).toContain("Release v2.0.0"); + expect(section).not.toContain("lodash"); + }); + + it("extracts the other dependency independently", () => { + const section = extractDependabotSection(groupBody, "lodash"); + expect(section).toContain("lodash"); + expect(section).toContain("Security fix"); + expect(section).not.toContain("axios"); + }); + + it("returns the full body when no details blocks exist", () => { + const plain = "Just a simple PR body with no HTML blocks."; + expect(extractDependabotSection(plain, "axios")).toBe(plain); + }); + + it("returns the full body when no details block mentions the dep", () => { + const unrelated = [ + "
Notes for react", + "

Some react notes

", + "
", + ].join("\n"); + expect(extractDependabotSection(unrelated, "axios")).toBe(unrelated); + }); + + it("returns multiple matching blocks if dep appears in more than one", () => { + const body = [ + '
Release notes for axios', + "

Release notes here

", + "
", + '
Changelog for axios', + "

Changelog entries

", + "
", + '
Commits for lodash', + "

Commit list

", + "
", + ].join("\n"); + + const section = extractDependabotSection(body, "axios"); + expect(section).toContain("Release notes here"); + expect(section).toContain("Changelog entries"); + expect(section).not.toContain("lodash"); + }); +}); diff --git a/dependency-impact/src/parsers.ts b/dependency-impact/src/parsers.ts index 73e142c..226352e 100644 --- a/dependency-impact/src/parsers.ts +++ b/dependency-impact/src/parsers.ts @@ -234,3 +234,25 @@ export function getImportPatterns(depName: string, ecosystem: string): string[] return [depName]; } } + +/** + * Extract the section of a Dependabot PR body relevant to a specific dependency. + * + * Dependabot group PRs embed per-dependency release notes inside `
` + * blocks whose content mentions the package name. This function returns only + * the matching block(s) instead of the full body, avoiding token duplication + * when the body is attached to every dependency. + * + * Falls back to the full body when no per-dep section can be isolated (e.g. + * single-dependency Dependabot PRs where the whole body is relevant). + */ +export function extractDependabotSection(body: string, depName: string): string { + // Match all
blocks (non-greedy, case-insensitive tags) + const detailsBlocks = body.match(//gi); + if (!detailsBlocks || detailsBlocks.length === 0) return body; + + const matching = detailsBlocks.filter((block) => block.includes(depName)); + if (matching.length === 0) return body; + + return matching.join("\n\n"); +} From df383c2d460bdab42dbb4cefb6b64cba62434de0 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Fri, 27 Feb 2026 09:48:16 -0500 Subject: [PATCH 09/27] fix(dependency-impact): exact boundary matching and no-newline marker in findDepLineInPatch Use a word-boundary regex instead of raw includes() so a short name like "aws" no longer matches "aws-sdk" lines in the diff patch. Also skip the "\\ No newline at end of file" diff marker when counting line numbers to prevent off-by-one errors in inline comment placement. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/parsers.test.ts | 22 ++++++++++++++++++++++ dependency-impact/src/parsers.ts | 10 ++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/dependency-impact/src/parsers.test.ts b/dependency-impact/src/parsers.test.ts index 20a754b..efd0442 100644 --- a/dependency-impact/src/parsers.test.ts +++ b/dependency-impact/src/parsers.test.ts @@ -466,6 +466,28 @@ describe("findDepLineInPatch", () => { expect(findDepLineInPatch(patch, "target")).toBe(21); }); + + it("does not match a dep name that is a prefix of another dep name", () => { + const patch = [ + `@@ -10,3 +10,3 @@`, + `- "aws-sdk": "^2.0.0"`, + `+ "aws-sdk": "^3.0.0"`, + ].join("\n"); + + expect(findDepLineInPatch(patch, "aws")).toBeNull(); + }); + + it("skips the no-newline-at-end-of-file marker when counting lines", () => { + const patch = [ + `@@ -10,3 +10,3 @@`, + ` "lodash": "^4.17.0",`, + `- "axios": "^1.6.0"`, + `\\ No newline at end of file`, + `+ "axios": "^2.0.0"`, + ].join("\n"); + + expect(findDepLineInPatch(patch, "axios")).toBe(11); + }); }); describe("extractDependabotSection", () => { diff --git a/dependency-impact/src/parsers.ts b/dependency-impact/src/parsers.ts index 226352e..9b07f76 100644 --- a/dependency-impact/src/parsers.ts +++ b/dependency-impact/src/parsers.ts @@ -180,6 +180,11 @@ export function classifyUpgrade(from: string, to: string): UpgradeType { */ export function findDepLineInPatch(patch: string, depName: string): number | null { let lineNum = 0; + const escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Require exact word boundary: dep name must not be preceded or followed by + // a character that is valid in a package name (alphanumeric, _, ., /, @, -) + // so that e.g. "aws" does not match "aws-sdk". + const depPattern = new RegExp(`(? Date: Fri, 27 Feb 2026 09:48:23 -0500 Subject: [PATCH 10/27] fix(dependency-impact): restrict inline comment placement to manifest files buildInlineComments now filters prFiles to known manifest filenames (package.json, go.mod, requirements.txt, etc.) before searching for the dependency version-change line. This prevents annotations from being placed on source or doc files that happen to mention the dependency name. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/review.test.ts | 22 ++++++++++++++++++++++ dependency-impact/src/review.ts | 9 ++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/dependency-impact/src/review.test.ts b/dependency-impact/src/review.test.ts index d7d6f03..04b8377 100644 --- a/dependency-impact/src/review.test.ts +++ b/dependency-impact/src/review.test.ts @@ -189,6 +189,28 @@ describe("buildInlineComments", () => { expect(comments).toHaveLength(0); }); + it("does not attach inline comments to non-manifest source files", () => { + const filesWithSource: PullRequestFile[] = [ + { + filename: "src/client.ts", + status: "modified", + additions: 1, + deletions: 1, + patch: `@@ -1,3 +1,3 @@\n-// uses axios 1.x\n+// uses axios 2.x`, + }, + { + filename: "package.json", + status: "modified", + additions: 1, + deletions: 1, + patch: `@@ -10,3 +10,3 @@\n- "axios": "^1.6.0"\n+ "axios": "^2.0.0"`, + }, + ]; + const comments = buildInlineComments(baseAssessment, enrichedDeps, filesWithSource); + expect(comments).toHaveLength(1); + expect(comments[0].path).toBe("package.json"); + }); + it("produces one comment per dependency even with multiple files", () => { const multiFiles: PullRequestFile[] = [ { diff --git a/dependency-impact/src/review.ts b/dependency-impact/src/review.ts index 3f16027..41d71ba 100644 --- a/dependency-impact/src/review.ts +++ b/dependency-impact/src/review.ts @@ -2,6 +2,13 @@ import type { ReviewComment, PullRequestFile } from "@gemini-actions/shared"; import { findDepLineInPatch } from "./parsers"; import type { DependencyAssessment, EnrichedDependencyChange, RiskLevel } from "./types"; +const MANIFEST_FILE_RE = + /(?:^|\/)(package\.json|package-lock\.json|composer\.json|composer\.lock|requirements\.txt|Pipfile|go\.mod|\.terraform\.lock\.hcl)$/; + +function isManifestFile(filename: string): boolean { + return MANIFEST_FILE_RE.test(filename); +} + const riskLabel: Record = { low: "LOW RISK", medium: "MEDIUM RISK", @@ -57,7 +64,7 @@ export function buildInlineComments( const dep = enrichedDeps.find((d) => d.name === annotation.dependency); if (!dep) continue; - for (const file of prFiles) { + for (const file of prFiles.filter((f) => isManifestFile(f.filename))) { if (!file.patch) continue; const line = findDepLineInPatch(file.patch, dep.name); if (line != null && line > 0) { From 6a7d79b7c97b2630b31b83aa3732d90221451f70 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Fri, 27 Feb 2026 09:49:13 -0500 Subject: [PATCH 11/27] fix(dependency-impact): avoid duplicating PR body for every dep in fallback When no GitHub release notes are found for any dep, the code fell back to storing the full PR body for every changed dependency. In multi-dep PRs this embedded the same content N times in the Step 1 prompt, inflating token usage and mixing unrelated notes under each dependency section. Now the PR body is attached only to the first dep that has no dedicated release notes. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index a0baf17..a7306d1 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -178,11 +178,11 @@ runAction(async () => { } } } - // Fall back to PR body if no GitHub Releases found - if (releaseNotesPerDep.size === 0 && hasBody) { - for (const dep of depChanges) { - releaseNotesPerDep.set(dep.name, pr.body!); - } + // Fall back to PR body if no GitHub Releases found. Only attach it to the + // first dep to avoid duplicating the same content N times in the Step 1 + // prompt for multi-dep PRs, which inflates token usage unnecessarily. + if (releaseNotesPerDep.size === 0 && hasBody && depChanges.length > 0) { + releaseNotesPerDep.set(depChanges[0].name, pr.body!); } } From 6d716597193a76e069d29cd467228eed244a386e Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Fri, 27 Feb 2026 09:50:02 -0500 Subject: [PATCH 12/27] fix(dependency-impact): guard against schema conformance failures in step results parseJsonResponse only validates JSON syntax, not schema shape. If the LLM returns a structurally valid JSON object that is missing required fields (dependencySummaries, actionItems, inlineAnnotations, deprecations, etc.) the action would throw a TypeError outside any try-catch, bypassing runLegacyFallback. - Use optional chaining for d.deprecations/d.notableChanges in the hasBreakingChanges check so a partial step-1 result doesn't crash step 2 - Wrap buildReviewBody/buildInlineComments in a try-catch that falls back to the legacy prompt when the step-3 assessment is structurally incomplete Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/index.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index a7306d1..6906c3a 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -223,8 +223,8 @@ runAction(async () => { const hasBreakingChanges = step1Result.dependencies.some( (d) => d.hasConfirmedBreakingChanges || - d.deprecations.length > 0 || - d.notableChanges.length > 0, + (d.deprecations?.length ?? 0) > 0 || + (d.notableChanges?.length ?? 0) > 0, ); let step2Result: Step2Result = { impacts: [], unaffectedUsages: [] }; @@ -260,8 +260,16 @@ runAction(async () => { } // 10. Post the analysis as a review with inline comments - const body = buildReviewBody(assessment); - const inlineComments = buildInlineComments(assessment, enrichedDeps, pr.files); + let body: string; + let inlineComments: ReturnType; + try { + body = buildReviewBody(assessment); + inlineComments = buildInlineComments(assessment, enrichedDeps, pr.files); + } catch (err) { + core.warning(`Review rendering failed (${err instanceof Error ? err.message : err}), falling back to legacy prompt`); + await runLegacyFallback(enrichedDeps, usageSections, hasUsage, pr.diff); + return; + } if (inlineComments.length > 0) { await createReview(octokit, owner, repo, prNumber, body, inlineComments); From ab19fd118e74654c94ee0d814230ff1381591221 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Fri, 27 Feb 2026 10:03:23 -0500 Subject: [PATCH 13/27] fix(dependency-impact): guard against partial step-1 results in buildStep2Prompt buildStep2Prompt accessed d.deprecations.length and d.notableChanges.length directly. Since parseJsonResponse only validates JSON syntax, a partial LLM response missing these arrays throws a TypeError that silently swallows Step 2 via the catch handler even when confirmed breaking changes exist. Use optional chaining and nullish coalescing throughout the filter and the spread map so a schema-incomplete step-1 result degrades gracefully. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/prompts.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dependency-impact/src/prompts.ts b/dependency-impact/src/prompts.ts index da23474..c916d4b 100644 --- a/dependency-impact/src/prompts.ts +++ b/dependency-impact/src/prompts.ts @@ -50,16 +50,16 @@ export function buildStep2Prompt( const relevantDeps = step1Result.dependencies.filter( (d) => d.hasConfirmedBreakingChanges || - d.deprecations.length > 0 || - d.notableChanges.length > 0, + (d.deprecations?.length ?? 0) > 0 || + (d.notableChanges?.length ?? 0) > 0, ); const changesSummary = relevantDeps .map((d) => { const items = [ - ...d.breakingChanges.map((c) => ` - [BREAKING] ${c}`), - ...d.deprecations.map((c) => ` - [DEPRECATED] ${c}`), - ...d.notableChanges.map((c) => ` - [CHANGED] ${c}`), + ...(d.breakingChanges ?? []).map((c) => ` - [BREAKING] ${c}`), + ...(d.deprecations ?? []).map((c) => ` - [DEPRECATED] ${c}`), + ...(d.notableChanges ?? []).map((c) => ` - [CHANGED] ${c}`), ].join("\n"); return `### ${d.dependency}\n${items}`; }) From 16d48a100a9e3e0fd8805be3b6faf0d92251052e Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Fri, 27 Feb 2026 10:03:32 -0500 Subject: [PATCH 14/27] fix(dependency-impact): exact boundary matching in extractDependabotSection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit block.includes(depName) caused substring collisions in grouped Dependabot PRs — e.g. searching for "aws" could match a block for "aws-sdk", attaching the wrong release notes to the dependency and propagating misattributed breaking-change analysis through all three steps. Apply the same word-boundary regex used in findDepLineInPatch so only blocks that contain the exact package name (not a prefix or suffix of it) are selected. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/parsers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dependency-impact/src/parsers.ts b/dependency-impact/src/parsers.ts index 9b07f76..fae1e1d 100644 --- a/dependency-impact/src/parsers.ts +++ b/dependency-impact/src/parsers.ts @@ -257,7 +257,9 @@ export function extractDependabotSection(body: string, depName: string): string const detailsBlocks = body.match(//gi); if (!detailsBlocks || detailsBlocks.length === 0) return body; - const matching = detailsBlocks.filter((block) => block.includes(depName)); + const escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const depBoundaryRe = new RegExp(`(? depBoundaryRe.test(block)); if (matching.length === 0) return body; return matching.join("\n\n"); From bc92c22410365c6850266018b8d829cc34f3afd3 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Fri, 27 Feb 2026 10:03:40 -0500 Subject: [PATCH 15/27] fix(dependency-impact): restore PR body fallback for all deps to prevent under-analysis The previous fix attached pr.body only to the first dependency to reduce prompt token usage. This caused the remaining dependencies in multi-dep PRs to receive no release notes context in Step 1, leading to falsely low risk summaries. The non-Dependabot fallback is already less token-intensive than the original Dependabot case (which duplicated the entire body before extractDependabotSection was introduced), so restoring it for all deps is the correct trade-off. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index 6906c3a..42ad2c0 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -178,11 +178,11 @@ runAction(async () => { } } } - // Fall back to PR body if no GitHub Releases found. Only attach it to the - // first dep to avoid duplicating the same content N times in the Step 1 - // prompt for multi-dep PRs, which inflates token usage unnecessarily. - if (releaseNotesPerDep.size === 0 && hasBody && depChanges.length > 0) { - releaseNotesPerDep.set(depChanges[0].name, pr.body!); + // Fall back to PR body if no GitHub Releases found. + if (releaseNotesPerDep.size === 0 && hasBody) { + for (const dep of depChanges) { + releaseNotesPerDep.set(dep.name, pr.body!); + } } } From ce8e46cd8a3c6c3ca48ab26d2a07c17abdbcabbc Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Fri, 27 Feb 2026 10:03:47 -0500 Subject: [PATCH 16/27] build(dependency-impact): rebuild dist bundle Regenerate dist/index.js to include all recent source changes: manifest file filter in buildInlineComments, word-boundary regex in findDepLineInPatch, no-newline marker skipping, optional chaining for partial LLM results, and extractDependabotSection exact matching. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/dist/index.js | 49 +++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/dependency-impact/dist/index.js b/dependency-impact/dist/index.js index 3f77dcf..88b1526 100644 --- a/dependency-impact/dist/index.js +++ b/dependency-impact/dist/index.js @@ -32020,7 +32020,7 @@ async function resolveGitHubRepo(dep) { } } } - // Fall back to PR body if no GitHub Releases found + // Fall back to PR body if no GitHub Releases found. if (releaseNotesPerDep.size === 0 && hasBody) { for (const dep of depChanges) { releaseNotesPerDep.set(dep.name, pr.body); @@ -32058,8 +32058,8 @@ async function resolveGitHubRepo(dep) { } // 8. Step 2: Cross-reference with codebase usage (conditional) const hasBreakingChanges = step1Result.dependencies.some((d) => d.hasConfirmedBreakingChanges || - d.deprecations.length > 0 || - d.notableChanges.length > 0); + (d.deprecations?.length ?? 0) > 0 || + (d.notableChanges?.length ?? 0) > 0); let step2Result = { impacts: [], unaffectedUsages: [] }; if (hasUsage && hasBreakingChanges) { core.info("Step 2: Cross-referencing breaking changes with codebase usage..."); @@ -32092,8 +32092,17 @@ async function resolveGitHubRepo(dep) { return; } // 10. Post the analysis as a review with inline comments - const body = (0, review_1.buildReviewBody)(assessment); - const inlineComments = (0, review_1.buildInlineComments)(assessment, enrichedDeps, pr.files); + let body; + let inlineComments; + try { + body = (0, review_1.buildReviewBody)(assessment); + inlineComments = (0, review_1.buildInlineComments)(assessment, enrichedDeps, pr.files); + } + catch (err) { + core.warning(`Review rendering failed (${err instanceof Error ? err.message : err}), falling back to legacy prompt`); + await runLegacyFallback(enrichedDeps, usageSections, hasUsage, pr.diff); + return; + } if (inlineComments.length > 0) { await (0, shared_1.createReview)(octokit, owner, repo, prNumber, body, inlineComments); } @@ -32278,16 +32287,22 @@ function classifyUpgrade(from, to) { */ function findDepLineInPatch(patch, depName) { let lineNum = 0; + const escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Require exact word boundary: dep name must not be preceded or followed by + // a character that is valid in a package name (alphanumeric, _, ., /, @, -) + // so that e.g. "aws" does not match "aws-sdk". + const depPattern = new RegExp(`(?/gi); if (!detailsBlocks || detailsBlocks.length === 0) return body; - const matching = detailsBlocks.filter((block) => block.includes(depName)); + const escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const depBoundaryRe = new RegExp(`(? depBoundaryRe.test(block)); if (matching.length === 0) return body; return matching.join("\n\n"); @@ -32409,14 +32426,14 @@ Respond ONLY with the JSON object.`; } function buildStep2Prompt(step1Result, usageSections) { const relevantDeps = step1Result.dependencies.filter((d) => d.hasConfirmedBreakingChanges || - d.deprecations.length > 0 || - d.notableChanges.length > 0); + (d.deprecations?.length ?? 0) > 0 || + (d.notableChanges?.length ?? 0) > 0); const changesSummary = relevantDeps .map((d) => { const items = [ - ...d.breakingChanges.map((c) => ` - [BREAKING] ${c}`), - ...d.deprecations.map((c) => ` - [DEPRECATED] ${c}`), - ...d.notableChanges.map((c) => ` - [CHANGED] ${c}`), + ...(d.breakingChanges ?? []).map((c) => ` - [BREAKING] ${c}`), + ...(d.deprecations ?? []).map((c) => ` - [DEPRECATED] ${c}`), + ...(d.notableChanges ?? []).map((c) => ` - [CHANGED] ${c}`), ].join("\n"); return `### ${d.dependency}\n${items}`; }) @@ -32624,6 +32641,10 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildReviewBody = buildReviewBody; exports.buildInlineComments = buildInlineComments; const parsers_1 = __nccwpck_require__(2149); +const MANIFEST_FILE_RE = /(?:^|\/)(package\.json|package-lock\.json|composer\.json|composer\.lock|requirements\.txt|Pipfile|go\.mod|\.terraform\.lock\.hcl)$/; +function isManifestFile(filename) { + return MANIFEST_FILE_RE.test(filename); +} const riskLabel = { low: "LOW RISK", medium: "MEDIUM RISK", @@ -32666,7 +32687,7 @@ function buildInlineComments(assessment, enrichedDeps, prFiles) { const dep = enrichedDeps.find((d) => d.name === annotation.dependency); if (!dep) continue; - for (const file of prFiles) { + for (const file of prFiles.filter((f) => isManifestFile(f.filename))) { if (!file.patch) continue; const line = (0, parsers_1.findDepLineInPatch)(file.patch, dep.name); From e50aabc8000ed4dbd3c4a2340b77cd53e3aa5377 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 28 Feb 2026 12:26:17 -0500 Subject: [PATCH 17/27] fix(dependency-impact): use composite name::ecosystem key for release notes map releaseNotesPerDep was keyed by dep.name alone. In monorepos or PRs that update identically named packages across different ecosystems, later entries silently overwrote earlier ones, causing Step 1/3 to attribute breaking changes from the wrong upgrade. Key by "name::ecosystem" instead to keep each dependency's notes isolated. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/index.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index 42ad2c0..1bca6bf 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -155,12 +155,15 @@ runAction(async () => { // 5. Fetch release notes const isDependabot = /\[bot\]$/.test(pr.author); const hasBody = pr.body != null && pr.body.trim().length > 50; + // Keyed by "name::ecosystem" to avoid collisions when a PR updates packages + // with the same name across different ecosystems (e.g. a monorepo). const releaseNotesPerDep = new Map(); + const depNoteKey = (d: { name: string; ecosystem: string }) => `${d.name}::${d.ecosystem}`; if (isDependabot && hasBody) { // Dependabot PRs embed release notes in the body — extract per-dep sections for (const dep of depChanges) { - releaseNotesPerDep.set(dep.name, extractDependabotSection(pr.body!, dep.name)); + releaseNotesPerDep.set(depNoteKey(dep), extractDependabotSection(pr.body!, dep.name)); } } else { for (const dep of depChanges) { @@ -174,14 +177,14 @@ runAction(async () => { dep.toVersion, ); if (notes) { - releaseNotesPerDep.set(dep.name, notes); + releaseNotesPerDep.set(depNoteKey(dep), notes); } } } // Fall back to PR body if no GitHub Releases found. if (releaseNotesPerDep.size === 0 && hasBody) { for (const dep of depChanges) { - releaseNotesPerDep.set(dep.name, pr.body!); + releaseNotesPerDep.set(depNoteKey(dep), pr.body!); } } } @@ -190,7 +193,7 @@ runAction(async () => { const enrichedDeps: EnrichedDependencyChange[] = depChanges.map((dep) => ({ ...dep, upgradeType: classifyUpgrade(dep.fromVersion, dep.toVersion), - releaseNotes: releaseNotesPerDep.get(dep.name) ?? null, + releaseNotes: releaseNotesPerDep.get(depNoteKey(dep)) ?? null, })); const maxUsageCharsPerDep = 5000; From b21077574e32bcd62d88aa089d926f4f03afcfa6 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 28 Feb 2026 12:26:26 -0500 Subject: [PATCH 18/27] fix(dependency-impact): normalize LLM risk values before riskLabel lookup riskLabel is keyed by lowercase RiskLevel values. parseJsonResponse does no schema validation, so the LLM can return "Low", "HIGH", etc. and the direct lookup silently produces undefined, rendering "**undefined**" in the review body header and summary table without triggering the legacy fallback. Introduce safeRiskLabel() which lowercases the value before the lookup and falls back to the uppercased raw value if the key is still not found. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/review.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/dependency-impact/src/review.ts b/dependency-impact/src/review.ts index 41d71ba..26cae88 100644 --- a/dependency-impact/src/review.ts +++ b/dependency-impact/src/review.ts @@ -16,6 +16,15 @@ const riskLabel: Record = { critical: "CRITICAL RISK", }; +/** + * Normalize an LLM-returned risk value to a display label, guarding against + * case variations (e.g. "Low" vs "low") that parseJsonResponse won't catch. + */ +function safeRiskLabel(risk: unknown): string { + const normalized = typeof risk === "string" ? (risk.toLowerCase() as RiskLevel) : undefined; + return (normalized && riskLabel[normalized]) ?? String(risk).toUpperCase(); +} + /** * Build the review body with a summary table and narrative. */ @@ -24,7 +33,7 @@ export function buildReviewBody(assessment: DependencyAssessment): string { const tableRows = assessment.dependencySummaries .map( (d) => - `| ${d.dependency} | ${d.fromVersion} → ${d.toVersion} | ${d.upgradeType} | **${riskLabel[d.risk]}** | ${d.oneLiner} |`, + `| ${d.dependency} | ${d.fromVersion} → ${d.toVersion} | ${d.upgradeType} | **${safeRiskLabel(d.risk)}** | ${d.oneLiner} |`, ) .join("\n"); @@ -35,7 +44,7 @@ export function buildReviewBody(assessment: DependencyAssessment): string { return `## Gemini Dependency Impact Analysis -**${riskLabel[assessment.overallRisk]}** — ${assessment.riskJustification} +**${safeRiskLabel(assessment.overallRisk)}** — ${assessment.riskJustification} ### Summary From 366c35300803069adc01391ad516fec8f4730e5d Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 28 Feb 2026 12:26:33 -0500 Subject: [PATCH 19/27] build(dependency-impact): rebuild dist with composite key and risk normalization Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/dist/index.js | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/dependency-impact/dist/index.js b/dependency-impact/dist/index.js index 88b1526..4f559fd 100644 --- a/dependency-impact/dist/index.js +++ b/dependency-impact/dist/index.js @@ -32003,11 +32003,14 @@ async function resolveGitHubRepo(dep) { // 5. Fetch release notes const isDependabot = /\[bot\]$/.test(pr.author); const hasBody = pr.body != null && pr.body.trim().length > 50; + // Keyed by "name::ecosystem" to avoid collisions when a PR updates packages + // with the same name across different ecosystems (e.g. a monorepo). const releaseNotesPerDep = new Map(); + const depNoteKey = (d) => `${d.name}::${d.ecosystem}`; if (isDependabot && hasBody) { // Dependabot PRs embed release notes in the body — extract per-dep sections for (const dep of depChanges) { - releaseNotesPerDep.set(dep.name, (0, parsers_1.extractDependabotSection)(pr.body, dep.name)); + releaseNotesPerDep.set(depNoteKey(dep), (0, parsers_1.extractDependabotSection)(pr.body, dep.name)); } } else { @@ -32016,14 +32019,14 @@ async function resolveGitHubRepo(dep) { if (ghRepo) { const notes = await (0, shared_1.listReleaseNotesBetween)(octokit, ghRepo.owner, ghRepo.repo, dep.fromVersion, dep.toVersion); if (notes) { - releaseNotesPerDep.set(dep.name, notes); + releaseNotesPerDep.set(depNoteKey(dep), notes); } } } // Fall back to PR body if no GitHub Releases found. if (releaseNotesPerDep.size === 0 && hasBody) { for (const dep of depChanges) { - releaseNotesPerDep.set(dep.name, pr.body); + releaseNotesPerDep.set(depNoteKey(dep), pr.body); } } } @@ -32031,7 +32034,7 @@ async function resolveGitHubRepo(dep) { const enrichedDeps = depChanges.map((dep) => ({ ...dep, upgradeType: (0, parsers_1.classifyUpgrade)(dep.fromVersion, dep.toVersion), - releaseNotes: releaseNotesPerDep.get(dep.name) ?? null, + releaseNotes: releaseNotesPerDep.get(depNoteKey(dep)) ?? null, })); const maxUsageCharsPerDep = 5000; const usageSections = Object.entries(usageContext) @@ -32651,20 +32654,28 @@ const riskLabel = { high: "HIGH RISK", critical: "CRITICAL RISK", }; +/** + * Normalize an LLM-returned risk value to a display label, guarding against + * case variations (e.g. "Low" vs "low") that parseJsonResponse won't catch. + */ +function safeRiskLabel(risk) { + const normalized = typeof risk === "string" ? risk.toLowerCase() : undefined; + return (normalized && riskLabel[normalized]) ?? String(risk).toUpperCase(); +} /** * Build the review body with a summary table and narrative. */ function buildReviewBody(assessment) { const tableHeader = "| Dependency | Version | Type | Risk | Summary |\n|---|---|---|---|---|"; const tableRows = assessment.dependencySummaries - .map((d) => `| ${d.dependency} | ${d.fromVersion} → ${d.toVersion} | ${d.upgradeType} | **${riskLabel[d.risk]}** | ${d.oneLiner} |`) + .map((d) => `| ${d.dependency} | ${d.fromVersion} → ${d.toVersion} | ${d.upgradeType} | **${safeRiskLabel(d.risk)}** | ${d.oneLiner} |`) .join("\n"); const actionSection = assessment.actionItems.length > 0 ? `### Action Required\n\n${assessment.actionItems.map((a) => `- **[${a.severity.toUpperCase()}]** \`${a.file}\` — ${a.description}`).join("\n")}\n\n` : ""; return `## Gemini Dependency Impact Analysis -**${riskLabel[assessment.overallRisk]}** — ${assessment.riskJustification} +**${safeRiskLabel(assessment.overallRisk)}** — ${assessment.riskJustification} ### Summary From e9a6cb430ada65cc52297ba1f2760ac8788405f8 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 28 Feb 2026 13:09:44 -0500 Subject: [PATCH 20/27] fix(dependency-impact): deduplicate dep changes by name::ecosystem to prevent map key collisions parseDependencyChanges can emit multiple entries with the same name and ecosystem when a package appears in several manifests within a monorepo. This caused releaseNotesPerDep.set() to overwrite earlier notes for the same package, and buildInlineComments' enrichedDeps.find() to always bind annotations to the first occurrence regardless of ecosystem. Deduplicate depChanges by name::ecosystem immediately after parsing, keeping the first occurrence. Analysis and release-note lookups apply to the package identity, not to individual manifest files, so this is the correct unit. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/index.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index 1bca6bf..c0d899f 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -88,8 +88,19 @@ runAction(async () => { 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); + // 2. Parse dependency changes from the diff, deduplicating by name::ecosystem. + // The same package can appear in multiple manifests in a monorepo; analysis + // and release-note lookups are per-package identity, so we keep only the + // first occurrence and let findDepLineInPatch locate the right line per file. + const depChanges = (() => { + const seen = new Set(); + return parseDependencyChanges(pr.diff, pr.files).filter((dep) => { + const key = `${dep.name}::${dep.ecosystem}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + })(); if (depChanges.length === 0) { core.info("No dependency version changes detected in this PR"); From 046c285225d9e09d84e47c08a633a919c43484d1 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 28 Feb 2026 13:09:44 -0500 Subject: [PATCH 21/27] build(dependency-impact): rebuild dist with deduplication fix Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/dist/index.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/dependency-impact/dist/index.js b/dependency-impact/dist/index.js index 4f559fd..18f7a08 100644 --- a/dependency-impact/dist/index.js +++ b/dependency-impact/dist/index.js @@ -31960,8 +31960,20 @@ async function resolveGitHubRepo(dep) { // 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); + // 2. Parse dependency changes from the diff, deduplicating by name::ecosystem. + // The same package can appear in multiple manifests in a monorepo; analysis + // and release-note lookups are per-package identity, so we keep only the + // first occurrence and let findDepLineInPatch locate the right line per file. + const depChanges = (() => { + const seen = new Set(); + return (0, parsers_1.parseDependencyChanges)(pr.diff, pr.files).filter((dep) => { + const key = `${dep.name}::${dep.ecosystem}`; + if (seen.has(key)) + return false; + seen.add(key); + return true; + }); + })(); 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."); From f313b0cd6fba05c4ef5e64132346f40f0bdb19b9 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 28 Feb 2026 15:03:51 -0500 Subject: [PATCH 22/27] fix(dependency-impact): classify 0.x minor bumps as major in classifyUpgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In 0.x packages, semver allows breaking changes on minor version increments (0.4.0 → 0.5.0). Treating them as "minor" caused Step 1 to be primed with "minor upgrades rarely have breaking changes", leading to systematic under-analysis of pre-1.0 packages. Return "major" when the major segment is 0 and the minor segment changes. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/parsers.test.ts | 9 +++++++++ dependency-impact/src/parsers.ts | 3 +++ 2 files changed, 12 insertions(+) diff --git a/dependency-impact/src/parsers.test.ts b/dependency-impact/src/parsers.test.ts index efd0442..cd92c7e 100644 --- a/dependency-impact/src/parsers.test.ts +++ b/dependency-impact/src/parsers.test.ts @@ -426,6 +426,15 @@ describe("classifyUpgrade", () => { expect(classifyUpgrade("abc", "def")).toBe("unknown"); }); + it("treats 0.x minor bumps as major", () => { + expect(classifyUpgrade("0.4.0", "0.5.0")).toBe("major"); + expect(classifyUpgrade("0.1.0", "0.2.0")).toBe("major"); + }); + + it("treats 0.x patch bumps as patch", () => { + expect(classifyUpgrade("0.4.0", "0.4.1")).toBe("patch"); + }); + it("handles two-segment versions", () => { expect(classifyUpgrade("1.0", "1.1")).toBe("minor"); expect(classifyUpgrade("1.0", "2.0")).toBe("major"); diff --git a/dependency-impact/src/parsers.ts b/dependency-impact/src/parsers.ts index fae1e1d..beda34d 100644 --- a/dependency-impact/src/parsers.ts +++ b/dependency-impact/src/parsers.ts @@ -170,6 +170,9 @@ export function classifyUpgrade(from: string, to: string): UpgradeType { if (!fromParts || !toParts) return "unknown"; if (toParts[0] !== fromParts[0]) return "major"; + // In 0.x packages, minor bumps carry the same breaking-change risk as a + // major bump (semver allows breaking changes throughout 0.y.z). + if (fromParts[0] === 0 && toParts[1] !== fromParts[1]) return "major"; if (toParts[1] !== fromParts[1]) return "minor"; return "patch"; } From 2d68bb74a6aa0ee9e4a22f1b8380a4c350e1937d Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 28 Feb 2026 15:03:51 -0500 Subject: [PATCH 23/27] fix(dependency-impact): preserve step-2 safe default when parsed result is malformed parseJsonResponse could succeed (valid JSON) but return an object missing required fields like impacts. The previous code assigned the parsed value to step2Result before accessing .impacts.length, so a TypeError in that access entered the catch block with step2Result already holding the malformed object instead of the safe default. Step 3 then received garbage data despite the warning saying "empty impact analysis". Move the step2Result assignment after all field accesses so the safe default is preserved whenever parsing or validation throws. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index c0d899f..689cfcd 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -247,8 +247,11 @@ runAction(async () => { core.info("Step 2: Cross-referencing breaking changes with codebase usage..."); try { const step2Response = await generateContent(model, buildStep2Prompt(step1Result, usageSections), 300_000); - step2Result = parseJsonResponse(step2Response); - core.info(`Step 2 complete: ${step2Result.impacts.length} impact(s) found`); + const parsed = parseJsonResponse(step2Response); + core.info(`Step 2 complete: ${parsed.impacts.length} impact(s) found`); + // Assign only after all field accesses succeed so a malformed response + // does not overwrite the safe default before the catch block runs. + step2Result = parsed; } catch (err) { core.warning(`Step 2 failed (${err instanceof Error ? err.message : err}), proceeding with empty impact analysis`); } From b1cfe5b0de4df18acccee894fcbb7a3d4227ff18 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 28 Feb 2026 15:03:52 -0500 Subject: [PATCH 24/27] build(dependency-impact): rebuild dist with 0.x classification and step-2 fix Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/dist/index.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/dependency-impact/dist/index.js b/dependency-impact/dist/index.js index 18f7a08..e6563d8 100644 --- a/dependency-impact/dist/index.js +++ b/dependency-impact/dist/index.js @@ -32080,8 +32080,11 @@ async function resolveGitHubRepo(dep) { core.info("Step 2: Cross-referencing breaking changes with codebase usage..."); try { const step2Response = await (0, shared_1.generateContent)(model, (0, prompts_1.buildStep2Prompt)(step1Result, usageSections), 300_000); - step2Result = (0, shared_1.parseJsonResponse)(step2Response); - core.info(`Step 2 complete: ${step2Result.impacts.length} impact(s) found`); + const parsed = (0, shared_1.parseJsonResponse)(step2Response); + core.info(`Step 2 complete: ${parsed.impacts.length} impact(s) found`); + // Assign only after all field accesses succeed so a malformed response + // does not overwrite the safe default before the catch block runs. + step2Result = parsed; } catch (err) { core.warning(`Step 2 failed (${err instanceof Error ? err.message : err}), proceeding with empty impact analysis`); @@ -32292,6 +32295,10 @@ function classifyUpgrade(from, to) { return "unknown"; if (toParts[0] !== fromParts[0]) return "major"; + // In 0.x packages, minor bumps carry the same breaking-change risk as a + // major bump (semver allows breaking changes throughout 0.y.z). + if (fromParts[0] === 0 && toParts[1] !== fromParts[1]) + return "major"; if (toParts[1] !== fromParts[1]) return "minor"; return "patch"; From 266ebc151215f302d33b756ba46a735640705a5e Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sun, 1 Mar 2026 04:20:33 -0500 Subject: [PATCH 25/27] fix(dependency-impact): deduplicate by full version range in monorepo PRs Use name::ecosystem::fromVersion::toVersion as the dedup key so that the same package upgraded to different ranges across manifests is analyzed once per distinct range instead of only once per package. Update depNoteKey to match so release notes are fetched per range. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/index.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index 689cfcd..8bcc710 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -88,14 +88,16 @@ runAction(async () => { const pr = await getPullRequest(octokit, owner, repo, prNumber); core.info(`PR: ${pr.title}`); - // 2. Parse dependency changes from the diff, deduplicating by name::ecosystem. - // The same package can appear in multiple manifests in a monorepo; analysis - // and release-note lookups are per-package identity, so we keep only the - // first occurrence and let findDepLineInPatch locate the right line per file. + // 2. Parse dependency changes from the diff, deduplicating by + // name::ecosystem::fromVersion::toVersion. The same package can appear in + // multiple manifests in a monorepo, sometimes with different version ranges + // (e.g. one manifest is behind). We keep one entry per distinct upgrade so + // each unique range is analyzed; findDepLineInPatch locates the right line + // per manifest file at annotation time. const depChanges = (() => { const seen = new Set(); return parseDependencyChanges(pr.diff, pr.files).filter((dep) => { - const key = `${dep.name}::${dep.ecosystem}`; + const key = `${dep.name}::${dep.ecosystem}::${dep.fromVersion}::${dep.toVersion}`; if (seen.has(key)) return false; seen.add(key); return true; @@ -166,10 +168,11 @@ runAction(async () => { // 5. Fetch release notes const isDependabot = /\[bot\]$/.test(pr.author); const hasBody = pr.body != null && pr.body.trim().length > 50; - // Keyed by "name::ecosystem" to avoid collisions when a PR updates packages - // with the same name across different ecosystems (e.g. a monorepo). + // Keyed by "name::ecosystem::from::to" so distinct version ranges for the + // same package (possible in monorepos) each get their own release notes. const releaseNotesPerDep = new Map(); - const depNoteKey = (d: { name: string; ecosystem: string }) => `${d.name}::${d.ecosystem}`; + const depNoteKey = (d: { name: string; ecosystem: string; fromVersion: string; toVersion: string }) => + `${d.name}::${d.ecosystem}::${d.fromVersion}::${d.toVersion}`; if (isDependabot && hasBody) { // Dependabot PRs embed release notes in the body — extract per-dep sections From 95b94f29a7e61397857d7646b90db29ba6464491 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sun, 1 Mar 2026 04:20:42 -0500 Subject: [PATCH 26/27] fix(dependency-impact): fall back to postComment when createReview fails If GitHub rejects the review (e.g. an inline comment line falls outside the diff range), catch the error and post the analysis body as a plain comment so the review is never silently lost. Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/src/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dependency-impact/src/index.ts b/dependency-impact/src/index.ts index 8bcc710..7bc9217 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -292,7 +292,12 @@ runAction(async () => { } if (inlineComments.length > 0) { - await createReview(octokit, owner, repo, prNumber, body, inlineComments); + try { + await createReview(octokit, owner, repo, prNumber, body, inlineComments); + } catch (err) { + core.warning(`createReview failed (${err instanceof Error ? err.message : err}), falling back to plain comment`); + await postComment(octokit, owner, repo, prNumber, body); + } } else { await postComment(octokit, owner, repo, prNumber, body); } From f48bd033497e59336cbddf096d49e0c1b45b8298 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sun, 1 Mar 2026 04:20:57 -0500 Subject: [PATCH 27/27] build(dependency-impact): rebuild dist Co-Authored-By: Claude Sonnet 4.6 --- dependency-impact/dist/index.js | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/dependency-impact/dist/index.js b/dependency-impact/dist/index.js index e6563d8..47a5378 100644 --- a/dependency-impact/dist/index.js +++ b/dependency-impact/dist/index.js @@ -31960,14 +31960,16 @@ async function resolveGitHubRepo(dep) { // 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, deduplicating by name::ecosystem. - // The same package can appear in multiple manifests in a monorepo; analysis - // and release-note lookups are per-package identity, so we keep only the - // first occurrence and let findDepLineInPatch locate the right line per file. + // 2. Parse dependency changes from the diff, deduplicating by + // name::ecosystem::fromVersion::toVersion. The same package can appear in + // multiple manifests in a monorepo, sometimes with different version ranges + // (e.g. one manifest is behind). We keep one entry per distinct upgrade so + // each unique range is analyzed; findDepLineInPatch locates the right line + // per manifest file at annotation time. const depChanges = (() => { const seen = new Set(); return (0, parsers_1.parseDependencyChanges)(pr.diff, pr.files).filter((dep) => { - const key = `${dep.name}::${dep.ecosystem}`; + const key = `${dep.name}::${dep.ecosystem}::${dep.fromVersion}::${dep.toVersion}`; if (seen.has(key)) return false; seen.add(key); @@ -32015,10 +32017,10 @@ async function resolveGitHubRepo(dep) { // 5. Fetch release notes const isDependabot = /\[bot\]$/.test(pr.author); const hasBody = pr.body != null && pr.body.trim().length > 50; - // Keyed by "name::ecosystem" to avoid collisions when a PR updates packages - // with the same name across different ecosystems (e.g. a monorepo). + // Keyed by "name::ecosystem::from::to" so distinct version ranges for the + // same package (possible in monorepos) each get their own release notes. const releaseNotesPerDep = new Map(); - const depNoteKey = (d) => `${d.name}::${d.ecosystem}`; + const depNoteKey = (d) => `${d.name}::${d.ecosystem}::${d.fromVersion}::${d.toVersion}`; if (isDependabot && hasBody) { // Dependabot PRs embed release notes in the body — extract per-dep sections for (const dep of depChanges) { @@ -32122,7 +32124,13 @@ async function resolveGitHubRepo(dep) { return; } if (inlineComments.length > 0) { - await (0, shared_1.createReview)(octokit, owner, repo, prNumber, body, inlineComments); + try { + await (0, shared_1.createReview)(octokit, owner, repo, prNumber, body, inlineComments); + } + catch (err) { + core.warning(`createReview failed (${err instanceof Error ? err.message : err}), falling back to plain comment`); + await (0, shared_1.postComment)(octokit, owner, repo, prNumber, body); + } } else { await (0, shared_1.postComment)(octokit, owner, repo, prNumber, body);