From 14c5537be815c6bb4a0306e03bd8fe9f2506f1a0 Mon Sep 17 00:00:00 2001 From: Bruce Arctor <5032356+brucearctor@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:58:22 -0700 Subject: [PATCH] feat: inject review summary into MR/PR description (#45) --- .code-reviewer.example.yaml | 2 + internal/config/config.go | 12 ++++++ internal/config/config_test.go | 20 ++++++++++ internal/github/client.go | 35 +++++++++++++++- internal/github/types.go | 2 + internal/gitlab/client.go | 51 +++++++++++++++++++++++- internal/gitlab/types.go | 15 ++++++- internal/model/prompt.go | 3 +- internal/model/provider.go | 9 +++++ internal/reviewer/description.go | 31 +++++++++++++++ internal/reviewer/description_test.go | 56 ++++++++++++++++++++++++++ internal/reviewer/output.go | 31 ++++++++++++++- internal/reviewer/output_test.go | 57 ++++++++++++++++++++++++++- internal/vcs/types.go | 16 +++++++- 14 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 internal/reviewer/description.go create mode 100644 internal/reviewer/description_test.go diff --git a/.code-reviewer.example.yaml b/.code-reviewer.example.yaml index d45ea3c..8393034 100644 --- a/.code-reviewer.example.yaml +++ b/.code-reviewer.example.yaml @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 2e10ed9..1b3fa31 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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"` } @@ -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 { @@ -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) } @@ -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") @@ -577,6 +586,9 @@ func (c *Config) loadFlags() error { if *summaryUpdateDesc { c.SummaryUpdateDescription = true } + if *updateDesc { + c.UpdateDescription = true + } if *intentFlag { c.IntentReview = true } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 307a1b7..3da4096 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 { diff --git a/internal/github/client.go b/internal/github/client.go index 35cd49a..2bcff8f 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -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)) @@ -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) diff --git a/internal/github/types.go b/internal/github/types.go index 7abba04..b45f3e9 100644 --- a/internal/github/types.go +++ b/internal/github/types.go @@ -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. diff --git a/internal/gitlab/client.go b/internal/gitlab/client.go index a32ca38..b4b32f8 100644 --- a/internal/gitlab/client.go +++ b/internal/gitlab/client.go @@ -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) @@ -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 { @@ -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, @@ -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", @@ -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, @@ -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", diff --git a/internal/gitlab/types.go b/internal/gitlab/types.go index b8c5ecc..4ccd425 100644 --- a/internal/gitlab/types.go +++ b/internal/gitlab/types.go @@ -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"` @@ -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. diff --git a/internal/model/prompt.go b/internal/model/prompt.go index 7f63b60..1f54fb4 100644 --- a/internal/model/prompt.go +++ b/internal/model/prompt.go @@ -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", @@ -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 diff --git a/internal/model/provider.go b/internal/model/provider.go index 031c4c4..b89fe16 100644 --- a/internal/model/provider.go +++ b/internal/model/provider.go @@ -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"` @@ -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 } diff --git a/internal/reviewer/description.go b/internal/reviewer/description.go new file mode 100644 index 0000000..2982e73 --- /dev/null +++ b/internal/reviewer/description.go @@ -0,0 +1,31 @@ +package reviewer + +import ( + "fmt" + "strings" +) + +const ( + descMarkerStart = "" + descMarkerEnd = "" +) + +// 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 +} diff --git a/internal/reviewer/description_test.go b/internal/reviewer/description_test.go new file mode 100644 index 0000000..4bf7b0b --- /dev/null +++ b/internal/reviewer/description_test.go @@ -0,0 +1,56 @@ +package reviewer + +import ( + "testing" +) + +func TestBuildDescriptionSection(t *testing.T) { + summary := "This is a summary." + out := buildDescriptionSection(summary) + want := "\nThis is a summary.\n" + if out != want { + t.Errorf("got %q, want %q", out, want) + } +} + +func TestReplaceDescriptionSection(t *testing.T) { + tests := []struct { + name string + existing string + section string + want string + }{ + { + name: "empty existing description", + existing: "", + section: "SECTION", + want: "SECTION", + }, + { + name: "existing description without markers", + existing: "Original Description.", + section: "SECTION", + want: "Original Description.\n\n---\n\nSECTION", + }, + { + name: "existing description with markers (replace)", + existing: "Top\n\nOld\n\nBottom", + section: "\nNew\n", + want: "Top\n\nNew\n\nBottom", + }, + { + name: "markers with content after them (preserve trailing)", + existing: "Start\nMiddleEnd", + section: "NewMiddle", + want: "Start\nNewMiddleEnd", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := replaceDescriptionSection(tt.existing, tt.section) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/reviewer/output.go b/internal/reviewer/output.go index 81bb38d..c758c2f 100644 --- a/internal/reviewer/output.go +++ b/internal/reviewer/output.go @@ -3,6 +3,7 @@ package reviewer import ( "context" "fmt" + "log/slog" "strings" "github.com/OpticDiff/code-reviewer/internal/config" @@ -40,7 +41,11 @@ func TerminalOutput(result *model.ReviewResult) string { for _, file := range fileOrder { fmt.Fprintf(&sb, "## File: %s\n", file) for _, f := range byFile[file] { - fmt.Fprintf(&sb, "### L%d: [%s] %s\n", f.Line, f.Severity, f.Title) + if f.EndLine > 0 && f.EndLine > f.Line { + fmt.Fprintf(&sb, "### L%d-%d: [%s] %s\n", f.Line, f.EndLine, f.Severity, f.Title) + } else { + fmt.Fprintf(&sb, "### L%d: [%s] %s\n", f.Line, f.Severity, f.Title) + } sb.WriteString(f.Body + "\n") if f.Suggestion != "" { fmt.Fprintf(&sb, "\n```suggestion\n%s\n```\n", f.Suggestion) @@ -70,13 +75,35 @@ func PostReview(ctx context.Context, cfg *config.Config, client VCSClient, resul req.Comments = append(req.Comments, vcs.ReviewComment{ Path: f.File, Line: f.Line, + EndLine: f.EndLine, Body: formatInlineComment(f), Suggestion: f.Suggestion, }) } } - return client.SubmitReview(ctx, cfg.CIProjectID, cfg.CIMergeRequestID, req) + if err := client.SubmitReview(ctx, cfg.CIProjectID, cfg.CIMergeRequestID, req); err != nil { + return err + } + + if cfg.UpdateDescription { + if updater, ok := client.(vcs.DescriptionUpdater); ok { + section := buildDescriptionSection(formatSummaryNote(result)) + existing, err := updater.GetDescription(ctx, cfg.CIProjectID, cfg.CIMergeRequestID) + if err != nil { + slog.Warn("failed to get description for update", "error", err) + } else { + newDesc := replaceDescriptionSection(existing, section) + if err := updater.SetDescription(ctx, cfg.CIProjectID, cfg.CIMergeRequestID, newDesc); err != nil { + slog.Warn("failed to update description", "error", err) + } else { + slog.Info("updated MR/PR description with review summary") + } + } + } + } + + return nil } func formatSummaryNote(result *model.ReviewResult) string { diff --git a/internal/reviewer/output_test.go b/internal/reviewer/output_test.go index ec79474..04de97b 100644 --- a/internal/reviewer/output_test.go +++ b/internal/reviewer/output_test.go @@ -344,6 +344,13 @@ type outputMockVCS struct { submitReviewCalls int submitReviewReq *vcs.SubmitReviewRequest submitReviewErr error + + getDescriptionCalls int + getDescription string + getDescriptionErr error + setDescriptionCalls int + setDescriptionVal string + setDescriptionErr error } func (m *outputMockVCS) GetMRChanges(context.Context, string, string) (*vcs.MRChanges, error) { @@ -399,8 +406,24 @@ func (m *outputMockVCS) SubmitReview(_ context.Context, _, _ string, req vcs.Sub return m.submitReviewErr } -// Compile-time check that outputMockVCS implements VCSClient. +func (m *outputMockVCS) GetDescription(ctx context.Context, projectID, mrIID string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.getDescriptionCalls++ + return m.getDescription, m.getDescriptionErr +} + +func (m *outputMockVCS) SetDescription(ctx context.Context, projectID, mrIID, description string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.setDescriptionCalls++ + m.setDescriptionVal = description + return m.setDescriptionErr +} + +// Compile-time check that outputMockVCS implements VCSClient and DescriptionUpdater. var _ VCSClient = (*outputMockVCS)(nil) +var _ vcs.DescriptionUpdater = (*outputMockVCS)(nil) // --------------------------------------------------------------------------- // PostToGitLab tests @@ -477,3 +500,35 @@ func TestPostReview_NotesMode_NoComments(t *testing.T) { t.Errorf("notes mode should not include inline comments, got %d", len(req.Comments)) } } + +func TestPostReview_UpdateDescription(t *testing.T) { + mockClient := &outputMockVCS{ + getDescription: "Old description", + } + + cfg := &config.Config{ + CommentMode: config.CommentModeNotes, + CIProjectID: "proj", + CIMergeRequestID: "1", + UpdateDescription: true, + } + + result := &model.ReviewResult{ + Summary: "Summary update", + } + + err := PostReview(context.Background(), cfg, mockClient, result, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if mockClient.getDescriptionCalls != 1 { + t.Errorf("expected 1 GetDescription call, got %d", mockClient.getDescriptionCalls) + } + if mockClient.setDescriptionCalls != 1 { + t.Errorf("expected 1 SetDescription call, got %d", mockClient.setDescriptionCalls) + } + if !strings.Contains(mockClient.setDescriptionVal, "Summary update") { + t.Errorf("expected description to contain summary, got %q", mockClient.setDescriptionVal) + } +} diff --git a/internal/vcs/types.go b/internal/vcs/types.go index 5dec43d..95d6d24 100644 --- a/internal/vcs/types.go +++ b/internal/vcs/types.go @@ -3,7 +3,10 @@ // platforms (GitLab, GitHub, Bitbucket), enabling multi-platform support. package vcs -import "time" +import ( + "context" + "time" +) // MRChanges holds the file changes from a merge/pull request. type MRChanges struct { @@ -55,6 +58,7 @@ type InlineCommentPosition struct { NewPath string OldLine *int NewLine *int + EndLine *int } // InlineCommentRequest is the payload for creating an inline @@ -69,7 +73,8 @@ type InlineCommentRequest struct { // 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. + Line int // Start line (or single line). + EndLine int // End line (0 means single-line comment). Body string // Pre-formatted markdown body. Suggestion string // Raw replacement code (empty if no suggestion). } @@ -83,3 +88,10 @@ type SubmitReviewRequest struct { Version *DiffVersion // SHA context for inline comment positioning (may be nil). CleanupMode string // "delete" or "resolve" — controls how old bot comments are handled. } + +// DescriptionUpdater is implemented by VCS clients that support updating +// MR/PR descriptions. This is a separate interface for interface segregation. +type DescriptionUpdater interface { + GetDescription(ctx context.Context, projectID, mrIID string) (string, error) + SetDescription(ctx context.Context, projectID, mrIID, description string) error +}