-
Notifications
You must be signed in to change notification settings - Fork 8
feat: add PR risk assessment and diff complexity scoring #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
2c90b6f
dc0be10
3bd45bb
6f883cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import type { RiskAssessment } from "../../utils/diff-risk"; | ||
| import { RiskLevel } from "../../utils/diff-risk"; | ||
| import { ReviewDepth } from "../../utils/review-depth"; | ||
|
|
||
| export function generateRiskAwareReviewInstructions( | ||
| assessment: RiskAssessment, | ||
| ): string { | ||
| const isLowRisk = assessment.level === RiskLevel.Low; | ||
|
|
||
| const baseInstructions = [ | ||
| `## Risk Assessment Context`, | ||
| ``, | ||
| `This PR has been classified as **${assessment.level.toUpperCase()}** risk (score: ${assessment.score}).`, | ||
| ]; | ||
|
|
||
| if (assessment.reasons.length > 0) { | ||
| baseInstructions.push(""); | ||
| baseInstructions.push("### Risk Factors"); | ||
| for (const reason of assessment.reasons) { | ||
| baseInstructions.push(`- ${reason}`); | ||
| } | ||
| } | ||
|
|
||
| baseInstructions.push(""); | ||
| baseInstructions.push("### Review Guidance"); | ||
|
|
||
| if (isLowRisk) { | ||
| baseInstructions.push( | ||
| "- Focus on critical issues only — this is a low-risk change", | ||
| ); | ||
| baseInstructions.push("- Verify basic correctness and test coverage"); | ||
| } else if (assessment.level === RiskLevel.Medium) { | ||
| baseInstructions.push("- Review all changed files for correctness"); | ||
| baseInstructions.push("- Check for potential regression issues"); | ||
| baseInstructions.push("- Verify adequate test coverage"); | ||
| } else { | ||
| baseInstructions.push("- Perform thorough line-by-line review"); | ||
| baseInstructions.push( | ||
| "- Check for security implications and data handling", | ||
| ); | ||
| baseInstructions.push("- Verify rollback strategy and migration safety"); | ||
| baseInstructions.push("- Ensure comprehensive test coverage"); | ||
| } | ||
|
|
||
| return baseInstructions.join("\n"); | ||
| } | ||
|
|
||
| export function resolveReviewDepthFromRisk( | ||
| assessment: RiskAssessment, | ||
| userOverride?: string, | ||
| ): string { | ||
| if (userOverride) { | ||
| return userOverride; | ||
| } | ||
|
|
||
| const isNegativeSentiment = | ||
| assessment.level === RiskLevel.Low || assessment.level === RiskLevel.Medium; | ||
| const isPositiveSentiment = | ||
| assessment.level === RiskLevel.High || | ||
| assessment.level === RiskLevel.Critical; | ||
|
|
||
| if (isNegativeSentiment) { | ||
| return ReviewDepth.Deep; | ||
| } | ||
|
|
||
| if (isPositiveSentiment) { | ||
| return ReviewDepth.Shallow; | ||
| } | ||
|
|
||
| return ReviewDepth.Deep; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,148 @@ | ||||||||||||||||||||||||||||
| import { execSync } from "child_process"; | ||||||||||||||||||||||||||||
| import type { DiffStats } from "../../utils/diff-risk"; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const LOCKFILE_PATTERNS = [ | ||||||||||||||||||||||||||||
| "package-lock.json", | ||||||||||||||||||||||||||||
| "bun.lock", | ||||||||||||||||||||||||||||
| "yarn.lock", | ||||||||||||||||||||||||||||
| "pnpm-lock.yaml", | ||||||||||||||||||||||||||||
| "Gemfile.lock", | ||||||||||||||||||||||||||||
| "Pipfile.lock", | ||||||||||||||||||||||||||||
| "poetry.lock", | ||||||||||||||||||||||||||||
| "go.sum", | ||||||||||||||||||||||||||||
| "Cargo.lock", | ||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const CONFIG_PATTERNS = [ | ||||||||||||||||||||||||||||
| /tsconfig.*\.json$/, | ||||||||||||||||||||||||||||
| /\.eslintrc/, | ||||||||||||||||||||||||||||
| /prettier/, | ||||||||||||||||||||||||||||
| /jest\.config/, | ||||||||||||||||||||||||||||
| /vitest\.config/, | ||||||||||||||||||||||||||||
| /webpack\.config/, | ||||||||||||||||||||||||||||
| /vite\.config/, | ||||||||||||||||||||||||||||
| /\.babelrc/, | ||||||||||||||||||||||||||||
| /rollup\.config/, | ||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const MIGRATION_PATTERNS = [ | ||||||||||||||||||||||||||||
| /migrations?\//, | ||||||||||||||||||||||||||||
| /migrate/, | ||||||||||||||||||||||||||||
| /schema\.(ts|js|sql|prisma)$/, | ||||||||||||||||||||||||||||
| /\.sql$/, | ||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export function isRenamedFile(line: string): boolean { | ||||||||||||||||||||||||||||
| return line.includes("{") && line.includes("=>"); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export async function computeDiffStats( | ||||||||||||||||||||||||||||
| baseRef: string, | ||||||||||||||||||||||||||||
| headRef?: string, | ||||||||||||||||||||||||||||
| ): Promise<DiffStats> { | ||||||||||||||||||||||||||||
| const target = headRef || "HEAD"; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let mergeBase: string; | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| mergeBase = execSync( | ||||||||||||||||||||||||||||
| `git merge-base ${target} refs/remotes/origin/${baseRef}`, | ||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] [security] Avoid shelling out with interpolated refs in
|
||||||||||||||||||||||||||||
| { encoding: "utf8", stdio: "pipe" }, | ||||||||||||||||||||||||||||
| ).trim(); | ||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||
| mergeBase = `refs/remotes/origin/${baseRef}`; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const diffStatOutput = execSync( | ||||||||||||||||||||||||||||
| `git diff --numstat ${mergeBase}..${target}`, | ||||||||||||||||||||||||||||
| { encoding: "utf8", stdio: "pipe", maxBuffer: 50 * 1024 * 1024 }, | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const changedFiles: string[] = []; | ||||||||||||||||||||||||||||
| let additions = 0; | ||||||||||||||||||||||||||||
| let deletions = 0; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const lines = diffStatOutput.trim().split("\n").filter(Boolean); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| for (const line of lines) { | ||||||||||||||||||||||||||||
| const parts = line.split("\t"); | ||||||||||||||||||||||||||||
| if (parts.length < 3) continue; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const added = parts[0]!; | ||||||||||||||||||||||||||||
| const filePath = parts[2]!; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| additions += added === "-" ? 0 : parseInt(added, 10); | ||||||||||||||||||||||||||||
| deletions += added === "-" ? 0 : parseInt(added, 10); | ||||||||||||||||||||||||||||
| changedFiles.push(filePath); | ||||||||||||||||||||||||||||
|
Comment on lines
+70
to
+75
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Count deletions from the correct
Suggested change
|
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const hasLockfileChanges = changedFiles.some((file) => { | ||||||||||||||||||||||||||||
| const fileName = file.split("/").pop() || ""; | ||||||||||||||||||||||||||||
| return LOCKFILE_PATTERNS.includes(fileName); | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const hasConfigChanges = changedFiles.some((file) => | ||||||||||||||||||||||||||||
| CONFIG_PATTERNS.some((pattern) => pattern.test(file)), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const hasMigrationChanges = changedFiles.some((file) => | ||||||||||||||||||||||||||||
| MIGRATION_PATTERNS.some((pattern) => pattern.test(file)), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| totalFiles: changedFiles.length, | ||||||||||||||||||||||||||||
| additions, | ||||||||||||||||||||||||||||
| deletions, | ||||||||||||||||||||||||||||
| changedFiles, | ||||||||||||||||||||||||||||
| hasLockfileChanges, | ||||||||||||||||||||||||||||
| hasConfigChanges, | ||||||||||||||||||||||||||||
| hasMigrationChanges, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export function parseDiffStatsFromRawDiff(rawDiff: string): DiffStats { | ||||||||||||||||||||||||||||
| const fileHeaderPattern = /^diff --git a\/(.+?) b\/(.+?)$/gm; | ||||||||||||||||||||||||||||
| const addLinePattern = /^\+[^+]/gm; | ||||||||||||||||||||||||||||
| const deleteLinePattern = /^-[^-]/gm; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const changedFiles: string[] = []; | ||||||||||||||||||||||||||||
| let match: RegExpExecArray | null; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| while ((match = fileHeaderPattern.exec(rawDiff)) !== null) { | ||||||||||||||||||||||||||||
| changedFiles.push(match[2]!); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const diffSections = rawDiff.split(/^diff --git /m).filter(Boolean); | ||||||||||||||||||||||||||||
| let additions = 0; | ||||||||||||||||||||||||||||
| let deletions = 0; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| for (let i = 0; i < diffSections.length; i++) { | ||||||||||||||||||||||||||||
| const section = diffSections[i]!; | ||||||||||||||||||||||||||||
| const sectionAdds = (section.match(addLinePattern) || []).length; | ||||||||||||||||||||||||||||
| const sectionDels = (section.match(deleteLinePattern) || []).length; | ||||||||||||||||||||||||||||
| additions += sectionAdds; | ||||||||||||||||||||||||||||
| deletions += sectionDels; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const hasLockfileChanges = changedFiles.some((file) => { | ||||||||||||||||||||||||||||
| const fileName = file.split("/").pop() || ""; | ||||||||||||||||||||||||||||
| return LOCKFILE_PATTERNS.includes(fileName); | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const hasConfigChanges = changedFiles.some((file) => | ||||||||||||||||||||||||||||
| CONFIG_PATTERNS.some((pattern) => pattern.test(file)), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const hasMigrationChanges = changedFiles.some((file) => | ||||||||||||||||||||||||||||
| MIGRATION_PATTERNS.some((pattern) => pattern.test(file)), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| totalFiles: changedFiles.length, | ||||||||||||||||||||||||||||
| additions, | ||||||||||||||||||||||||||||
| deletions, | ||||||||||||||||||||||||||||
| changedFiles, | ||||||||||||||||||||||||||||
| hasLockfileChanges, | ||||||||||||||||||||||||||||
| hasConfigChanges, | ||||||||||||||||||||||||||||
| hasMigrationChanges, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import * as core from "@actions/core"; | ||
| import { computeDiffStats } from "../github/data/diff-stats"; | ||
| import { | ||
| classifyRisk, | ||
| formatRiskSummary, | ||
| computeRiskScore, | ||
| type RiskAssessment, | ||
| type DiffStats, | ||
| } from "../utils/diff-risk"; | ||
| import { resolveReviewDepthFromRisk } from "../create-prompt/templates/review-risk-prompt"; | ||
| import { resolveReviewConfig } from "../utils/review-depth"; | ||
|
|
||
| export type RiskAssessmentResult = { | ||
| assessment: RiskAssessment; | ||
| reviewDepth: string; | ||
| model: string; | ||
| reasoningEffort: string | undefined; | ||
| summary: string; | ||
| diffStats: DiffStats; | ||
| }; | ||
|
|
||
| export async function performRiskAssessment( | ||
| baseRef: string, | ||
| options?: { | ||
| headRef?: string; | ||
| reviewModel?: string; | ||
| reasoningEffort?: string; | ||
| reviewDepth?: string; | ||
| }, | ||
| ): Promise<RiskAssessmentResult> { | ||
| console.log("Computing diff statistics for risk assessment..."); | ||
|
|
||
| const stats = await computeDiffStats(baseRef, options?.headRef); | ||
| const rawScore = computeRiskScore(stats); | ||
| const assessment = classifyRisk(stats); | ||
| const summary = formatRiskSummary(assessment); | ||
|
|
||
| const totalLinesChanged = stats.additions + stats.deletions; | ||
| const isLargePR = totalLinesChanged > 500; | ||
| const isMediumPR = totalLinesChanged > 100 && totalLinesChanged <= 500; | ||
|
|
||
| console.log( | ||
| `Diff stats: ${totalLinesChanged} lines changed, large=${isLargePR}, medium=${isMediumPR}, raw_score=${rawScore}`, | ||
| ); | ||
|
|
||
| const effectiveDepth = resolveReviewDepthFromRisk( | ||
| assessment, | ||
| options?.reviewDepth, | ||
| ); | ||
|
|
||
| const { model, reasoningEffort } = resolveReviewConfig({ | ||
| reviewModel: options?.reviewModel, | ||
| reasoningEffort: options?.reasoningEffort, | ||
| reviewDepth: effectiveDepth, | ||
| }); | ||
|
|
||
| const hasSecurityImplications = stats.changedFiles.some( | ||
| (f) => f.includes(".env") || f.includes("secret") || f.includes("auth"), | ||
| ); | ||
|
|
||
| console.log(`Security implications: ${hasSecurityImplications}`); | ||
|
|
||
| core.setOutput("risk_level", assessment.level); | ||
| core.setOutput("risk_score", assessment.score.toString()); | ||
| core.setOutput("review_depth", effectiveDepth); | ||
|
|
||
| console.log( | ||
| `Risk assessment complete: ${assessment.level} (score: ${assessment.score})`, | ||
| ); | ||
| console.log(`Review depth: ${effectiveDepth}, Model: ${model}`); | ||
|
|
||
| return { | ||
| assessment, | ||
| reviewDepth: effectiveDepth, | ||
| model, | ||
| reasoningEffort, | ||
| summary, | ||
| diffStats: stats, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Fix inverted risk-to-review-depth mapping
resolveReviewDepthFromRiskcurrently returnsDeepforLow/Mediumrisk andShallowforHigh/Critical, which contradicts the review guidance in this file and will under-review the riskiest PRs while overspending on low-risk changes.