diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index ad17593..7e897ca 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -38,9 +38,10 @@ jobs: --owner "${{ github.event.pull_request.head.repo.owner.login || github.repository_owner }}" \ --repo "${{ github.event.pull_request.head.repo.name || github.event.repository.name }}" \ --pr "${{ github.event.pull_request.number }}" \ + --language ja \ --post-comment env: - AISettings__Endpoint: ${{ vars.AI_ENDPOINT || 'https://api.openai.com/v1' }} - AISettings__ApiKey: ${{ secrets.AI_API_KEY }} - AISettings__ModelId: ${{ vars.AI_MODEL_ID || 'gpt-4o-mini' }} + AISettings__Endpoint: ${{ vars.AISETTINGS__ENDPOINT }} + AISettings__ApiKey: ${{ secrets.AISETTINGS__APIKEY }} + AISettings__ModelId: ${{ vars.AISETTINGS__MODELID || 'gpt-4o-mini' }} PRSettings__GitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pragent-native.yml b/.github/workflows/pragent-native.yml index 3ca5c9f..f840c2d 100644 --- a/.github/workflows/pragent-native.yml +++ b/.github/workflows/pragent-native.yml @@ -1,25 +1,9 @@ # PRAgent - Native .NET CLI Version -# PR作成/更新時に自動実行、または他のリポジトリから参照して使用 +# 他のリポジトリから参照して使用 name: PRAgent (Native) on: - pull_request: - types: [opened, synchronize] - workflow_dispatch: - inputs: - pr_number: - description: 'PR number' - required: true - type: number - command: - description: 'Command (review/summary/approve)' - required: true - type: choice - options: - - review - - summary - - approve workflow_call: inputs: pr_number: diff --git a/PRAgent.Tests/CommentCommandTests.cs b/PRAgent.Tests/CommentCommandTests.cs new file mode 100644 index 0000000..6ef5bd1 --- /dev/null +++ b/PRAgent.Tests/CommentCommandTests.cs @@ -0,0 +1,320 @@ +using PRAgent.Models; +using Xunit; + +namespace PRAgent.Tests; + +public class CommentCommandTests +{ + [Fact] + public void Parse_CommentCommandWithSingleComment_ReturnsCorrectOptions() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@150", "Test comment" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Equal("testowner", options.Owner); + Assert.Equal("testrepo", options.Repo); + Assert.Equal(123, options.PrNumber); + Assert.Single(options.Comments); + Assert.Equal(150, options.Comments[0].LineNumber); + Assert.Equal("src/index.cs", options.Comments[0].FilePath); + Assert.Equal("Test comment", options.Comments[0].CommentText); + Assert.Null(options.Comments[0].SuggestionText); + } + + [Fact] + public void Parse_CommentCommandWithMultipleComments_ReturnsCorrectOptions() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@100", "Comment1", "@200", "Comment2" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Equal("testowner", options.Owner); + Assert.Equal("testrepo", options.Repo); + Assert.Equal(123, options.PrNumber); + Assert.Equal(2, options.Comments.Count); + Assert.Equal(100, options.Comments[0].LineNumber); + Assert.Equal("Comment1", options.Comments[0].CommentText); + Assert.Equal(200, options.Comments[1].LineNumber); + Assert.Equal("Comment2", options.Comments[1].CommentText); + } + + [Fact] + public void Parse_CommentCommandWithSuggestion_ReturnsCorrectOptions() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@200", "Test comment", "--suggestion", "Suggested code" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Equal("testowner", options.Owner); + Assert.Equal("testrepo", options.Repo); + Assert.Equal(123, options.PrNumber); + Assert.Single(options.Comments); + Assert.Equal(200, options.Comments[0].LineNumber); + Assert.Equal("src/index.cs", options.Comments[0].FilePath); + Assert.Equal("Test comment", options.Comments[0].CommentText); + Assert.Equal("Suggested code", options.Comments[0].SuggestionText); + } + + [Fact] + public void Parse_CommentCommandWithShortOptions_ReturnsCorrectOptions() + { + // Arrange + string[] args = { "comment", "-o", "testowner", "-r", "testrepo", "-p", "123", "@150", "Test comment" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Equal("testowner", options.Owner); + Assert.Equal("testrepo", options.Repo); + Assert.Equal(123, options.PrNumber); + Assert.Single(options.Comments); + Assert.Equal(150, options.Comments[0].LineNumber); + Assert.Equal("Test comment", options.Comments[0].CommentText); + } + + [Fact] + public void Parse_CommentCommandWithoutOwner_ReturnsError() + { + // Arrange + string[] args = { "comment", "--repo", "testrepo", "--pr", "123", "@150", "Test comment" }; + + // Act + var options = CommentCommandOptions.Parse(args); + var isValid = options.IsValid(out var errors); + + // Assert + Assert.False(isValid); + Assert.Contains("--owner is required", errors); + } + + [Fact] + public void Parse_CommentCommandWithoutRepo_ReturnsError() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--pr", "123", "@150", "Test comment" }; + + // Act + var options = CommentCommandOptions.Parse(args); + var isValid = options.IsValid(out var errors); + + // Assert + Assert.False(isValid); + Assert.Contains("--repo is required", errors); + } + + [Fact] + public void Parse_CommentCommandWithoutPrNumber_ReturnsError() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "@150", "Test comment" }; + + // Act + var options = CommentCommandOptions.Parse(args); + var isValid = options.IsValid(out var errors); + + // Assert + Assert.False(isValid); + Assert.Contains("--pr is required and must be a positive number", errors); + } + + [Fact] + public void Parse_CommentCommandWithoutComments_ReturnsError() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123" }; + + // Act + var options = CommentCommandOptions.Parse(args); + var isValid = options.IsValid(out var errors); + + // Assert + Assert.False(isValid); + // コメントが指定されていない場合はエラー + Assert.Contains("No valid comments specified", errors); + } + + [Fact] + public void Parse_CommentCommandWithInvalidLineNumber_IsIgnored() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@-1", "Test comment" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + // 無効な行番号のコメントはパース段階で除外される + Assert.Empty(options.Comments); + } + + [Fact] + public void Parse_CommentCommandWithEmptyCommentText_IsIgnored() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@150", "" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + // 空のコメントはパース段階で除外される + Assert.Empty(options.Comments); + } + + [Fact] + public void Parse_CommentCommandWithWhitespaceOnlyCommentText_IsIgnored() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@150", " \t\n " }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + // 空白のみのコメントはパース段階で除外される + Assert.Empty(options.Comments); + } + + [Fact] + public void Parse_CommentCommandWithEmptySuggestion_IgnoresSuggestion() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@200", "Test comment", "--suggestion", "" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Single(options.Comments); + Assert.Equal(200, options.Comments[0].LineNumber); + Assert.Equal("Test comment", options.Comments[0].CommentText); + Assert.Null(options.Comments[0].SuggestionText); + } + + [Fact] + public void Parse_CommentCommandWithWhitespaceOnlySuggestion_IgnoresSuggestion() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@200", "Test comment", "--suggestion", " \t\n " }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Single(options.Comments); + Assert.Equal(200, options.Comments[0].LineNumber); + Assert.Equal("Test comment", options.Comments[0].CommentText); + Assert.Null(options.Comments[0].SuggestionText); + } + + [Fact] + public void Parse_CommentCommandWithFilePath_ReturnsCorrectOptions() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "src/file.cs@123", "Test comment" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Equal("testowner", options.Owner); + Assert.Equal("testrepo", options.Repo); + Assert.Equal(123, options.PrNumber); + Assert.Single(options.Comments); + Assert.Equal(123, options.Comments[0].LineNumber); + Assert.Equal("src/file.cs", options.Comments[0].FilePath); + Assert.Equal("Test comment", options.Comments[0].CommentText); + Assert.Null(options.Comments[0].SuggestionText); + } + + [Fact] + public void Parse_CommentCommandWithApprove_ReturnsCorrectOptions() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@150", "Test comment", "--approve" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Equal("testowner", options.Owner); + Assert.Equal("testrepo", options.Repo); + Assert.Equal(123, options.PrNumber); + Assert.Single(options.Comments); + Assert.Equal(150, options.Comments[0].LineNumber); + Assert.Equal("Test comment", options.Comments[0].CommentText); + Assert.True(options.Approve); + Assert.Null(options.Comments[0].SuggestionText); + } + + [Fact] + public void Parse_CommentCommandWithApproveAndSuggestion_ReturnsCorrectOptions() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@200", "Test comment", "--suggestion", "Fixed code", "--approve" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Equal("testowner", options.Owner); + Assert.Equal("testrepo", options.Repo); + Assert.Equal(123, options.PrNumber); + Assert.Single(options.Comments); + Assert.Equal(200, options.Comments[0].LineNumber); + Assert.Equal("Test comment", options.Comments[0].CommentText); + Assert.Equal("Fixed code", options.Comments[0].SuggestionText); + Assert.True(options.Approve); + } + + [Fact] + public void Parse_CommentCommandWithMultipleCommentsAndApprove_ReturnsCorrectOptions() + { + // Arrange + string[] args = { "comment", "--owner", "testowner", "--repo", "testrepo", "--pr", "123", "@100", "Comment1", "@200", "Comment2", "--approve" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Equal("testowner", options.Owner); + Assert.Equal("testrepo", options.Repo); + Assert.Equal(123, options.PrNumber); + Assert.Equal(2, options.Comments.Count); + Assert.Equal(100, options.Comments[0].LineNumber); + Assert.Equal("Comment1", options.Comments[0].CommentText); + Assert.Equal(200, options.Comments[1].LineNumber); + Assert.Equal("Comment2", options.Comments[1].CommentText); + Assert.True(options.Approve); + } + + [Fact] + public void Parse_CommentCommandWithShortApprove_ReturnsCorrectOptions() + { + // Arrange + string[] args = { "comment", "-o", "testowner", "-r", "testrepo", "-p", "123", "@150", "Test comment", "--approve" }; + + // Act + var options = CommentCommandOptions.Parse(args); + + // Assert + Assert.Equal("testowner", options.Owner); + Assert.Equal("testrepo", options.Repo); + Assert.Equal(123, options.PrNumber); + Assert.Single(options.Comments); + Assert.Equal(150, options.Comments[0].LineNumber); + Assert.Equal("Test comment", options.Comments[0].CommentText); + Assert.True(options.Approve); + } +} \ No newline at end of file diff --git a/PRAgent.Tests/DiffPositionCalculatorTests.cs b/PRAgent.Tests/DiffPositionCalculatorTests.cs new file mode 100644 index 0000000..3445cf2 --- /dev/null +++ b/PRAgent.Tests/DiffPositionCalculatorTests.cs @@ -0,0 +1,225 @@ +using Xunit; + +namespace PRAgent.Tests; + +/// +/// DiffPositionCalculatorのテスト +/// +public class DiffPositionCalculatorTests +{ + /// + /// テスト用のdiff計算メソッド(GitHubServiceと同じロジック) + /// + private static int? CalculateDiffPosition(string? patch, int lineNumber) + { + if (string.IsNullOrEmpty(patch)) + return null; + + var lines = patch.Split('\n'); + int position = 0; + int currentNewLine = 0; + + foreach (var line in lines) + { + position++; + + // Hunk headerを解析 + var hunkMatch = System.Text.RegularExpressions.Regex.Match(line, @"^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@"); + if (hunkMatch.Success) + { + // 開始行番号の1つ前に設定(次の行でインクリメントして正しい行番号になるように) + currentNewLine = int.Parse(hunkMatch.Groups[1].Value) - 1; + continue; + } + + // 行のタイプを判定 + if (line.StartsWith("+")) + { + currentNewLine++; + if (currentNewLine == lineNumber) + { + return position; + } + } + else if (line.StartsWith("-")) + { + // 削除行はスキップ + } + else if (line.StartsWith(" ") || line == "") + { + currentNewLine++; + if (currentNewLine == lineNumber) + { + return position; + } + } + } + + return null; + } + + [Fact] + public void CalculateDiffPosition_SimpleAddition_ReturnsCorrectPosition() + { + // Arrange + // diffは以下のようになる(行番号はdiff内のposition): + // 1: @@ -1,3 +1,4 @@ <- hunk header (position=1) + // 2: line1 <- position=2, ファイル内1行目 + // 3: line2 <- position=3, ファイル内2行目 + // 4: +newLine <- position=4, ファイル内3行目(追加) + // 5: line3 <- position=5, ファイル内4行目 + var patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+newLine\n line3"; + + // Act & Assert + Assert.Equal(2, CalculateDiffPosition(patch, 1)); // line1 + Assert.Equal(3, CalculateDiffPosition(patch, 2)); // line2 + Assert.Equal(4, CalculateDiffPosition(patch, 3)); // newLine (added) + Assert.Equal(5, CalculateDiffPosition(patch, 4)); // line3 + } + + [Fact] + public void CalculateDiffPosition_AddedLine_ReturnsCorrectPosition() + { + // Arrange + var patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+newLine\n line3"; + + // Act - newLineはファイル内の3行目(追加された行) + var result = CalculateDiffPosition(patch, 3); + + // Assert - position 4は "+newLine" の行 + Assert.Equal(4, result); + } + + [Fact] + public void CalculateDiffPosition_AfterAddedLine_ReturnsCorrectPosition() + { + // Arrange + var patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+newLine\n line3"; + + // Act - line3は追加後のファイル内の4行目 + var result = CalculateDiffPosition(patch, 4); + + // Assert - position 5は " line3" の行 + // diff: 1=hunk, 2=line1, 3=line2, 4=+newLine, 5=line3 + Assert.Equal(5, result); + } + + [Fact] + public void CalculateDiffPosition_WithDeletion_SkipsDeletedLine() + { + // Arrange + // diff: + // 1: @@ -1,4 +1,3 @@ + // 2: line1 <- ファイル内1行目 + // 3: -oldLine2 <- 削除行(ファイル内行番号は進まない) + // 4: line3 <- ファイル内2行目 + // 5: line4 <- ファイル内3行目 + var patch = "@@ -1,4 +1,3 @@\n line1\n-oldLine2\n line3\n line4"; + + // Act & Assert + Assert.Equal(2, CalculateDiffPosition(patch, 1)); // line1 + Assert.Equal(4, CalculateDiffPosition(patch, 2)); // line3 (削除行の後) + Assert.Equal(5, CalculateDiffPosition(patch, 3)); // line4 + } + + [Fact] + public void CalculateDiffPosition_LineNotFound_ReturnsNull() + { + // Arrange + var patch = "@@ -1,2 +1,2 @@\n line1\n line2"; + + // Act - 行番号100は存在しない + var result = CalculateDiffPosition(patch, 100); + + // Assert + Assert.Null(result); + } + + [Fact] + public void CalculateDiffPosition_EmptyPatch_ReturnsNull() + { + // Arrange + var patch = ""; + + // Act + var result = CalculateDiffPosition(patch, 1); + + // Assert + Assert.Null(result); + } + + [Fact] + public void CalculateDiffPosition_NullPatch_ReturnsNull() + { + // Arrange + string? patch = null; + + // Act + var result = CalculateDiffPosition(patch, 1); + + // Assert + Assert.Null(result); + } + + [Fact] + public void CalculateDiffPosition_MultipleHunks_ReturnsCorrectPosition() + { + // Arrange - 複数のhunkがあるケース + // 1: @@ -1,3 +1,4 @@ <- hunk 1 header + // 2: line1 + // 3: line2 + // 4: +newLine + // 5: line3 + // 6: @@ -10,3 +11,4 @@ <- hunk 2 header (ファイル内11行目から開始) + // 7: line10 <- ファイル内11行目 + // 8: line11 <- ファイル内12行目 + // 9: +anotherNewLine <- ファイル内13行目 + // 10: line12 <- ファイル内14行目 + var patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+newLine\n line3\n@@ -10,3 +11,4 @@\n line10\n line11\n+anotherNewLine\n line12"; + + // Act & Assert + Assert.Equal(9, CalculateDiffPosition(patch, 13)); // anotherNewLine + Assert.Equal(10, CalculateDiffPosition(patch, 14)); // line12 + } + + [Fact] + public void CalculateDiffPosition_HunkStartLine_ReturnsCorrectPosition() + { + // Arrange - hunkが+15から始まる場合 + // ファイル内の15行目からdiffが始まる + var patch = "@@ -10,3 +15,4 @@\n line15\n line16\n+newLine\n line17"; + + // Act & Assert + Assert.Equal(2, CalculateDiffPosition(patch, 15)); // line15 + Assert.Equal(3, CalculateDiffPosition(patch, 16)); // line16 + Assert.Equal(4, CalculateDiffPosition(patch, 17)); // newLine + Assert.Equal(5, CalculateDiffPosition(patch, 18)); // line17 + } + + [Fact] + public void CalculateDiffPosition_RealWorldExample_WorksCorrectly() + { + // Arrange - 実際のGitHubのdiff例 + // hunkは+15から始まるので、ファイル内の15行目がdiffの最初の行 + var patch = "@@ -15,8 +15,10 @@ public class Example\n public void Method1()\n {\n var x = 1;\n+ var y = 2;\n+ var z = 3;\n Console.WriteLine(x);\n- Console.WriteLine(\"old\");\n+ Console.WriteLine(\"new\");\n }\n}"; + + // diff position: + // 1: @@ -15,8 +15,10 @@ public class Example + // 2: public void Method1() <- ファイル内15行目 + // 3: { <- ファイル内16行目 + // 4: var x = 1; <- ファイル内17行目 + // 5: + var y = 2; <- ファイル内18行目(追加) + // 6: + var z = 3; <- ファイル内19行目(追加) + // 7: Console.WriteLine(x); <- ファイル内20行目 + // 8: - Console.WriteLine("old"); <- 削除行 + // 9: + Console.WriteLine("new"); <- ファイル内21行目(追加) + // 10: } + // 11: } + + // Act & Assert + Assert.Equal(5, CalculateDiffPosition(patch, 18)); // var y = 2 + Assert.Equal(6, CalculateDiffPosition(patch, 19)); // var z = 3 + Assert.Equal(7, CalculateDiffPosition(patch, 20)); // Console.WriteLine(x) + Assert.Equal(9, CalculateDiffPosition(patch, 21)); // Console.WriteLine("new") + } +} diff --git a/PRAgent.Tests/DraftPullRequestReviewCommentTests.cs b/PRAgent.Tests/DraftPullRequestReviewCommentTests.cs new file mode 100644 index 0000000..6de9d34 --- /dev/null +++ b/PRAgent.Tests/DraftPullRequestReviewCommentTests.cs @@ -0,0 +1,112 @@ +using Octokit; +using Xunit; + +namespace PRAgent.Tests; + +/// +/// DraftPullRequestReviewCommentの引数順序が正しいことを確認するテスト +/// +public class DraftPullRequestReviewCommentTests +{ + [Fact] + public void Constructor_ParameterOrder_ShouldBeBodyPathPosition() + { + // Arrange + var body = "This is a test comment"; + var path = "src/test/File.cs"; + var position = 42; + + // Act + var comment = new DraftPullRequestReviewComment(body, path, position); + + // Assert - Octokitの仕様通り (body, path, position) の順序であることを確認 + Assert.Equal(body, comment.Body); + Assert.Equal(path, comment.Path); + Assert.Equal(position, comment.Position); + } + + [Fact] + public void Constructor_WithJapaneseBody_ShouldWork() + { + // Arrange + var body = "これはテストコメントです。修正が必要です。"; + var path = "src/test/File.cs"; + var position = 10; + + // Act + var comment = new DraftPullRequestReviewComment(body, path, position); + + // Assert + Assert.Equal(body, comment.Body); + Assert.Equal(path, comment.Path); + Assert.Equal(position, comment.Position); + } + + [Fact] + public void Constructor_WithSuggestionInBody_ShouldWork() + { + // Arrange + var body = "Please fix this\n```suggestion\nvar fixed = true;\n```"; + var path = "src/test/File.cs"; + var position = 15; + + // Act + var comment = new DraftPullRequestReviewComment(body, path, position); + + // Assert + Assert.Equal(body, comment.Body); + Assert.Contains("```suggestion", comment.Body); + } + + [Fact] + public void Constructor_WithNullBody_ShouldThrow() + { + // Arrange + string? body = null; + var path = "src/test/File.cs"; + var position = 1; + + // Act & Assert + Assert.Throws(() => + new DraftPullRequestReviewComment(body!, path, position)); + } + + [Fact] + public void Constructor_WithNullPath_ShouldThrow() + { + // Arrange + var body = "Test comment"; + string? path = null; + var position = 1; + + // Act & Assert + Assert.Throws(() => + new DraftPullRequestReviewComment(body, path!, position)); + } + + [Fact] + public void Constructor_WithEmptyBody_ShouldThrow() + { + // Arrange + var body = ""; + var path = "src/test/File.cs"; + var position = 1; + + // Act & Assert + Assert.Throws(() => + new DraftPullRequestReviewComment(body, path, position)); + } + + [Fact] + public void Constructor_WithEmptyPath_ShouldThrow() + { + // Arrange + var body = "Test comment"; + var path = ""; + var position = 1; + + // Act & Assert + Assert.Throws(() => + new DraftPullRequestReviewComment(body, path, position)); + } +} diff --git a/PRAgent.Tests/GitHubServiceLineCommentTests.cs b/PRAgent.Tests/GitHubServiceLineCommentTests.cs new file mode 100644 index 0000000..a248f85 --- /dev/null +++ b/PRAgent.Tests/GitHubServiceLineCommentTests.cs @@ -0,0 +1,145 @@ +using Moq; +using Octokit; +using PRAgent.Services; +using Xunit; + +namespace PRAgent.Tests; + +/// +/// GitHubServiceの行コメント機能のテスト +/// +public class GitHubServiceLineCommentTests +{ + /// + /// DraftPullRequestReviewCommentのリストが正しく作成されることを確認するテスト + /// + [Fact] + public void DraftPullRequestReviewComment_CreatesCorrectComment_ForSingleLine() + { + // Arrange + var body = "This needs to be fixed"; + var path = "src/Services/GitHubService.cs"; + var position = 42; + + // Act + var comment = new DraftPullRequestReviewComment(body, path, position); + + // Assert + Assert.Equal(body, comment.Body); + Assert.Equal(path, comment.Path); + Assert.Equal(position, comment.Position); + } + + /// + /// 複数行コメントのデータ構造が正しく作成されることを確認するテスト + /// + [Fact] + public void MultipleLineComments_CreatesCorrectDataStructure() + { + // Arrange & Act + var comments = new List<(string FilePath, int? LineNumber, int? StartLine, int? EndLine, string Comment, string? Suggestion)> + { + ("src/File1.cs", 10, null, null, "Fix this issue", null), + ("src/File2.cs", 20, null, null, "Another issue", "var x = 1;"), + ("src/File3.cs", null, 30, 35, "Multi-line issue", null) + }; + + // Assert + Assert.Equal(3, comments.Count); + Assert.Equal("src/File1.cs", comments[0].FilePath); + Assert.Equal(10, comments[0].LineNumber); + Assert.Equal("Fix this issue", comments[0].Comment); + + Assert.Equal("src/File2.cs", comments[1].FilePath); + Assert.Equal(20, comments[1].LineNumber); + Assert.Equal("var x = 1;", comments[1].Suggestion); + + Assert.Equal("src/File3.cs", comments[2].FilePath); + Assert.Null(comments[2].LineNumber); + Assert.Equal(30, comments[2].StartLine); + Assert.Equal(35, comments[2].EndLine); + } + + /// + /// DraftPullRequestReviewCommentのリストが正しく作成されることを確認するテスト + /// + [Fact] + public void DraftPullRequestReviewComment_CreatesCorrectList() + { + // Arrange + var commentsData = new List<(string FilePath, int? LineNumber, int? StartLine, int? EndLine, string Comment, string? Suggestion)> + { + ("src/File1.cs", 10, null, null, "Issue 1", null), + ("src/File2.cs", 20, null, null, "Issue 2", "var x = 1;") + }; + + // Act + var draftComments = commentsData.Select(c => + { + var commentBody = c.Suggestion != null ? $"{c.Comment}\n```suggestion\n{c.Suggestion}\n```" : c.Comment; + + if (c.LineNumber.HasValue) + { + return new DraftPullRequestReviewComment(commentBody, c.FilePath, c.LineNumber.Value); + } + else if (c.StartLine.HasValue) + { + return new DraftPullRequestReviewComment(commentBody, c.FilePath, c.StartLine.Value); + } + else + { + throw new ArgumentException($"Comment must have either LineNumber or StartLine: {c.FilePath}"); + } + }).ToList(); + + // Assert + Assert.Equal(2, draftComments.Count); + + Assert.Equal("Issue 1", draftComments[0].Body); + Assert.Equal("src/File1.cs", draftComments[0].Path); + Assert.Equal(10, draftComments[0].Position); + + Assert.Contains("Issue 2", draftComments[1].Body); + Assert.Contains("```suggestion", draftComments[1].Body); + Assert.Contains("var x = 1;", draftComments[1].Body); + Assert.Equal("src/File2.cs", draftComments[1].Path); + Assert.Equal(20, draftComments[1].Position); + } + + /// + /// サジェスチョン付きコメントのフォーマットが正しいことを確認するテスト + /// + [Fact] + public void CommentWithSuggestion_FormatsCorrectly() + { + // Arrange + var comment = "Please use var instead"; + var suggestion = "var number = 42;"; + + // Act + var commentBody = $"{comment}\n```suggestion\n{suggestion}\n```"; + + // Assert + Assert.Equal("Please use var instead\n```suggestion\nvar number = 42;\n```", commentBody); + } + + /// + /// 日本語コメントでも正しく動作することを確認するテスト + /// + [Fact] + public void CommentWithJapanese_WorksCorrectly() + { + // Arrange + var body = "この変数名は分かりにくいです。より説明的な名前に変更することを検討してください。"; + var path = "src/サービス/メイン.cs"; + var position = 100; + + // Act + var comment = new DraftPullRequestReviewComment(body, path, position); + + // Assert + Assert.Equal(body, comment.Body); + Assert.Equal(path, comment.Path); + Assert.Equal(position, comment.Position); + } +} diff --git a/PRAgent.Tests/YamlConfigurationProviderTests.cs b/PRAgent.Tests/YamlConfigurationProviderTests.cs deleted file mode 100644 index 39affab..0000000 --- a/PRAgent.Tests/YamlConfigurationProviderTests.cs +++ /dev/null @@ -1,136 +0,0 @@ -using PRAgent.Configuration; - -namespace PRAgent.Tests; - -public class YamlConfigurationProviderTests -{ - [Fact] - public void Deserialize_ValidYaml_ReturnsConfig() - { - // Arrange - var yaml = """ - pragent: - enabled: true - system_prompt: "Test prompt" - review: - enabled: true - auto_post: false - summary: - enabled: true - post_as_comment: true - approve: - enabled: true - auto_approve_threshold: "minor" - require_review_first: true - ignore_paths: - - "*.min.js" - - "dist/**" - """; - - // Act - var result = YamlConfigurationProvider.Deserialize(yaml); - - // Assert - Assert.NotNull(result); - Assert.NotNull(result.PRAgent); - Assert.True(result.PRAgent.Enabled); - Assert.Equal("Test prompt", result.PRAgent.SystemPrompt); - Assert.True(result.PRAgent.Review?.Enabled); - Assert.False(result.PRAgent.Review?.AutoPost); - Assert.True(result.PRAgent.Summary?.Enabled); - Assert.True(result.PRAgent.Summary?.PostAsComment); - Assert.True(result.PRAgent.Approve?.Enabled); - Assert.Equal("minor", result.PRAgent.Approve?.AutoApproveThreshold); - Assert.True(result.PRAgent.Approve?.RequireReviewFirst); - Assert.NotNull(result.PRAgent.IgnorePaths); - Assert.Equal(2, result.PRAgent.IgnorePaths?.Count); - } - - [Fact] - public void Deserialize_EmptyYaml_ReturnsNull() - { - // Arrange - var yaml = ""; - - // Act - var result = YamlConfigurationProvider.Deserialize(yaml); - - // Assert - Assert.Null(result); - } - - [Fact] - public void Deserialize_NullYaml_ReturnsNull() - { - // Act - var result = YamlConfigurationProvider.Deserialize(null!); - - // Assert - Assert.Null(result); - } - - [Fact] - public void IsValidYaml_ValidYaml_ReturnsTrue() - { - // Arrange - var yaml = """ - pragent: - enabled: true - """; - - // Act - var result = YamlConfigurationProvider.IsValidYaml(yaml); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsValidYaml_InvalidYaml_ReturnsFalse() - { - // Arrange - var yaml = """ - pragent: - enabled: [unclosed bracket - """; - - // Act - var result = YamlConfigurationProvider.IsValidYaml(yaml); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsValidYaml_EmptyString_ReturnsFalse() - { - // Act - var result = YamlConfigurationProvider.IsValidYaml(""); - - // Assert - Assert.False(result); - } - - [Fact] - public void Deserialize_MinimalYaml_ReturnsConfigWithDefaults() - { - // Arrange - var yaml = """ - pragent: - enabled: true - """; - - // Act - var result = YamlConfigurationProvider.Deserialize(yaml); - - // Assert - Assert.NotNull(result); - Assert.NotNull(result.PRAgent); - Assert.True(result.PRAgent.Enabled); - Assert.Null(result.PRAgent.SystemPrompt); - Assert.Null(result.PRAgent.Review); - Assert.Null(result.PRAgent.Summary); - Assert.Null(result.PRAgent.Approve); - Assert.Null(result.PRAgent.IgnorePaths); - } -} diff --git a/PRAgent.Tests/packages.lock.json b/PRAgent.Tests/packages.lock.json index 827b938..10ad3ab 100644 --- a/PRAgent.Tests/packages.lock.json +++ b/PRAgent.Tests/packages.lock.json @@ -63,6 +63,16 @@ "System.Memory.Data": "8.0.1" } }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.0", + "contentHash": "QZiMa1O5lTniWTSDPyPVdBKOS0/M5DWXTfQ2xLOot96yp8Q5A69iScGtzCIxfbg/4bmp/TynibZ2VK1v3qsSNA==", + "dependencies": { + "Azure.Core": "1.49.0", + "Microsoft.Identity.Client": "4.76.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.76.0" + } + }, "Castle.Core": { "type": "Transitive", "resolved": "5.1.1", @@ -71,6 +81,16 @@ "System.Diagnostics.EventLog": "6.0.0" } }, + "Microsoft.Agents.AI.Abstractions": { + "type": "Transitive", + "resolved": "1.0.0-preview.251009.1", + "contentHash": "8Bfppenx9ETyhDsJ30Hixy/zf/t2t/vt03+jl7uLd5XgadZo+xQcEYa16ANHUAcJzBH7ou85sSRO8MohNiFgiw==", + "dependencies": { + "Microsoft.Extensions.AI.Abstractions": "9.9.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Logging.Abstractions": "9.0.9" + } + }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", "resolved": "8.0.0", @@ -408,6 +428,28 @@ "Microsoft.Extensions.AI.Abstractions": "9.5.0" } }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.76.0", + "contentHash": "j2FtmljuCveDJ7umBVYm6Bx3iVGA71U07Dc7byGr2Hrj7XlByZSknruCBUeYN3V75nn1VEhXegxE0MerxvxrXQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.35.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.76.0", + "contentHash": "D0n3yMTa3gnDh1usrJmyxGWKYeqbQNCWLgQ0Tswf7S6nk9YobiCOX+M2V8EX5SPqkZxpwkTT6odAhaafu3TIeA==", + "dependencies": { + "Microsoft.Identity.Client": "4.76.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.35.0", + "contentHash": "xuR8E4Rd96M41CnUSCiOJ2DBh+z+zQSmyrYHdYhD6K4fXBcQGVnRCFQ0efROUYpP+p0zC1BLKr0JRpVuujTZSg==" + }, "Microsoft.SemanticKernel": { "type": "Transitive", "resolved": "1.68.0", @@ -427,6 +469,36 @@ "Microsoft.Extensions.VectorData.Abstractions": "9.7.0" } }, + "Microsoft.SemanticKernel.Agents.Abstractions": { + "type": "Transitive", + "resolved": "1.68.0", + "contentHash": "hkKxc0Fuy4+ax3HjDyGtzm0EESEX0Qq/Q2vEYjCe2x9KEsiAAU+droPeqrWoy48+u0o01oGkA0IgA07u9Ta/jw==", + "dependencies": { + "Microsoft.Agents.AI.Abstractions": "1.0.0-preview.251009.1", + "Microsoft.SemanticKernel.Abstractions": "1.68.0" + } + }, + "Microsoft.SemanticKernel.Agents.Core": { + "type": "Transitive", + "resolved": "1.68.0", + "contentHash": "b+3d4cHXLKSg73BZLlqGUf0jb254fVdYCH5gQiUYHNLEoGNRk5JkUt6gr0p0Uomp+ooBz7tFfhWYEpBsTni2Bw==", + "dependencies": { + "Microsoft.SemanticKernel.Agents.Abstractions": "1.68.0", + "Microsoft.SemanticKernel.Core": "1.68.0" + } + }, + "Microsoft.SemanticKernel.Agents.OpenAI": { + "type": "Transitive", + "resolved": "1.68.0-preview", + "contentHash": "I5GQkj9BdzI5e1y1iWl6BPkZCbA5IqC+07CraTa/Y+/EGDI88RO4UEoClmyq7bGv/hF+9eFD5pasWmhWnWGf1Q==", + "dependencies": { + "Azure.AI.OpenAI": "2.7.0-beta.2", + "Azure.Identity": "1.17.0", + "Microsoft.SemanticKernel.Agents.Abstractions": "1.68.0", + "Microsoft.SemanticKernel.Agents.Core": "1.68.0", + "Microsoft.SemanticKernel.Connectors.OpenAI": "1.68.0" + } + }, "Microsoft.SemanticKernel.Connectors.AzureOpenAI": { "type": "Transitive", "resolved": "1.68.0", @@ -560,6 +632,11 @@ "resolved": "10.0.0", "contentHash": "PxZKFx8ACAt7gRnTSDgtZFA06cimls5XL1Olv63bWF8qKL5+EQdqq3K02etW4LNut4xDoREPe9z7QYATLMkBPA==" }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -615,6 +692,8 @@ "Microsoft.Extensions.Hosting": "[10.0.0, )", "Microsoft.Extensions.Logging": "[10.0.0, )", "Microsoft.SemanticKernel": "[1.68.0, )", + "Microsoft.SemanticKernel.Agents.Core": "[1.68.0, )", + "Microsoft.SemanticKernel.Agents.OpenAI": "[1.68.0-preview, )", "Microsoft.SemanticKernel.Connectors.OpenAI": "[1.68.0, )", "Microsoft.VisualStudio.Azure.Containers.Tools.Targets": "[1.23.0, )", "Octokit": "[14.0.0, )", diff --git a/PRAgent/Agents/AgentDefinition.cs b/PRAgent/Agents/AgentDefinition.cs index 4ab7b24..72a2b3a 100644 --- a/PRAgent/Agents/AgentDefinition.cs +++ b/PRAgent/Agents/AgentDefinition.cs @@ -181,8 +181,13 @@ You are an expert code reviewer with deep knowledge of software engineering best - Code organization and readability - Adherence to best practices and design patterns - Test coverage and quality + + After completing the review, you can: + - Approve the PR if no major issues are found + - Request changes if there are issues that need to be addressed + - Add line comments for specific issues """, - description: "Reviews pull requests for code quality, security, and best practices" + description: "Reviews pull requests for code quality, security, and best practices. Can approve or request changes." ); public static AgentDefinition DetailedCommentAgent => new( @@ -213,44 +218,4 @@ 4. Detailed comment body with suggestions """, description: "Creates structured review comments for detailed line-by-line review" ); - - public static AgentDefinition ApprovalAgent => new( - name: "ApprovalAgent", - role: "Approval Authority", - systemPrompt: """ - You are a senior technical lead responsible for making approval decisions on pull requests. - - Your role is to: - 1. Analyze code review results - 2. Evaluate findings against approval thresholds - 3. Make conservative, risk-aware approval decisions - 4. Provide clear reasoning for your decisions - - Approval thresholds: - - critical: PR must have NO critical issues - - major: PR must have NO major or critical issues - - minor: PR must have NO minor, major, or critical issues - - none: Always approve - - When in doubt, err on the side of caution and recommend rejection or additional review. - """, - description: "Makes approval decisions based on review results and configured thresholds" - ); - - public static AgentDefinition SummaryAgent => new( - name: "SummaryAgent", - role: "Technical Writer", - systemPrompt: """ - You are a technical writer specializing in creating clear, concise documentation. - - Your role is to: - 1. Summarize pull request changes accurately - 2. Highlight the purpose and impact of changes - 3. Assess risk levels objectively - 4. Identify areas needing special testing attention - - Keep summaries under 300 words. Use markdown formatting with bullet points for readability. - """, - description: "Creates concise summaries of pull request changes" - ); } diff --git a/PRAgent/Agents/AgentFactory.cs b/PRAgent/Agents/AgentFactory.cs new file mode 100644 index 0000000..573a1d1 --- /dev/null +++ b/PRAgent/Agents/AgentFactory.cs @@ -0,0 +1,119 @@ +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using PRAgent.Services; + +namespace PRAgent.Agents; + +/// +/// Semantic Kernel ChatCompletionAgentの作成を集中管理するファクトリクラス +/// +public class PRAgentFactory +{ + private readonly IKernelService _kernelService; + private readonly IGitHubService _gitHubService; + private readonly PullRequestDataService _prDataService; + + public PRAgentFactory( + IKernelService kernelService, + IGitHubService gitHubService, + PullRequestDataService prDataService) + { + _kernelService = kernelService; + _gitHubService = gitHubService; + _prDataService = prDataService; + } + + /// + /// Reviewエージェントを作成 + /// + public async Task CreateReviewAgentAsync( + string owner, + string repo, + int prNumber, + string? customSystemPrompt = null, + IEnumerable? functions = null) + { + var kernel = _kernelService.CreateAgentKernel(AgentDefinition.ReviewAgent.SystemPrompt); + + if (functions != null) + { + foreach (var function in functions) + { + kernel.ImportPluginFromObject(function); + } + } + + var agent = new ChatCompletionAgent + { + Name = AgentDefinition.ReviewAgent.Name, + Description = AgentDefinition.ReviewAgent.Description, + Instructions = customSystemPrompt ?? AgentDefinition.ReviewAgent.SystemPrompt, + Kernel = kernel + }; + + return await Task.FromResult(agent); + } + + /// + /// Reviewエージェント用のKernelを作成(FunctionCalling有効) + /// + public Kernel CreateReviewKernel( + string owner, + string repo, + int prNumber, + string? customSystemPrompt = null) + { + return _kernelService.CreateAgentKernel( + customSystemPrompt ?? AgentDefinition.ReviewAgent.SystemPrompt); + } + + /// + /// Approvalエージェント用のKernelを作成(後方互換性のため残す) + /// + [Obsolete("Use CreateReviewKernel instead. Approval functionality is now integrated into ReviewAgent.")] + public Kernel CreateApprovalKernel( + string owner, + string repo, + int prNumber, + string? customSystemPrompt = null) + { + return CreateReviewKernel(owner, repo, prNumber, customSystemPrompt); + } + + /// + /// カスタムエージェントを作成(汎用メソッド) + /// + public async Task CreateCustomAgentAsync( + string name, + string description, + string systemPrompt, + string owner, + string repo, + int prNumber, + IEnumerable? functions = null, + KernelArguments? arguments = null) + { + var kernel = _kernelService.CreateAgentKernel(systemPrompt); + + if (functions != null) + { + foreach (var function in functions) + { + kernel.ImportPluginFromObject(function); + } + } + + var agent = new ChatCompletionAgent + { + Name = name, + Description = description, + Instructions = systemPrompt, + Kernel = kernel, + Arguments = arguments + }; + + return await Task.FromResult(agent); + } +} diff --git a/PRAgent/Agents/ApprovalAgent.cs b/PRAgent/Agents/ApprovalAgent.cs deleted file mode 100644 index 81323ce..0000000 --- a/PRAgent/Agents/ApprovalAgent.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.SemanticKernel; -using PRAgent.Models; -using PRAgent.Services; - -namespace PRAgent.Agents; - -public class ApprovalAgent : BaseAgent -{ - public ApprovalAgent( - IKernelService kernelService, - IGitHubService gitHubService, - PullRequestDataService prDataService, - AISettings aiSettings, - string? customSystemPrompt = null) - : base(kernelService, gitHubService, prDataService, aiSettings, AgentDefinition.ApprovalAgent, customSystemPrompt) - { - } - - public new void SetLanguage(string language) => base.SetLanguage(language); - - public async Task<(bool ShouldApprove, string Reasoning, string? Comment)> DecideAsync( - string owner, - string repo, - int prNumber, - string reviewResult, - ApprovalThreshold threshold, - CancellationToken cancellationToken = default) - { - var pr = await GitHubService.GetPullRequestAsync(owner, repo, prNumber); - var thresholdDescription = ApprovalThresholdHelper.GetDescription(threshold); - - var prompt = $""" - Based on the code review below, make an approval decision for this pull request. - - ## Pull Request - - Title: {pr.Title} - - Author: {pr.User.Login} - - ## Code Review Result - {reviewResult} - - ## Approval Threshold - {thresholdDescription} - - Provide your decision in this format: - - DECISION: [APPROVE/REJECT] - REASONING: [Explain why, listing any issues above the threshold] - CONDITIONS: [Any conditions for merge, or N/A] - APPROVAL_COMMENT: [Brief comment if approved, or N/A] - - Be conservative - when in doubt, reject or request additional review. - """; - - var response = await KernelService.InvokePromptAsStringAsync(CreateKernel(), prompt, cancellationToken); - - return ApprovalResponseParser.Parse(response); - } - - public async Task ApproveAsync( - string owner, - string repo, - int prNumber, - string? comment = null) - { - var result = await GitHubService.ApprovePullRequestAsync(owner, repo, prNumber, comment); - return $"PR approved: {result.HtmlUrl}"; - } -} diff --git a/PRAgent/Agents/BaseAgent.cs b/PRAgent/Agents/BaseAgent.cs deleted file mode 100644 index dacc034..0000000 --- a/PRAgent/Agents/BaseAgent.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.SemanticKernel; -using Octokit; -using PRAgent.Models; -using PRAgent.Services; - -namespace PRAgent.Agents; - -public abstract class BaseAgent -{ - protected readonly IKernelService KernelService; - protected readonly IGitHubService GitHubService; - protected readonly PullRequestDataService PRDataService; - protected readonly AISettings AISettings; - protected AgentDefinition Definition; - - protected BaseAgent( - IKernelService kernelService, - IGitHubService gitHubService, - PullRequestDataService prDataService, - AISettings aiSettings, - AgentDefinition definition, - string? customSystemPrompt = null) - { - KernelService = kernelService; - GitHubService = gitHubService; - PRDataService = prDataService; - AISettings = aiSettings; - - // Apply language setting to the agent definition - Definition = definition.WithLanguage(aiSettings.Language); - - if (!string.IsNullOrEmpty(customSystemPrompt)) - { - Definition = new AgentDefinition( - Definition.Name, - Definition.Role, - customSystemPrompt, - Definition.Description - ); - } - } - - /// - /// Sets the language for AI responses dynamically - /// - protected void SetLanguage(string language) - { - Definition = Definition.WithLanguage(language); - } - - protected Kernel CreateKernel() - { - return KernelService.CreateKernel(Definition.SystemPrompt); - } - - protected async Task<(PullRequest pr, IReadOnlyList files, string diff)> GetPRDataAsync( - string owner, string repo, int prNumber) - { - return await PRDataService.GetPullRequestDataAsync(owner, repo, prNumber); - } -} diff --git a/PRAgent/Agents/DetailedCommentAgent.cs b/PRAgent/Agents/DetailedCommentAgent.cs index c975fec..416080d 100644 --- a/PRAgent/Agents/DetailedCommentAgent.cs +++ b/PRAgent/Agents/DetailedCommentAgent.cs @@ -1,210 +1,289 @@ -using Microsoft.SemanticKernel; using Microsoft.Extensions.Logging; -using Octokit; -using PRAgent.Models; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; using PRAgent.Services; namespace PRAgent.Agents; /// /// 詳細な行コメントを作成するサブエージェント -/// Tool呼び出しベースで各問題点ごとにコメントを作成 +/// ReviewAgentからFunction Callingで呼び出され、個々の問題について詳細なコメントを生成 /// -public class DetailedCommentAgent : ReviewAgentBase, IDetailedCommentAgent +public class DetailedCommentAgent : IDetailedCommentAgent { + private readonly IKernelService _kernelService; private readonly ILogger _logger; + private string _language = "en"; public DetailedCommentAgent( IKernelService kernelService, - IGitHubService gitHubService, - PullRequestDataService prDataService, - AISettings aiSettings, - ILogger logger, - string? customSystemPrompt = null) - : base(kernelService, gitHubService, prDataService, aiSettings, AgentDefinition.DetailedCommentAgent, customSystemPrompt) + ILogger logger) { + _kernelService = kernelService; _logger = logger; } /// /// 言語を動的に設定 /// - public new void SetLanguage(string language) => base.SetLanguage(language); + public void SetLanguage(string language) + { + _language = language; + } /// - /// レビュー結果から詳細な行コメントを作成 + /// 個々の問題について詳細なコメントを生成(Function Calling用) /// - public async Task> CreateCommentsAsync(string review, string language) + /// ファイルパス + /// 行番号 + /// 周辺コード + /// 問題の概要 + /// 詳細なコメント(JSON形式) + [KernelFunction("get_detailed_comment")] + public async Task GetDetailedCommentAsync( + string filePath, + int lineNumber, + string codeSnippet, + string issueSummary) { - SetLanguage(language); + var systemPrompt = GetDetailedCommentPrompt(_language); + var kernel = _kernelService.CreateAgentKernel(systemPrompt); - // レビュー内容から問題点を抽出 - var issues = ExtractIssuesFromReview(review); + var prompt = $$""" + 以下のコードの問題点について、GitHubのプルリクエストレビュー用の詳細なコメントを作成してください。 - // 各問題点に対してTool呼び出しでコメントを作成 - var comments = new List(); + ## ファイル情報 + - ファイルパス: {{filePath}} + - 行番号: {{lineNumber}} - foreach (var issue in issues) - { - var comment = await CreateCommentForIssueAsync(issue, language); - if (comment != null) + ## 対象コード + ``` + {{codeSnippet}} + ``` + + ## 問題の概要 + {{issueSummary}} + + ## 出力形式(JSON) + 以下の形式で出力してください: + ```json { - comments.Add(comment); + "path": "{{filePath}}", + "line": {{lineNumber}}, + "body": "詳細なコメント本文", + "suggestion": "修正提案のコード(あれば)" } - } + ``` - return comments; + 重要: 必ず有効なJSONオブジェクトのみを出力してください。 + """; + + var chatService = kernel.GetRequiredService(); + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage(prompt); + + var response = await chatService.GetChatMessageContentsAsync(chatHistory, executionSettings: null, kernel); + var responseText = response.FirstOrDefault()?.Content ?? "{}"; + + _logger.LogInformation("=== DetailedCommentAgent Response for {File}:{Line} ===\n{Response}", + filePath, lineNumber, responseText); + + return responseText; } /// - /// レビューから問題点を抽出 + /// 複数の問題について一括で詳細コメントを生成(バッチ処理用) /// - private List ExtractIssuesFromReview(string review) + public async Task> CreateDetailedCommentsAsync( + List issues, + string language) { - var issues = new List(); - - // セクションごとに分割 - var sections = review.Split(new[] { "\n\n### ", "\n## ", "\n##\n\n" }, StringSplitOptions.RemoveEmptyEntries); + SetLanguage(language); + var results = new List(); - foreach (var section in sections) + foreach (var issue in issues) { - // セクションタイトルを解析 - var titleMatch = System.Text.RegularExpressions.Regex.Match(section, @"^(###\s*\[([A-Z]+)\])?\s*(.+)"); - if (titleMatch.Success) + try { - var levelStr = titleMatch.Groups[2].Value; - var title = titleMatch.Groups[3].Value.Trim(); + var jsonResult = await GetDetailedCommentAsync( + issue.FilePath, + issue.LineNumber, + issue.CodeSnippet, + issue.IssueSummary); - // レベルを判定 - var level = levelStr switch + var comment = ParseSingleCommentFromJson(jsonResult); + if (comment != null) { - "CRITICAL" => Severity.Critical, - "MAJOR" => Severity.Major, - "MINOR" => Severity.Minor, - "POSITIVE" => Severity.Positive, - _ => Severity.Major - }; - - // ファイルパスを抽出 - var pathMatch = System.Text.RegularExpressions.Regex.Match(section, @"\*\*ファイル:\*\*`([^`]+)`"); - var path = pathMatch.Success ? pathMatch.Groups[1].Value : "src/File.cs"; - - // 行番号を抽出 - var lineMatch = System.Text.RegularExpressions.Regex.Match(section, @"\(lines?\s*(\d+)(?:-(\d+))?\)"); - int? startLine = null; - int? endLine = null; - - if (lineMatch.Success) - { - startLine = int.Parse(lineMatch.Groups[1].Value); - if (lineMatch.Groups[2].Success) - { - endLine = int.Parse(lineMatch.Groups[2].Value); - } + results.Add(comment); } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create detailed comment for {File}:{Line}", + issue.FilePath, issue.LineNumber); + } + } - // 問題説明を抽出 - var problemSection = System.Text.RegularExpressions.Regex.Split(section, @"\n\*\*\w+:\*\*"); - var problem = problemSection.Length > 1 ? problemSection[1].Split('\n')[0].Trim() : section.Split('\n')[0].Trim(); + return results; + } - // 修正提案を抽出 - var suggestion = ""; - var suggestionMatch = System.Text.RegularExpressions.Regex.Match(section, @"```suggestion\s*\n?([^\n`]+)"); - if (suggestionMatch.Success) - { - suggestion = suggestionMatch.Groups[1].Value.Trim(); - } + /// + /// レビュー概要から詳細な行コメントを作成(従来のメソッド、互換性のため残す) + /// + public async Task> CreateCommentsAsync(string reviewOverview, string language) + { + SetLanguage(language); - issues.Add(new ReviewIssue - { - Title = title, - Level = level, - FilePath = path, - StartLine = startLine ?? 1, - EndLine = endLine ?? 1, - Description = problem, - Suggestion = suggestion - }); - } - } + var systemPrompt = GetDetailedCommentPrompt(_language); + var kernel = _kernelService.CreateAgentKernel(systemPrompt); + + var prompt = $$""" + 以下のレビュー概要に基づいて、GitHubのプルリクエストレビュー用の詳細な行コメントを作成してください。 + + ## レビュー概要 + {{reviewOverview}} + + ## 指示 + 1. 各問題点に対して、ファイルパス、行番号、コメント本文を抽出 + 2. コメントは簡潔かつ建設的に + 3. 修正提案がある場合は suggestion ブロックを含める + + ## 出力形式(JSON) + ```json + [ + { + "path": "src/File.cs", + "line": 45, + "body": "ここでメモリリークが発生する可能性があります。using文を使用してください。", + "suggestion": "using var resource = ...;" + } + ] + ``` + + 重要: 必ず有効なJSON配列のみを出力してください。 + """; + + var chatService = kernel.GetRequiredService(); + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage(prompt); - return issues; + var response = await chatService.GetChatMessageContentsAsync(chatHistory, executionSettings: null, kernel); + var responseText = response.FirstOrDefault()?.Content ?? "[]"; + + _logger.LogInformation("=== DetailedCommentAgent Response ===\n{Response}", responseText); + + return ParseCommentsFromJson(responseText); } /// - /// 各問題点に対してコメントを作成 + /// 単一のJSONオブジェクトから行コメントデータをパース /// - private async Task CreateCommentForIssueAsync(ReviewIssue issue, string language) + private LineCommentData? ParseSingleCommentFromJson(string json) { try { - var prompt = CreateCommentPrompt(issue, language); + // コードブロック内のJSONを探す + var codeBlockMatch = System.Text.RegularExpressions.Regex.Match(json, @"```(?:json)?\s*([\s\S]*?)```"); + if (codeBlockMatch.Success) + { + json = codeBlockMatch.Groups[1].Value.Trim(); + } + + // JSONオブジェクトを探す + var jsonMatch = System.Text.RegularExpressions.Regex.Match(json, @"\{[\s\S]*?\}"); + if (!jsonMatch.Success) + { + _logger.LogWarning("No valid JSON object found in response"); + return null; + } + + return System.Text.Json.JsonSerializer.Deserialize(jsonMatch.Value); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse single comment from JSON"); + return null; + } + } - // プロンプットを出力 - _logger.LogInformation("=== DetailedCommentAgent Prompt for Issue ===\n{Prompt}", prompt); + /// + /// JSON配列から行コメントデータをパース + /// + private List ParseCommentsFromJson(string json) + { + try + { + // まずコードブロック内のJSONを探す + var codeBlockMatch = System.Text.RegularExpressions.Regex.Match(json, @"```(?:json)?\s*([\s\S]*?)```"); + if (codeBlockMatch.Success) + { + json = codeBlockMatch.Groups[1].Value.Trim(); + } - var aiResponse = await KernelService.InvokePromptAsStringAsync(CreateKernel(), prompt); + // JSON配列を探す(非貪欲マッチ) + var jsonMatch = System.Text.RegularExpressions.Regex.Match(json, @"\[[\s\S]*?\]"); + if (!jsonMatch.Success) + { + _logger.LogWarning("No valid JSON array found in response"); + return new List(); + } - _logger.LogInformation("=== DetailedCommentAgent Response ===\n{Response}", aiResponse); + var jsonArray = jsonMatch.Value; + var comments = System.Text.Json.JsonSerializer.Deserialize>(jsonArray); - return new DraftPullRequestReviewComment(issue.FilePath, aiResponse, issue.StartLine); + return comments ?? new List(); } - catch + catch (Exception ex) { - // フォールバック:簡潔なコメントを作成 - return new DraftPullRequestReviewComment( - issue.FilePath, - $"{issue.Level}: {issue.Description}\n\n{issue.Suggestion}", - issue.StartLine); + _logger.LogError(ex, "Failed to parse comments from JSON"); + return new List(); } } /// - /// コメント生成用のプロンプトを作成 + /// 言語に応じた詳細コメント用プロンプトを取得 /// - private string CreateCommentPrompt(ReviewIssue issue, string language) + private static string GetDetailedCommentPrompt(string language) { - return $$""" - Create a detailed GitHub pull request review comment for this issue: - - **Issue Title:** {{issue.Title}} - **Level:** {{issue.Level}} - **File:** {{issue.FilePath}} (Line {{issue.StartLine}}) - **Description:** {{issue.Description}} - **Suggestion:** {{issue.Suggestion}} - - Create a concise comment that: - 1. Clearly describes the problem - 2. Provides actionable feedback - 3. Uses professional and constructive language - 4. Keep it under 200 words - - **Output:** Only the comment text, no formatting. - """; - } -} + var isJapanese = language?.ToLowerInvariant() == "ja"; -/// -/// レビュー問題のデータモデル -/// -public class ReviewIssue -{ - public string Title { get; set; } = string.Empty; - public Severity Level { get; set; } - public string FilePath { get; set; } = string.Empty; - public int StartLine { get; set; } - public int EndLine { get; set; } - public string Description { get; set; } = string.Empty; - public string Suggestion { get; set; } = string.Empty; -} + if (isJapanese) + { + return """ + あなたはGitHubのプルリクエストレビュー用の詳細コメントを作成する専門家です。 -/// -/// レベルの列挙型 -/// -public enum Severity -{ - Critical, - Major, - Minor, - Positive -} \ No newline at end of file + ## 役割 + 指定されたコードの問題点について、詳細なコメントを生成 + + ## 出力ルール + 1. 有効なJSONオブジェクトのみを出力 + 2. コメントには以下を含める: + - path: ファイルパス(入力と同じ値) + - line: 行番号(入力と同じ値) + - body: 詳細なコメント本文 + - suggestion: 修正提案(オプション) + 3. コメントは建設的で、修正方法を具体的に提示 + 4. コードスニペットを参照して、具体的な改善案を提示 + """; + } + else + { + return """ + You are an expert at creating detailed line comments for GitHub pull request reviews. + + ## Your Role + Generate detailed comments for specified code issues. + + ## Output Rules + 1. Output only valid JSON object + 2. Include in comment: + - path: file path (same as input) + - line: line number (same as input) + - body: detailed comment body + - suggestion: fix suggestion (optional) + 3. Keep comments constructive with specific improvement suggestions + 4. Reference the code snippet and provide concrete fixes + """; + } + } +} diff --git a/PRAgent/Agents/ReviewAgent.cs b/PRAgent/Agents/ReviewAgent.cs deleted file mode 100644 index 21ccd19..0000000 --- a/PRAgent/Agents/ReviewAgent.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Microsoft.SemanticKernel; -using Microsoft.Extensions.Logging; -using Octokit; -using PRAgent.Models; -using PRAgent.Services; - -namespace PRAgent.Agents; - -public class ReviewAgent : ReviewAgentBase -{ - private readonly IDetailedCommentAgent _detailedCommentAgent; - private readonly ILogger _logger; - - public ReviewAgent( - IKernelService kernelService, - IGitHubService gitHubService, - PullRequestDataService prDataService, - AISettings aiSettings, - IDetailedCommentAgent detailedCommentAgent, - ILogger logger, - string? customSystemPrompt = null) - : base(kernelService, gitHubService, prDataService, aiSettings, AgentDefinition.ReviewAgent, customSystemPrompt) - { - _detailedCommentAgent = detailedCommentAgent; - _logger = logger; - } - - public new void SetLanguage(string language) => base.SetLanguage(language); - - public async Task ReviewAsync( - string owner, - string repo, - int prNumber, - CancellationToken cancellationToken = default) - { - var (pr, files, diff) = await GetPRDataAsync(owner, repo, prNumber); - var fileList = PullRequestDataService.FormatFileList(files); - - var systemPrompt = """ - You are an expert code reviewer with deep knowledge of software engineering best practices. - Your role is to provide thorough, constructive, and actionable feedback on pull requests. - - When reviewing code, focus on: - - Correctness and potential bugs - - Security vulnerabilities - - Performance considerations - - Code organization and readability - - Adherence to best practices and design patterns - - Test coverage and quality - - **Output Format:** - - Organize findings by individual issue/problem - - Each issue should be a separate section starting with ### - - Use severity labels: [CRITICAL], [MAJOR], [MINOR], [POSITIVE] - - For each issue, include: - * File path (extract from review or use a reasonable default) - * Line numbers (if applicable) - * Detailed description - * Specific code examples - * Concrete suggestions - - Example: - ### [CRITICAL] SQL Injection Vulnerability - - **File:** `src/Authentication.cs` (lines 45-52) - - **Problem:** User input is directly concatenated into SQL queries... - - **Suggested Fix:** - ```suggestion - // Use parameterized queries - var query = "SELECT * FROM Users WHERE Id = @Id"; - ``` - """; - - var prompt = PullRequestDataService.CreateReviewPrompt(pr, fileList, diff, systemPrompt); - - // プロンプットを出力 - _logger.LogInformation("=== ReviewAgent Prompt ===\n{Prompt}", prompt); - - var review = await KernelService.InvokePromptAsStringAsync(CreateKernel(), prompt, cancellationToken); - - _logger.LogInformation("=== ReviewAgent Response ===\n{Response}", review); - - // サブエージェントに渡すために言語設定 - _detailedCommentAgent.SetLanguage(AISettings.Language); - - // サブエージェントで詳細なコメントを作成 - var comments = await _detailedCommentAgent.CreateCommentsAsync(review, AISettings.Language); - - return review; - } -} \ No newline at end of file diff --git a/PRAgent/Agents/SK/SKReviewAgent.cs b/PRAgent/Agents/SK/SKReviewAgent.cs new file mode 100644 index 0000000..06789cd --- /dev/null +++ b/PRAgent/Agents/SK/SKReviewAgent.cs @@ -0,0 +1,496 @@ +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; +using PRAgent.Models; +using PRAgent.Services; +using PRAgent.Plugins.GitHub; +using PRAgentDefinition = PRAgent.Agents.AgentDefinition; + +namespace PRAgent.Agents.SK; + +/// +/// Semantic Kernel ChatCompletionAgentベースのレビューエージェント +/// ReviewAgent(概要)とDetailedCommentAgent(詳細)を連携させ、一つのReviewとして投稿 +/// +public class SKReviewAgent +{ + private readonly PRAgentFactory _agentFactory; + private readonly PullRequestDataService _prDataService; + private readonly IGitHubService _gitHubService; + private readonly IDetailedCommentAgent _detailedCommentAgent; + + public SKReviewAgent( + PRAgentFactory agentFactory, + PullRequestDataService prDataService, + IGitHubService gitHubService, + IDetailedCommentAgent detailedCommentAgent) + { + _agentFactory = agentFactory; + _prDataService = prDataService; + _gitHubService = gitHubService; + _detailedCommentAgent = detailedCommentAgent; + } + + /// + /// プルリクエストのコードレビューを実行します + /// + public async Task ReviewAsync( + string owner, + string repo, + int prNumber, + string? customSystemPrompt = null, + CancellationToken cancellationToken = default) + { + // Reviewエージェントを作成 + var agent = await _agentFactory.CreateReviewAgentAsync(owner, repo, prNumber, customSystemPrompt); + + // PRデータを取得 + var (pr, files, diff) = await _prDataService.GetPullRequestDataAsync(owner, repo, prNumber); + var fileList = PullRequestDataService.FormatFileList(files); + + // プロンプトを作成 + var systemPrompt = customSystemPrompt ?? PRAgentDefinition.ReviewAgent.SystemPrompt; + var prompt = PullRequestDataService.CreateReviewPrompt(pr, fileList, diff, systemPrompt); + + // チャット履歴を作成してエージェントを実行 + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage(prompt); + + var responses = new System.Text.StringBuilder(); + await foreach (var response in agent.InvokeAsync(chatHistory, cancellationToken: cancellationToken)) + { + responses.Append(response.Message.Content); + } + + return responses.ToString(); + } + + /// + /// ストリーミングでコードレビューを実行します + /// + public async IAsyncEnumerable ReviewStreamingAsync( + string owner, + string repo, + int prNumber, + string? customSystemPrompt = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Reviewエージェントを作成 + var agent = await _agentFactory.CreateReviewAgentAsync(owner, repo, prNumber, customSystemPrompt); + + // PRデータを取得 + var (pr, files, diff) = await _prDataService.GetPullRequestDataAsync(owner, repo, prNumber); + var fileList = PullRequestDataService.FormatFileList(files); + + // プロンプトを作成 + var systemPrompt = customSystemPrompt ?? PRAgentDefinition.ReviewAgent.SystemPrompt; + var prompt = PullRequestDataService.CreateReviewPrompt(pr, fileList, diff, systemPrompt); + + // チャット履歴を作成してエージェントを実行 + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage(prompt); + + await foreach (var response in agent.InvokeAsync(chatHistory, cancellationToken: cancellationToken)) + { + yield return response.Message.Content ?? string.Empty; + } + } + + /// + /// ReviewAgent(概要)とDetailedCommentAgent(詳細)を連携させたレビューを実行 + /// Function CallingでDetailedCommentAgentを呼び出し + /// + public async Task<(string ReviewText, PRActionResult? ActionResult)> ReviewWithLineCommentsAsync( + string owner, + string repo, + int prNumber, + string? language = null, + CancellationToken cancellationToken = default) + { + // PRデータを取得 + var (pr, files, diff) = await _prDataService.GetPullRequestDataAsync(owner, repo, prNumber); + var fileList = PullRequestDataService.FormatFileList(files); + + // ===== バッファとKernelを準備 ===== + var buffer = new PRActionBuffer(); + _detailedCommentAgent.SetLanguage(language ?? "en"); + + var reviewSystemPrompt = GetReviewWithSubAgentPrompt(language); + + // Kernelを作成してツールを登録 + var kernel = _agentFactory.CreateReviewKernel(owner, repo, prNumber, reviewSystemPrompt); + + // ツールを登録 + var commentPlugin = new PostCommentFunction(buffer); + var approvePlugin = new ApprovePRFunction(buffer); + kernel.Plugins.AddFromObject(commentPlugin, "pr_actions"); + kernel.Plugins.AddFromObject(approvePlugin, "pr_actions"); + kernel.Plugins.AddFromObject(_detailedCommentAgent, "sub_agents"); + + // Function Calling設定 + var executionSettings = new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + // ===== ReviewAgentでレビュー実行(Function CallingでSubAgent呼び出し) ===== + var reviewPrompt = $""" + 以下のプルリクエストのコードレビューを行ってください。 + + ## プルリクエスト情報 + - タイトル: {pr.Title} + - 作成者: {pr.User.Login} + - 説明: {pr.Body} + + ## 変更されたファイル + {fileList} + + ## 差分 + {diff} + + ## レビュー指示 + 利用可能なツールを使ってレビューを行ってください: + + 1. **post_review_comment**: レビューの概要を投稿 + - 全体的な概要(3-5行程度) + - レビューのまとめ + + 2. **get_detailed_comment**: 個々の問題について詳細なコメントを生成 + - ファイルパス、行番号、周辺コード、問題の概要を指定 + - SubAgentが詳細なコメントと修正提案を生成 + - 各問題に対して呼び出す + + 3. **post_line_comment**: 生成された詳細コメントを投稿 + - get_detailed_commentの結果を使って投稿 + + 4. **approve_pull_request**: PRを承認 + - Criticalな問題がない場合に使用 + + 5. **request_changes**: 変更を依頼 + - Criticalな問題がある場合に使用 + + ## ワークフロー + 1. まず post_review_comment で概要を投稿 + 2. 各問題について: + a. get_detailed_comment で詳細なコメントを生成 + b. post_line_comment でコメントを投稿 + 3. 最後に approve_pull_request または request_changes で判定 + """; + + var chatService = kernel.GetRequiredService(); + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage(reviewPrompt); + + var reviewResponses = new System.Text.StringBuilder(); + await foreach (var content in chatService.GetStreamingChatMessageContentsAsync(chatHistory, executionSettings, kernel, cancellationToken)) + { + reviewResponses.Append(content.Content); + } + + var reviewText = reviewResponses.ToString(); + + // ===== アクションを実行 ===== + PRActionResult? actionResult = null; + var executor = new PRActionExecutor(_gitHubService, owner, repo, prNumber); + var state = buffer.GetState(); + + if (state.LineCommentCount > 0 || state.ReviewCommentCount > 0 || state.ApprovalState != PRApprovalState.None) + { + actionResult = await executor.ExecuteAsync(buffer, cancellationToken); + } + + return (reviewText, actionResult); + } + + /// + /// 言語に応じた概要レビュープロンプトを取得します + /// + private static string GetReviewOverviewPrompt(string? language) + { + var isJapanese = language?.ToLowerInvariant() == "ja"; + + if (isJapanese) + { + return """ + あなたはシニアソフトウェアエンジニアとしてプルリクエストのコードレビューを行います。 + + ## 役割 + 1. コードの全体的な概要を作成(3-5行程度) + 2. 発見した問題点をリストアップ + 3. 各問題には重要度を付与(Critical/Major/Minor) + + ## 出力形式 + ## 概要 + [全体的な概要] + + ## 発見した問題 + ### [Critical/Major/Minor] 問題タイトル + **ファイル:** `path/to/file.cs` + **行番号:** 45 + **説明:** 問題の詳細説明 + **修正提案:** + ```suggestion + // 修正内容 + ``` + + 簡潔で建設的なフィードバックを心がけてください。 + """; + } + else + { + return """ + You are a senior software engineer performing code reviews on pull requests. + + ## Your Role + 1. Create a brief overview (3-5 lines) + 2. List all issues found with severity (Critical/Major/Minor) + 3. For each issue, include file path, line number, description, and suggestion + + ## Output Format + ## Overview + [Brief overview of the changes] + + ## Issues Found + ### [Critical/Major/Minor] Issue Title + **File:** `path/to/file.cs` + **Line:** 45 + **Description:** Detailed explanation of the issue + **Suggestion:** + ```suggestion + // Fixed code + ``` + + Keep feedback concise and constructive. + """; + } + } + + /// + /// SubAgentありモード用のプロンプトを取得 + /// + private static string GetReviewWithSubAgentPrompt(string? language) + { + var isJapanese = language?.ToLowerInvariant() == "ja"; + + if (isJapanese) + { + return """ + あなたはシニアソフトウェアエンジニアとしてプルリクエストのコードレビューを行います。 + + ## 役割 + 提供されたツールを使ってレビューを行い、詳細なコメントはSubAgentに委任します。 + + ## 利用可能なツール + - post_review_comment: レビューの概要を投稿 + - get_detailed_comment: SubAgentを呼び出して詳細なコメントを生成 + - post_line_comment: 生成された詳細コメントを投稿 + - approve_pull_request: PRを承認 + - request_changes: 変更を依頼 + + ## レビューの基準 + - Critical: セキュリティ脆弱性、バグ、データ損失の可能性 + - Major: パフォーマンス問題、保守性の問題 + - Minor: コードスタイル、軽微な改善提案 + + ## ワークフロー + 1. コードを分析して問題を特定 + 2. post_review_commentで概要を投稿 + 3. 各問題について: + a. get_detailed_commentでSubAgentに詳細コメントを生成させる + b. post_line_commentでコメントを投稿 + 4. Criticalな問題がなければapprove、あればrequest_changes + + 重要: 行コメントの内容は必ず get_detailed_comment を使ってSubAgentに生成させてください。 + """; + } + else + { + return """ + You are a senior software engineer performing code reviews on pull requests. + + ## Your Role + Review code using provided tools, delegating detailed comments to SubAgent. + + ## Available Tools + - post_review_comment: Post review overview + - get_detailed_comment: Call SubAgent to generate detailed comment + - post_line_comment: Post the generated detailed comment + - approve_pull_request: Approve the PR + - request_changes: Request changes + + ## Review Criteria + - Critical: Security vulnerabilities, bugs, data loss potential + - Major: Performance issues, maintainability problems + - Minor: Code style, minor improvements + + ## Workflow + 1. Analyze code and identify issues + 2. Post overview with post_review_comment + 3. For each issue: + a. Use get_detailed_comment to have SubAgent generate detailed comment + b. Post comment with post_line_comment + 4. approve if no Critical issues, request_changes if any + + Important: Always use get_detailed_comment to have SubAgent generate line comment content. + """; + } + } + public async Task<(string ReviewText, PRActionResult? ActionResult)> ReviewDirectAsync( + string owner, + string repo, + int prNumber, + string? language = null, + CancellationToken cancellationToken = default) + { + // PRデータを取得 + var (pr, files, diff) = await _prDataService.GetPullRequestDataAsync(owner, repo, prNumber); + var fileList = PullRequestDataService.FormatFileList(files); + + // ===== バッファとKernelを準備 ===== + var buffer = new PRActionBuffer(); + var systemPrompt = GetDirectReviewPrompt(language); + + // Kernelを作成してツールを登録 + var kernel = _agentFactory.CreateReviewKernel(owner, repo, prNumber, systemPrompt); + + // すべてのツールを登録 + var commentPlugin = new PostCommentFunction(buffer); + var approvePlugin = new ApprovePRFunction(buffer); + kernel.Plugins.AddFromObject(commentPlugin, "pr_actions"); + kernel.Plugins.AddFromObject(approvePlugin, "pr_actions"); + + // Function Calling設定 + var executionSettings = new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + + // ===== レビュープロンプト ===== + var reviewPrompt = $""" + 以下のプルリクエストのコードレビューを行ってください。 + + ## プルリクエスト情報 + - タイトル: {pr.Title} + - 作成者: {pr.User.Login} + - 説明: {pr.Body} + + ## 変更されたファイル + {fileList} + + ## 差分 + {diff} + + ## レビュー指示 + 利用可能なツールを使ってレビューを行ってください: + + 1. **post_review_comment**: レビューの概要を投稿(必須) + - 全体的な概要(3-5行程度) + - レビューのまとめ + + 2. **post_line_comment**: 特定の行にコメントを投稿 + - ファイルパス、行番号、コメント内容を指定 + - 修正提案がある場合はsuggestionパラメータを使用 + + 3. **approve_pull_request**: PRを承認 + - Criticalな問題がない場合に使用 + + 4. **request_changes**: 変更を依頼 + - Criticalな問題がある場合に使用 + + ## ワークフロー + 1. まず post_review_comment で概要を投稿 + 2. 問題がある行に対して post_line_comment でコメント + 3. 最後に approve_pull_request または request_changes で判定 + """; + + var chatService = kernel.GetRequiredService(); + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage(reviewPrompt); + + var reviewResponses = new System.Text.StringBuilder(); + await foreach (var content in chatService.GetStreamingChatMessageContentsAsync(chatHistory, executionSettings, kernel, cancellationToken)) + { + reviewResponses.Append(content.Content); + } + + var reviewText = reviewResponses.ToString(); + + // ===== アクションを実行 ===== + PRActionResult? actionResult = null; + var executor = new PRActionExecutor(_gitHubService, owner, repo, prNumber); + var state = buffer.GetState(); + + if (state.LineCommentCount > 0 || state.ReviewCommentCount > 0 || state.ApprovalState != PRApprovalState.None) + { + actionResult = await executor.ExecuteAsync(buffer, cancellationToken); + } + + return (reviewText, actionResult); + } + + /// + /// SubAgentなしモード用のプロンプトを取得 + /// + private static string GetDirectReviewPrompt(string? language) + { + var isJapanese = language?.ToLowerInvariant() == "ja"; + + if (isJapanese) + { + return """ + あなたはシニアソフトウェアエンジニアとしてプルリクエストのコードレビューを行います。 + + ## 役割 + 提供されたツールを使って、プルリクエストのレビューを行ってください。 + + ## 利用可能なツール + - post_review_comment: レビューの概要を投稿 + - post_line_comment: 特定の行にコメントを投稿 + - approve_pull_request: PRを承認 + - request_changes: 変更を依頼 + + ## レビューの基準 + - Critical: セキュリティ脆弱性、バグ、データ損失の可能性 + - Major: パフォーマンス問題、保守性の問題 + - Minor: コードスタイル、軽微な改善提案 + + ## ワークフロー + 1. コードを分析 + 2. post_review_commentで概要を投稿 + 3. 問題がある箇所にpost_line_commentでコメント + 4. Criticalな問題がなければapprove、あればrequest_changes + + 簡潔で建設的なフィードバックを心がけてください。 + """; + } + else + { + return """ + You are a senior software engineer performing code reviews on pull requests. + + ## Your Role + Use the provided tools to review pull requests. + + ## Available Tools + - post_review_comment: Post review overview + - post_line_comment: Post comment on specific line + - approve_pull_request: Approve the PR + - request_changes: Request changes + + ## Review Criteria + - Critical: Security vulnerabilities, bugs, data loss potential + - Major: Performance issues, maintainability problems + - Minor: Code style, minor improvements + + ## Workflow + 1. Analyze the code + 2. Post overview with post_review_comment + 3. Comment on issues with post_line_comment + 4. approve if no Critical issues, request_changes if any + + Keep feedback concise and constructive. + """; + } + } +} diff --git a/PRAgent/Agents/SelectionStrategies.cs b/PRAgent/Agents/SelectionStrategies.cs new file mode 100644 index 0000000..40537d7 --- /dev/null +++ b/PRAgent/Agents/SelectionStrategies.cs @@ -0,0 +1,262 @@ +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Chat; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace PRAgent.Agents; + +/// +/// Semantic Kernel AgentGroupChat用の選択戦略 +/// +public static class SelectionStrategies +{ + /// + /// シーケンシャル選択戦略 - エージェントを順番に選択 + /// + public class SequentialSelectionStrategy : SelectionStrategy + { + private int _currentIndex = 0; + + public SequentialSelectionStrategy() + { + } + + protected override async Task SelectAgentAsync( + IReadOnlyList agents, + IReadOnlyList history, + CancellationToken cancellationToken = default) + { + // シーケンシャルにエージェントを選択 + if (agents.Count == 0) + { + throw new InvalidOperationException("No agents available for selection."); + } + + var selectedAgent = agents[_currentIndex]; + _currentIndex = (_currentIndex + 1) % agents.Count; + + return await Task.FromResult(selectedAgent); + } + } + + /// + /// Approvalワークフロー選択戦略 - Review → Summary → Approval の順に選択 + /// + public class ApprovalWorkflowStrategy : SelectionStrategy + { + private enum WorkflowStage + { + Review, + Summary, + Approval, + Complete + } + + private WorkflowStage _currentStage = WorkflowStage.Review; + + public ApprovalWorkflowStrategy() + { + } + + protected override async Task SelectAgentAsync( + IReadOnlyList agents, + IReadOnlyList history, + CancellationToken cancellationToken = default) + { + // ワークフローの現在ステージに基づいてエージェントを選択 + Agent? selectedAgent = _currentStage switch + { + WorkflowStage.Review => agents.FirstOrDefault(a => a.Name == "ReviewAgent"), + WorkflowStage.Summary => agents.FirstOrDefault(a => a.Name == "SummaryAgent"), + WorkflowStage.Approval => agents.FirstOrDefault(a => a.Name == "ApprovalAgent"), + _ => null + }; + + if (selectedAgent == null) + { + // 指定した名前のエージェントが見つからない場合は最初のエージェントを使用 + selectedAgent = agents.FirstOrDefault(); + if (selectedAgent == null) + { + throw new InvalidOperationException($"No agent available for stage: {_currentStage}"); + } + } + + // 次のステージに進む + _currentStage = _currentStage switch + { + WorkflowStage.Review => WorkflowStage.Summary, + WorkflowStage.Summary => WorkflowStage.Approval, + WorkflowStage.Approval => WorkflowStage.Complete, + _ => WorkflowStage.Complete + }; + + // ワークフローが完了したら最初に戻す(必要に応じて) + if (_currentStage == WorkflowStage.Complete) + { + _currentStage = WorkflowStage.Review; + } + + return await Task.FromResult(selectedAgent); + } + } + + /// + /// 条件付き選択戦略 - 履歴の内容に基づいて次のエージェントを選択 + /// + public class ConditionalSelectionStrategy : SelectionStrategy + { + public ConditionalSelectionStrategy() + { + } + + protected override async Task SelectAgentAsync( + IReadOnlyList agents, + IReadOnlyList history, + CancellationToken cancellationToken = default) + { + if (agents.Count == 0) + { + throw new InvalidOperationException("No agents available for selection."); + } + + // 最後のメッセージの内容を確認して、次のエージェントを決定 + Agent? selectedAgent; + + if (history.Count == 0) + { + // 最初はReviewAgentから + selectedAgent = agents.FirstOrDefault(a => a.Name == "ReviewAgent") ?? agents[0]; + } + else + { + var lastMessage = history[^1]; + var lastAgentName = lastMessage.AuthorName ?? string.Empty; + + // 前のエージェントに基づいて次のエージェントを選択 + selectedAgent = lastAgentName switch + { + "ReviewAgent" => agents.FirstOrDefault(a => a.Name == "SummaryAgent"), + "SummaryAgent" => agents.FirstOrDefault(a => a.Name == "ApprovalAgent"), + "ApprovalAgent" => agents.FirstOrDefault(a => a.Name == "ReviewAgent"), // ループ + _ => agents[0] + }; + } + + return await Task.FromResult(selectedAgent ?? agents[0]); + } + } + + /// + /// ラウンドロビン選択戦略 - エージェントを均等に選択 + /// + public class RoundRobinSelectionStrategy : SelectionStrategy + { + private readonly Dictionary _agentUsageCount = new(); + + public RoundRobinSelectionStrategy() + { + } + + protected override async Task SelectAgentAsync( + IReadOnlyList agents, + IReadOnlyList history, + CancellationToken cancellationToken = default) + { + if (agents.Count == 0) + { + throw new InvalidOperationException("No agents available for selection."); + } + + // 初期化 + foreach (var agent in agents) + { + if (!_agentUsageCount.ContainsKey(agent.Name ?? string.Empty)) + { + _agentUsageCount[agent.Name ?? string.Empty] = 0; + } + } + + // 最も使用回数が少ないエージェントを選択 + var selectedAgent = agents + .OrderBy(a => _agentUsageCount.TryGetValue(a.Name ?? string.Empty, out var count) ? count : 0) + .First(); + + if (selectedAgent.Name != null) + { + _agentUsageCount[selectedAgent.Name]++; + } + + return await Task.FromResult(selectedAgent); + } + } + + /// + /// 最後のメッセージに基づく選択戦略 - メッセージの内容を解析して次のエージェントを選択 + /// + public class ContentBasedSelectionStrategy : SelectionStrategy + { + public ContentBasedSelectionStrategy() + { + } + + protected override async Task SelectAgentAsync( + IReadOnlyList agents, + IReadOnlyList history, + CancellationToken cancellationToken = default) + { + if (agents.Count == 0) + { + throw new InvalidOperationException("No agents available for selection."); + } + + // 最初のメッセージの場合はReviewAgentから + if (history.Count == 0) + { + var reviewAgent = agents.FirstOrDefault(a => a.Name == "ReviewAgent"); + if (reviewAgent != null) + { + return await Task.FromResult(reviewAgent); + } + return await Task.FromResult(agents[0]); + } + + // 最後のメッセージの内容を解析 + var lastMessage = history[^1]; + var content = lastMessage.Content ?? string.Empty; + + // キーワードに基づいてエージェントを選択 + Agent? selectedAgent; + + if (content.Contains("DECISION:", StringComparison.OrdinalIgnoreCase) || + content.Contains("approve", StringComparison.OrdinalIgnoreCase)) + { + // 承認決定が必要な場合はReviewAgentに戻るか、次のステップへ + selectedAgent = agents.FirstOrDefault(a => a.Name == "ReviewAgent") ?? agents[0]; + } + else if (content.Contains("[CRITICAL]") || content.Contains("[MAJOR]")) + { + // 重要な問題が見つかった場合はApprovalAgentへ + selectedAgent = agents.FirstOrDefault(a => a.Name == "ApprovalAgent") ?? agents[0]; + } + else if (content.Contains("## Summary") || content.Contains("Summary")) + { + // 要約が含まれている場合はApprovalAgentへ + selectedAgent = agents.FirstOrDefault(a => a.Name == "ApprovalAgent") ?? agents[0]; + } + else + { + // それ以外の場合は次のエージェントへ + var lastAgentName = lastMessage.AuthorName ?? string.Empty; + selectedAgent = lastAgentName switch + { + "ReviewAgent" => agents.FirstOrDefault(a => a.Name == "SummaryAgent"), + "SummaryAgent" => agents.FirstOrDefault(a => a.Name == "ApprovalAgent"), + _ => agents.FirstOrDefault(a => a.Name == "ReviewAgent") + }; + } + + return await Task.FromResult(selectedAgent ?? agents[0]); + } + } +} diff --git a/PRAgent/Agents/SummaryAgent.cs b/PRAgent/Agents/SummaryAgent.cs deleted file mode 100644 index 6d3fb72..0000000 --- a/PRAgent/Agents/SummaryAgent.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.SemanticKernel; -using PRAgent.Models; -using PRAgent.Services; - -namespace PRAgent.Agents; - -public class SummaryAgent : BaseAgent -{ - public SummaryAgent( - IKernelService kernelService, - IGitHubService gitHubService, - PullRequestDataService prDataService, - AISettings aiSettings, - string? customSystemPrompt = null) - : base(kernelService, gitHubService, prDataService, aiSettings, AgentDefinition.SummaryAgent, customSystemPrompt) - { - } - - public new void SetLanguage(string language) => base.SetLanguage(language); - - public async Task SummarizeAsync( - string owner, - string repo, - int prNumber, - CancellationToken cancellationToken = default) - { - var (pr, files, diff) = await GetPRDataAsync(owner, repo, prNumber); - var fileList = PullRequestDataService.FormatFileList(files); - - var systemPrompt = """ - You are a technical writer specializing in creating clear, concise documentation. - Your role is to summarize pull request changes accurately. - """; - - var prompt = PullRequestDataService.CreateSummaryPrompt(pr, fileList, diff, systemPrompt); - - return await KernelService.InvokePromptAsStringAsync(CreateKernel(), prompt, cancellationToken); - } -} diff --git a/PRAgent/CommandLine/CommandLineOptions.cs b/PRAgent/CommandLine/CommandLineOptions.cs index 594fda2..f6c66dd 100644 --- a/PRAgent/CommandLine/CommandLineOptions.cs +++ b/PRAgent/CommandLine/CommandLineOptions.cs @@ -11,7 +11,7 @@ public abstract record CommandOptions public string? Repo { get; init; } public int PrNumber { get; init; } public bool PostComment { get; init; } - public string? Language { get; init; } = "en"; + public string? Language { get; init; } = null; /// /// Validates the common options shared across all commands diff --git a/PRAgent/CommandLine/CommentCommandHandler.cs b/PRAgent/CommandLine/CommentCommandHandler.cs new file mode 100644 index 0000000..b57c833 --- /dev/null +++ b/PRAgent/CommandLine/CommentCommandHandler.cs @@ -0,0 +1,125 @@ +using PRAgent.Models; +using PRAgent.Services; +using Serilog; + +namespace PRAgent.CommandLine; + +/// +/// Handles the comment command +/// +public class CommentCommandHandler : ICommandHandler +{ + private readonly CommentCommandOptions _options; + private readonly IGitHubService _gitHubService; + + public CommentCommandHandler(CommentCommandOptions options, IGitHubService gitHubService) + { + _options = options; + _gitHubService = gitHubService; + } + + public async Task ExecuteAsync() + { + if (!_options.IsValid(out var errors)) + { + Log.Error("Invalid options:"); + foreach (var error in errors) + { + Log.Error(" - {Error}", error); + } + return 1; + } + + try + { + // 最初にPR情報を取得してファイルを確認 + var pr = await _gitHubService.GetPullRequestAsync(_options.Owner!, _options.Repo!, _options.PrNumber); + + Console.WriteLine("以下のコメントを投稿しますか?"); + Console.WriteLine($"PR: {pr.Title} (#{_options.PrNumber})"); + Console.WriteLine(); + + foreach (var (comment, index) in _options.Comments.Select((c, i) => (c, i))) + { + if (comment == null) continue; + + Console.WriteLine($"コメント {index + 1}:"); + Console.WriteLine($" ファイル: {comment.FilePath}"); + Console.WriteLine($" 行数: {comment.LineNumber}"); + Console.WriteLine($" コメント: {comment.CommentText}"); + + if (!string.IsNullOrEmpty(comment.SuggestionText)) + { + Console.WriteLine($" 修正案: {comment.SuggestionText}"); + } + Console.WriteLine(); + } + + Console.Write("[投稿する] [キャンセル]: "); + var input = Console.ReadLine()?.Trim().ToLowerInvariant(); + + if (input == "投稿する" || input == "post" || input == "y" || input == "yes") + { + // 複数のコメントを一度に投稿 + var commentList = _options.Comments + .Where(c => c != null) + .Select(c => ( + FilePath: c!.FilePath, + LineNumber: (int?)c.LineNumber, + StartLine: (int?)null, + EndLine: (int?)null, + Comment: c.CommentText, + Suggestion: c.SuggestionText + )) + .ToList(); + + if (commentList.Any()) + { + await _gitHubService.CreateMultipleLineCommentsAsync( + _options.Owner!, + _options.Repo!, + _options.PrNumber, + commentList + ); + + Console.WriteLine("コメントを投稿しました."); + } + + // --approveオプションが指定されていた場合はPRを承認 + if (_options.Approve) + { + Console.WriteLine("\nPRを承認しますか? [承認する] [キャンセル]: "); + var approveInput = Console.ReadLine()?.Trim().ToLowerInvariant(); + + if (approveInput == "承認する" || approveInput == "approve" || approveInput == "y" || approveInput == "yes") + { + await _gitHubService.ApprovePullRequestAsync( + _options.Owner!, + _options.Repo!, + _options.PrNumber, + "Approved by PRAgent with comments" + ); + Console.WriteLine("PRを承認しました."); + } + else + { + Console.WriteLine("PRの承認をキャンセルしました."); + } + } + + return 0; + } + else + { + Console.WriteLine("キャンセルしました。"); + return 0; + } + } + catch (Exception ex) + { + Log.Error(ex, "Comment posting failed"); + Console.WriteLine("コメントの投稿に失敗しました。"); + return 1; + } + } +} diff --git a/PRAgent/Configuration/ServiceCollectionExtensions.cs b/PRAgent/Configuration/ServiceCollectionExtensions.cs index e550e2d..cc41b56 100644 --- a/PRAgent/Configuration/ServiceCollectionExtensions.cs +++ b/PRAgent/Configuration/ServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PRAgent.Agents; +using PRAgent.Agents.SK; using PRAgent.Models; using PRAgent.Services; +using PRAgent.Services.SK; using PRAgent.Validators; using Serilog; @@ -65,15 +67,15 @@ public static IServiceCollection AddPRAgentServices( // Data Services services.AddSingleton(); - // Agents - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + // Detailed Comment Agent services.AddSingleton(); - // Agent Orchestrator - services.AddSingleton(); + // SK Agents (Semantic Kernel Agent Framework) + services.AddSingleton(); + services.AddSingleton(); + + // Agent Orchestrator - SKAgentOrchestratorServiceを使用 + services.AddSingleton(); // PR Analysis Service services.AddSingleton(); diff --git a/PRAgent/Configuration/YamlConfigurationProvider.cs b/PRAgent/Configuration/YamlConfigurationProvider.cs deleted file mode 100644 index ca47b57..0000000 --- a/PRAgent/Configuration/YamlConfigurationProvider.cs +++ /dev/null @@ -1,47 +0,0 @@ -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace PRAgent.Configuration; - -public static class YamlConfigurationProvider -{ - private static readonly IDeserializer Deserializer = new DeserializerBuilder() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); - - public static T? Deserialize(string yaml) where T : class - { - if (string.IsNullOrWhiteSpace(yaml)) - { - return default; - } - - try - { - return Deserializer.Deserialize(yaml); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to parse YAML: {ex.Message}", ex); - } - } - - public static bool IsValidYaml(string yaml) - { - if (string.IsNullOrWhiteSpace(yaml)) - { - return false; - } - - try - { - Deserializer.Deserialize(yaml); - return true; - } - catch - { - return false; - } - } -} diff --git a/PRAgent/Models/AISettings.cs b/PRAgent/Models/AISettings.cs index f40bdf1..e91d681 100644 --- a/PRAgent/Models/AISettings.cs +++ b/PRAgent/Models/AISettings.cs @@ -4,7 +4,7 @@ public class AISettings { public const string SectionName = "AISettings"; - public string Endpoint { get; set; } = "https://api.openai.com/v1"; + public string Endpoint { get; set; } = "https://api.openai.com/v1"; // デフォルトはOpenAI(環境変数で上書き可能) public string ApiKey { get; set; } = string.Empty; public string ModelId { get; set; } = "gpt-4o-mini"; public int MaxTokens { get; set; } = 4000; diff --git a/PRAgent/Models/CommentCommandOptions.cs b/PRAgent/Models/CommentCommandOptions.cs new file mode 100644 index 0000000..2ec1868 --- /dev/null +++ b/PRAgent/Models/CommentCommandOptions.cs @@ -0,0 +1,273 @@ +using System.Text.RegularExpressions; + +namespace PRAgent.Models; + +public record CommentCommandOptions +{ + public string? Owner { get; init; } + public string? Repo { get; init; } + public int PrNumber { get; init; } + public List Comments { get; init; } = new(); + public bool Approve { get; init; } + public bool IsValid(out List errors) + { + errors = new List(); + + if (string.IsNullOrEmpty(Owner)) + errors.Add("--owner is required"); + if (string.IsNullOrEmpty(Repo)) + errors.Add("--repo is required"); + if (PrNumber <= 0) + errors.Add("--pr is required and must be a positive number"); + + // 有効なコメントが1つ以上あるかチェック + if (!Comments.Any()) + errors.Add("No valid comments specified"); + + foreach (var (comment, index) in Comments.Select((c, i) => (c, i))) + { + if (comment == null) + { + errors.Add($"Comment {index + 1}: Invalid comment format"); + continue; + } + + // パース段階でチェック済みのため、ここでは不要 + // if (string.IsNullOrWhiteSpace(comment.CommentText)) + // { + // if (string.IsNullOrEmpty(comment.CommentText)) + // { + // errors.Add($"Comment {index + 1}: Comment text is required"); + // } + // else + // { + // errors.Add($"Comment {index + 1}: Comment text cannot be whitespace only"); + // } + // } + + if (comment.LineNumber <= 0) + { + errors.Add($"Comment {index + 1}: Line number must be greater than 0"); + } + + if (!string.IsNullOrEmpty(comment.FilePath) && !IsValidFilePath(comment.FilePath)) + { + errors.Add($"Comment {index + 1}: Invalid file path format"); + } + } + + return errors.Count == 0; + } + + private static bool IsValidFilePath(string path) + { + try + { + // 基本的なファイルパスの検証 + return !string.IsNullOrWhiteSpace(path) && + !path.StartsWith("..") && + !path.Contains("/") && + !path.Contains("\\"); + } + catch + { + return false; + } + } + + public static CommentCommandOptions Parse(string[] args) + { + var options = new CommentCommandOptions(); + var commentArgs = new List(); + + // commentコマンド以降の引数を収集 + bool inCommentSection = false; + foreach (var arg in args) + { + if (arg == "comment") + { + inCommentSection = true; + continue; + } + + if (inCommentSection) + { + commentArgs.Add(arg); + } + } + + options = ParseCommentOptions(args, options); + options = options with { Comments = ParseComments(commentArgs.ToArray()) }; + + return options; + } + + private static CommentCommandOptions ParseCommentOptions(string[] args, CommentCommandOptions current) + { + var options = current; + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--owner": + case "-o": + if (i + 1 < args.Length) + options = options with { Owner = args[++i] }; + break; + case "--repo": + case "-r": + if (i + 1 < args.Length) + options = options with { Repo = args[++i] }; + break; + case "--pr": + case "-p": + case "--number": + if (i + 1 < args.Length && int.TryParse(args[++i], out var pr)) + options = options with { PrNumber = pr }; + break; + case "--approve": + options = options with { Approve = true }; + break; + } + } + + return options; + } + + private static List ParseComments(string[] commentArgs) + { + var comments = new List(); + + // オプション引数(--owner, --repo, --pr, --approve)とその値をスキップしながらパース + // ただし、--suggestionはコメント処理内で扱う + + var skipNext = false; + + for (int i = 0; i < commentArgs.Length; i++) + { + // 前の引数がオプションの値だった場合はスキップ + if (skipNext) + { + skipNext = false; + continue; + } + + var arg = commentArgs[i]; + + // --owner, --repo, --pr, --approve などのオプションはスキップ + if (arg == "--owner" || arg == "-o" || arg == "--repo" || arg == "-r" || + arg == "--pr" || arg == "-p" || arg == "--number" || arg == "--approve") + { + skipNext = true; + continue; + } + + // --suggestion は最後のコメントにsuggestionを追加 + if (arg == "--suggestion" || arg.Equals("--suggestion", StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 < commentArgs.Length && comments.Count > 0) + { + var suggestionText = commentArgs[i + 1]; + if (!string.IsNullOrWhiteSpace(suggestionText)) + { + var lastComment = comments[^1]; + comments[^1] = lastComment with { SuggestionText = suggestionText }; + } + i++; // --suggestionの値もスキップ + } + continue; + } + + // @で始まる、または@を含む引数はコメントとして処理 + if (arg.StartsWith("@") || arg.Contains("@")) + { + string lineRange; + + if (arg.StartsWith("@")) + { + // @123 形式 + lineRange = arg.Substring(1); + } + else + { + // src/file.cs@123 形式 - 全体を渡す + lineRange = arg; + } + + if (TryParseLineRange(lineRange, out var lineNumber, out var filePath) && lineNumber > 0) + { + // 次の引数をコメントテキストとして取得 + if (i + 1 < commentArgs.Length) + { + var textArg = commentArgs[i + 1]; + + // 次の引数がオプションでない場合はコメントテキストとして扱う + if (!textArg.StartsWith("-") && !string.IsNullOrWhiteSpace(textArg)) + { + comments.Add(new CommentTarget( + LineNumber: lineNumber, + FilePath: filePath ?? "src/index.cs", + CommentText: textArg, + SuggestionText: null // 後で --suggestion で設定される + )); + i++; // コメントテキストをスキップ + } + } + } + } + } + + return comments; + } + + private static bool TryParseLineRange(string lineRange, out int lineNumber, out string? filePath) + { + lineNumber = 0; + filePath = null; + + if (string.IsNullOrWhiteSpace(lineRange)) + return false; + + // ファイルパスを含む形式(例: src/file.cs@123)を最初にチェック + if (lineRange.Contains("@")) + { + var parts = lineRange.Split('@', 2); + if (parts.Length == 2 && + int.TryParse(parts[1], out var fileLine) && + fileLine > 0 && // 行数は正の数のみ + !string.IsNullOrWhiteSpace(parts[0])) + { + lineNumber = fileLine; + filePath = parts[0]; + return true; + } + } + + // 行範囲指定形式(例: 123, 45-67) + if (lineRange.Contains("-")) + { + var parts = lineRange.Split('-', 2); + if (parts.Length == 2 && int.TryParse(parts[0], out var startLine) && startLine > 0) + { + lineNumber = startLine; + return true; + } + } + // 単一行指定形式(例: 123) + else if (int.TryParse(lineRange, out var singleLine) && singleLine > 0) + { + lineNumber = singleLine; + return true; + } + + return false; + } +} + +public record CommentTarget( + int LineNumber, + string FilePath, + string CommentText, + string? SuggestionText +); \ No newline at end of file diff --git a/PRAgent/Models/PRActionBuffer.cs b/PRAgent/Models/PRActionBuffer.cs new file mode 100644 index 0000000..5153650 --- /dev/null +++ b/PRAgent/Models/PRActionBuffer.cs @@ -0,0 +1,173 @@ +namespace PRAgent.Models; + +/// +/// PRアクションを蓄積するバッファクラス +/// エージェントが実行中にコメントやサマリーを追加し、最後にまとめて投稿できる +/// +public class PRActionBuffer +{ + private readonly List _lineComments = new(); + private readonly List _reviewComments = new(); + private readonly List _summaries = new(); + private string? _generalComment; + private PRApprovalState _approvalState = PRApprovalState.None; + private string? _approvalComment; + + /// + /// 行コメントを追加します + /// + public void AddLineComment(string filePath, int lineNumber, string comment, string? suggestion = null) + { + _lineComments.Add(new LineCommentAction + { + FilePath = filePath, + LineNumber = lineNumber, + Comment = comment, + Suggestion = suggestion + }); + } + + /// + /// 範囲コメントを追加します + /// + public void AddRangeComment(string filePath, int startLine, int endLine, string comment, string? suggestion = null) + { + _lineComments.Add(new LineCommentAction + { + FilePath = filePath, + StartLine = startLine, + EndLine = endLine, + Comment = comment, + Suggestion = suggestion + }); + } + + /// + /// レビューコメントを追加します + /// + public void AddReviewComment(string comment) + { + _reviewComments.Add(new ReviewCommentAction + { + Comment = comment + }); + } + + /// + /// サマリーを追加します + /// + public void AddSummary(string summary) + { + _summaries.Add(summary); + } + + /// + /// 全体コメントを設定します + /// + public void SetGeneralComment(string comment) + { + _generalComment = comment; + } + + /// + /// PRを承認します + /// + public void MarkForApproval(string? comment = null) + { + _approvalState = PRApprovalState.Approved; + _approvalComment = comment; + } + + /// + /// 変更を依頼します + /// + public void MarkForChangesRequested(string? comment = null) + { + _approvalState = PRApprovalState.ChangesRequested; + _approvalComment = comment; + } + + /// + /// バッファをクリアします + /// + public void Clear() + { + _lineComments.Clear(); + _reviewComments.Clear(); + _summaries.Clear(); + _generalComment = null; + _approvalState = PRApprovalState.None; + _approvalComment = null; + } + + /// + /// バッファの状態を取得します + /// + public PRActionState GetState() + { + return new PRActionState + { + LineCommentCount = _lineComments.Count, + ReviewCommentCount = _reviewComments.Count, + SummaryCount = _summaries.Count, + HasGeneralComment = !string.IsNullOrEmpty(_generalComment), + ApprovalState = _approvalState + }; + } + + /// + /// 蓄積されたアクションを実行するためのデータを取得します + /// + public IReadOnlyList LineComments => _lineComments.AsReadOnly(); + public IReadOnlyList ReviewComments => _reviewComments.AsReadOnly(); + public IReadOnlyList Summaries => _summaries.AsReadOnly(); + public string? GeneralComment => _generalComment; + public PRApprovalState ApprovalState => _approvalState; + public string? ApprovalComment => _approvalComment; +} + +/// +/// PR承認ステータス +/// +public enum PRApprovalState +{ + /// なし(コメントのみ) + None, + /// 承認 + Approved, + /// 変更依頼 + ChangesRequested +} + +/// +/// 行コメントアクション +/// +public class LineCommentAction +{ + public required string FilePath { get; init; } + public int? LineNumber { get; init; } + public int? StartLine { get; init; } + public int? EndLine { get; init; } + public required string Comment { get; init; } + public string? Suggestion { get; init; } +} + +/// +/// レビューコメントアクション +/// +public class ReviewCommentAction +{ + public required string Comment { get; init; } +} + +/// +/// PRアクションの状態 +/// +public class PRActionState +{ + public int LineCommentCount { get; init; } + public int ReviewCommentCount { get; init; } + public int SummaryCount { get; init; } + public bool HasGeneralComment { get; init; } + public PRApprovalState ApprovalState { get; init; } +} diff --git a/PRAgent/Models/PRAgentYmlConfig.cs b/PRAgent/Models/PRAgentYmlConfig.cs index 86b7c19..f241ce2 100644 --- a/PRAgent/Models/PRAgentYmlConfig.cs +++ b/PRAgent/Models/PRAgentYmlConfig.cs @@ -1,63 +1,39 @@ -using YamlDotNet.Serialization; - namespace PRAgent.Models; public class PRAgentYmlConfig { - [YamlMember(Alias = "pragent")] public PRAgentConfig? PRAgent { get; set; } } public class PRAgentConfig { public bool Enabled { get; set; } = true; - - [YamlMember(Alias = "system_prompt")] public string? SystemPrompt { get; set; } - - [YamlMember(Alias = "review")] public ReviewConfig? Review { get; set; } - - [YamlMember(Alias = "summary")] public SummaryConfig? Summary { get; set; } - - [YamlMember(Alias = "approve")] public ApproveConfig? Approve { get; set; } - - [YamlMember(Alias = "ignore_paths")] public List? IgnorePaths { get; set; } + public AgentFrameworkConfig? AgentFramework { get; set; } } public class ReviewConfig { public bool Enabled { get; set; } = true; - - [YamlMember(Alias = "auto_post")] public bool AutoPost { get; set; } = false; - - [YamlMember(Alias = "custom_prompt")] public string? CustomPrompt { get; set; } } public class SummaryConfig { public bool Enabled { get; set; } = true; - - [YamlMember(Alias = "post_as_comment")] public bool PostAsComment { get; set; } = true; - - [YamlMember(Alias = "custom_prompt")] public string? CustomPrompt { get; set; } } public class ApproveConfig { public bool Enabled { get; set; } = true; - - [YamlMember(Alias = "auto_approve_threshold")] public string AutoApproveThreshold { get; set; } = "minor"; - - [YamlMember(Alias = "require_review_first")] public bool RequireReviewFirst { get; set; } = true; } @@ -68,3 +44,95 @@ public enum ApprovalThreshold Minor, None } + +/// +/// Semantic Kernel Agent Frameworkの設定 +/// +public class AgentFrameworkConfig +{ + /// + /// Agent Frameworkを有効にするかどうか + /// + public bool Enabled { get; set; } = false; + + /// + /// 使用するオーケストレーションモード + /// + public string OrchestrationMode { get; set; } = "sequential"; // sequential, agent_chat, collaborative, parallel + + /// + /// 選択戦略 + /// + public string SelectionStrategy { get; set; } = "approval_workflow"; // sequential, approval_workflow, conditional, round_robin, content_based + + /// + /// 関数呼び出しを有効にするかどうか + /// + public bool EnableFunctionCalling { get; set; } = true; + + /// + /// 自動承認を有効にするかどうか + /// + public bool EnableAutoApproval { get; set; } = false; + + /// + /// SubAgent(DetailedCommentAgent)を使用するかどうか + /// true: ReviewAgentがFunction CallingでDetailedCommentAgentを呼び出し + /// false: ReviewAgentだけで完結(Function Callingで直接コメント投稿) + /// + public bool UseSubAgent { get; set; } = false; + + /// + /// 最大ターン数(AgentGroupChat用) + /// + public int MaxTurns { get; set; } = 10; + + /// + /// 各エージェントの設定 + /// + public AgentConfigs? Agents { get; set; } +} + +/// +/// 個別エージェントの設定 +/// +public class AgentConfigs +{ + public ReviewAgentConfig? Review { get; set; } + public SummaryAgentConfig? Summary { get; set; } + public ApprovalAgentConfig? Approval { get; set; } +} + +/// +/// Reviewエージェントの設定 +/// +public class ReviewAgentConfig +{ + public string? CustomSystemPrompt { get; set; } + public bool EnablePlugins { get; set; } = false; + public int? MaxTokens { get; set; } + public double? Temperature { get; set; } +} + +/// +/// Summaryエージェントの設定 +/// +public class SummaryAgentConfig +{ + public string? CustomSystemPrompt { get; set; } + public bool EnablePlugins { get; set; } = false; + public int? MaxTokens { get; set; } + public double? Temperature { get; set; } +} + +/// +/// Approvalエージェントの設定 +/// +public class ApprovalAgentConfig +{ + public string? CustomSystemPrompt { get; set; } + public bool EnableGitHubFunctions { get; set; } = true; + public bool AutoApproveOnDecision { get; set; } = false; + public int? MaxTokens { get; set; } + public double? Temperature { get; set; } +} diff --git a/PRAgent/PRAgent.csproj b/PRAgent/PRAgent.csproj index 050a99f..ef0cf74 100644 --- a/PRAgent/PRAgent.csproj +++ b/PRAgent/PRAgent.csproj @@ -6,10 +6,14 @@ enable enable Linux + + $(NoWarn);SKEXP0110;SKEXP0111;SKEXP0112;SKEXP0113;SKEXP0114;SKEXP0115 + + @@ -25,4 +29,6 @@ + + diff --git a/PRAgent/Plugins/Agent/AgentInvocationFunctions.cs b/PRAgent/Plugins/Agent/AgentInvocationFunctions.cs new file mode 100644 index 0000000..6940114 --- /dev/null +++ b/PRAgent/Plugins/Agent/AgentInvocationFunctions.cs @@ -0,0 +1,104 @@ +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.ChatCompletion; +using PRAgent.Agents; +using PRAgent.Services; +using PRAgentDefinition = PRAgent.Agents.AgentDefinition; + +namespace PRAgent.Plugins.Agent; + +/// +/// Agent-as-Functionパターンを実装するプラグイン +/// ReviewAgentを関数として呼び出すことを可能にします +/// +public class AgentInvocationFunctions +{ + private readonly PRAgentFactory _agentFactory; + private readonly PullRequestDataService _prDataService; + private readonly string _owner; + private readonly string _repo; + private readonly int _prNumber; + + public AgentInvocationFunctions( + PRAgentFactory agentFactory, + PullRequestDataService prDataService, + string owner, + string repo, + int prNumber) + { + _agentFactory = agentFactory; + _prDataService = prDataService; + _owner = owner; + _repo = repo; + _prNumber = prNumber; + } + + /// + /// Reviewエージェントを呼び出してコードレビューを実行します + /// + /// カスタムプロンプト(オプション) + /// キャンセレーショントークン + /// レビュー結果 + [KernelFunction("invoke_review_agent")] + public async Task InvokeReviewAgentAsync( + string? customPrompt = null, + CancellationToken cancellationToken = default) + { + try + { + // Reviewエージェントを作成 + var agent = await _agentFactory.CreateReviewAgentAsync(_owner, _repo, _prNumber); + + // PRデータを取得 + var (pr, files, diff) = await _prDataService.GetPullRequestDataAsync(_owner, _repo, _prNumber); + var fileList = PullRequestDataService.FormatFileList(files); + + // プロンプトを作成 + var prompt = string.IsNullOrEmpty(customPrompt) + ? PullRequestDataService.CreateReviewPrompt(pr, fileList, diff, PRAgentDefinition.ReviewAgent.SystemPrompt) + : customPrompt; + + // チャット履歴を作成してエージェントを実行 + var chatHistory = new ChatHistory(); + chatHistory.AddUserMessage(prompt); + + var responses = new System.Text.StringBuilder(); + await foreach (var response in agent.InvokeAsync(chatHistory, cancellationToken: cancellationToken)) + { + responses.Append(response.Message.Content); + } + + return responses.ToString(); + } + catch (Exception ex) + { + return $"Error invoking review agent: {ex.Message}"; + } + } + + /// + /// Reviewエージェントを関数として呼び出すためのKernelFunctionを作成します + /// + public static KernelFunction InvokeReviewAgentFunction( + PRAgentFactory agentFactory, + PullRequestDataService prDataService, + string owner, + string repo, + int prNumber) + { + var invocationPlugin = new AgentInvocationFunctions(agentFactory, prDataService, owner, repo, prNumber); + return KernelFunctionFactory.CreateFromMethod( + (string? customPrompt, CancellationToken ct) => invocationPlugin.InvokeReviewAgentAsync(customPrompt, ct), + functionName: "invoke_review_agent", + description: "Invokes the review agent to perform code review on a pull request", + parameters: new[] + { + new KernelParameterMetadata("customPrompt") + { + Description = "Optional custom prompt for the review agent", + IsRequired = false, + DefaultValue = null + } + }); + } +} diff --git a/PRAgent/Plugins/GitHub/ApprovePRFunction.cs b/PRAgent/Plugins/GitHub/ApprovePRFunction.cs new file mode 100644 index 0000000..abc95c0 --- /dev/null +++ b/PRAgent/Plugins/GitHub/ApprovePRFunction.cs @@ -0,0 +1,115 @@ +using Microsoft.SemanticKernel; +using PRAgent.Models; + +namespace PRAgent.Plugins.GitHub; + +/// +/// Semantic Kernel用のプルリクエスト承認機能プラグイン(バッファリング版) +/// Approve/Changes Request/Comment Onlyを区別 +/// +public class ApprovePRFunction +{ + private readonly PRActionBuffer _buffer; + + public ApprovePRFunction(PRActionBuffer buffer) + { + _buffer = buffer; + } + + /// + /// プルリクエストを承認します(バッファに追加) + /// + /// 承認時に追加するコメント(オプション) + /// 承認アクションが追加されたことを示すメッセージ + [KernelFunction("approve_pull_request")] + public string ApproveAsync(string? comment = null) + { + _buffer.MarkForApproval(comment); + return $"Pull request approval action has been added to buffer.{(!string.IsNullOrEmpty(comment) ? $" Comment: {comment}" : "")}"; + } + + /// + /// プルリクエストの変更を依頼します(バッファに追加) + /// + /// 変更依頼時のコメント(オプション) + /// 変更依頼アクションが追加されたことを示すメッセージ + [KernelFunction("request_changes")] + public string RequestChangesAsync(string? comment = null) + { + _buffer.MarkForChangesRequested(comment); + return $"Pull request changes requested action has been added to buffer.{(!string.IsNullOrEmpty(comment) ? $" Comment: {comment}" : "")}"; + } + + /// + /// プルリクエストの承認ステータスを確認します(ダミー実装) + /// + /// 現在のバッファ状態を含むステータス + [KernelFunction("get_approval_status")] + public string GetApprovalStatus() + { + var state = _buffer.GetState(); + var statusText = state.ApprovalState switch + { + PRApprovalState.None => "No approval action (comments only)", + PRApprovalState.Approved => "Approved", + PRApprovalState.ChangesRequested => "Changes Requested", + _ => "Unknown" + }; + return $"Current buffer state - ApprovalState: {statusText}, Comments: {state.ReviewCommentCount}"; + } + + /// + /// KernelFunctionとして使用するためのファクトリメソッド + /// + public static KernelFunction ApproveAsyncFunction(PRActionBuffer buffer) + { + var functionPlugin = new ApprovePRFunction(buffer); + return KernelFunctionFactory.CreateFromMethod( + (string? comment) => functionPlugin.ApproveAsync(comment), + functionName: "approve_pull_request", + description: "Adds a pull request approval action to the buffer with an optional comment", + parameters: new[] + { + new KernelParameterMetadata("comment") + { + Description = "Optional comment to add when approving", + DefaultValue = null, + IsRequired = false + } + }); + } + + /// + /// KernelFunctionとして使用するためのファクトリメソッド(変更依頼) + /// + public static KernelFunction RequestChangesFunction(PRActionBuffer buffer) + { + var functionPlugin = new ApprovePRFunction(buffer); + return KernelFunctionFactory.CreateFromMethod( + (string? comment) => functionPlugin.RequestChangesAsync(comment), + functionName: "request_changes", + description: "Adds a pull request changes requested action to the buffer with an optional comment", + parameters: new[] + { + new KernelParameterMetadata("comment") + { + Description = "Optional comment explaining the changes requested", + DefaultValue = null, + IsRequired = false + } + }); + } + + /// + /// KernelFunctionとして使用するためのファクトリメソッド(ステータス取得) + /// + public static KernelFunction GetApprovalStatusFunction(PRActionBuffer buffer) + { + var functionPlugin = new ApprovePRFunction(buffer); + return KernelFunctionFactory.CreateFromMethod( + () => functionPlugin.GetApprovalStatus(), + functionName: "get_approval_status", + description: "Gets the current buffer state including pending approval status" + ); + } +} diff --git a/PRAgent/Plugins/GitHub/PostCommentFunction.cs b/PRAgent/Plugins/GitHub/PostCommentFunction.cs new file mode 100644 index 0000000..04f89f0 --- /dev/null +++ b/PRAgent/Plugins/GitHub/PostCommentFunction.cs @@ -0,0 +1,203 @@ +using Microsoft.SemanticKernel; +using PRAgent.Models; + +namespace PRAgent.Plugins.GitHub; + +/// +/// Semantic Kernel用のプルリクエストコメント投稿機能プラグイン(バッファリング版) +/// +public class PostCommentFunction +{ + private readonly PRActionBuffer _buffer; + + public PostCommentFunction(PRActionBuffer buffer) + { + _buffer = buffer; + } + + /// + /// プルリクエストに全体コメントを追加します(バッファに追加) + /// + /// コメント内容 + /// コメントがバッファに追加されたことを示すメッセージ + [KernelFunction("post_pr_comment")] + public string PostCommentAsync(string comment) + { + if (string.IsNullOrWhiteSpace(comment)) + { + return "Error: Comment cannot be empty"; + } + + _buffer.SetGeneralComment(comment); + return $"General comment has been added to buffer. Length: {comment.Length} characters"; + } + + /// + /// プルリクエストの特定の行にコメントを追加します(バッファに追加) + /// + /// ファイルパス + /// 行番号(1以上) + /// コメント内容 + /// 提案される変更内容(オプション) + /// 行コメントがバッファに追加されたことを示すメッセージ + [KernelFunction("post_line_comment")] + public string PostLineCommentAsync( + string filePath, + int lineNumber, + string comment, + string? suggestion = null) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return "Error: File path cannot be empty"; + } + + if (lineNumber <= 0) + { + return "Error: Line number must be a positive integer (1 or greater)"; + } + + if (string.IsNullOrWhiteSpace(comment)) + { + return "Error: Comment cannot be empty"; + } + + _buffer.AddLineComment(filePath, lineNumber, comment, suggestion); + return $"Line comment has been added to buffer for {filePath}:{lineNumber}"; + } + + /// + /// プルリクエストの特定の範囲にコメントを追加します(バッファに追加) + /// + /// ファイルパス + /// 開始行番号 + /// 終了行番号 + /// コメント内容 + /// 提案される変更内容(オプション) + /// 範囲コメントがバッファに追加されたことを示すメッセージ + [KernelFunction("post_range_comment")] + public string PostRangeCommentAsync( + string filePath, + int startLine, + int endLine, + string comment, + string? suggestion = null) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return "Error: File path cannot be empty"; + } + + if (startLine <= 0 || endLine <= 0) + { + return "Error: Line numbers must be positive"; + } + + if (startLine > endLine) + { + return "Error: startLine must be less than or equal to endLine"; + } + + if (string.IsNullOrWhiteSpace(comment)) + { + return "Error: Comment cannot be empty"; + } + + _buffer.AddRangeComment(filePath, startLine, endLine, comment, suggestion); + return $"Range comment has been added to buffer for {filePath}:{startLine}-{endLine}"; + } + + /// + /// レビューコメントを追加します(バッファに追加) + /// + /// レビュー本文 + /// レビューコメントがバッファに追加されたことを示すメッセージ + [KernelFunction("post_review_comment")] + public string PostReviewCommentAsync(string reviewBody) + { + if (string.IsNullOrWhiteSpace(reviewBody)) + { + return "Error: Review body cannot be empty"; + } + + _buffer.AddReviewComment(reviewBody); + return $"Review comment has been added to buffer. Length: {reviewBody.Length} characters"; + } + + /// + /// KernelFunctionとして使用するためのファクトリメソッド(PRコメント) + /// + public static KernelFunction PostCommentAsyncFunction(PRActionBuffer buffer) + { + var functionPlugin = new PostCommentFunction(buffer); + return KernelFunctionFactory.CreateFromMethod( + (string comment) => functionPlugin.PostCommentAsync(comment), + functionName: "post_pr_comment", + description: "Adds a general comment to the buffer for posting to a pull request", + parameters: new[] + { + new KernelParameterMetadata("comment") + { + Description = "The comment content to add to buffer", + IsRequired = true + } + }); + } + + /// + /// KernelFunctionとして使用するためのファクトリメソッド(行コメント) + /// + public static KernelFunction PostLineCommentAsyncFunction(PRActionBuffer buffer) + { + var functionPlugin = new PostCommentFunction(buffer); + return KernelFunctionFactory.CreateFromMethod( + (string filePath, int lineNumber, string comment, string? suggestion) => + functionPlugin.PostLineCommentAsync(filePath, lineNumber, comment, suggestion), + functionName: "post_line_comment", + description: "Adds a line comment to the buffer for posting to a specific line in a pull request file", + parameters: new[] + { + new KernelParameterMetadata("filePath") + { + Description = "The path to the file in the repository", + IsRequired = true + }, + new KernelParameterMetadata("lineNumber") + { + Description = "The line number to comment on", + IsRequired = true + }, + new KernelParameterMetadata("comment") + { + Description = "The comment content", + IsRequired = true + }, + new KernelParameterMetadata("suggestion") + { + Description = "Optional suggestion for the change", + IsRequired = false, + DefaultValue = null + } + }); + } + + /// + /// KernelFunctionとして使用するためのファクトリメソッド(レビューコメント) + /// + public static KernelFunction PostReviewCommentAsyncFunction(PRActionBuffer buffer) + { + var functionPlugin = new PostCommentFunction(buffer); + return KernelFunctionFactory.CreateFromMethod( + (string reviewBody) => functionPlugin.PostReviewCommentAsync(reviewBody), + functionName: "post_review_comment", + description: "Adds a review comment to the buffer for posting to a pull request", + parameters: new[] + { + new KernelParameterMetadata("reviewBody") + { + Description = "The review content to add to buffer", + IsRequired = true + } + }); + } +} diff --git a/PRAgent/Plugins/PRActionFunctions.cs b/PRAgent/Plugins/PRActionFunctions.cs new file mode 100644 index 0000000..8f09228 --- /dev/null +++ b/PRAgent/Plugins/PRActionFunctions.cs @@ -0,0 +1,145 @@ +using Microsoft.SemanticKernel; +using PRAgent.Models; + +namespace PRAgent.Plugins; + +/// +/// PRアクションを蓄積してまとめて投稿するための関数プラグイン +/// エージェントはこれらの関数を呼び出してアクションを追加し、最後にcommit_actionsでまとめて投稿する +/// +public class PRActionFunctions +{ + private readonly PRActionBuffer _buffer; + + public PRActionFunctions(PRActionBuffer buffer) + { + _buffer = buffer; + } + + /// + /// 特定の行にコメントを追加します + /// + [KernelFunction("add_line_comment")] + public string AddLineComment( + string filePath, + int lineNumber, + string comment, + string? suggestion = null) + { + _buffer.AddLineComment(filePath, lineNumber, comment, suggestion); + var suggestionText = !string.IsNullOrEmpty(suggestion) ? $" (提案: {suggestion})" : ""; + return $"行コメントを追加しました: {filePath}:{lineNumber} - {comment}{suggestionText}"; + } + + /// + /// レビューコメントを追加します + /// + [KernelFunction("add_review_comment")] + public string AddReviewComment(string comment) + { + _buffer.AddReviewComment(comment); + return $"レビューコメントを追加しました: {comment}"; + } + + /// + /// サマリーを追加します + /// + [KernelFunction("add_summary")] + public string AddSummary(string summary) + { + _buffer.AddSummary(summary); + return $"サマリーを追加しました: {summary.Substring(0, Math.Min(50, summary.Length))}..."; + } + + /// + /// 全体コメントを設定します + /// + [KernelFunction("set_general_comment")] + public string SetGeneralComment(string comment) + { + _buffer.SetGeneralComment(comment); + return $"全体コメントを設定しました: {comment.Substring(0, Math.Min(50, comment.Length))}..."; + } + + /// + /// 承認をマークします + /// + [KernelFunction("mark_for_approval")] + public string MarkForApproval(string? comment = null) + { + _buffer.MarkForApproval(comment); + var commentText = !string.IsNullOrEmpty(comment) ? $" (コメント: {comment})" : ""; + return $"承認をマークしました{commentText}"; + } + + /// + /// 現在のバッファ状態を取得します + /// + [KernelFunction("get_buffer_state")] + public string GetBufferState() + { + var state = _buffer.GetState(); + var approvalText = state.ApprovalState switch + { + PRApprovalState.Approved => "承認", + PRApprovalState.ChangesRequested => "変更依頼", + PRApprovalState.None => "なし", + _ => "なし" + }; + return $""" + 現在のバッファ状態: + - 行コメント: {state.LineCommentCount}件 + - レビューコメント: {state.ReviewCommentCount}件 + - サマリー: {state.SummaryCount}件 + - 全体コメント: {(state.HasGeneralComment ? "あり" : "なし")} + - 承認ステータス: {approvalText} + """; + } + + /// + /// バッファをクリアします + /// + [KernelFunction("clear_buffer")] + public string ClearBuffer() + { + _buffer.Clear(); + return "バッファをクリアしました"; + } + + /// + /// アクションのコミット準備が完了したことを示します + /// 実際のコミットは呼び出し元で行います + /// + [KernelFunction("ready_to_commit")] + public string ReadyToCommit() + { + var state = _buffer.GetState(); + var totalActions = state.LineCommentCount + state.ReviewCommentCount + state.SummaryCount + + (state.HasGeneralComment ? 1 : 0) + + (state.ApprovalState != PRApprovalState.None ? 1 : 0); + + if (totalActions == 0) + { + return "コミットするアクションがありません。"; + } + + var approvalText = state.ApprovalState switch + { + PRApprovalState.Approved => "承認", + PRApprovalState.ChangesRequested => "変更依頼", + PRApprovalState.None => "なし", + _ => "なし" + }; + + return $""" + {totalActions}件のアクションをコミット準備完了: + - 行コメント: {state.LineCommentCount}件 + - レビューコメント: {state.ReviewCommentCount}件 + - サマリー: {state.SummaryCount}件 + - 全体コメント: {(state.HasGeneralComment ? "あり" : "なし")} + - 承認ステータス: {approvalText} + + これらのアクションをGitHubに投稿します。 + """; + } +} diff --git a/PRAgent/Program.cs b/PRAgent/Program.cs index e4322a9..d3c682d 100644 --- a/PRAgent/Program.cs +++ b/PRAgent/Program.cs @@ -1,9 +1,13 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using PRAgent.Agents; +using PRAgent.Agents.SK; using PRAgent.CommandLine; -using PRAgent.Configuration; +using PRAgent.Models; using PRAgent.Services; +using PRAgent.Services.SK; +using PRAgent.Validators; using Serilog; namespace PRAgent; @@ -36,7 +40,66 @@ static async Task Main(string[] args) .UseSerilog() .ConfigureServices((context, services) => { - services.AddPRAgentServices(configuration); + // Configuration + services.Configure( + configuration.GetSection(AISettings.SectionName)); + services.Configure( + configuration.GetSection(PRSettings.SectionName)); + + // Core Services + var aiSettings = configuration.GetSection(AISettings.SectionName).Get() + ?? new AISettings(); + var prSettings = configuration.GetSection(PRSettings.SectionName).Get() + ?? new PRSettings(); + + // Validate settings + var errors = new List(); + ConfigValidator.ValidateAISettings(aiSettings, errors); + ConfigValidator.ValidatePRSettings(prSettings, errors); + + if (errors.Any()) + { + Log.Error("Configuration validation failed:"); + foreach (var error in errors) + { + Log.Error(" - {Error}", error); + } + throw new InvalidOperationException("Invalid configuration"); + } + + services.AddSingleton(_ => aiSettings); + services.AddSingleton(_ => prSettings); + + // PRAgent Configuration + var prAgentConfig = configuration.GetSection("PRAgent").Get() + ?? new PRAgentConfig(); + services.AddSingleton(_ => prAgentConfig); + + // GitHub Service + services.AddSingleton(_ => new GitHubService(prSettings.GitHubToken)); + + // Kernel Service + services.AddSingleton(); + + // Configuration Service + services.AddSingleton(); + + // Data Services + services.AddSingleton(); + + // Detailed Comment Agent + services.AddSingleton(); + + // SK Agents (Semantic Kernel Agent Framework) + services.AddSingleton(); + services.AddSingleton(); + + // Agent Orchestrator - SKAgentOrchestratorServiceを使用 + services.AddSingleton(); + Log.Information("Using SKAgentOrchestratorService (Agent Framework)"); + + // PR Analysis Service + services.AddSingleton(); }) .Build(); @@ -71,6 +134,7 @@ static async Task RunCliAsync(string[] args, IServiceProvider services) "review" => CreateReviewHandler(args, services), "summary" => CreateSummaryHandler(args, services), "approve" => CreateApproveHandler(args, services), + "comment" => CreateCommentHandler(args, services), "help" or "--help" or "-h" => null, _ => null }; @@ -80,12 +144,6 @@ static async Task RunCliAsync(string[] args, IServiceProvider services) return await commandHandler.ExecuteAsync(); } - if (command is "help" or "--help" or "-h") - { - HelpTextGenerator.ShowHelp(); - return 0; - } - Log.Error("Unknown command: {Command}", command); HelpTextGenerator.ShowHelp(); return 1; @@ -118,4 +176,11 @@ private static ICommandHandler CreateApproveHandler(string[] args, IServiceProvi var gitHubService = services.GetRequiredService(); return new ApproveCommandHandler(options, prAnalysisService, gitHubService); } + + private static ICommandHandler CreateCommentHandler(string[] args, IServiceProvider services) + { + var options = CommentCommandOptions.Parse(args); + var gitHubService = services.GetRequiredService(); + return new CommentCommandHandler(options, gitHubService); + } } diff --git a/PRAgent/Services/AgentOrchestratorService.cs b/PRAgent/Services/AgentOrchestratorService.cs deleted file mode 100644 index dc063e1..0000000 --- a/PRAgent/Services/AgentOrchestratorService.cs +++ /dev/null @@ -1,142 +0,0 @@ -using PRAgent.Agents; -using PRAgent.Models; -using PRAgent.Services; - -namespace PRAgent.Services; - -public class AgentOrchestratorService : IAgentOrchestratorService -{ - private readonly ReviewAgent _reviewAgent; - private readonly ApprovalAgent _approvalAgent; - private readonly SummaryAgent _summaryAgent; - private readonly DetailedCommentAgent _detailedCommentAgent; - private readonly IGitHubService _gitHubService; - - public AgentOrchestratorService( - ReviewAgent reviewAgent, - ApprovalAgent approvalAgent, - SummaryAgent summaryAgent, - DetailedCommentAgent detailedCommentAgent, - IGitHubService gitHubService) - { - _reviewAgent = reviewAgent; - _approvalAgent = approvalAgent; - _summaryAgent = summaryAgent; - _detailedCommentAgent = detailedCommentAgent; - _gitHubService = gitHubService; - } - - public async Task ReviewAsync(string owner, string repo, int prNumber, CancellationToken cancellationToken = default) - { - return await _reviewAgent.ReviewAsync(owner, repo, prNumber, cancellationToken); - } - - public async Task SummarizeAsync(string owner, string repo, int prNumber, CancellationToken cancellationToken = default) - { - return await _summaryAgent.SummarizeAsync(owner, repo, prNumber, cancellationToken); - } - - public async Task ReviewAndApproveAsync( - string owner, - string repo, - int prNumber, - ApprovalThreshold threshold, - CancellationToken cancellationToken = default) - { - // Step 1: ReviewAgent performs the review - var review = await _reviewAgent.ReviewAsync(owner, repo, prNumber, cancellationToken); - - // Step 2: ApprovalAgent decides based on the review - var (shouldApprove, reasoning, comment) = await _approvalAgent.DecideAsync( - owner, repo, prNumber, review, threshold, cancellationToken); - - // Step 3: If approved, execute the approval - string? approvalUrl = null; - if (shouldApprove) - { - var result = await _approvalAgent.ApproveAsync(owner, repo, prNumber, comment); - approvalUrl = result; - } - - return new ApprovalResult - { - Approved = shouldApprove, - Review = review, - Reasoning = reasoning, - Comment = comment, - ApprovalUrl = approvalUrl - }; - } - - public async Task ReviewAsync(string owner, string repo, int prNumber, string language, CancellationToken cancellationToken = default) - { - _reviewAgent.SetLanguage(language); - - var review = await _reviewAgent.ReviewAsync(owner, repo, prNumber, cancellationToken); - var detailedComments = await _detailedCommentAgent.CreateCommentsAsync(review, language); - - // レビューを投稿 - await _gitHubService.CreateReviewCommentAsync(owner, repo, prNumber, review); - - // 詳細コメントは個別に投稿(複数の場合はリクエスト制限に注意) - if (detailedComments.Count > 0) - { - // 最初のコメントを投稿(GitHub APIでは一度に複数コメントを投稿できる) - await _gitHubService.CreateReviewCommentAsync(owner, repo, prNumber, review); - } - - return review; - } - - public async Task SummarizeAsync(string owner, string repo, int prNumber, string language, CancellationToken cancellationToken = default) - { - _summaryAgent.SetLanguage(language); - return await _summaryAgent.SummarizeAsync(owner, repo, prNumber, cancellationToken); - } - - public async Task ReviewAndApproveAsync( - string owner, - string repo, - int prNumber, - ApprovalThreshold threshold, - string language, - CancellationToken cancellationToken = default) - { - _reviewAgent.SetLanguage(language); - _approvalAgent.SetLanguage(language); - - // Step 1: ReviewAgent performs the review - var review = await _reviewAgent.ReviewAsync(owner, repo, prNumber, cancellationToken); - var detailedComments = await _detailedCommentAgent.CreateCommentsAsync(review, language); - - // レビューを投稿 - await _gitHubService.CreateReviewCommentAsync(owner, repo, prNumber, review); - - // 詳細コメントは個別に投稿(複数の場合はリクエスト制限に注意) - if (detailedComments.Count > 0) - { - await _gitHubService.CreateReviewCommentAsync(owner, repo, prNumber, review); - } - - // Step 2: ApprovalAgent decides based on the review - var (shouldApprove, reasoning, comment) = await _approvalAgent.DecideAsync( - owner, repo, prNumber, review, threshold, cancellationToken); - - // Step 3: If approved, execute the approval - string? approvalUrl = null; - if (shouldApprove) - { - var result = await _approvalAgent.ApproveAsync(owner, repo, prNumber, comment); - approvalUrl = result; - } - - return new ApprovalResult - { - Approved = shouldApprove, - Review = review, - Reasoning = reasoning, - Comment = comment, - ApprovalUrl = approvalUrl - }; - } -} diff --git a/PRAgent/Services/ConfigurationService.cs b/PRAgent/Services/ConfigurationService.cs index f23c16d..fb8a064 100644 --- a/PRAgent/Services/ConfigurationService.cs +++ b/PRAgent/Services/ConfigurationService.cs @@ -1,99 +1,80 @@ +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using PRAgent.Configuration; using PRAgent.Models; namespace PRAgent.Services; public class ConfigurationService : IConfigurationService { - private readonly IGitHubService _gitHubService; + private readonly IConfiguration _configuration; private readonly ILogger _logger; public ConfigurationService( - IGitHubService gitHubService, + IConfiguration configuration, ILogger logger) { - _gitHubService = gitHubService; + _configuration = configuration; _logger = logger; } - public async Task GetConfigurationAsync(string owner, string repo, int prNumber) + public Task GetConfigurationAsync(string owner, string repo, int prNumber) { - try + // appsettings.jsonから設定を読み込み + var config = new PRAgentConfig { - // Try to get .github/pragent.yml from the repository - var yamlContent = await _gitHubService.GetRepositoryFileContentAsync(owner, repo, ".github/pragent.yml"); - - if (!string.IsNullOrEmpty(yamlContent)) - { - var config = YamlConfigurationProvider.Deserialize(yamlContent); - if (config?.PRAgent != null) - { - _logger.LogInformation("Loaded PRAgent configuration from .github/pragent.yml"); - return config.PRAgent; - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load .github/pragent.yml, using default configuration"); - } - - // Return default configuration - _logger.LogInformation("Using default PRAgent configuration"); - return GetDefaultConfiguration(); - } - - public async Task GetCustomPromptAsync(string owner, string repo, string promptPath) - { - if (string.IsNullOrEmpty(promptPath)) - { - return null; - } - - try - { - var content = await _gitHubService.GetRepositoryFileContentAsync(owner, repo, promptPath); - return content; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load custom prompt from {PromptPath}", promptPath); - return null; - } - } - - private static PRAgentConfig GetDefaultConfiguration() - { - return new PRAgentConfig - { - Enabled = true, - SystemPrompt = "You are an expert code reviewer and technical writer.", + Enabled = _configuration.GetValue("PRAgent:Enabled", true), + SystemPrompt = _configuration["PRAgent:SystemPrompt"], Review = new ReviewConfig { - Enabled = true, - AutoPost = false + Enabled = _configuration.GetValue("PRAgent:Review:Enabled", true), + AutoPost = _configuration.GetValue("PRAgent:Review:AutoPost", false), + CustomPrompt = _configuration["PRAgent:Review:CustomPrompt"] }, Summary = new SummaryConfig { - Enabled = true, - PostAsComment = true + Enabled = _configuration.GetValue("PRAgent:Summary:Enabled", true), + PostAsComment = _configuration.GetValue("PRAgent:Summary:PostAsComment", true), + CustomPrompt = _configuration["PRAgent:Summary:CustomPrompt"] }, Approve = new ApproveConfig { - Enabled = true, - AutoApproveThreshold = "minor", - RequireReviewFirst = true + Enabled = _configuration.GetValue("PRAgent:Approve:Enabled", true), + AutoApproveThreshold = _configuration.GetValue("PRAgent:Approve:AutoApproveThreshold", "minor") ?? "minor", + RequireReviewFirst = _configuration.GetValue("PRAgent:Approve:RequireReviewFirst", true) }, - IgnorePaths = new List + IgnorePaths = _configuration.GetSection("PRAgent:IgnorePaths").Get>() ?? GetDefaultIgnorePaths(), + AgentFramework = new AgentFrameworkConfig { - "*.min.js", - "dist/**", - "node_modules/**", - "*.min.css", - "bin/**", - "obj/**" + Enabled = _configuration.GetValue("PRAgent:AgentFramework:Enabled", true), + OrchestrationMode = _configuration.GetValue("PRAgent:AgentFramework:OrchestrationMode", "sequential") ?? "sequential", + SelectionStrategy = _configuration.GetValue("PRAgent:AgentFramework:SelectionStrategy", "approval_workflow") ?? "approval_workflow", + EnableFunctionCalling = _configuration.GetValue("PRAgent:AgentFramework:EnableFunctionCalling", true), + EnableAutoApproval = _configuration.GetValue("PRAgent:AgentFramework:EnableAutoApproval", true), + MaxTurns = _configuration.GetValue("PRAgent:AgentFramework:MaxTurns", 10) } }; + + _logger.LogInformation("Loaded PRAgent configuration from appsettings.json"); + return Task.FromResult(config); + } + + public Task GetCustomPromptAsync(string owner, string repo, string promptPath) + { + // appsettings.jsonからカスタムプロンプトを取得 + var customPrompt = _configuration[$"PRAgent:CustomPrompts:{promptPath}"]; + return Task.FromResult(customPrompt); + } + + private static List GetDefaultIgnorePaths() + { + return new List + { + "*.min.js", + "dist/**", + "node_modules/**", + "*.min.css", + "bin/**", + "obj/**" + }; } } diff --git a/PRAgent/Services/GitHubService.cs b/PRAgent/Services/GitHubService.cs index b6d164e..2b7d4fc 100644 --- a/PRAgent/Services/GitHubService.cs +++ b/PRAgent/Services/GitHubService.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Octokit; using PRAgent.Models; @@ -15,6 +16,75 @@ public GitHubService(string gitHubToken) }; } + /// + /// ファイルのdiffから行番号に対応するdiff positionを計算します + /// + /// ファイルのdiffパッチ + /// ファイル内の行番号(1ベース) + /// diff position(1ベース)、見つからない場合はnull + private static int? CalculateDiffPosition(string? patch, int lineNumber) + { + if (string.IsNullOrEmpty(patch)) + return null; + + var lines = patch.Split('\n'); + int position = 0; + int currentNewLine = 0; + + foreach (var line in lines) + { + position++; + + // Hunk headerを解析: @@ -start_old,count +start_new,count @@ heading + var hunkMatch = Regex.Match(line, @"^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@"); + if (hunkMatch.Success) + { + // 開始行番号の1つ前に設定(次の行でインクリメントして正しい行番号になるように) + currentNewLine = int.Parse(hunkMatch.Groups[1].Value) - 1; + continue; + } + + // 行のタイプを判定 + if (line.StartsWith("+")) + { + // 追加行: 新しいファイルの行番号が増える + currentNewLine++; + if (currentNewLine == lineNumber) + { + return position; + } + } + else if (line.StartsWith("-")) + { + // 削除行: 新しいファイルの行番号は変わらない + // 削除行にはコメントできないのでスキップ + } + else if (line.StartsWith(" ") || (line.Length == 0 && position < lines.Length)) + { + // コンテキスト行 + // 空行はdiffの最後のアーティファクト(Splitの結果)を除く + currentNewLine++; + if (currentNewLine == lineNumber) + { + return position; + } + } + // \ No newline at end of file などのメタ行はスキップ + } + + return null; + } + + /// + /// PRのファイルのdiffを取得します + /// + private async Task GetFilePatchAsync(string owner, string repo, int prNumber, string filePath) + { + var files = await _client.PullRequest.Files(owner, repo, prNumber); + var file = files.FirstOrDefault(f => f.FileName == filePath); + return file?.Patch; + } + public async Task GetPullRequestAsync(string owner, string repo, int prNumber) { return await _client.PullRequest.Get(owner, repo, prNumber); @@ -91,18 +161,24 @@ public async Task CreateReviewWithCommentsAsync(string owner, } /// - /// レビュー本文と詳細コメントをまとめて投稿 + /// レビュー本文、行コメント、承認ステータスをまとめて1つのReviewとして投稿します /// - public async Task CreateCompleteReviewAsync(string owner, string repo, int prNumber, string reviewBody, List comments) + public async Task CreateCompleteReviewAsync( + string owner, + string repo, + int prNumber, + string? reviewBody, + List comments, + bool approve = false) { var review = new PullRequestReviewCreate() { - Body = reviewBody, - Event = PullRequestReviewEvent.Comment, + Body = reviewBody ?? string.Empty, + Event = approve ? PullRequestReviewEvent.Approve : PullRequestReviewEvent.Comment, Comments = comments }; - await _client.PullRequest.Review.Create(owner, repo, prNumber, review); + return await _client.PullRequest.Review.Create(owner, repo, prNumber, review); } public async Task CreateIssueCommentAsync(string owner, string repo, int prNumber, string body) @@ -152,4 +228,95 @@ public async Task FileExistsAsync(string owner, string repo, string path, var content = await GetRepositoryFileContentAsync(owner, repo, path, branch); return content != null; } + + public async Task CreateLineCommentAsync(string owner, string repo, int prNumber, string filePath, int lineNumber, string comment, string? suggestion = null) + { + // 行コメントを作成 + var commentBody = suggestion != null ? $"{comment}\n```suggestion\n{suggestion}\n```" : comment; + + // diffを取得してpositionを計算 + var patch = await GetFilePatchAsync(owner, repo, prNumber, filePath); + var position = CalculateDiffPosition(patch, lineNumber); + + if (!position.HasValue) + { + throw new ArgumentException($"Could not find line {lineNumber} in diff for file {filePath}. The line may not be part of the changes."); + } + + return await _client.PullRequest.Review.Create( + owner, + repo, + prNumber, + new PullRequestReviewCreate + { + Event = PullRequestReviewEvent.Comment, + Comments = new List + { + new DraftPullRequestReviewComment(commentBody, filePath, position.Value) + } + } + ); + } + + public async Task CreateMultipleLineCommentsAsync(string owner, string repo, int prNumber, List<(string FilePath, int? LineNumber, int? StartLine, int? EndLine, string Comment, string? Suggestion)> comments) + { + // ファイルごとのdiffをキャッシュ + var patchCache = new Dictionary(); + + var draftComments = new List(); + var errors = new List(); + + foreach (var c in comments) + { + var commentBody = c.Suggestion != null ? $"{c.Comment}\n```suggestion\n{c.Suggestion}\n```" : c.Comment; + + int targetLine; + if (c.LineNumber.HasValue) + { + targetLine = c.LineNumber.Value; + } + else if (c.StartLine.HasValue) + { + targetLine = c.StartLine.Value; + } + else + { + errors.Add($"Comment must have either LineNumber or StartLine: {c.FilePath}"); + continue; + } + + // diffを取得(キャッシュを使用) + if (!patchCache.TryGetValue(c.FilePath, out var patch)) + { + patch = await GetFilePatchAsync(owner, repo, prNumber, c.FilePath); + patchCache[c.FilePath] = patch; + } + + // positionを計算 + var position = CalculateDiffPosition(patch, targetLine); + if (!position.HasValue) + { + errors.Add($"Could not find line {targetLine} in diff for file {c.FilePath}"); + continue; + } + + draftComments.Add(new DraftPullRequestReviewComment(commentBody, c.FilePath, position.Value)); + } + + if (errors.Count > 0 && draftComments.Count == 0) + { + throw new ArgumentException($"Failed to create any comments: {string.Join("; ", errors)}"); + } + + return await _client.PullRequest.Review.Create( + owner, + repo, + prNumber, + new PullRequestReviewCreate + { + Event = PullRequestReviewEvent.Comment, + Comments = draftComments + } + ); + } } diff --git a/PRAgent/Services/IAgentOrchestratorService.cs b/PRAgent/Services/IAgentOrchestratorService.cs index 35aeb57..49441b8 100644 --- a/PRAgent/Services/IAgentOrchestratorService.cs +++ b/PRAgent/Services/IAgentOrchestratorService.cs @@ -1,34 +1,74 @@ -using PRAgent.Agents; using PRAgent.Models; namespace PRAgent.Services; +/// +/// エージェントオーケストレーションサービスのインターフェース +/// ReviewAgentを中心に簡素化された構成 +/// public interface IAgentOrchestratorService { + /// + /// プルリクエストのコードレビューを実行します + /// Task ReviewAsync(string owner, string repo, int prNumber, CancellationToken cancellationToken = default); - Task SummarizeAsync(string owner, string repo, int prNumber, CancellationToken cancellationToken = default); + + /// + /// プルリクエストのコードレビューを実行します(language指定) + /// + Task ReviewAsync(string owner, string repo, int prNumber, string language, CancellationToken cancellationToken = default); + + /// + /// レビューと承認を一連のワークフローとして実行します + /// Task ReviewAndApproveAsync( string owner, string repo, int prNumber, - ApprovalThreshold threshold, + ApprovalThreshold threshold = ApprovalThreshold.Minor, CancellationToken cancellationToken = default); - Task ReviewAsync(string owner, string repo, int prNumber, string language, CancellationToken cancellationToken = default); - Task SummarizeAsync(string owner, string repo, int prNumber, string language, CancellationToken cancellationToken = default); + + /// + /// レビューと承認を一連のワークフローとして実行します(language指定) + /// Task ReviewAndApproveAsync( string owner, string repo, int prNumber, - ApprovalThreshold threshold, string language, + ApprovalThreshold threshold = ApprovalThreshold.Minor, + CancellationToken cancellationToken = default); + + /// + /// AgentGroupChatを使用したマルチエージェント協調によるレビューと承認 + /// + Task ReviewAndApproveWithAgentChatAsync( + string owner, + string repo, + int prNumber, + ApprovalThreshold threshold = ApprovalThreshold.Minor, + CancellationToken cancellationToken = default); + + /// + /// カスタムワークフローを使用したレビューと承認 + /// + Task ReviewAndApproveWithCustomWorkflowAsync( + string owner, + string repo, + int prNumber, + string workflowType, + ApprovalThreshold threshold = ApprovalThreshold.Minor, CancellationToken cancellationToken = default); } +/// +/// 承認結果 +/// public class ApprovalResult { - public bool Approved { get; set; } - public string Review { get; set; } = string.Empty; - public string Reasoning { get; set; } = string.Empty; - public string? Comment { get; set; } - public string? ApprovalUrl { get; set; } + public bool Approved { get; init; } + public string Review { get; init; } = string.Empty; + public string Reasoning { get; init; } = string.Empty; + public string? Comment { get; init; } + public string? ApprovalUrl { get; init; } } diff --git a/PRAgent/Services/IDetailedCommentAgent.cs b/PRAgent/Services/IDetailedCommentAgent.cs index 03d2f4f..12fd9c5 100644 --- a/PRAgent/Services/IDetailedCommentAgent.cs +++ b/PRAgent/Services/IDetailedCommentAgent.cs @@ -1,5 +1,3 @@ -using Octokit; - namespace PRAgent.Services; /// @@ -8,15 +6,55 @@ namespace PRAgent.Services; public interface IDetailedCommentAgent { /// - /// レビュー結果から詳細な行コメントを作成 + /// 言語を設定 + /// + void SetLanguage(string language); + + /// + /// 個々の問題について詳細なコメントを生成(Function Calling用) /// - /// レビュー結果の文字列 + /// ファイルパス + /// 行番号 + /// 周辺コード + /// 問題の概要 + /// 詳細なコメント(JSON形式) + Task GetDetailedCommentAsync(string filePath, int lineNumber, string codeSnippet, string issueSummary); + + /// + /// 複数の問題について一括で詳細コメントを生成 + /// + /// 問題のリスト /// 出力言語 - /// GitHubレビュー用のコメントリスト - Task> CreateCommentsAsync(string review, string language); + /// 行コメントのリスト + Task> CreateDetailedCommentsAsync(List issues, string language); /// - /// 言語を動的に設定 + /// レビュー概要から詳細な行コメントを作成(従来のメソッド) /// - void SetLanguage(string language); -} \ No newline at end of file + /// レビュー結果の文字列 + /// 出力言語 + /// 行コメントのリスト + Task> CreateCommentsAsync(string reviewOverview, string language); +} + +/// +/// 行コメントデータ +/// +public class LineCommentData +{ + public required string Path { get; init; } + public required int Line { get; init; } + public required string Body { get; init; } + public string? Suggestion { get; init; } +} + +/// +/// 問題のコンテキスト情報 +/// +public class IssueContext +{ + public required string FilePath { get; init; } + public required int LineNumber { get; init; } + public required string CodeSnippet { get; init; } + public required string IssueSummary { get; init; } +} diff --git a/PRAgent/Services/IGitHubService.cs b/PRAgent/Services/IGitHubService.cs index 7315e10..0007d66 100644 --- a/PRAgent/Services/IGitHubService.cs +++ b/PRAgent/Services/IGitHubService.cs @@ -10,8 +10,21 @@ public interface IGitHubService Task> GetPullRequestReviewCommentsAsync(string owner, string repo, int prNumber); Task GetPullRequestDiffAsync(string owner, string repo, int prNumber); Task CreateReviewCommentAsync(string owner, string repo, int prNumber, string body); + Task CreateLineCommentAsync(string owner, string repo, int prNumber, string filePath, int lineNumber, string comment, string? suggestion = null); + Task CreateMultipleLineCommentsAsync(string owner, string repo, int prNumber, List<(string FilePath, int? LineNumber, int? StartLine, int? EndLine, string Comment, string? Suggestion)> comments); Task CreateIssueCommentAsync(string owner, string repo, int prNumber, string body); Task ApprovePullRequestAsync(string owner, string repo, int prNumber, string? comment = null); Task GetRepositoryFileContentAsync(string owner, string repo, string path, string? branch = null); Task FileExistsAsync(string owner, string repo, string path, string? branch = null); + + /// + /// レビュー本文、行コメント、承認ステータスをまとめて1つのReviewとして投稿します + /// + Task CreateCompleteReviewAsync( + string owner, + string repo, + int prNumber, + string? reviewBody, + List comments, + bool approve = false); } diff --git a/PRAgent/Services/IKernelService.cs b/PRAgent/Services/IKernelService.cs index e4f4624..9d3f516 100644 --- a/PRAgent/Services/IKernelService.cs +++ b/PRAgent/Services/IKernelService.cs @@ -6,6 +6,9 @@ namespace PRAgent.Services; public interface IKernelService { Kernel CreateKernel(string? systemPrompt = null); + Kernel CreateAgentKernel(string? systemPrompt = null); + Kernel RegisterFunctionPlugins(Kernel kernel, IEnumerable plugins); + Kernel RegisterFunctionPlugin(Kernel kernel, object plugin, string? pluginName = null); IAsyncEnumerable InvokePromptAsync(Kernel kernel, string prompt, CancellationToken cancellationToken = default); Task InvokePromptAsStringAsync(Kernel kernel, string prompt, CancellationToken cancellationToken = default); diff --git a/PRAgent/Services/KernelService.cs b/PRAgent/Services/KernelService.cs index 4b1f3b3..7257189 100644 --- a/PRAgent/Services/KernelService.cs +++ b/PRAgent/Services/KernelService.cs @@ -25,17 +25,62 @@ public Kernel CreateKernel(string? systemPrompt = null) { var builder = Kernel.CreateBuilder(); - builder.AddOpenAIChatCompletion( - modelId: _aiSettings.ModelId, - apiKey: _aiSettings.ApiKey, - endpoint: new Uri(_aiSettings.Endpoint) - ); + var endpoint = _aiSettings.Endpoint; + + // エンドポイントが指定されている場合はカスタムエンドポイントを使用 + if (!string.IsNullOrEmpty(endpoint)) + { + _logger?.LogInformation("Using custom endpoint: {Endpoint}", endpoint); + builder.Services.AddOpenAIChatCompletion( + modelId: _aiSettings.ModelId, + apiKey: _aiSettings.ApiKey, + endpoint: new Uri(endpoint) + ); + } + else + { + _logger?.LogInformation("Using default OpenAI endpoint"); + builder.AddOpenAIChatCompletion( + modelId: _aiSettings.ModelId, + apiKey: _aiSettings.ApiKey + ); + } var kernel = builder.Build(); return kernel; } + public Kernel CreateAgentKernel(string? systemPrompt = null) + { + // CreateKernelと同じ実装なので委譲 + return CreateKernel(systemPrompt); + } + + public Kernel RegisterFunctionPlugins(Kernel kernel, IEnumerable plugins) + { + foreach (var plugin in plugins) + { + kernel.ImportPluginFromObject(plugin); + } + + return kernel; + } + + public Kernel RegisterFunctionPlugin(Kernel kernel, object plugin, string? pluginName = null) + { + if (!string.IsNullOrEmpty(pluginName)) + { + kernel.ImportPluginFromObject(plugin, pluginName); + } + else + { + kernel.ImportPluginFromObject(plugin); + } + + return kernel; + } + public async IAsyncEnumerable InvokePromptAsync( Kernel kernel, string prompt, diff --git a/PRAgent/Services/PRActionExecutor.cs b/PRAgent/Services/PRActionExecutor.cs new file mode 100644 index 0000000..132cea1 --- /dev/null +++ b/PRAgent/Services/PRActionExecutor.cs @@ -0,0 +1,343 @@ +using Octokit; +using PRAgent.Models; +using PRAgent.Services; + +namespace PRAgent.Services; + +/// +/// 蓄積されたPRアクションをGitHubに投稿するサービス +/// +public class PRActionExecutor +{ + private readonly IGitHubService _gitHubService; + private readonly string _owner; + private readonly string _repo; + private readonly int _prNumber; + + public PRActionExecutor( + IGitHubService gitHubService, + string owner, + string repo, + int prNumber) + { + _gitHubService = gitHubService; + _owner = owner; + _repo = repo; + _prNumber = prNumber; + } + + /// + /// バッファ内のすべてのアクションをGitHubに投稿します + /// + public async Task ExecuteAsync(PRActionBuffer buffer, CancellationToken cancellationToken = default) + { + var result = new PRActionResult + { + Owner = _owner, + Repo = _repo, + PrNumber = _prNumber + }; + + try + { + // ファイルごとのdiffをキャッシュ + var patchCache = new Dictionary(); + var draftComments = new List(); + var errors = new List(); + + // 行コメントを処理してDraftPullRequestReviewCommentに変換 + foreach (var lineComment in buffer.LineComments) + { + int targetLine; + if (lineComment.LineNumber.HasValue) + { + targetLine = lineComment.LineNumber.Value; + } + else if (lineComment.StartLine.HasValue) + { + targetLine = lineComment.StartLine.Value; + } + else + { + errors.Add($"Line comment must have either LineNumber or StartLine: {lineComment.FilePath}"); + continue; + } + + var commentBody = lineComment.Suggestion != null + ? $"{lineComment.Comment}\n```suggestion\n{lineComment.Suggestion}\n```" + : lineComment.Comment; + + // diffを取得(キャッシュを使用) + if (!patchCache.TryGetValue(lineComment.FilePath, out var patch)) + { + patch = await GetFilePatchAsync(lineComment.FilePath); + patchCache[lineComment.FilePath] = patch; + } + + // positionを計算 + var position = CalculateDiffPosition(patch, targetLine); + if (!position.HasValue) + { + errors.Add($"Could not find line {targetLine} in diff for file {lineComment.FilePath}"); + continue; + } + + draftComments.Add(new DraftPullRequestReviewComment(commentBody, lineComment.FilePath, position.Value)); + } + + // レビュー本文を作成 + var reviewBodyBuilder = new System.Text.StringBuilder(); + + // レビューコメントを追加 + if (buffer.ReviewComments.Count > 0) + { + foreach (var reviewComment in buffer.ReviewComments) + { + reviewBodyBuilder.AppendLine(reviewComment.Comment); + reviewBodyBuilder.AppendLine(); + } + } + + // 承認コメントを追加 + if (!string.IsNullOrEmpty(buffer.ApprovalComment)) + { + reviewBodyBuilder.AppendLine(buffer.ApprovalComment); + } + + var reviewBody = reviewBodyBuilder.ToString().Trim(); + + // 1つのReviewとしてまとめて投稿 + bool hasComments = draftComments.Count > 0 || !string.IsNullOrEmpty(reviewBody); + bool shouldApprove = buffer.ApprovalState == PRApprovalState.Approved; + + if (hasComments || shouldApprove) + { + var reviewResult = await _gitHubService.CreateCompleteReviewAsync( + _owner, + _repo, + _prNumber, + reviewBody, + draftComments, + shouldApprove); + + result.ReviewCommentsPosted = buffer.ReviewComments.Count; + result.LineCommentsPosted = draftComments.Count; + + if (shouldApprove) + { + result.Approved = true; + result.ApprovalState = PRApprovalState.Approved; + result.ApprovalUrl = reviewResult.HtmlUrl; + } + } + + // 変更依頼の場合は別途投稿 + if (buffer.ApprovalState == PRApprovalState.ChangesRequested) + { + var changesComment = $"## Changes Requested\n\n{buffer.ApprovalComment ?? "Please address the issues mentioned in the review."}"; + await _gitHubService.CreateReviewCommentAsync( + _owner, _repo, _prNumber, changesComment); + + result.ApprovalState = PRApprovalState.ChangesRequested; + result.ChangesRequested = true; + } + + // サマリーを全体コメントとして投稿 + if (buffer.Summaries.Count > 0) + { + var summaryText = string.Join("\n\n", buffer.Summaries); + var commentResult = await _gitHubService.CreateIssueCommentAsync( + _owner, _repo, _prNumber, + $@"## PR Summary + +{summaryText}"); + + result.SummariesPosted = buffer.Summaries.Count; + result.SummaryCommentUrl = commentResult.HtmlUrl; + } + + // 全体コメントを投稿 + if (!string.IsNullOrEmpty(buffer.GeneralComment)) + { + var commentResult = await _gitHubService.CreateIssueCommentAsync( + _owner, _repo, _prNumber, buffer.GeneralComment); + + result.GeneralCommentPosted = true; + result.GeneralCommentUrl = commentResult.HtmlUrl; + } + + result.TotalActionsPosted = + result.ReviewCommentsPosted + + result.LineCommentsPosted + + result.SummariesPosted + + (result.GeneralCommentPosted ? 1 : 0) + + (result.Approved ? 1 : 0) + + (result.ChangesRequested ? 1 : 0); + + result.Message = $"Successfully posted {result.TotalActionsPosted} action(s) to PR #{_prNumber}"; + + if (errors.Count > 0) + { + result.Message += $"\n\nWarnings: {string.Join("; ", errors)}"; + } + } + catch (Exception ex) + { + result.Success = false; + result.Error = ex.Message; + result.Message = $"Failed to post actions: {ex.Message}"; + } + + return result; + } + + /// + /// ファイルのdiffを取得します + /// + private async Task GetFilePatchAsync(string filePath) + { + var files = await _gitHubService.GetPullRequestFilesAsync(_owner, _repo, _prNumber); + var file = files.FirstOrDefault(f => f.FileName == filePath); + return file?.Patch; + } + + /// + /// ファイルのdiffから行番号に対応するdiff positionを計算します + /// + private static int? CalculateDiffPosition(string? patch, int lineNumber) + { + if (string.IsNullOrEmpty(patch)) + return null; + + var lines = patch.Split('\n'); + int position = 0; + int currentNewLine = 0; + + foreach (var line in lines) + { + position++; + + // Hunk headerを解析: @@ -start_old,count +start_new,count @@ heading + var hunkMatch = System.Text.RegularExpressions.Regex.Match(line, @"^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@"); + if (hunkMatch.Success) + { + // 開始行番号の1つ前に設定(次の行でインクリメントして正しい行番号になるように) + currentNewLine = int.Parse(hunkMatch.Groups[1].Value) - 1; + continue; + } + + // 行のタイプを判定 + if (line.StartsWith("+")) + { + currentNewLine++; + if (currentNewLine == lineNumber) + { + return position; + } + } + else if (line.StartsWith("-")) + { + // 削除行: 新しいファイルの行番号は変わらない + } + else if (line.StartsWith(" ") || (line.Length == 0 && position < lines.Length)) + { + // コンテキスト行(空行はdiffの最後のアーティファクトを除く) + currentNewLine++; + if (currentNewLine == lineNumber) + { + return position; + } + } + } + + return null; + } + + /// + /// アクションをGitHubに投稿する前に確認するためのサマリーを作成します + /// + public string CreatePreview(PRActionBuffer buffer) + { + var preview = $""" + ## PR # {_prNumber} に投稿されるアクションのプレビュー + + """; + + if (buffer.LineComments.Count > 0) + { + preview += $"### 行コメント ({buffer.LineComments.Count}件)\n"; + foreach (var comment in buffer.LineComments) + { + var suggestion = !string.IsNullOrEmpty(comment.Suggestion) + ? $"\n 提案: {comment.Suggestion}" + : ""; + preview += $"- {comment.FilePath}:{comment.LineNumber}: {comment.Comment}{suggestion}\n"; + } + preview += "\n"; + } + + if (buffer.Summaries.Count > 0) + { + preview += $"### サマリー ({buffer.Summaries.Count}件)\n"; + foreach (var summary in buffer.Summaries) + { + preview += $"- {summary.Substring(0, Math.Min(100, summary.Length))}...\n"; + } + preview += "\n"; + } + + if (!string.IsNullOrEmpty(buffer.GeneralComment)) + { + preview += $"### 全体コメント\n{buffer.GeneralComment.Substring(0, Math.Min(200, buffer.GeneralComment.Length))}...\n\n"; + } + + // 承認ステータスに応じた表示 + switch (buffer.ApprovalState) + { + case PRApprovalState.Approved: + preview += $"### 承認\nはい - {buffer.ApprovalComment ?? "コメントなし"}\n\n"; + break; + + case PRApprovalState.ChangesRequested: + preview += $"### 変更依頼\nはい - {buffer.ApprovalComment ?? "コメントなし"}\n\n"; + break; + + case PRApprovalState.None: + // 何も表示しない(コメントのみ) + break; + } + + var totalActions = buffer.LineComments.Count + + buffer.Summaries.Count + + (string.IsNullOrEmpty(buffer.GeneralComment) ? 0 : 1) + + (buffer.ApprovalState != PRApprovalState.None ? 1 : 0); + + preview += $"**合計: {totalActions}件のアクション**"; + + return preview; + } +} + +/// +/// PRアクションの実行結果 +/// +public class PRActionResult +{ + public string Owner { get; init; } = string.Empty; + public string Repo { get; init; } = string.Empty; + public int PrNumber { get; init; } + public bool Success { get; set; } + public int TotalActionsPosted { get; set; } + public int ReviewCommentsPosted { get; set; } + public int LineCommentsPosted { get; set; } + public int SummariesPosted { get; set; } + public bool GeneralCommentPosted { get; set; } + public bool Approved { get; set; } + public bool ChangesRequested { get; set; } + public PRApprovalState? ApprovalState { get; set; } + public string? SummaryCommentUrl { get; set; } + public string? GeneralCommentUrl { get; set; } + public string? ApprovalUrl { get; set; } + public string? Error { get; set; } + public string Message { get; set; } = string.Empty; +} diff --git a/PRAgent/Services/PRAnalysisService.cs b/PRAgent/Services/PRAnalysisService.cs index ddb3827..a02c15e 100644 --- a/PRAgent/Services/PRAnalysisService.cs +++ b/PRAgent/Services/PRAnalysisService.cs @@ -63,14 +63,10 @@ public async Task SummarizePullRequestAsync(string owner, string repo, i return "PRAgent is disabled for this repository."; } - if (config.Summary == null || !config.Summary.Enabled) - { - return "Summary is disabled for this repository."; - } - + // SummaryAgentは廃止されたため、ReviewAgentを使用 var summary = !string.IsNullOrEmpty(language) - ? await _agentOrchestrator.SummarizeAsync(owner, repo, prNumber, language) - : await _agentOrchestrator.SummarizeAsync(owner, repo, prNumber); + ? await _agentOrchestrator.ReviewAsync(owner, repo, prNumber, language) + : await _agentOrchestrator.ReviewAsync(owner, repo, prNumber); if (postComment) { @@ -115,7 +111,7 @@ public async Task ReviewAndApproveAsync( } var result = !string.IsNullOrEmpty(language) - ? await _agentOrchestrator.ReviewAndApproveAsync(owner, repo, prNumber, threshold, language) + ? await _agentOrchestrator.ReviewAndApproveAsync(owner, repo, prNumber, language, threshold) : await _agentOrchestrator.ReviewAndApproveAsync(owner, repo, prNumber, threshold); if (postComment && !result.Approved) diff --git a/PRAgent/Services/SK/SKAgentOrchestratorService.cs b/PRAgent/Services/SK/SKAgentOrchestratorService.cs new file mode 100644 index 0000000..d7c2c34 --- /dev/null +++ b/PRAgent/Services/SK/SKAgentOrchestratorService.cs @@ -0,0 +1,235 @@ +using Microsoft.Extensions.Logging; +using PRAgent.Agents.SK; +using PRAgent.Models; + +namespace PRAgent.Services.SK; + +/// +/// Semantic Kernelを使用したエージェントオーケストレーションサービス +/// ReviewAgentを中心に簡素化された構成 +/// +public class SKAgentOrchestratorService : IAgentOrchestratorService +{ + private readonly SKReviewAgent _reviewAgent; + private readonly IGitHubService _gitHubService; + private readonly PullRequestDataService _prDataService; + private readonly PRAgentConfig _config; + private readonly ILogger _logger; + + public SKAgentOrchestratorService( + SKReviewAgent reviewAgent, + IGitHubService gitHubService, + PullRequestDataService prDataService, + PRAgentConfig config, + ILogger logger) + { + _reviewAgent = reviewAgent; + _gitHubService = gitHubService; + _prDataService = prDataService; + _config = config; + _logger = logger; + } + + /// + /// プルリクエストのコードレビューを実行します + /// + public async Task ReviewAsync(string owner, string repo, int prNumber, CancellationToken cancellationToken = default) + { + // FunctionCalling設定に応じてメソッドを選択 + var useFunctionCalling = _config.AgentFramework?.EnableFunctionCalling ?? false; + var useSubAgent = _config.AgentFramework?.UseSubAgent ?? true; + + if (useFunctionCalling) + { + if (useSubAgent) + { + // SubAgentあり: ReviewAgentで概要 → DetailedCommentAgentで詳細 + var (reviewText, actionResult) = await _reviewAgent.ReviewWithLineCommentsAsync( + owner, repo, prNumber, language: null, cancellationToken); + + if (actionResult != null) + { + _logger.LogInformation( + "Review completed (with SubAgent). Line comments: {LineComments}, Review comments: {ReviewComments}, Approved: {Approved}", + actionResult.LineCommentsPosted, + actionResult.ReviewCommentsPosted, + actionResult.Approved); + } + + return reviewText; + } + else + { + // SubAgentなし: ReviewAgentだけで完結 + var (reviewText, actionResult) = await _reviewAgent.ReviewDirectAsync( + owner, repo, prNumber, language: null, cancellationToken); + + if (actionResult != null) + { + _logger.LogInformation( + "Review completed (direct mode). Line comments: {LineComments}, Review comments: {ReviewComments}, Approved: {Approved}", + actionResult.LineCommentsPosted, + actionResult.ReviewCommentsPosted, + actionResult.Approved); + } + + return reviewText; + } + } + + return await _reviewAgent.ReviewAsync(owner, repo, prNumber, cancellationToken: cancellationToken); + } + + /// + /// プルリクエストのコードレビューを実行します(language指定) + /// + public async Task ReviewAsync(string owner, string repo, int prNumber, string language, CancellationToken cancellationToken = default) + { + var useFunctionCalling = _config.AgentFramework?.EnableFunctionCalling ?? false; + var useSubAgent = _config.AgentFramework?.UseSubAgent ?? true; + + if (useFunctionCalling) + { + if (useSubAgent) + { + var (reviewText, actionResult) = await _reviewAgent.ReviewWithLineCommentsAsync( + owner, repo, prNumber, language, cancellationToken); + + if (actionResult != null) + { + _logger.LogInformation( + "Review completed (with SubAgent). Line comments: {LineComments}, Review comments: {ReviewComments}, Approved: {Approved}", + actionResult.LineCommentsPosted, + actionResult.ReviewCommentsPosted, + actionResult.Approved); + } + + return reviewText; + } + else + { + var (reviewText, actionResult) = await _reviewAgent.ReviewDirectAsync( + owner, repo, prNumber, language, cancellationToken); + + if (actionResult != null) + { + _logger.LogInformation( + "Review completed (direct mode). Line comments: {LineComments}, Review comments: {ReviewComments}, Approved: {Approved}", + actionResult.LineCommentsPosted, + actionResult.ReviewCommentsPosted, + actionResult.Approved); + } + + return reviewText; + } + } + + return await _reviewAgent.ReviewAsync(owner, repo, prNumber, cancellationToken: cancellationToken); + } + + /// + /// レビューと承認を一連のワークフローとして実行します + /// ReviewAgentに統合されたため、ReviewWithLineCommentsAsyncまたはReviewDirectAsyncを使用 + /// + public async Task ReviewAndApproveAsync( + string owner, + string repo, + int prNumber, + ApprovalThreshold threshold = ApprovalThreshold.Minor, + CancellationToken cancellationToken = default) + { + var useFunctionCalling = _config.AgentFramework?.EnableFunctionCalling ?? false; + var useSubAgent = _config.AgentFramework?.UseSubAgent ?? true; + + if (useFunctionCalling) + { + var (reviewText, actionResult) = useSubAgent + ? await _reviewAgent.ReviewWithLineCommentsAsync(owner, repo, prNumber, language: null, cancellationToken) + : await _reviewAgent.ReviewDirectAsync(owner, repo, prNumber, language: null, cancellationToken); + + return new ApprovalResult + { + Approved = actionResult?.Approved ?? false, + Review = reviewText, + Reasoning = actionResult?.Message ?? string.Empty, + ApprovalUrl = actionResult?.ApprovalUrl + }; + } + + // FunctionCalling無効の場合は通常のレビューのみ + var review = await _reviewAgent.ReviewAsync(owner, repo, prNumber, cancellationToken: cancellationToken); + return new ApprovalResult + { + Approved = false, + Review = review, + Reasoning = "Function calling is disabled. Manual approval required." + }; + } + + /// + /// レビューと承認を一連のワークフローとして実行します(language指定) + /// + public async Task ReviewAndApproveAsync( + string owner, + string repo, + int prNumber, + string language, + ApprovalThreshold threshold = ApprovalThreshold.Minor, + CancellationToken cancellationToken = default) + { + var useFunctionCalling = _config.AgentFramework?.EnableFunctionCalling ?? false; + var useSubAgent = _config.AgentFramework?.UseSubAgent ?? true; + + if (useFunctionCalling) + { + var (reviewText, actionResult) = useSubAgent + ? await _reviewAgent.ReviewWithLineCommentsAsync(owner, repo, prNumber, language, cancellationToken) + : await _reviewAgent.ReviewDirectAsync(owner, repo, prNumber, language, cancellationToken); + + return new ApprovalResult + { + Approved = actionResult?.Approved ?? false, + Review = reviewText, + Reasoning = actionResult?.Message ?? string.Empty, + ApprovalUrl = actionResult?.ApprovalUrl + }; + } + + var review = await _reviewAgent.ReviewAsync(owner, repo, prNumber, cancellationToken: cancellationToken); + return new ApprovalResult + { + Approved = false, + Review = review, + Reasoning = "Function calling is disabled. Manual approval required." + }; + } + + /// + /// AgentGroupChatを使用したマルチエージェント協調によるレビューと承認 + /// 現在はReviewAgentに統合されたため、ReviewAndApproveAsyncと同じ + /// + public async Task ReviewAndApproveWithAgentChatAsync( + string owner, + string repo, + int prNumber, + ApprovalThreshold threshold = ApprovalThreshold.Minor, + CancellationToken cancellationToken = default) + { + return await ReviewAndApproveAsync(owner, repo, prNumber, threshold, cancellationToken); + } + + /// + /// カスタムワークフローを使用したレビューと承認 + /// + public async Task ReviewAndApproveWithCustomWorkflowAsync( + string owner, + string repo, + int prNumber, + string workflowType, + ApprovalThreshold threshold = ApprovalThreshold.Minor, + CancellationToken cancellationToken = default) + { + // すべてのワークフローはReviewAgentに統合 + return await ReviewAndApproveAsync(owner, repo, prNumber, threshold, cancellationToken); + } +} diff --git a/PRAgent/appsettings.Development.json b/PRAgent/appsettings.Development.json new file mode 100644 index 0000000..3aac74b --- /dev/null +++ b/PRAgent/appsettings.Development.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AISettings": { + "Endpoint": "https://api.openai.com/v1", + "ApiKey": "", + "ModelId": "gpt-4o-mini", + "Language": "ja" + }, + "PRSettings": { + "GitHubToken": "", + "DefaultOwner": "", + "DefaultRepo": "" + } +} \ No newline at end of file diff --git a/PRAgent/appsettings.json b/PRAgent/appsettings.json index 7cb2ee7..298a08e 100644 --- a/PRAgent/appsettings.json +++ b/PRAgent/appsettings.json @@ -1,4 +1,51 @@ { + "AISettings": { + "Endpoint": "https://api.openai.com/v1", + "ApiKey": "", + "ModelId": "gpt-4o-mini", + "MaxTokens": 4000, + "Temperature": 0.7 + }, + "PRSettings": { + "GitHubToken": "", + "DefaultOwner": "", + "DefaultRepo": "" + }, + "PRAgent": { + "Enabled": true, + "SystemPrompt": "You are an expert code reviewer and technical writer.", + "Review": { + "Enabled": true, + "AutoPost": false, + "CustomPrompt": null + }, + "Summary": { + "Enabled": true, + "PostAsComment": true, + "CustomPrompt": null + }, + "Approve": { + "Enabled": true, + "AutoApproveThreshold": "minor", + "RequireReviewFirst": true + }, + "IgnorePaths": [ + "*.min.js", + "dist/**", + "node_modules/**", + "*.min.css", + "bin/**", + "obj/**" + ], + "AgentFramework": { + "Enabled": true, + "OrchestrationMode": "sequential", + "SelectionStrategy": "approval_workflow", + "EnableFunctionCalling": true, + "EnableAutoApproval": true, + "MaxTurns": 10 + } + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/PRAgent/packages.lock.json b/PRAgent/packages.lock.json index db03f5c..84795ca 100644 --- a/PRAgent/packages.lock.json +++ b/PRAgent/packages.lock.json @@ -94,6 +94,29 @@ "Microsoft.SemanticKernel.Core": "1.68.0" } }, + "Microsoft.SemanticKernel.Agents.Core": { + "type": "Direct", + "requested": "[1.68.0, )", + "resolved": "1.68.0", + "contentHash": "b+3d4cHXLKSg73BZLlqGUf0jb254fVdYCH5gQiUYHNLEoGNRk5JkUt6gr0p0Uomp+ooBz7tFfhWYEpBsTni2Bw==", + "dependencies": { + "Microsoft.SemanticKernel.Agents.Abstractions": "1.68.0", + "Microsoft.SemanticKernel.Core": "1.68.0" + } + }, + "Microsoft.SemanticKernel.Agents.OpenAI": { + "type": "Direct", + "requested": "[1.68.0-preview, )", + "resolved": "1.68.0-preview", + "contentHash": "I5GQkj9BdzI5e1y1iWl6BPkZCbA5IqC+07CraTa/Y+/EGDI88RO4UEoClmyq7bGv/hF+9eFD5pasWmhWnWGf1Q==", + "dependencies": { + "Azure.AI.OpenAI": "2.7.0-beta.2", + "Azure.Identity": "1.17.0", + "Microsoft.SemanticKernel.Agents.Abstractions": "1.68.0", + "Microsoft.SemanticKernel.Agents.Core": "1.68.0", + "Microsoft.SemanticKernel.Connectors.OpenAI": "1.68.0" + } + }, "Microsoft.SemanticKernel.Connectors.OpenAI": { "type": "Direct", "requested": "[1.68.0, )", @@ -173,6 +196,26 @@ "System.Memory.Data": "8.0.1" } }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.17.0", + "contentHash": "QZiMa1O5lTniWTSDPyPVdBKOS0/M5DWXTfQ2xLOot96yp8Q5A69iScGtzCIxfbg/4bmp/TynibZ2VK1v3qsSNA==", + "dependencies": { + "Azure.Core": "1.49.0", + "Microsoft.Identity.Client": "4.76.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.76.0" + } + }, + "Microsoft.Agents.AI.Abstractions": { + "type": "Transitive", + "resolved": "1.0.0-preview.251009.1", + "contentHash": "8Bfppenx9ETyhDsJ30Hixy/zf/t2t/vt03+jl7uLd5XgadZo+xQcEYa16ANHUAcJzBH7ou85sSRO8MohNiFgiw==", + "dependencies": { + "Microsoft.Extensions.AI.Abstractions": "9.9.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9", + "Microsoft.Extensions.Logging.Abstractions": "9.0.9" + } + }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", "resolved": "8.0.0", @@ -429,6 +472,28 @@ "Microsoft.Extensions.AI.Abstractions": "9.5.0" } }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.76.0", + "contentHash": "j2FtmljuCveDJ7umBVYm6Bx3iVGA71U07Dc7byGr2Hrj7XlByZSknruCBUeYN3V75nn1VEhXegxE0MerxvxrXQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.35.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.76.0", + "contentHash": "D0n3yMTa3gnDh1usrJmyxGWKYeqbQNCWLgQ0Tswf7S6nk9YobiCOX+M2V8EX5SPqkZxpwkTT6odAhaafu3TIeA==", + "dependencies": { + "Microsoft.Identity.Client": "4.76.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.35.0", + "contentHash": "xuR8E4Rd96M41CnUSCiOJ2DBh+z+zQSmyrYHdYhD6K4fXBcQGVnRCFQ0efROUYpP+p0zC1BLKr0JRpVuujTZSg==" + }, "Microsoft.SemanticKernel.Abstractions": { "type": "Transitive", "resolved": "1.68.0", @@ -439,6 +504,15 @@ "Microsoft.Extensions.VectorData.Abstractions": "9.7.0" } }, + "Microsoft.SemanticKernel.Agents.Abstractions": { + "type": "Transitive", + "resolved": "1.68.0", + "contentHash": "hkKxc0Fuy4+ax3HjDyGtzm0EESEX0Qq/Q2vEYjCe2x9KEsiAAU+droPeqrWoy48+u0o01oGkA0IgA07u9Ta/jw==", + "dependencies": { + "Microsoft.Agents.AI.Abstractions": "1.0.0-preview.251009.1", + "Microsoft.SemanticKernel.Abstractions": "1.68.0" + } + }, "Microsoft.SemanticKernel.Connectors.AzureOpenAI": { "type": "Transitive", "resolved": "1.68.0", @@ -504,6 +578,11 @@ "type": "Transitive", "resolved": "10.0.0", "contentHash": "PxZKFx8ACAt7gRnTSDgtZFA06cimls5XL1Olv63bWF8qKL5+EQdqq3K02etW4LNut4xDoREPe9z7QYATLMkBPA==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q==" } } }