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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ cd code-reviewer && go build -o code-reviewer ./cmd/code-reviewer
- **Context-aware** — Modular chunking strategies for large MRs
- **Repo-aware context** — Tree-sitter extracts changed symbols from diffs; grep finds usages in unchanged files to give the reviewer cross-file awareness
- **REVIEW.md** — Drop a `REVIEW.md` in your repo root to inject team-specific review instructions at the highest priority
- **Auto-summary** — `--summarize` generates structured MR descriptions from diffs: classification, intent, risk level, scope areas, and breaking changes
- **Configurable** — CLI flags, env vars, per-repo `.code-reviewer.yaml`, or `REVIEW.md`

## Quick Start
Expand Down Expand Up @@ -139,6 +140,7 @@ Settings are applied in priority order: **CLI flags > env vars > `.code-reviewer
| `--api-key` | API key for HTTP provider (optional for IAM/ADC auth) | — |
| `--incremental` | Only review files changed in latest push (CI mode) | `false` |
| `--proxy-url` | Route model calls through an LLM proxy (e.g. Candela) | — |
| `--summarize` | Generate structured MR summary instead of review | `false` |
| `--version` | Print version and exit | — |

### Environment Variables
Expand Down Expand Up @@ -233,6 +235,27 @@ Noise mitigation is built in:

Disable with `--no-context` or the `disable_context: true` config field.

### Auto-Summary

Use `--summarize` to generate a structured MR description from the diff instead of a code review. The model analyzes the changes and produces:

- **Classification** — `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `security`, `config`, `perf`
- **Intent** — What the developer is trying to accomplish
- **Risk level** — `low`, `medium`, `high` based on scope, complexity, and sensitivity
- **Scope areas** — Which parts of the codebase are affected (e.g. `auth`, `api`, `database`)
- **Breaking changes** — Any backward-incompatible changes

```bash
# Local: summarize your branch diff
code-reviewer --summarize --diff

# CI: post summary as MR comment
code-reviewer --summarize --ci

# JSON output for scripting
code-reviewer --summarize --diff --json
```

## Models

All models are accessed via Vertex AI using Application Default Credentials (ADC). No separate API keys needed.
Expand Down
21 changes: 15 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,25 @@ Current status: **v0.4.0 — Smarter reviews with repo-aware context, REVIEW.md,
- [x] **REVIEW.md** — Drop a `REVIEW.md` in your repo root; contents injected as highest-priority system prompt instruction (PR #19)
- [x] **Repo-aware context** — Tree-sitter extracts changed symbols, grep finds usages in unchanged files, injected as _Related Unchanged Code_. Supports Go, Kotlin, Java, Python, TypeScript. Opt-out via `--no-context` / `disable_context` (PR #20)

## 🔜 v0.5 — Platform Expansion
## v0.5 — Auto-Summary & Intent (Done)

- [ ] **GitHub support** — New `internal/github/` client implementing same posting interface. Core engine unchanged
- [ ] **GitHub Actions integration** — Native `action.yml` for GitHub-hosted repos
- [ ] **Auto-approve / block MR** — Add `Approve()`/`Unapprove()` to GitLab client + `--approve-mode` flag
- [x] **Auto-summary** — `--summarize` generates structured MR descriptions from diffs: classification, intent, risk level, scope areas, breaking changes
- [x] **SummarizeProvider interface** — Both Vertex AI and HTTP providers support summarize mode via shared `generateRaw()` refactor
- [x] **Rich output** — Colored terminal display, JSON output, GitLab markdown comments

## 🧠 v0.6 — Deep Intelligence
## 🔜 v0.6 — Intent-Aware Review

- [ ] **Two-pass review** — Infer developer intent (pass 1), review against it (pass 2). New finding categories: `intent-mismatch`, `scope-creep`, `incomplete-implementation`
- [ ] **Conventional commit parsing** — Auto-detect `fix:`, `feat:`, `refactor:` from MR titles and cross-reference with model-inferred intent
- [ ] **Intent confidence** — Surface how confident the model is in its intent classification, let developers confirm or correct
- [ ] **Platform expansion** — GitHub support (`internal/github/` client), GitHub Actions integration, auto-approve/block MR

## 🏢 v1.0 — Compliance & Audit

- [ ] **Policy engine** — Define path-based policies in `.code-reviewer.yaml` (e.g., auth changes require security focus)
- [ ] **Scope enforcement** — Block MRs with scope creep above configurable threshold
- [ ] **Audit trail** — Structured JSONL log of all reviews: intent, classification, findings, token usage, timing
- [ ] **Multi-pass review** — First pass with Flash (fast/cheap), escalate flagged files to Pro (deep analysis)
- [ ] **Advanced chunk strategies** — Semantic chunking (group related files), AST-aware splitting, dependency-ordered review
- [ ] **RAG-based context** — Embed repo into a vector store for enterprise-scale cross-file context (beyond grep)
- [ ] **Import-aware resolution** — Per-language import graph traversal to find transitive dependencies of changed symbols
- [ ] **Reply to bot comments** — Monitor MR note webhooks, respond to follow-up questions ("why is this a problem?")
Expand Down
12 changes: 9 additions & 3 deletions cmd/code-reviewer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,19 @@ func run(ctx, initCtx context.Context) (int, error) {
}

rev := reviewer.NewWithContext(cfg, modelProvider, glClient, ctxProvider)
findingCount, err := rev.Run(ctx)

var exitCode int
if cfg.Summarize {
exitCode, err = rev.RunSummary(ctx)
} else {
exitCode, err = rev.Run(ctx)
Comment on lines +118 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/sh
fd -t f -e go . internal/model -x rg -n -C2 \
  'type MultiProvider|func \(.*MultiProvider.*\) Summarize|SummarizeProvider' {}

Repository: OpticDiff/code-reviewer

Length of output: 675


🏁 Script executed:

#!/bin/sh
set -eu

printf '\n== cmd/code-reviewer/main.go (relevant range) ==\n'
sed -n '90,150p' cmd/code-reviewer/main.go

printf '\n== internal/model outline ==\n'
ast-grep outline internal/model --view expanded || true

printf '\n== search for Summary support in internal/model ==\n'
rg -n -C 2 'SummarizeProvider|func \(.*\) Summarize\(|type MultiProvider|RunSummary|SummaryResult' internal/model cmd/code-reviewer

Repository: OpticDiff/code-reviewer

Length of output: 14960


🏁 Script executed:

#!/bin/sh
set -eu

printf '\n== reviewer package summary entry points ==\n'
rg -n -C 3 'func .*RunSummary|SummarizeProvider|SummaryResult|Summarize\(' reviewer cmd internal | sed -n '1,220p'

printf '\n== outline reviewer files ==\n'
ast-grep outline reviewer --view expanded || true

Repository: OpticDiff/code-reviewer

Length of output: 5911


🏁 Script executed:

#!/bin/sh
set -eu

printf '\n== search for summarize/model validation ==\n'
rg -n -C 3 'Summarize|summarize|models|NewMultiProvider|MultiProvider|provider does not support summarize mode|Mode\(' cmd internal | sed -n '1,260p'

printf '\n== internal/model/multi.go relevant methods ==\n'
sed -n '1,140p' internal/model/multi.go

Repository: OpticDiff/code-reviewer

Length of output: 19440


Reject --summarize --models

RunSummary requires model.SummarizeProvider, but internal/model.MultiProvider only implements Review/Close, so this flag combination fails at runtime. Block it in validation or add summarize support to MultiProvider.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/code-reviewer/main.go` around lines 118 - 121, Update the validation
around cfg.Summarize and the configured models to reject the --summarize
--models combination before dispatching to rev.RunSummary, or extend
internal/model.MultiProvider with the required model.SummarizeProvider
implementation. Ensure invalid combinations fail during validation rather than
at runtime, while preserving normal summary and review execution paths.

}
if err != nil {
return 0, err
}

if findingCount > 0 {
slog.Info(fmt.Sprintf("review complete: %d finding(s)", findingCount))
if exitCode > 0 {
slog.Info(fmt.Sprintf("review complete: %d finding(s)", exitCode))
return 1, nil
}

Expand Down
35 changes: 30 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ type Config struct {
// Context discovery.
DisableContext bool // Skip repo-aware context discovery (--no-context).

// Summary mode.
Summarize bool // Generate MR summary instead of review.
SummaryUpdateDescription bool // Update MR description with the generated summary.

// Budget.
MaxTokens int // Maximum total tokens per review (0 = unlimited).
}
Expand All @@ -136,10 +140,12 @@ type repoConfig struct {
ExcludedPatterns []string `yaml:"excluded_patterns"`
ExtraRules string `yaml:"extra_rules"`
OutputJSON bool `yaml:"output_json"`
CustomPrompt string `yaml:"custom_prompt"`
ProxyURL string `yaml:"proxy_url"`
MaxTokens int `yaml:"max_tokens"`
APIURL string `yaml:"api_url"`
CustomPrompt string `yaml:"custom_prompt"`
ProxyURL string `yaml:"proxy_url"`
MaxTokens int `yaml:"max_tokens"`
APIURL string `yaml:"api_url"`
Summarize bool `yaml:"summarize"`
SummaryUpdateDescription bool `yaml:"summary_update_description"`
}

// DefaultExcludedPatterns are file patterns excluded by default.
Expand Down Expand Up @@ -265,6 +271,12 @@ func (c *Config) applyRepoConfig(data []byte) error {
if rc.APIURL != "" {
c.APIURL = rc.APIURL
}
if rc.Summarize {
c.Summarize = true
}
if rc.SummaryUpdateDescription {
c.SummaryUpdateDescription = true
}
return nil
}

Expand Down Expand Up @@ -375,6 +387,8 @@ func (c *Config) loadFlags() error {
maxTokens := fs.Int("max-tokens", 0, "Maximum total tokens (input+output) per review (0 = unlimited)")
apiURL := fs.String("api-url", "", "OpenAI-compatible API endpoint (e.g., http://localhost:11434/v1)")
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")

if err := fs.Parse(os.Args[1:]); err != nil {
return err
Expand Down Expand Up @@ -459,6 +473,12 @@ func (c *Config) loadFlags() error {
if *apiKey != "" {
c.APIKey = *apiKey
}
if *summarize {
c.Summarize = true
}
if *summaryUpdateDesc {
c.SummaryUpdateDescription = true
}

return nil
}
Expand Down Expand Up @@ -505,7 +525,7 @@ func (c *Config) validate() error {
c.GitLabBaseURL)
}
}
if c.GitLabToken == "" {
if c.GitLabToken == "" && (!c.Summarize || !c.DryRun) {
return fmt.Errorf("CI mode requires GITLAB_TOKEN env var\n\n" +
"Options:\n" +
" CI_JOB_TOKEN: Add 'GITLAB_TOKEN: $CI_JOB_TOKEN' to your job variables\n" +
Expand All @@ -528,6 +548,11 @@ func (c *Config) validate() error {
return fmt.Errorf("invalid chunk-strategy: %q (valid: fail, split)", c.ChunkStrategy)
}

// Summarize mode is single-model only.
if c.Summarize && len(c.Models) > 0 {
return fmt.Errorf("--summarize cannot be used with --models (multi-model consensus); use --model instead")
}

return nil
}

Expand Down
46 changes: 29 additions & 17 deletions internal/model/http_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func NewHTTPProvider(baseURL, apiKey, modelName string) (*HTTPProvider, error) {
modelName: modelName,
httpClient: &http.Client{
Timeout: 5 * time.Minute, // LLM calls can be slow on large diffs.
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return fmt.Errorf("redirects are not followed for model API calls")
},
},
}

Expand Down Expand Up @@ -117,6 +120,23 @@ type chatResponse struct {

// Review sends a diff to the model for review and returns structured findings.
func (p *HTTPProvider) Review(ctx context.Context, systemPrompt, userPrompt string) (*ReviewResult, error) {
text, usage, err := p.generateRaw(ctx, systemPrompt, userPrompt)
if err != nil {
return nil, err
}

review, err := parseReviewJSON(text)
if err != nil {
return nil, fmt.Errorf("parsing model response: %w (raw: %s)", err, truncate(text, 500))
}

review.Usage = usage
return review, nil
}

// generateRaw sends a prompt via the OpenAI-compatible API and returns the raw
// text response along with token usage. Used by both Review and Summarize.
func (p *HTTPProvider) generateRaw(ctx context.Context, systemPrompt, userPrompt string) (string, *TokenUsage, error) {
reqBody := chatRequest{
Model: p.modelName,
Messages: []chatMessage{
Expand All @@ -128,7 +148,7 @@ func (p *HTTPProvider) Review(ctx context.Context, systemPrompt, userPrompt stri

payload, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err)
return "", nil, fmt.Errorf("marshaling request: %w", err)
}

endpoint := p.baseURL + "/chat/completions"
Expand All @@ -143,14 +163,13 @@ func (p *HTTPProvider) Review(ctx context.Context, systemPrompt, userPrompt stri
return true
}
}
// Fallback: network-level errors.
errStr := strings.ToLower(err.Error())
return strings.Contains(errStr, "unavailable") ||
strings.Contains(errStr, "overloaded") ||
strings.Contains(errStr, "temporarily")
}

if err := retry.Do(ctx, "http model review", func() error {
if err := retry.Do(ctx, "http model call", func() error {
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if reqErr != nil {
return fmt.Errorf("creating request: %w", reqErr)
Expand Down Expand Up @@ -188,42 +207,35 @@ func (p *HTTPProvider) Review(ctx context.Context, systemPrompt, userPrompt stri

return nil
}, retryOpts); err != nil {
return nil, fmt.Errorf("generating content: %w", err)
return "", nil, fmt.Errorf("generating content: %w", err)
}

// Parse the OpenAI-format response.
var chatResp chatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, fmt.Errorf("parsing response JSON: %w (raw: %s)", err, truncateBytes(respBody, 500))
return "", nil, fmt.Errorf("parsing response JSON: %w (raw: %s)", err, truncateBytes(respBody, 500))
}

if len(chatResp.Choices) == 0 {
return nil, fmt.Errorf("empty response from model (no choices)")
return "", nil, fmt.Errorf("empty response from model (no choices)")
}

text := chatResp.Choices[0].Message.Content
if text == "" {
return nil, fmt.Errorf("empty content in model response")
return "", nil, fmt.Errorf("empty content in model response")
}

slog.Debug("raw model response", "length", len(text))

// Parse the review JSON from the content.
review, err := parseReviewJSON(text)
if err != nil {
return nil, fmt.Errorf("parsing model response: %w (raw: %s)", err, truncate(text, 500))
}

// Capture token usage.
var usage *TokenUsage
if chatResp.Usage != nil {
review.Usage = &TokenUsage{
usage = &TokenUsage{
InputTokens: chatResp.Usage.PromptTokens,
OutputTokens: chatResp.Usage.CompletionTokens,
TotalTokens: chatResp.Usage.TotalTokens,
}
}

return review, nil
return text, usage, nil
}

// Close is a no-op for the HTTP provider.
Expand Down
Loading
Loading