diff --git a/dependency-impact/dist/index.js b/dependency-impact/dist/index.js index 59a2fa0..47a5378 100644 --- a/dependency-impact/dist/index.js +++ b/dependency-impact/dist/index.js @@ -31898,6 +31898,8 @@ 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); +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("/"); @@ -31958,8 +31960,22 @@ 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::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}::${dep.fromVersion}::${dep.toVersion}`; + 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."); @@ -31979,12 +31995,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 +32014,18 @@ 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; + // 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}::${d.fromVersion}::${d.toVersion}`; if (isDependabot && hasBody) { - releaseNotes = pr.body; + // Dependabot PRs embed release notes in the body — extract per-dep sections + for (const dep of depChanges) { + releaseNotesPerDep.set(depNoteKey(dep), (0, parsers_1.extractDependabotSection)(pr.body, dep.name)); + } } else { for (const dep of depChanges) { @@ -32022,77 +32033,134 @@ 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(depNoteKey(dep), 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(depNoteKey(dep), 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(depNoteKey(dep)) ?? 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) > 0 || + (d.notableChanges?.length ?? 0) > 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); + 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`); + } } 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"); + } + // 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 + let body; + let inlineComments; + try { + body = (0, review_1.buildReviewBody)(assessment); + inlineComments = (0, review_1.buildInlineComments)(assessment, enrichedDeps, pr.files); } - const analysis = await (0, shared_1.generateContent)(model, prompt); - // 6. Post the analysis as a comment - const comment = `## Gemini Dependency Impact Analysis + 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) { + 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); + } + 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,7 +32173,10 @@ ${analysis} Object.defineProperty(exports, "__esModule", ({ value: true })); 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 @@ -32212,6 +32283,61 @@ 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"; + // 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"; +} +/** + * 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; + 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(`(?` + * 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 escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const depBoundaryRe = new RegExp(`(? depBoundaryRe.test(block)); + if (matching.length === 0) + return body; + return matching.join("\n\n"); +} + + +/***/ }), + +/***/ 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) > 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}`), + ].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.`; +} + + +/***/ }), + +/***/ 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 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", + 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} | **${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 + +**${safeRiskLabel(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.filter((f) => isManifestFile(f.filename))) { + 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; +} /***/ }), diff --git a/dependency-impact/dist/parsers.d.ts b/dependency-impact/dist/parsers.d.ts index a70bbff..aca062f 100644 --- a/dependency-impact/dist/parsers.d.ts +++ b/dependency-impact/dist/parsers.d.ts @@ -8,4 +8,26 @@ 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[]; +/** + * 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/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/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 {}; 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/index.ts b/dependency-impact/src/index.ts index 67b5b0a..7bc9217 100644 --- a/dependency-impact/src/index.ts +++ b/dependency-impact/src/index.ts @@ -2,16 +2,32 @@ 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 { parseDependencyChanges, getImportPatterns, classifyUpgrade, extractDependabotSection } from "./parsers"; +import type { + EnrichedDependencyChange, + Step1Result, + Step2Result, + DependencyAssessment, +} from "./types"; +import { + buildStep1Prompt, + buildStep2Prompt, + buildStep3Prompt, + 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/")) { @@ -72,8 +88,21 @@ 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::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}::${dep.fromVersion}::${dep.toVersion}`; + 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"); @@ -105,7 +134,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 +145,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 +165,20 @@ 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; + // 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; fromVersion: string; toVersion: string }) => + `${d.name}::${d.ecosystem}::${d.fromVersion}::${d.toVersion}`; if (isDependabot && hasBody) { - releaseNotes = pr.body!; + // Dependabot PRs embed release notes in the body — extract per-dep sections + for (const dep of depChanges) { + releaseNotesPerDep.set(depNoteKey(dep), extractDependabotSection(pr.body!, dep.name)); + } } else { for (const dep of depChanges) { const ghRepo = await resolveGitHubRepo(dep); @@ -167,85 +191,156 @@ runAction(async () => { dep.toVersion, ); if (notes) { - releaseNotes = (releaseNotes ?? "") + `\n\n## ${dep.name}\n${notes}`; + releaseNotesPerDep.set(depNoteKey(dep), 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(depNoteKey(dep), 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; + // 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(depNoteKey(dep)) ?? null, + })); - 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} + 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) > 0 || + (d.notableChanges?.length ?? 0) > 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); + 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`); + } } 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 + 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; + } -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) { + 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); } - 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)"); + } }); diff --git a/dependency-impact/src/parsers.test.ts b/dependency-impact/src/parsers.test.ts index 8d25bd2..cd92c7e 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, extractDependabotSection } from "./parsers"; describe("parseDependencyChanges", () => { describe("terraform", () => { @@ -405,3 +405,157 @@ 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("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"); + }); +}); + +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); + }); + + 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", () => { + 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 a39a007..beda34d 100644 --- a/dependency-impact/src/parsers.ts +++ b/dependency-impact/src/parsers.ts @@ -149,6 +149,62 @@ 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"; + // 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"; +} + +/** + * 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; + 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(`(?` + * 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 escaped = depName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const depBoundaryRe = new RegExp(`(? depBoundaryRe.test(block)); + if (matching.length === 0) return body; + + return matching.join("\n\n"); +} 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/prompts.ts b/dependency-impact/src/prompts.ts new file mode 100644 index 0000000..c916d4b --- /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) > 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}`), + ].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.`; +} diff --git a/dependency-impact/src/review.test.ts b/dependency-impact/src/review.test.ts new file mode 100644 index 0000000..04b8377 --- /dev/null +++ b/dependency-impact/src/review.test.ts @@ -0,0 +1,236 @@ +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("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[] = [ + { + 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"); + }); +}); diff --git a/dependency-impact/src/review.ts b/dependency-impact/src/review.ts new file mode 100644 index 0000000..26cae88 --- /dev/null +++ b/dependency-impact/src/review.ts @@ -0,0 +1,91 @@ +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", + 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: 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. + */ +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} | **${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 + +**${safeRiskLabel(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.filter((f) => isManifestFile(f.filename))) { + 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; +} 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; +}