using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Windows; namespace AxCopilot.Services.Agent; public class CodeReviewTool : IAgentTool { private class DiffFile { public string Path { get; set; } = ""; public string Status { get; set; } = "modified"; public int Added { get; set; } public int Removed { get; set; } public List Hunks { get; } = new List(); } private record ReviewIssue(int Line, string Severity, string Message); public string Name => "code_review"; public string Description => "코드 리뷰를 수행합니다. Git diff 분석, 파일 정적 검사, PR 요약을 생성합니다.\naction별 기능:\n- diff_review: git diff 출력을 분석하여 이슈/개선점을 구조화\n- file_review: 특정 파일의 코드 품질을 정적 검사\n- pr_summary: 변경사항을 PR 설명 형식으로 요약"; public ToolParameterSchema Parameters { get { ToolParameterSchema toolParameterSchema = new ToolParameterSchema(); Dictionary dictionary = new Dictionary(); ToolProperty obj = new ToolProperty { Type = "string", Description = "리뷰 유형: diff_review (diff 분석), file_review (파일 검사), pr_summary (PR 요약)" }; int num = 3; List list = new List(num); CollectionsMarshal.SetCount(list, num); Span span = CollectionsMarshal.AsSpan(list); span[0] = "diff_review"; span[1] = "file_review"; span[2] = "pr_summary"; obj.Enum = list; dictionary["action"] = obj; dictionary["target"] = new ToolProperty { Type = "string", Description = "대상 지정. diff_review: '--staged' 또는 빈값(working tree). file_review: 파일 경로. pr_summary: 브랜치명(선택)." }; ToolProperty obj2 = new ToolProperty { Type = "string", Description = "리뷰 초점: all (전체), bugs (버그), performance (성능), security (보안), style (스타일). 기본 all." }; num = 5; List list2 = new List(num); CollectionsMarshal.SetCount(list2, num); Span span2 = CollectionsMarshal.AsSpan(list2); span2[0] = "all"; span2[1] = "bugs"; span2[2] = "performance"; span2[3] = "security"; span2[4] = "style"; obj2.Enum = list2; dictionary["focus"] = obj2; toolParameterSchema.Properties = dictionary; num = 1; List list3 = new List(num); CollectionsMarshal.SetCount(list3, num); CollectionsMarshal.AsSpan(list3)[0] = "action"; toolParameterSchema.Required = list3; return toolParameterSchema; } } public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default(CancellationToken)) { if (!((Application.Current as App)?.SettingsService?.Settings.Llm.Code.EnableCodeReview ?? true)) { return ToolResult.Ok("코드 리뷰가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요."); } JsonElement a; string action = (args.TryGetProperty("action", out a) ? (a.GetString() ?? "") : ""); JsonElement t; string target = (args.TryGetProperty("target", out t) ? (t.GetString() ?? "") : ""); JsonElement f; string focus = (args.TryGetProperty("focus", out f) ? (f.GetString() ?? "all") : "all"); if (string.IsNullOrEmpty(context.WorkFolder)) { return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다."); } if (1 == 0) { } ToolResult result = action switch { "diff_review" => await DiffReviewAsync(context, target, focus, ct), "file_review" => await FileReviewAsync(context, target, focus, ct), "pr_summary" => await PrSummaryAsync(context, target, ct), _ => ToolResult.Fail("지원하지 않는 action: " + action + ". diff_review, file_review, pr_summary 중 선택하세요."), }; if (1 == 0) { } return result; } private async Task DiffReviewAsync(AgentContext ctx, string target, string focus, CancellationToken ct) { string diffResult = await RunGitAsync(args: string.IsNullOrEmpty(target) ? "diff" : ("diff " + target), workDir: ctx.WorkFolder, ct: ct); if (diffResult == null) { return ToolResult.Fail("Git을 찾을 수 없습니다."); } if (string.IsNullOrWhiteSpace(diffResult)) { return ToolResult.Ok("변경사항이 없습니다. (clean working tree)"); } List files = ParseDiffFiles(diffResult); StringBuilder sb = new StringBuilder(); sb.AppendLine("═══ Code Review Report (diff_review) ═══\n"); int totalAdded = 0; int totalRemoved = 0; foreach (DiffFile file in files) { totalAdded += file.Added; totalRemoved += file.Removed; } StringBuilder stringBuilder = sb; StringBuilder stringBuilder2 = stringBuilder; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(26, 3, stringBuilder); handler.AppendLiteral("\ud83d\udcca 파일 "); handler.AppendFormatted(files.Count); handler.AppendLiteral("개 변경 | +"); handler.AppendFormatted(totalAdded); handler.AppendLiteral(" 추가 -"); handler.AppendFormatted(totalRemoved); handler.AppendLiteral(" 삭제\n"); stringBuilder2.AppendLine(ref handler); stringBuilder = sb; StringBuilder stringBuilder3 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder); handler.AppendLiteral("\ud83d\udd0d 리뷰 초점: "); handler.AppendFormatted(focus); handler.AppendLiteral("\n"); stringBuilder3.AppendLine(ref handler); foreach (DiffFile file2 in files) { stringBuilder = sb; StringBuilder stringBuilder4 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(11, 2, stringBuilder); handler.AppendLiteral("─── "); handler.AppendFormatted(file2.Path); handler.AppendLiteral(" ("); handler.AppendFormatted(file2.Status); handler.AppendLiteral(") ───"); stringBuilder4.AppendLine(ref handler); stringBuilder = sb; StringBuilder stringBuilder5 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(9, 2, stringBuilder); handler.AppendLiteral(" 변경: +"); handler.AppendFormatted(file2.Added); handler.AppendLiteral(" -"); handler.AppendFormatted(file2.Removed); stringBuilder5.AppendLine(ref handler); List issues = AnalyzeDiffHunks(file2.Hunks, focus); if (issues.Count > 0) { foreach (ReviewIssue issue in issues) { stringBuilder = sb; StringBuilder stringBuilder6 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(12, 3, stringBuilder); handler.AppendLiteral(" ["); handler.AppendFormatted(issue.Severity); handler.AppendLiteral("] Line "); handler.AppendFormatted(issue.Line); handler.AppendLiteral(": "); handler.AppendFormatted(issue.Message); stringBuilder6.AppendLine(ref handler); } } else { sb.AppendLine(" [OK] 정적 검사에서 특이사항 없음"); } sb.AppendLine(); } sb.AppendLine("═══ 위 분석 결과를 바탕으로 상세한 코드 리뷰를 작성하세요. ═══"); return ToolResult.Ok(sb.ToString()); } private async Task FileReviewAsync(AgentContext ctx, string target, string focus, CancellationToken ct) { if (string.IsNullOrEmpty(target)) { return ToolResult.Fail("file_review에는 target(파일 경로)이 필요합니다."); } string fullPath = (Path.IsPathRooted(target) ? target : Path.Combine(ctx.WorkFolder, target)); if (!File.Exists(fullPath)) { return ToolResult.Fail("파일을 찾을 수 없습니다: " + target); } if (!ctx.IsPathAllowed(fullPath)) { return ToolResult.Fail("접근이 차단된 경로입니다: " + target); } string[] lines = (await File.ReadAllTextAsync(fullPath, ct)).Split('\n'); StringBuilder sb = new StringBuilder(); sb.AppendLine("═══ Code Review Report (file_review) ═══\n"); StringBuilder stringBuilder = sb; StringBuilder stringBuilder2 = stringBuilder; StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(7, 1, stringBuilder); handler.AppendLiteral("\ud83d\udcc1 파일: "); handler.AppendFormatted(target); stringBuilder2.AppendLine(ref handler); stringBuilder = sb; StringBuilder stringBuilder3 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(17, 2, stringBuilder); handler.AppendLiteral("\ud83d\udccf "); handler.AppendFormatted(lines.Length); handler.AppendLiteral("줄 | \ud83d\udd0d 초점: "); handler.AppendFormatted(focus); handler.AppendLiteral("\n"); stringBuilder3.AppendLine(ref handler); List issues = AnalyzeFile(lines, focus); if (issues.Count > 0) { stringBuilder = sb; StringBuilder stringBuilder4 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(13, 1, stringBuilder); handler.AppendLiteral("⚠\ufe0f 발견된 이슈 "); handler.AppendFormatted(issues.Count); handler.AppendLiteral("개:\n"); stringBuilder4.AppendLine(ref handler); foreach (ReviewIssue issue in issues) { stringBuilder = sb; StringBuilder stringBuilder5 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(12, 3, stringBuilder); handler.AppendLiteral(" ["); handler.AppendFormatted(issue.Severity); handler.AppendLiteral("] Line "); handler.AppendFormatted(issue.Line); handler.AppendLiteral(": "); handler.AppendFormatted(issue.Message); stringBuilder5.AppendLine(ref handler); } } else { sb.AppendLine("✅ 정적 검사에서 특이사항 없음"); } sb.AppendLine("\n─── 파일 내용 (처음 200줄) ───"); string preview = string.Join('\n', lines.Take(200)); sb.AppendLine(preview); if (lines.Length > 200) { stringBuilder = sb; StringBuilder stringBuilder6 = stringBuilder; handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder); handler.AppendLiteral("\n... ("); handler.AppendFormatted(lines.Length - 200); handler.AppendLiteral("줄 생략)"); stringBuilder6.AppendLine(ref handler); } sb.AppendLine("\n═══ 위 분석 결과와 코드를 바탕으로 상세한 리뷰를 작성하세요. ═══"); return ToolResult.Ok(sb.ToString()); } private async Task PrSummaryAsync(AgentContext ctx, string target, CancellationToken ct) { StringBuilder sb = new StringBuilder(); sb.AppendLine("═══ PR Summary Data ═══\n"); string log = await RunGitAsync(args: string.IsNullOrEmpty(target) ? "log --oneline -20" : ("log --oneline " + target + "..HEAD"), workDir: ctx.WorkFolder, ct: ct); if (!string.IsNullOrWhiteSpace(log)) { sb.AppendLine("\ud83d\udccb 커밋 이력:"); sb.AppendLine(log); sb.AppendLine(); } string stat = await RunGitAsync(args: string.IsNullOrEmpty(target) ? "diff --stat" : ("diff --stat " + target + "..HEAD"), workDir: ctx.WorkFolder, ct: ct); if (!string.IsNullOrWhiteSpace(stat)) { sb.AppendLine("\ud83d\udcca 변경 통계:"); sb.AppendLine(stat); sb.AppendLine(); } string status = await RunGitAsync(ctx.WorkFolder, "status --short", ct); if (!string.IsNullOrWhiteSpace(status)) { sb.AppendLine("\ud83d\udcc1 현재 상태:"); sb.AppendLine(status); sb.AppendLine(); } sb.AppendLine("═══ 위 데이터를 바탕으로 PR 제목과 설명(Summary, Changes, Test Plan)을 작성하세요. ═══"); return ToolResult.Ok(sb.ToString()); } private static List AnalyzeDiffHunks(List hunks, string focus) { List list = new List(); int num = 0; foreach (string hunk in hunks) { Match match = Regex.Match(hunk, "@@ -\\d+(?:,\\d+)? \\+(\\d+)"); if (match.Success) { num = int.Parse(match.Groups[1].Value); } else { if (!hunk.StartsWith('+') || hunk.StartsWith("+++")) { continue; } num++; string text = hunk; string text2 = text.Substring(1, text.Length - 1); if ((focus == "all" || focus == "bugs") ? true : false) { if (Regex.IsMatch(text2, "catch\\s*\\{?\\s*\\}")) { list.Add(new ReviewIssue(num, "WARNING", "빈 catch 블록 — 예외가 무시됩니다")); } if (Regex.IsMatch(text2, "\\.Result\\b|\\.Wait\\(\\)")) { list.Add(new ReviewIssue(num, "WARNING", "동기 대기 (.Result/.Wait()) — 데드락 위험")); } if (Regex.IsMatch(text2, "==\\s*null") && text2.Contains('.')) { list.Add(new ReviewIssue(num, "INFO", "null 비교 — null 조건 연산자(?.) 사용 검토")); } } if ((focus == "all" || focus == "security") ? true : false) { if (Regex.IsMatch(text2, "(password|secret|token|api_?key)\\s*=\\s*\"[^\"]+\"", RegexOptions.IgnoreCase)) { list.Add(new ReviewIssue(num, "CRITICAL", "하드코딩된 비밀번호/키 감지")); } if (Regex.IsMatch(text2, "(TODO|FIXME|HACK|XXX)\\b", RegexOptions.IgnoreCase)) { list.Add(new ReviewIssue(num, "INFO", "TODO/FIXME 마커 발견: " + text2.Trim())); } } if ((focus == "all" || focus == "performance") ? true : false) { if (Regex.IsMatch(text2, "new\\s+List<.*>\\(\\).*\\.Add\\(") || Regex.IsMatch(text2, "\\.ToList\\(\\).*\\.Where\\(")) { list.Add(new ReviewIssue(num, "INFO", "불필요한 컬렉션 할당 가능성")); } if (Regex.IsMatch(text2, "string\\s*\\+\\s*=|\".*\"\\s*\\+\\s*\"")) { list.Add(new ReviewIssue(num, "INFO", "문자열 연결 — StringBuilder 사용 검토")); } } bool flag = ((focus == "all" || focus == "style") ? true : false); if (flag && text2.Length > 150) { list.Add(new ReviewIssue(num, "STYLE", $"긴 라인 ({text2.Length}자) — 가독성 저하")); } } } return list; } private static List AnalyzeFile(string[] lines, string focus) { List list = new List(); int num = 0; int num2 = 0; bool flag = false; for (int i = 0; i < lines.Length; i++) { string text = lines[i]; int num3 = i + 1; string text2 = text.TrimStart(); if (Regex.IsMatch(text2, "(public|private|protected|internal|static|async|override)\\s+.*\\(.*\\)\\s*\\{?\\s*$") && !text2.Contains(';')) { flag = true; num2 = num3; } if (text2.Contains('{')) { num++; } bool flag4; if (text2.Contains('}')) { num--; if (flag && num <= 1) { int num4 = num3 - num2; bool flag2 = num4 > 60; bool flag3 = flag2; if (flag3) { flag4 = ((focus == "all" || focus == "style") ? true : false); flag3 = flag4; } if (flag3) { list.Add(new ReviewIssue(num2, "STYLE", $"긴 메서드 ({num4}줄) — 분할 검토")); } flag = false; } } if ((focus == "all" || focus == "bugs") ? true : false) { if (Regex.IsMatch(text2, "catch\\s*(\\(\\s*Exception)?\\s*\\)?\\s*\\{\\s*\\}")) { list.Add(new ReviewIssue(num3, "WARNING", "빈 catch 블록")); } if (Regex.IsMatch(text2, "\\.Result\\b|\\.Wait\\(\\)")) { list.Add(new ReviewIssue(num3, "WARNING", "동기 대기 (.Result/.Wait()) — 데드락 위험")); } } if ((focus == "all" || focus == "security") ? true : false) { if (Regex.IsMatch(text2, "(password|secret|token|api_?key)\\s*=\\s*\"[^\"]+\"", RegexOptions.IgnoreCase)) { list.Add(new ReviewIssue(num3, "CRITICAL", "하드코딩된 비밀번호/키")); } if (Regex.IsMatch(text2, "(TODO|FIXME|HACK|XXX)\\b", RegexOptions.IgnoreCase)) { list.Add(new ReviewIssue(num3, "INFO", "마커: " + text2.Trim())); } } flag4 = ((focus == "all" || focus == "performance") ? true : false); if (flag4 && Regex.IsMatch(text2, "string\\s*\\+\\s*=")) { list.Add(new ReviewIssue(num3, "INFO", "루프 내 문자열 연결 — StringBuilder 검토")); } flag4 = ((focus == "all" || focus == "style") ? true : false); if (flag4 && text.Length > 150) { list.Add(new ReviewIssue(num3, "STYLE", $"긴 라인 ({text.Length}자)")); } } bool flag5 = lines.Length > 500; bool flag6 = flag5; if (flag6) { bool flag4 = ((focus == "all" || focus == "style") ? true : false); flag6 = flag4; } if (flag6) { list.Add(new ReviewIssue(1, "STYLE", $"큰 파일 ({lines.Length}줄) — 클래스 분할 검토")); } return list; } private static async Task RunGitAsync(string workDir, string args, CancellationToken ct) { string gitPath = FindGit(); if (gitPath == null) { return null; } try { ProcessStartInfo psi = new ProcessStartInfo(gitPath, args) { WorkingDirectory = workDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(30.0)); using Process proc = Process.Start(psi); if (proc == null) { return null; } string stdout = await proc.StandardOutput.ReadToEndAsync(cts.Token); await proc.WaitForExitAsync(cts.Token); return (stdout.Length > 12000) ? (stdout.Substring(0, 12000) + "\n... (출력 잘림)") : stdout; } catch { return null; } } private static string? FindGit() { string[] array = new string[3] { "git", "C:\\Program Files\\Git\\bin\\git.exe", "C:\\Program Files (x86)\\Git\\bin\\git.exe" }; string[] array2 = array; foreach (string text in array2) { try { ProcessStartInfo startInfo = new ProcessStartInfo(text, "--version") { RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }; using Process process = Process.Start(startInfo); process?.WaitForExit(3000); if (process != null && process.ExitCode == 0) { return text; } } catch { } } return null; } private static List ParseDiffFiles(string diff) { List list = new List(); DiffFile diffFile = null; string[] array = diff.Split('\n'); foreach (string text in array) { if (text.StartsWith("diff --git")) { diffFile = new DiffFile(); list.Add(diffFile); string[] array2 = text.Split(" b/"); diffFile.Path = ((array2.Length > 1) ? array2[1].Trim() : text); } else { if (diffFile == null) { continue; } if (text.StartsWith("new file")) { diffFile.Status = "added"; } else if (text.StartsWith("deleted file")) { diffFile.Status = "deleted"; } else if (text.StartsWith("@@") || text.StartsWith("+") || text.StartsWith("-")) { diffFile.Hunks.Add(text); if (text.StartsWith("+") && !text.StartsWith("+++")) { diffFile.Added++; } if (text.StartsWith("-") && !text.StartsWith("---")) { diffFile.Removed++; } } } } return list; } }