mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
* feat(telemetry): add OTLP HTTP exporter and print TraceID - Add HTTP/protobuf exporter support alongside existing gRPC exporter - Route based on OTEL_EXPORTER_OTLP_PROTOCOL config (http/protobuf vs grpc) - Print TraceID to stderr when telemetry is enabled for easier correlation - Add corresponding unit tests * feat(telemetry): add span coverage for LLM calls, tool execution, plan/filter phases - Add StartLLMSpan / RecordLLMResult helpers (span.go), symmetric with existing StartToolSpan / RecordToolResult - Wrap LLM completion calls in llmloop.RunPerFile with llm.request spans - Wrap all three tool execution paths in executeToolCall with tool.execute.* spans (dynamic tools, code_comment sync/async, other tools) - Add plan.execute span around executePlanPhase - Add main.loop span around RunPerFile call in executeSubtask - Add review_filter.execute span around executeReviewFilter, with comments.before / comments.filtered attributes - Record llm.error attribute on LLM failures for diagnosability - Record review.repo / review.from / review.to / review.model on the top-level review.run span - Metrics (RecordLLMRequest / RecordToolCall) are preserved alongside the new spans — they serve different purposes (aggregate dashboards vs per-run diagnosis) Verified end-to-end against Sunfire (OTLP HTTP gateway): full span tree observed for review.run -> subtask.execute -> plan.execute/main.loop/ review_filter.execute -> llm.request/tool.execute.* * fix(telemetry): address CR findings — span error handling, async span lifecycle, protocol robustness - Add span.RecordError(err) to RecordLLMResult and RecordToolResult for consistency with EndSpan - Use OTel standard pattern (span.SetStatus + span.RecordError) in error paths of review.run, plan.execute, main.loop, review_filter.execute - Move async code_comment span end into pool.Submit callback so span duration reflects actual execution time - Unify time.Since(startTime) in code_comment error path to a single dur - Remove http/json from supported OTLP protocols (not actually implemented) - Add stderr warning when unknown OTLP protocol falls back to gRPC * feat(telemetry): include trace_id in JSON output, restrict stderr to text format - Add trace_id as top-level field in jsonOutput struct (omitempty) - JSON format: trace_id in structured response for programmatic extraction - Text format: TraceID printed to stderr for human debugging - Telemetry disabled: trace_id field omitted entirely * fix: address PR review findings - loop.go: wrap async span lifecycle in defer to prevent leak on panic - exporter.go: update parseOTLPEndpoint comment to reflect gRPC+HTTP usage - scan_cmd.go: align traceID extraction and OTel error handling with review_cmd - output.go/shared.go: propagate traceID to outputJSONNoFiles for consistency - agent.go: move comments.filtered attribute before early return so 0 is distinguishable from not-executed * feat(telemetry): address PR review — http/json routing, LLM span coverage, trace_id tests - Route http/json to HTTP exporter (Go OTel SDK HTTP transport only supports protobuf serialization; users need HTTP transport, not JSON encoding) - Add llm.request spans to executePlanPhase, executeReviewFilter, and ReLocateComment with Usage nil-safety consistent with loop.go - Add trace_id assertions to output helper tests and emitRunResult end-to-end tests using real TracerProvider * docs: add OTLP protocol selection and endpoint format to telemetry section Sync across all 5 README language versions (en, zh-CN, ja-JP, ko-KR, ru-RU). * fix: unify time.Since in async code_comment defer to single dur variable
299 lines
11 KiB
Go
299 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/open-code-review/open-code-review/internal/config/template"
|
|
"github.com/open-code-review/open-code-review/internal/llmloop"
|
|
"github.com/open-code-review/open-code-review/internal/scan"
|
|
"github.com/open-code-review/open-code-review/internal/telemetry"
|
|
"github.com/open-code-review/open-code-review/internal/tool"
|
|
|
|
"go.opentelemetry.io/otel/codes"
|
|
)
|
|
|
|
// scanOptions mirrors reviewOptions for the full-scan subcommand. The two
|
|
// types are kept separate so the scan flag set can evolve independently of
|
|
// the diff-based review flags (e.g. --from/--to/--commit make no sense here).
|
|
//
|
|
// Bare `ocr scan` (no --path) scans the entire repository; --path narrows.
|
|
type scanOptions struct {
|
|
toolConfigPath string
|
|
rulePath string
|
|
repoDir string
|
|
paths string // comma-separated relative paths; empty = whole repo
|
|
excludes string // comma-separated gitignore-style exclude patterns
|
|
outputFormat string
|
|
audience string
|
|
background string
|
|
concurrency int
|
|
perFileTimeout int
|
|
maxTools int
|
|
maxGitProcs int
|
|
preview bool
|
|
noPlan bool // --no-plan: skip the PLAN_TASK pre-pass per file
|
|
noDedup bool // --no-dedup: skip the per-batch DEDUP_TASK
|
|
noSummary bool // --no-summary: skip the post-run PROJECT_SUMMARY_TASK
|
|
batch string // --batch: override scan template's BATCH_STRATEGY
|
|
maxTokensBudget int // --max-tokens-budget: cap total token usage; 0 = unlimited
|
|
model string // --model: override resolved LLM model for this scan
|
|
showHelp bool
|
|
}
|
|
|
|
func parseScanFlags(args []string) (scanOptions, error) {
|
|
a := newOcrFlagSet("ocr scan")
|
|
opts := scanOptions{}
|
|
|
|
a.StringVar(&opts.toolConfigPath, "tools", "", "path to JSON tools config file (default: embedded)")
|
|
a.StringVar(&opts.rulePath, "rule", "", "path to JSON file with system review rules")
|
|
a.StringVar(&opts.repoDir, "repo", "", "root directory of the git repository (default: current dir)")
|
|
a.StringVar(&opts.paths, "path", "", "comma-separated repo-relative directories or files to scan (default: whole repo)")
|
|
a.StringVar(&opts.excludes, "exclude", "", "comma-separated gitignore-style patterns to exclude; merged with rule.json excludes")
|
|
a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json")
|
|
a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file scans")
|
|
a.IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes")
|
|
a.StringVar(&opts.audience, "audience", "human", "output audience: human (show progress) or agent (summary only)")
|
|
a.StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the scan")
|
|
a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file; only takes effect when greater than template default")
|
|
a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses")
|
|
a.BoolVarP(&opts.preview, "preview", "p", false, "preview which files will be scanned without running the LLM")
|
|
a.BoolVar(&opts.noPlan, "no-plan", false, "skip the per-file PLAN_TASK pre-pass (one fewer LLM call per file; may reduce review focus)")
|
|
a.BoolVar(&opts.noDedup, "no-dedup", false, "skip the per-batch DEDUP_TASK (keeps raw comments; one fewer LLM call per batch)")
|
|
a.BoolVar(&opts.noSummary, "no-summary", false, "skip the post-run PROJECT_SUMMARY_TASK (no project-level markdown summary)")
|
|
a.StringVar(&opts.batch, "batch", "", "override BATCH_STRATEGY from scan template: none | by-language | by-directory")
|
|
a.IntVar(&opts.maxTokensBudget, "max-tokens-budget", 0, "cap total token usage (input+output); dispatch stops once exceeded (0 = unlimited)")
|
|
a.StringVar(&opts.model, "model", "", "override LLM model for this scan (e.g., claude-opus-4-6)")
|
|
|
|
if err := a.Parse(args); err != nil {
|
|
return opts, fmt.Errorf("parse flags: %w", err)
|
|
}
|
|
|
|
opts.showHelp = a.showHelp
|
|
if opts.showHelp {
|
|
return opts, nil
|
|
}
|
|
|
|
switch opts.audience {
|
|
case "human", "agent":
|
|
default:
|
|
return opts, fmt.Errorf("invalid --audience value %q: must be 'human' or 'agent'", opts.audience)
|
|
}
|
|
|
|
if opts.maxTools < 0 {
|
|
return opts, fmt.Errorf("--max-tools must be a non-negative integer (0 means use template default)")
|
|
}
|
|
if opts.maxGitProcs < 0 {
|
|
return opts, fmt.Errorf("--max-git-procs must be a non-negative integer (0 means use default 16)")
|
|
}
|
|
if opts.maxTokensBudget < 0 {
|
|
return opts, fmt.Errorf("--max-tokens-budget must be a non-negative integer (0 means unlimited)")
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func splitPaths(raw string) []string {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func runScan(args []string) error {
|
|
opts, err := parseScanFlags(args)
|
|
if err != nil {
|
|
// parseScanFlags already wraps with "parse flags: %w" — return as-is.
|
|
return err
|
|
}
|
|
if opts.showHelp {
|
|
printScanUsage()
|
|
return nil
|
|
}
|
|
|
|
// scan path: git is preferred (more accurate .gitignore handling) but not required;
|
|
// provider falls back to filepath.Walk when the dir is not a git repo.
|
|
cc, err := loadCommonContext(opts.repoDir, opts.rulePath, opts.maxTools, opts.maxGitProcs, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
applyCLIExcludes(cc, splitPaths(opts.excludes))
|
|
|
|
// scan owns its own template (scan_template.json) independent from the
|
|
// diff-review template loaded by loadCommonContext above. Apply --max-tools
|
|
// as an "only raise" override to the scan template's per-file budget.
|
|
scanTpl, err := template.LoadScanDefault()
|
|
if err != nil {
|
|
return fmt.Errorf("load scan template: %w", err)
|
|
}
|
|
if err := scanTpl.Validate(); err != nil {
|
|
return fmt.Errorf("invalid scan template: %w", err)
|
|
}
|
|
if opts.maxTools > scanTpl.MaxToolRequestTimes {
|
|
scanTpl.MaxToolRequestTimes = opts.maxTools
|
|
}
|
|
if opts.batch != "" {
|
|
// CLI override of BATCH_STRATEGY; validated downstream by parseBatchStrategy
|
|
// (unknown values silently fall back to "none").
|
|
scanTpl.BatchStrategy = opts.batch
|
|
}
|
|
// Token budget: --max-tokens-budget overrides the template value when set.
|
|
budget := scanTpl.MaxTokensBudget
|
|
if opts.maxTokensBudget > 0 {
|
|
budget = int64(opts.maxTokensBudget)
|
|
}
|
|
|
|
scanPaths := splitPaths(opts.paths)
|
|
|
|
if opts.preview {
|
|
return runScanPreview(cc, scanTpl, scanPaths)
|
|
}
|
|
|
|
rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Apply language to the scan template too (loadLLMRuntime only mutates
|
|
// the diff-review template it was handed).
|
|
if rt.AppCfg != nil {
|
|
scanTpl.ApplyLanguage(rt.AppCfg.Language)
|
|
}
|
|
|
|
// file_read_diff is meaningless in scan mode (no diff exists). Hiding it
|
|
// from MainToolDefs stops the LLM from burning tool-call rounds probing
|
|
// for diff content that does not exist.
|
|
scanToolDefs := excludeToolDef(rt.MainToolDefs, "file_read_diff")
|
|
|
|
// Scan mode always reads file contents from the working tree.
|
|
fileReader := &tool.FileReader{
|
|
RepoDir: cc.RepoDir,
|
|
Mode: tool.ModeWorkspace,
|
|
Runner: cc.GitRunner,
|
|
}
|
|
tools := buildToolRegistry(rt.Collector, fileReader)
|
|
|
|
ag := scan.NewAgent(scan.Args{
|
|
RepoDir: cc.RepoDir,
|
|
Paths: scanPaths,
|
|
Template: *scanTpl,
|
|
SystemRule: cc.Resolver,
|
|
FileFilter: cc.FileFilter,
|
|
LLMClient: rt.Client,
|
|
Tools: tools,
|
|
MainToolDefs: scanToolDefs,
|
|
CommentCollector: rt.Collector,
|
|
CommentWorkerPool: llmloop.NewCommentWorkerPool(opts.concurrency),
|
|
MaxConcurrency: opts.concurrency,
|
|
ConcurrentTaskTimeout: opts.perFileTimeout,
|
|
Model: rt.Model,
|
|
Background: opts.background,
|
|
GitRunner: cc.GitRunner,
|
|
MaxFileSizeBytes: scanTpl.MaxFileSizeBytes,
|
|
MaxTokensBudget: budget,
|
|
SkipPlan: opts.noPlan,
|
|
SkipDedup: opts.noDedup,
|
|
SkipSummary: opts.noSummary,
|
|
})
|
|
|
|
q := newQuietHandle(opts.outputFormat, opts.audience)
|
|
defer q.Restore()
|
|
|
|
ctx, span := telemetry.StartSpan(context.Background(), "scan.run")
|
|
defer span.End()
|
|
var traceID string
|
|
if telemetry.IsEnabled() {
|
|
traceID = telemetry.TraceIDFromContext(ctx)
|
|
if opts.outputFormat != "json" {
|
|
fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID)
|
|
}
|
|
}
|
|
startTime := time.Now()
|
|
|
|
comments, err := ag.Run(ctx)
|
|
if err != nil {
|
|
span.SetStatus(codes.Error, err.Error())
|
|
span.RecordError(err)
|
|
return fmt.Errorf("scan failed: %w", err)
|
|
}
|
|
|
|
return emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q)
|
|
}
|
|
|
|
func runScanPreview(cc *commonContext, scanTpl *template.ScanTemplate, scanPaths []string) error {
|
|
ag := scan.NewAgent(scan.Args{
|
|
RepoDir: cc.RepoDir,
|
|
Paths: scanPaths,
|
|
FileFilter: cc.FileFilter,
|
|
GitRunner: cc.GitRunner,
|
|
MaxFileSizeBytes: scanTpl.MaxFileSizeBytes,
|
|
// Template's prompt fields are unused by Preview; pass the same
|
|
// value so MaxFileSizeBytes is consistent.
|
|
Template: *scanTpl,
|
|
})
|
|
|
|
preview, err := ag.Preview(context.Background())
|
|
if err != nil {
|
|
return fmt.Errorf("scan preview failed: %w", err)
|
|
}
|
|
outputPreviewText(preview)
|
|
return nil
|
|
}
|
|
|
|
func printScanUsage() {
|
|
fmt.Println(`OpenCodeReview - Full-File Scan
|
|
|
|
Usage:
|
|
ocr scan [flags]
|
|
ocr s [flags] (alias)
|
|
|
|
Examples:
|
|
# Scan the entire repository (default when no --path is given)
|
|
ocr scan
|
|
|
|
# Scan a single directory
|
|
ocr scan --path internal/agent
|
|
|
|
# Scan multiple files
|
|
ocr scan --path internal/agent/agent.go,internal/diff/scan.go
|
|
|
|
# Exclude generated files / fixtures
|
|
ocr scan --exclude '**/generated/*,**/testdata/*'
|
|
|
|
# Preview which files would be scanned without calling the LLM
|
|
ocr scan --preview
|
|
|
|
# Skip the per-file PLAN_TASK pre-pass (saves ~1 LLM call per file, may
|
|
# reduce review focus)
|
|
ocr scan --no-plan
|
|
|
|
Flags:
|
|
--path string comma-separated repo-relative dirs/files to scan (default: whole repo)
|
|
--exclude string comma-separated gitignore-style patterns to exclude (merged with rule.json)
|
|
--no-plan skip the per-file PLAN_TASK pre-pass (faster, less focused)
|
|
--no-dedup skip the per-batch DEDUP_TASK (keeps raw comments)
|
|
--no-summary skip the post-run PROJECT_SUMMARY_TASK
|
|
--batch string override BATCH_STRATEGY: none | by-language | by-directory
|
|
--max-tokens-budget int cap total token usage; dispatch stops once exceeded (0 = unlimited)
|
|
--model string override LLM model for this scan (e.g., claude-opus-4-6)
|
|
--audience string output audience: human (show progress) or agent (summary only) (default "human")
|
|
-b, --background string optional requirement/business context for the scan
|
|
-f, --format string output format: text or json (default "text")
|
|
--concurrency int max concurrent file scans (default 8)
|
|
--max-git-procs int max concurrent git subprocesses (default 16)
|
|
--max-tools int max tool call rounds per file; only takes effect when greater than template default
|
|
-p, --preview preview which files will be scanned without running the LLM
|
|
--repo string root directory of the git repository (default: current dir)
|
|
--rule string path to JSON file with system review rules
|
|
--timeout int concurrent task timeout in minutes (default 10)
|
|
--tools string path to JSON tools config file (default: embedded)`)
|
|
}
|