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
2 changes: 2 additions & 0 deletions .code-reviewer.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ comment_mode: notes

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

# update_description: false # Inject review summary into MR/PR description

# 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
12 changes: 12 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ type Config struct {
NoColor bool // Disable ANSI color output.
SARIFOutput string // Path to write SARIF 2.1.0 output file.
ProxyURL string // Optional: LLM proxy URL for observability (e.g., Candela).
UpdateDescription bool

// GitLab settings.
GitLabToken string
Expand Down Expand Up @@ -174,6 +175,7 @@ type repoConfig struct {
APIURL string `yaml:"api_url"`
Summarize bool `yaml:"summarize"`
SummaryUpdateDescription bool `yaml:"summary_update_description"`
UpdateDescription *bool `yaml:"update_description"`
IntentReview *bool `yaml:"intent_review"`
}

Expand Down Expand Up @@ -352,6 +354,9 @@ func (c *Config) applyRepoConfig(data []byte) error {
if rc.SummaryUpdateDescription {
c.SummaryUpdateDescription = true
}
if rc.UpdateDescription != nil {
c.UpdateDescription = *rc.UpdateDescription
}
if rc.IntentReview != nil {
c.IntentReview = *rc.IntentReview
if !*rc.IntentReview {
Expand Down Expand Up @@ -379,6 +384,9 @@ func (c *Config) loadEnv() {
if v := os.Getenv("CODE_REVIEWER_CLEANUP_MODE"); v != "" {
c.CleanupMode = CleanupMode(v)
}
if v := os.Getenv("CODE_REVIEWER_UPDATE_DESCRIPTION"); strings.EqualFold(v, "true") {
c.UpdateDescription = true
}
if v := os.Getenv("REVIEW_CHUNK_STRATEGY"); v != "" {
c.ChunkStrategy = ChunkStrategy(v)
}
Expand Down Expand Up @@ -480,6 +488,7 @@ func (c *Config) loadFlags() error {
apiKey := fs.String("api-key", "", "API key for HTTP provider (optional for IAM/ADC auth)")
summarize := fs.Bool("summarize", false, "Generate MR summary instead of review")
summaryUpdateDesc := fs.Bool("summary-update-description", false, "Update MR description with the generated summary")
updateDesc := fs.Bool("update-description", false, "Inject review summary into MR/PR description")
intentFlag := fs.Bool("intent", false, "Enable two-pass intent-aware review")
noIntentFlag := fs.Bool("no-intent", false, "Disable intent-aware review (overrides CI default)")
explain := fs.Bool("explain", false, "Explain the diff instead of reviewing it")
Expand Down Expand Up @@ -577,6 +586,9 @@ func (c *Config) loadFlags() error {
if *summaryUpdateDesc {
c.SummaryUpdateDescription = true
}
if *updateDesc {
c.UpdateDescription = true
}
if *intentFlag {
c.IntentReview = true
}
Expand Down
20 changes: 20 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,26 @@ func TestLoad_FlagAndEnvOptions(t *testing.T) {
}
},
},
{
name: "update_description_flag",
args: []string{"code-reviewer", "--diff", "--update-description"},
env: map[string]string{"GOOGLE_CLOUD_PROJECT": "test-project"},
assert: func(t *testing.T, cfg *Config) {
if !cfg.UpdateDescription {
t.Errorf("UpdateDescription = false, want true")
}
},
},
{
name: "update_description_env",
args: []string{"code-reviewer", "--diff"},
env: map[string]string{"GOOGLE_CLOUD_PROJECT": "test-project", "CODE_REVIEWER_UPDATE_DESCRIPTION": "true"},
assert: func(t *testing.T, cfg *Config) {
if !cfg.UpdateDescription {
t.Errorf("UpdateDescription = false, want true")
}
},
},
}

for _, tt := range tests {
Expand Down
35 changes: 33 additions & 2 deletions internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,31 @@ func (c *Client) GetMRVersions(ctx context.Context, projectID, prNumber string)
}, nil
}

// GetDescription returns the current body of a pull request.
func (c *Client) GetDescription(ctx context.Context, owner, prNumber string) (string, error) {
apiURL := fmt.Sprintf("%s/repos/%s/pulls/%s", c.baseURL, owner, prNumber)
var pr struct {
Body string `json:"body"`
}
if err := c.get(ctx, apiURL, &pr); err != nil {
return "", fmt.Errorf("getting PR description: %w", err)
}
return pr.Body, nil
}

// SetDescription updates the body of a pull request.
func (c *Client) SetDescription(ctx context.Context, owner, prNumber, description string) error {
apiURL := fmt.Sprintf("%s/repos/%s/pulls/%s", c.baseURL, owner, prNumber)
type descReq struct {
Body string `json:"body"`
}
data, err := json.Marshal(descReq{Body: description})
if err != nil {
return err
}
return c.do(ctx, http.MethodPatch, apiURL, bytes.NewReader(data), "application/json", nil)
}

// CompareCommits returns the list of files changed between two commits.
func (c *Client) CompareCommits(ctx context.Context, projectID, from, to string) ([]string, error) {
apiURL := fmt.Sprintf("%s/repos/%s/compare/%s...%s", c.baseURL, projectID, url.PathEscape(from), url.PathEscape(to))
Expand Down Expand Up @@ -244,12 +269,18 @@ func (c *Client) SubmitReview(ctx context.Context, projectID, prNumber string, r
if comment.Suggestion != "" {
commentBody += fmt.Sprintf("\n\n```suggestion\n%s\n```", comment.Suggestion)
}
validComments = append(validComments, ReviewCommentRequest{
rc := ReviewCommentRequest{
Path: comment.Path,
Line: comment.Line,
Body: commentBody,
Side: "RIGHT",
})
}
if comment.EndLine > comment.Line {
startLine := comment.Line
rc.StartLine = &startLine
rc.Line = comment.EndLine
}
validComments = append(validComments, rc)
}
if dropped > 0 {
slog.Info("pre-validation filtered comments", "valid", len(validComments), "dropped", dropped)
Expand Down
2 changes: 2 additions & 0 deletions internal/github/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ type CreateReviewRequest struct {
type ReviewCommentRequest struct {
// Path is the file path for the comment.
Path string `json:"path"`
// StartLine is the start line number for the comment (optional).
StartLine *int `json:"start_line,omitempty"`
// Line is the line number for the comment.
Line int `json:"line"`
// Body is the text content of the inline comment.
Expand Down
51 changes: 49 additions & 2 deletions internal/gitlab/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,27 @@ func (c *Client) GetMRVersions(ctx context.Context, projectID, mrIID string) ([]
return result, nil
}

// GetDescription returns the current description of a merge request.
func (c *Client) GetDescription(ctx context.Context, projectID, mrIID string) (string, error) {
apiURL := fmt.Sprintf("%s/projects/%s/merge_requests/%s", c.baseURL, url.PathEscape(projectID), mrIID)
var mr struct {
Description string `json:"description"`
}
if err := c.get(ctx, apiURL, &mr); err != nil {
return "", fmt.Errorf("getting MR description: %w", err)
}
return mr.Description, nil
}

// SetDescription updates the description of a merge request.
func (c *Client) SetDescription(ctx context.Context, projectID, mrIID, description string) error {
apiURL := fmt.Sprintf("%s/projects/%s/merge_requests/%s", c.baseURL, url.PathEscape(projectID), mrIID)
type descReq struct {
Description string `json:"description"`
}
return c.put(ctx, apiURL, descReq{Description: description}, nil)
}

// PostNote creates a simple note (comment) on a merge request.
func (c *Client) PostNote(ctx context.Context, projectID, mrIID, body string) (*vcs.Comment, error) {
url := fmt.Sprintf("%s/projects/%s/merge_requests/%s/notes", c.baseURL, url.PathEscape(projectID), mrIID)
Expand Down Expand Up @@ -138,6 +159,12 @@ func (c *Client) CreateDiscussion(ctx context.Context, projectID, mrIID string,
OldLine: req.Position.OldLine,
NewLine: req.Position.NewLine,
}
if req.Position.EndLine != nil && req.Position.NewLine != nil {
glReq.Position.LineRange = &DiscussionLineRange{
Start: DiscussionLineRef{NewLine: *req.Position.NewLine, Type: "new"},
End: DiscussionLineRef{NewLine: *req.Position.EndLine, Type: "new"},
}
}
}

if err := c.post(ctx, url, glReq, nil); err != nil {
Expand Down Expand Up @@ -333,7 +360,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)
if comment.EndLine > comment.Line {
offset := comment.EndLine - comment.Line
noteBody += fmt.Sprintf("\n\n```suggestion:-%d+0\n%s\n```", offset, comment.Suggestion)
} else {
noteBody += fmt.Sprintf("\n\n```suggestion:-0+0\n%s\n```", comment.Suggestion)
}
}
draftReq := CreateDraftNoteRequest{
Note: noteBody + "\n" + botMarker,
Expand All @@ -347,6 +379,12 @@ func (c *Client) submitViaDraftNotes(ctx context.Context, projectID, mrIID strin
NewLine: &newLine,
},
}
if comment.EndLine > comment.Line {
draftReq.Position.LineRange = &DiscussionLineRange{
Start: DiscussionLineRef{NewLine: comment.Line, Type: "new"},
End: DiscussionLineRef{NewLine: comment.EndLine, Type: "new"},
}
}
if _, err := c.createDraftNote(ctx, projectID, mrIID, draftReq); err != nil {
// Fallback: post as a regular note so feedback is not lost.
slog.Warn("draft creation failed, posting as note fallback",
Expand Down Expand Up @@ -398,7 +436,12 @@ func (c *Client) submitViaIndividualComments(ctx context.Context, projectID, mrI
newLine := comment.Line
noteBody := comment.Body
if comment.Suggestion != "" {
noteBody += fmt.Sprintf("\n\n```suggestion:-0+0\n%s\n```", comment.Suggestion)
if comment.EndLine > comment.Line {
offset := comment.EndLine - comment.Line
noteBody += fmt.Sprintf("\n\n```suggestion:-%d+0\n%s\n```", offset, comment.Suggestion)
} else {
noteBody += fmt.Sprintf("\n\n```suggestion:-0+0\n%s\n```", comment.Suggestion)
}
}
inlineReq := vcs.InlineCommentRequest{
Body: noteBody,
Expand All @@ -411,6 +454,10 @@ func (c *Client) submitViaIndividualComments(ctx context.Context, projectID, mrI
NewLine: &newLine,
},
}
if comment.EndLine > comment.Line {
endLine := comment.EndLine
inlineReq.Position.EndLine = &endLine
}

if err := c.CreateDiscussion(ctx, projectID, mrIID, inlineReq); err != nil {
slog.Warn("failed to create inline discussion, posting as note instead",
Expand Down
15 changes: 13 additions & 2 deletions internal/gitlab/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ type Author struct {
Name string `json:"name"`
}

type DiscussionLineRef struct {
NewLine int `json:"new_line"`
Type string `json:"type"` // "new"
}

type DiscussionLineRange struct {
Start DiscussionLineRef `json:"start"`
End DiscussionLineRef `json:"end"`
}

// DiscussionPosition specifies where an inline comment should be anchored in the diff.
type DiscussionPosition struct {
PositionType string `json:"position_type"`
Expand All @@ -108,8 +118,9 @@ type DiscussionPosition struct {
StartSHA string `json:"start_sha"`
OldPath string `json:"old_path,omitempty"`
NewPath string `json:"new_path"`
OldLine *int `json:"old_line,omitempty"`
NewLine *int `json:"new_line,omitempty"`
OldLine *int `json:"old_line,omitempty"`
NewLine *int `json:"new_line,omitempty"`
LineRange *DiscussionLineRange `json:"line_range,omitempty"`
}

// CreateDiscussionRequest is the request body for creating an inline discussion.
Expand Down
3 changes: 2 additions & 1 deletion internal/model/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ You MUST respond with a valid JSON object matching this exact schema. Do NOT inc
{
"file": "path/to/file.go",
"line": 42,
"end_line": 45,
"severity": "HIGH",
"category": "bug",
"title": "Single sentence summary of the issue",
Expand All @@ -71,7 +72,7 @@ You MUST respond with a valid JSON object matching this exact schema. Do NOT inc
If no issues are found, return:
{"summary": "description of the change", "findings": []}

The "line" field MUST correspond to the new_line number shown in the diff. The "category" MUST be one of: bug, security, performance, style, docs, scope. Use "scope" ONLY for intent-driven findings (e.g., scope creep, missing tests for new features). Do NOT use "scope" to suggest splitting or resizing the change.
The "line" field MUST correspond to the new_line number shown in the diff. The "end_line" field is optional and represents the last line of the finding range. Omit for single-line findings. The "category" MUST be one of: bug, security, performance, style, docs, scope. Use "scope" ONLY for intent-driven findings (e.g., scope creep, missing tests for new features). Do NOT use "scope" to suggest splitting or resizing the change.

## SUGGESTION RULES

Expand Down
9 changes: 9 additions & 0 deletions internal/model/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type ReviewResult struct {
type Finding struct {
File string `json:"file"`
Line int `json:"line"`
EndLine int `json:"end_line,omitempty"`
Severity string `json:"severity"`
Category string `json:"category"`
Title string `json:"title"`
Expand Down Expand Up @@ -172,6 +173,14 @@ func parseReviewJSON(text string) (*ReviewResult, error) {
if err := json.Unmarshal([]byte(cleaned), &result); err != nil {
return nil, fmt.Errorf("could not parse model response as JSON: %w", err)
}

for i := range result.Findings {
f := &result.Findings[i]
if f.EndLine != 0 && f.EndLine < f.Line {
f.EndLine = 0 // Invalid range, fall back to single-line
}
}

return &result, nil
}

Expand Down
31 changes: 31 additions & 0 deletions internal/reviewer/description.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package reviewer

import (
"fmt"
"strings"
)

const (
descMarkerStart = "<!-- code-reviewer:start -->"
descMarkerEnd = "<!-- code-reviewer:end -->"
)

// buildDescriptionSection wraps the review summary in HTML comment markers.
func buildDescriptionSection(summary string) string {
return fmt.Sprintf("%s\n%s\n%s", descMarkerStart, summary, descMarkerEnd)
}

// replaceDescriptionSection replaces or appends the review section in a description.
func replaceDescriptionSection(existing, section string) string {
startIdx := strings.Index(existing, descMarkerStart)
endIdx := strings.Index(existing, descMarkerEnd)
if startIdx >= 0 && endIdx >= 0 {
endIdx += len(descMarkerEnd)
return existing[:startIdx] + section + existing[endIdx:]
}
// Append with separator.
if existing != "" {
return existing + "\n\n---\n\n" + section
}
return section
}
Loading
Loading