Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .code-reviewer.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ focus: [bugs, security]
min_severity: low

# How to post comments to GitLab MRs.
# notes: simple MR note (works with CI_JOB_TOKEN)
# comment_mode: notes
# discussions: inline diff-anchored comments (needs PAT with api scope)
comment_mode: notes

# cleanup_mode: delete # How to handle previous bot comments (delete or resolve)

# How to handle diffs that exceed the model's context window.
# fail: error out with a helpful message (default, forces smaller MRs)
# split: auto-split into chunks and merge results
Expand Down
46 changes: 46 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,36 @@ func TestLoad_FlagAndEnvOptions(t *testing.T) {
}
},
},
{
name: "cleanup_mode_flag",
args: []string{"code-reviewer", "--diff", "--cleanup-mode", "resolve"},
env: map[string]string{"GOOGLE_CLOUD_PROJECT": "test-project"},
assert: func(t *testing.T, cfg *Config) {
if cfg.CleanupMode != CleanupModeResolve {
t.Errorf("CleanupMode = %q, want %q", cfg.CleanupMode, CleanupModeResolve)
}
},
},
{
name: "cleanup_mode_env",
args: []string{"code-reviewer", "--diff"},
env: map[string]string{"GOOGLE_CLOUD_PROJECT": "test-project", "CODE_REVIEWER_CLEANUP_MODE": "resolve"},
assert: func(t *testing.T, cfg *Config) {
if cfg.CleanupMode != CleanupModeResolve {
t.Errorf("CleanupMode = %q, want %q", cfg.CleanupMode, CleanupModeResolve)
}
},
},
{
name: "cleanup_mode_flag_over_env",
args: []string{"code-reviewer", "--diff", "--cleanup-mode", "delete"},
env: map[string]string{"GOOGLE_CLOUD_PROJECT": "test-project", "CODE_REVIEWER_CLEANUP_MODE": "resolve"},
assert: func(t *testing.T, cfg *Config) {
if cfg.CleanupMode != CleanupModeDelete {
t.Errorf("CleanupMode = %q, want %q (flag should override env)", cfg.CleanupMode, CleanupModeDelete)
}
},
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -899,3 +929,19 @@ func TestIntentReview_MutuallyExclusiveWithSummarize(t *testing.T) {
t.Errorf("error = %q, want mention of 'mutually exclusive'", err.Error())
}
}

func TestLoad_InvalidCleanupMode(t *testing.T) {
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"code-reviewer", "--diff", "--cleanup-mode", "archive"}

t.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")

_, err := Load()
if err == nil {
t.Fatal("expected error for invalid cleanup-mode")
}
if !containsStr(err.Error(), "invalid cleanup-mode") {
t.Errorf("error = %q, want mention of 'invalid cleanup-mode'", err.Error())
}
}
6 changes: 5 additions & 1 deletion internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,14 @@ func (c *Client) SubmitReview(ctx context.Context, projectID, prNumber string, r
dropped++
continue
}
commentBody := comment.Body
if comment.Suggestion != "" {
commentBody += fmt.Sprintf("\n\n```suggestion\n%s\n```", comment.Suggestion)
}
validComments = append(validComments, ReviewCommentRequest{
Path: comment.Path,
Line: comment.Line,
Body: comment.Body,
Body: commentBody,
Side: "RIGHT",
})
}
Expand Down
26 changes: 19 additions & 7 deletions internal/gitlab/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,11 @@ func (c *Client) ResolvePreviousReviews(ctx context.Context, projectID, mrIID st
continue
}
resolved++
time.Sleep(apiRateDelay)
select {
case <-time.After(apiRateDelay):
case <-ctx.Done():
return resolved, ctx.Err()
}
}
}
return resolved, nil
Expand Down Expand Up @@ -327,8 +331,12 @@ func (c *Client) submitViaDraftNotes(ctx context.Context, projectID, mrIID strin
}

newLine := comment.Line
noteBody := comment.Body
if comment.Suggestion != "" {
noteBody += fmt.Sprintf("\n\n```suggestion:-0+0\n%s\n```", comment.Suggestion)
}
draftReq := CreateDraftNoteRequest{
Note: comment.Body,
Note: noteBody + "\n" + botMarker,
Position: &DiscussionPosition{
PositionType: "text",
BaseSHA: req.Version.BaseSHA,
Expand All @@ -346,8 +354,8 @@ func (c *Client) submitViaDraftNotes(ctx context.Context, projectID, mrIID strin
"line", comment.Line,
"error", err,
)
noteBody := fmt.Sprintf("**%s:%d** — %s", comment.Path, comment.Line, comment.Body)
if _, noteErr := c.PostNote(ctx, projectID, mrIID, noteBody); noteErr != nil {
noteBodyStr := fmt.Sprintf("**%s:%d** — %s", comment.Path, comment.Line, noteBody)
if _, noteErr := c.PostNote(ctx, projectID, mrIID, noteBodyStr); noteErr != nil {
slog.Error("note fallback also failed", "error", noteErr)
}
draftsFailed++
Expand Down Expand Up @@ -388,8 +396,12 @@ func (c *Client) submitViaIndividualComments(ctx context.Context, projectID, mrI
break
}
newLine := comment.Line
noteBody := comment.Body
if comment.Suggestion != "" {
noteBody += fmt.Sprintf("\n\n```suggestion:-0+0\n%s\n```", comment.Suggestion)
}
inlineReq := vcs.InlineCommentRequest{
Body: comment.Body,
Body: noteBody,
Position: &vcs.InlineCommentPosition{
BaseSHA: req.Version.BaseSHA,
HeadSHA: req.Version.HeadSHA,
Expand All @@ -407,8 +419,8 @@ func (c *Client) submitViaIndividualComments(ctx context.Context, projectID, mrI
"error", err,
)
// Fallback: post as a regular note.
noteBody := fmt.Sprintf("**%s:%d** — %s", comment.Path, comment.Line, comment.Body)
if _, err := c.PostNote(ctx, projectID, mrIID, noteBody); err != nil {
noteBodyStr := fmt.Sprintf("**%s:%d** — %s", comment.Path, comment.Line, noteBody)
if _, err := c.PostNote(ctx, projectID, mrIID, noteBodyStr); err != nil {
slog.Error("failed to post fallback note", "error", err)
} else {
fallbackPosted++
Expand Down
10 changes: 4 additions & 6 deletions internal/reviewer/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ func PostReview(ctx context.Context, cfg *config.Config, client VCSClient, resul
if cfg.CommentMode == config.CommentModeDiscussions && version != nil {
for _, f := range result.Findings {
req.Comments = append(req.Comments, vcs.ReviewComment{
Path: f.File,
Line: f.Line,
Body: formatInlineComment(f),
Path: f.File,
Line: f.Line,
Body: formatInlineComment(f),
Suggestion: f.Suggestion,
})
}
}
Expand Down Expand Up @@ -117,9 +118,6 @@ func formatInlineComment(f model.Finding) string {
var sb strings.Builder
fmt.Fprintf(&sb, "%s **[%s]** %s\n\n", severityEmoji(f.Severity), f.Severity, f.Title)
sb.WriteString(f.Body)
if f.Suggestion != "" {
fmt.Fprintf(&sb, "\n\n```suggestion\n%s\n```", f.Suggestion)
}
return sb.String()
}

Expand Down
60 changes: 47 additions & 13 deletions internal/reviewer/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,23 +201,57 @@ func TestFormatInlineComment_WithoutSuggestion(t *testing.T) {
t.Error("should not have suggestion block when suggestion is empty")
}
}

func TestFormatInlineComment_WithSuggestion(t *testing.T) {
f := model.Finding{
Severity: "CRITICAL",
Title: "SQL injection",
Body: "Raw concat.",
Suggestion: "db.Query(\"SELECT * FROM t WHERE id = ?\", id)",
}
out := formatInlineComment(f)
if !strings.Contains(out, "```suggestion") {
t.Error("expected suggestion code block")
func TestPostReview_PassesSuggestionAndCleanupMode(t *testing.T) {
tests := []struct {
name string
suggestion string
cleanupMode config.CleanupMode
}{
{"with_suggestion_and_delete", "fixed := sanitize(input)", config.CleanupModeDelete},
{"with_suggestion_and_resolve", "return fmt.Errorf(\"wrap: %w\", err)", config.CleanupModeResolve},
{"no_suggestion", "", config.CleanupModeDelete},
}
if !strings.Contains(out, "db.Query") {
t.Error("expected suggestion content")

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &outputMockVCS{}
cfg := &config.Config{
CIMode: true,
CIProjectID: "proj",
CIMergeRequestID: "1",
CommentMode: config.CommentModeDiscussions,
CleanupMode: tt.cleanupMode,
}
result := &model.ReviewResult{
Summary: "Review",
Findings: []model.Finding{
{File: "a.go", Line: 5, Severity: "HIGH", Category: "bug", Title: "issue", Body: "desc", Suggestion: tt.suggestion},
},
}
version := &vcs.DiffVersion{HeadSHA: "h", BaseSHA: "b", StartSHA: "s"}

if err := PostReview(context.Background(), cfg, mockClient, result, version); err != nil {
t.Fatalf("unexpected error: %v", err)
}

req := mockClient.submitReviewReq
if req == nil {
t.Fatal("expected SubmitReview to be called")
}
if req.CleanupMode != string(tt.cleanupMode) {
t.Errorf("CleanupMode = %q, want %q", req.CleanupMode, tt.cleanupMode)
}
if len(req.Comments) != 1 {
t.Fatalf("expected 1 comment, got %d", len(req.Comments))
}
if req.Comments[0].Suggestion != tt.suggestion {
t.Errorf("Suggestion = %q, want %q", req.Comments[0].Suggestion, tt.suggestion)
}
})
}
}


func TestTokenUsageRendering(t *testing.T) {
finding := model.Finding{File: "a.go", Line: 1, Severity: "LOW", Category: "style", Title: "test", Body: "body"}

Expand Down
7 changes: 4 additions & 3 deletions internal/vcs/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,10 @@ type InlineCommentRequest struct {
// submission. Unlike InlineCommentRequest, it carries only the essential
// positioning info — the platform client handles SHA context internally.
type ReviewComment struct {
Path string // File path relative to repo root.
Line int // Line number in the new file.
Body string // Pre-formatted markdown body.
Path string // File path relative to repo root.
Line int // Line number in the new file.
Body string // Pre-formatted markdown body.
Suggestion string // Raw replacement code (empty if no suggestion).
}

// SubmitReviewRequest is the payload for submitting a complete code review
Expand Down
Loading