diff --git a/.claude/commands/open-code-review.md b/.claude/commands/open-code-review.md index a58cb58..f7339cb 100644 --- a/.claude/commands/open-code-review.md +++ b/.claude/commands/open-code-review.md @@ -15,6 +15,7 @@ ocr review --audience agent [user-args] - If the user provides `--commit` or `--c`: pass through as-is. - If the user provides `--from` and `--to`: pass through as-is. - (Optional) Provide `--background "requirement context"` to review whether the requirements are correctly implemented. +- (Optional) Provide `--background-file ./requirements.md` to load the same context from a Markdown file (sanitised and limited to 8000 characters). Combined with `--background` the inline value is given first. - Capture full stdout. Set a 5-minute timeout. - If the `ocr` command is not found, install it by running `npm i -g @alibaba-group/open-code-review`. diff --git a/README.ja-JP.md b/README.ja-JP.md index 389de06..417a5c5 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -449,6 +449,7 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと | `--timeout` | — | `10` | 同時実行タスクのタイムアウト(分) | | `--audience` | — | `human` | `human`(進捗を表示)または`agent`(サマリーのみ) | | `--background` | `-b` | — | レビューのための任意の要件/ビジネスコンテキスト。`--commit`使用時に未指定の場合、コミットメッセージから自動取得 | +| `--background-file` | `-B` | — | Markdownファイルから読み込む任意の要件/ビジネスコンテキスト。`--background`と併用した場合はインラインの値が先に配置されます | | `--model` | — | — | このレビューでLLMモデルを選択または上書き | | `--rule` | — | — | カスタムJSONレビュールールへのパス | | `--max-tools` | — | 組み込み値 | ファイルごとのツール呼び出しラウンドの上限。テンプレートのデフォルトより大きい場合のみ有効 | @@ -507,6 +508,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # 要件コンテキストを提供してより的確なレビューを実施 ocr review --background "ログインAPIにレート制限を追加" +# Markdownファイルから要件コンテキストを提供 +ocr review --background-file ./docs/my_business_context.md + +# インラインのコンテキストとローカルのコンテキストファイルを組み合わせる(両方が使用されます) +ocr review --background "認証に注目" --background-file ./docs/my_business_context.md + # カスタムレビュールールを使用 ocr review --rule /path/to/my-rules.json diff --git a/README.ko-KR.md b/README.ko-KR.md index 6eec684..161cf5c 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -449,6 +449,7 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s | `--timeout` | - | `10` | 동시 task timeout(분) | | `--audience` | - | `human` | `human`(progress 표시) 또는 `agent`(summary only) | | `--background` | `-b` | - | 리뷰를 위한 선택적 요구사항/비즈니스 컨텍스트. `--commit` 사용 시 미지정이면 commit message에서 자동 추출 | +| `--background-file` | `-B` | - | Markdown 파일에서 읽어오는 선택적 요구사항/비즈니스 컨텍스트. `--background`와 함께 사용하면 inline 값이 먼저 배치됩니다 | | `--model` | - | - | 이번 리뷰에서 LLM model 선택 또는 override | | `--rule` | - | - | custom JSON review rules 경로 | | `--max-tools` | - | built-in | 파일별 최대 tool call round. template default보다 클 때만 적용 | @@ -507,6 +508,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # 요구사항 컨텍스트를 제공하여 더 정확한 리뷰 수행 ocr review --background "로그인 API에 rate limiting 추가" +# Markdown 파일에서 요구사항 컨텍스트 제공 +ocr review --background-file ./docs/my_business_context.md + +# inline 컨텍스트와 로컬 컨텍스트 파일을 함께 사용(둘 다 적용됨) +ocr review --background "인증에 집중" --background-file ./docs/my_business_context.md + # custom review rules 사용 ocr review --rule /path/to/my-rules.json diff --git a/README.md b/README.md index a4b9d58..9e62e01 100644 --- a/README.md +++ b/README.md @@ -451,6 +451,7 @@ See the [`examples/`](./examples/) directory for integration examples: | `--timeout` | — | `10` | Concurrent task timeout in minutes | | `--audience` | — | `human` | `human` (show progress) or `agent` (summary only) | | `--background` | `-b` | — | Optional requirement/business context for the review; auto-filled from commit message when using `--commit` | +| `--background-file` | `-B` | — | Optional requirement/business context from a Markdown file; Combined with `--background` the inline value is given first | | `--model` | — | — | Select or override the LLM model for this review | | `--rule` | — | — | Path to custom JSON review rules | | `--max-tools` | — | built-in | Max tool call rounds per file; only takes effect when greater than template default | @@ -512,6 +513,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # Provide requirement context for more targeted review ocr review --background "Adding rate limiting to the login API" +# Provide requirement context from a Markdown file +ocr review --background-file ./docs/my_business_context.md + +# Combine inline context with a local context file (both are used) +ocr review --background "Focus on auth" --background-file ./docs/my_business_context.md + # Use custom review rules ocr review --rule /path/to/my-rules.json diff --git a/README.ru-RU.md b/README.ru-RU.md index d5fe6d1..ef2a749 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -451,6 +451,7 @@ ocr review \ | `--timeout` | — | `10` | Таймаут конкурентной задачи в минутах | | `--audience` | — | `human` | `human` (показывать прогресс) или `agent` (только сводка) | | `--background` | `-b` | — | Необязательный контекст требований/бизнес-логики для ревью; при `--commit` автоматически заполняется из сообщения коммита | +| `--background-file` | `-B` | — | Необязательный контекст требований/бизнес-логики из Markdown-файла; при совместном использовании с `--background` встроенное значение идёт первым | | `--model` | — | — | Выбрать или переопределить LLM-модель для этого ревью | | `--rule` | — | — | Путь к пользовательским JSON-правилам ревью | | `--max-tools` | — | встроенное | Максимум раундов вызова инструментов на файл; действует, только если больше значения шаблона по умолчанию | @@ -509,6 +510,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # Передать контекст требований для более прицельного ревью ocr review --background "Добавляем rate limiting в API логина" +# Передать контекст требований из Markdown-файла +ocr review --background-file ./docs/my_business_context.md + +# Совместить встроенный контекст с локальным файлом контекста (используются оба) +ocr review --background "Фокус на аутентификации" --background-file ./docs/my_business_context.md + # Использовать собственные правила ревью ocr review --rule /path/to/my-rules.json diff --git a/README.zh-CN.md b/README.zh-CN.md index 17548c9..5d8d0db 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -449,6 +449,7 @@ ocr review \ | `--timeout` | — | `10` | 并发任务超时时间(分钟) | | `--audience` | — | `human` | `human`(显示进度)或 `agent`(仅输出摘要) | | `--background` | `-b` | — | 可选的需求/业务背景信息;使用 `--commit` 时如未指定则自动从 commit message 中提取 | +| `--background-file` | `-B` | — | 来自 Markdown 文件的可选需求/业务背景信息;与 `--background` 同时使用时,内联内容排在前面 | | `--model` | — | — | 为本次审查选择或覆盖 LLM 模型 | | `--rule` | — | — | 自定义 JSON 审查规则路径 | | `--max-tools` | — | 内置默认 | 每个文件的最大工具调用轮次;仅在大于模板默认值时生效 | @@ -507,6 +508,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # 提供需求背景以获得更有针对性的审查 ocr review --background "为登录 API 添加限流" +# 从 Markdown 文件提供需求背景 +ocr review --background-file ./docs/my_business_context.md + +# 将内联背景与本地背景文件结合使用(两者都会生效) +ocr review --background "关注鉴权" --background-file ./docs/my_business_context.md + # 使用自定义审查规则 ocr review --rule /path/to/my-rules.json diff --git a/cmd/opencodereview/background_file.go b/cmd/opencodereview/background_file.go new file mode 100644 index 0000000..2c14a5c --- /dev/null +++ b/cmd/opencodereview/background_file.go @@ -0,0 +1,135 @@ +package main + +import ( + "fmt" + "os" + "regexp" + "strings" + "unicode" +) + +const ( + backgroundSoftLimit = 2000 + backgroundHardLimit = 8000 + backgroundOpenTag = "" + backgroundCloseTag = "" + maxBackgroundFileBytes = 1 << 20 // 1 MB +) + +var multiNewline = regexp.MustCompile(`\n{3,}`) + +// mergeBackground combines the inline --background value (or an auto-populated +// commit message) with the content read from --background-file, separated by a +// blank line. The inline value is sanitised the same way as the file content so +// both portions are cleaned consistently. The file content is already wrapped +// and sanitised by loadBackgroundFile. +func mergeBackground(inline, fromFile string) string { + inline = sanitizeMarkdown(inline) + switch { + case inline == "": + return fromFile + case fromFile == "": + return inline + default: + return inline + "\n\n" + fromFile + } +} + +func loadBackgroundFile(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("read background file %q: %w", path, err) + } + if info.IsDir() { + return "", fmt.Errorf("background file %q is a directory, not a file", path) + } + if info.Size() > maxBackgroundFileBytes { + return "", fmt.Errorf( + "background file %q is %d bytes, exceeding the maximum of %d bytes; please provide a smaller file", + path, info.Size(), maxBackgroundFileBytes, + ) + } + + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read background file %q: %w", path, err) + } + + cleaned := sanitizeMarkdown(string(raw)) + if cleaned == "" { + return "", fmt.Errorf("background file %q is empty after sanitisation", path) + } + + if strings.Contains(cleaned, backgroundOpenTag) || strings.Contains(cleaned, backgroundCloseTag) { + return "", fmt.Errorf( + "background file %q must not contain the reserved delimiters %q or %q", + path, backgroundOpenTag, backgroundCloseTag, + ) + } + + // Enforce the limits on the cleaned content only: the wrapper delimiters add + // overhead the user cannot control, so counting them would make the reported + // character count misleading. + if n := len([]rune(cleaned)); n > backgroundHardLimit { + return "", fmt.Errorf( + "background content is %d characters, exceeding the hard limit of %d (aborting)", + n, backgroundHardLimit, + ) + } else if n > backgroundSoftLimit { + fmt.Fprintf(os.Stderr, + "[ocr] --background-file content is %d characters, exceeding the recommended %d (continuing but review quality might be impacted)\n", + n, backgroundSoftLimit, + ) + } + + return backgroundOpenTag + "\n" + cleaned + "\n" + backgroundCloseTag, nil +} + +func sanitizeMarkdown(s string) string { + var b strings.Builder + b.Grow(len(s)) + + for _, r := range s { + switch r { + case '\n', '\t': + b.WriteRune(r) + continue + case '\r': + continue + } + if isForbiddenChar(r) { + continue + } + b.WriteRune(r) + } + + collapsed := multiNewline.ReplaceAllString(b.String(), "\n\n") + return strings.TrimSpace(collapsed) +} + +func isForbiddenChar(r rune) bool { + switch { + case r <= 0x1F: // C0 control characters (includes NUL) + return true + case r >= 0x7F && r <= 0x9F: // DEL and C1 control characters + return true + } + + // The runes below all belong to Unicode category Cf and are therefore + // already caught by the unicode.Is(unicode.Cf, r) check at the end. They are + // listed explicitly only as documentation of the most common invisible + // characters we strip; the switch is redundant, not a correctness necessity. + switch r { + case '\u200B', // zero-width space + '\u200C', // zero-width non-joiner + '\u200D', // zero-width joiner + '\u200E', // left-to-right mark + '\u200F', // right-to-left mark + '\u2060', // word joiner + '\u00AD', // soft hyphen + '\uFEFF': // BOM / zero-width no-break space + return true + } + + return unicode.Is(unicode.Cf, r) +} diff --git a/cmd/opencodereview/background_file_test.go b/cmd/opencodereview/background_file_test.go new file mode 100644 index 0000000..cb18f21 --- /dev/null +++ b/cmd/opencodereview/background_file_test.go @@ -0,0 +1,312 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// writeTempFile writes content to a temporary file and returns its path. +func writeTempFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "background.md") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + return path +} + +func TestLoadBackgroundFileNotFound(t *testing.T) { + _, err := loadBackgroundFile(filepath.Join(t.TempDir(), "does-not-exist.md")) + if err == nil { + t.Fatal("expected an error for a missing file, got nil") + } +} + +func TestLoadBackgroundFileEmpty(t *testing.T) { + cases := map[string]string{ + "zero bytes": "", + "whitespace only": " \n\t \n ", + "invisible only": "\u200B\u200E\u00AD\uFEFF", + } + for name, content := range cases { + t.Run(name, func(t *testing.T) { + _, err := loadBackgroundFile(writeTempFile(t, content)) + if err == nil { + t.Fatal("expected an error for empty-after-sanitisation content, got nil") + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("error = %q, want it to mention 'empty'", err) + } + }) + } +} + +func TestLoadBackgroundFileControlCharRemoval(t *testing.T) { + // Mix in NUL, bell, DEL, a C1 control char, zero-width space, BOM and an + // LTR mark around legitimate text. + content := "Hello\x00\x07world\x7f\u0085!\u200B\uFEFF\u200E" + got, err := loadBackgroundFile(writeTempFile(t, content)) + if err != nil { + t.Fatalf("loadBackgroundFile: %v", err) + } + for _, bad := range []string{"\x00", "\x07", "\x7f", "\u0085", "\u200B", "\uFEFF", "\u200E"} { + if strings.Contains(got, bad) { + t.Errorf("result still contains control/invisible char %q: %q", bad, got) + } + } + if !strings.Contains(got, "Helloworld!") { + t.Errorf("expected cleaned text to contain %q, got %q", "Helloworld!", got) + } +} + +func TestSanitizeMarkdownPreservesNewlinesAndTabs(t *testing.T) { + got := sanitizeMarkdown("line1\n\tindented\nline3") + want := "line1\n\tindented\nline3" + if got != want { + t.Errorf("sanitizeMarkdown = %q, want %q", got, want) + } +} + +func TestSanitizeMarkdownCollapsesNewlines(t *testing.T) { + got := sanitizeMarkdown("a\n\n\n\n\nb") + want := "a\n\nb" + if got != want { + t.Errorf("sanitizeMarkdown = %q, want %q", got, want) + } +} + +func TestSanitizeMarkdownNormalizesCRLF(t *testing.T) { + got := sanitizeMarkdown("a\r\nb\r\nc") + want := "a\nb\nc" + if got != want { + t.Errorf("sanitizeMarkdown = %q, want %q", got, want) + } +} + +func TestSanitizeMarkdownTrims(t *testing.T) { + got := sanitizeMarkdown(" \n hello \n ") + if got != "hello" { + t.Errorf("sanitizeMarkdown = %q, want %q", got, "hello") + } +} + +func TestLoadBackgroundFileDelimiters(t *testing.T) { + got, err := loadBackgroundFile(writeTempFile(t, "Some requirement context.")) + if err != nil { + t.Fatalf("loadBackgroundFile: %v", err) + } + if !strings.HasPrefix(got, backgroundOpenTag+"\n") { + t.Errorf("result missing opening delimiter: %q", got) + } + if !strings.HasSuffix(got, "\n"+backgroundCloseTag) { + t.Errorf("result missing closing delimiter: %q", got) + } + want := backgroundOpenTag + "\nSome requirement context.\n" + backgroundCloseTag + if got != want { + t.Errorf("result = %q, want %q", got, want) + } +} + +func TestLoadBackgroundFileRejectsReservedDelimiters(t *testing.T) { + for _, tag := range []string{backgroundOpenTag, backgroundCloseTag} { + t.Run(tag, func(t *testing.T) { + content := "Some context " + tag + " and more text." + _, err := loadBackgroundFile(writeTempFile(t, content)) + if err == nil { + t.Fatalf("expected an error for content containing %q, got nil", tag) + } + if !strings.Contains(err.Error(), "reserved delimiters") { + t.Errorf("error = %q, want it to mention 'reserved delimiters'", err) + } + }) + } +} + +func TestMergeBackgroundSanitizesInline(t *testing.T) { + t.Run("inline only", func(t *testing.T) { + // Control char, zero-width space and surrounding whitespace must be removed. + got := mergeBackground(" \x00Inline\u200B context ", "") + if got != "Inline context" { + t.Errorf("mergeBackground = %q, want %q", got, "Inline context") + } + }) + + t.Run("inline combined with file", func(t *testing.T) { + wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag + got := mergeBackground("\x07dirty\uFEFF inline\n\n\n\nend", wrapped) + if strings.ContainsRune(got, '\x07') || strings.ContainsRune(got, '\uFEFF') { + t.Errorf("inline portion was not sanitised: %q", got) + } + // Excess blank lines in the inline portion are collapsed to one. + if strings.Contains(got, "\n\n\n") { + t.Errorf("inline newlines were not collapsed: %q", got) + } + // The file portion is preserved intact. + if !strings.Contains(got, wrapped) { + t.Errorf("file portion was altered: %q", got) + } + }) +} + +func TestMergeBackground(t *testing.T) { + wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag + + t.Run("both present are combined", func(t *testing.T) { + got := mergeBackground("inline context", wrapped) + want := "inline context\n\n" + wrapped + if got != want { + t.Errorf("mergeBackground = %q, want %q", got, want) + } + // Both inputs must survive in the result. + if !strings.Contains(got, "inline context") || !strings.Contains(got, "from file") { + t.Errorf("merged background dropped one of the inputs: %q", got) + } + }) + + t.Run("inline only", func(t *testing.T) { + if got := mergeBackground("inline only", ""); got != "inline only" { + t.Errorf("mergeBackground = %q, want %q", got, "inline only") + } + }) + + t.Run("file only", func(t *testing.T) { + if got := mergeBackground("", wrapped); got != wrapped { + t.Errorf("mergeBackground = %q, want %q", got, wrapped) + } + }) +} + +func TestLoadBackgroundFileSoftLimit(t *testing.T) { + // Just above the soft limit but below the hard size limit: must succeed. + content := strings.Repeat("a", backgroundSoftLimit+100) + got, err := loadBackgroundFile(writeTempFile(t, content)) + if err != nil { + t.Fatalf("loadBackgroundFile: %v", err) + } + if !strings.Contains(got, content) { + t.Error("expected content to be preserved past the soft limit") + } +} + +func TestLoadBackgroundFileOversized(t *testing.T) { + // A file larger than maxBackgroundFileBytes must be rejected up front, + // before its content is read into memory. + content := strings.Repeat("a", maxBackgroundFileBytes+1) + _, err := loadBackgroundFile(writeTempFile(t, content)) + if err == nil { + t.Fatal("expected an error for an oversized file, got nil") + } + if !strings.Contains(err.Error(), "maximum") { + t.Errorf("error = %q, want it to mention the byte 'maximum'", err) + } +} + +func TestLoadBackgroundFileDirectory(t *testing.T) { + if _, err := loadBackgroundFile(t.TempDir()); err == nil { + t.Fatal("expected an error when the path is a directory, got nil") + } +} + +func TestLoadBackgroundFileHardLimit(t *testing.T) { + content := strings.Repeat("a", backgroundHardLimit+1) + _, err := loadBackgroundFile(writeTempFile(t, content)) + if err == nil { + t.Fatal("expected an error when exceeding the hard size limit, got nil") + } + if !strings.Contains(err.Error(), "hard limit") { + t.Errorf("error = %q, want it to mention 'hard limit'", err) + } +} + +func TestLoadBackgroundFileHardLimitExcludesWrapper(t *testing.T) { + // The wrapper delimiters must NOT count toward the limit: cleaned content of + // exactly the hard limit is accepted even though the wrapped string is longer. + content := strings.Repeat("a", backgroundHardLimit) + if _, err := loadBackgroundFile(writeTempFile(t, content)); err != nil { + t.Fatalf("cleaned content at the hard limit must be accepted, got: %v", err) + } +} + +func TestLoadBackgroundFileMultiByteRuneCount(t *testing.T) { + // Multi-byte runes must be counted as single characters, not bytes. + // A precomposed accented letter is one rune but two bytes; a string of exactly + // backgroundHardLimit runes (~2x the byte count) must still be accepted. + content := strings.Repeat("\u00E9", backgroundHardLimit) + got, err := loadBackgroundFile(writeTempFile(t, content)) + if err != nil { + t.Fatalf("loadBackgroundFile rejected content within the rune limit: %v", err) + } + if !strings.Contains(got, content) { + t.Error("expected multi-byte content to be preserved") + } +} + +// initRepoWithCommit creates a real git repository with a single commit whose +// message is `message`, and returns the repo directory and the commit hash. +func initRepoWithCommit(t *testing.T, message string) (string, string) { + t.Helper() + repo := t.TempDir() + run := func(args ...string) []byte { + cmd := exec.Command("git", args...) + cmd.Dir = repo + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + return out + } + run("init", "-q") + run("config", "user.email", "test@example.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("hello\n"), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + run("add", ".") + run("commit", "-q", "-m", message) + hash := strings.TrimSpace(string(run("rev-parse", "HEAD"))) + return repo, hash +} + +// TestBackgroundFromCommitThenFile reproduces the resolution order used by +// runReview when --background-file is supplied but --background is not: the +// inline background is first auto-filled from the commit message, then the +// background file is appended. Both must end up in the final background. +func TestBackgroundFromCommitThenFile(t *testing.T) { + const commitMsg = "Implement rate limiting on login" + repo, hash := initRepoWithCommit(t, commitMsg) + + // Mirror runReview: --background empty + --commit set -> use commit message. + background := "" + msg, err := getCommitMessage(repo, hash) + if err != nil { + t.Fatalf("getCommitMessage: %v", err) + } + if msg != commitMsg { + t.Fatalf("commit message = %q, want %q", msg, commitMsg) + } + if background == "" { + background = msg + } + + // Then --background-file is loaded and merged in. + fileBg, err := loadBackgroundFile(writeTempFile(t, "Extra context from a file.")) + if err != nil { + t.Fatalf("loadBackgroundFile: %v", err) + } + background = mergeBackground(background, fileBg) + + // The commit message must come first, followed by the wrapped file content. + if !strings.HasPrefix(background, commitMsg+"\n\n") { + t.Errorf("expected commit message to lead the background, got %q", background) + } + if !strings.Contains(background, "Extra context from a file.") { + t.Errorf("expected file content to be appended, got %q", background) + } + if !strings.Contains(background, backgroundOpenTag) || !strings.Contains(background, backgroundCloseTag) { + t.Errorf("expected file content to keep its delimiters, got %q", background) + } +} diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index ccda515..56a35e9 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -105,6 +105,7 @@ type reviewOptions struct { outputFormat string audience string // --audience: "human" (default) or "agent" background string // --background: optional requirement context + backgroundFile string // --background-file: path to a Markdown file used as background model string // --model: override resolved LLM model for this review concurrency int perFileTimeout int @@ -131,6 +132,7 @@ func parseReviewFlags(args []string) (reviewOptions, error) { 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 review") + a.StringVarP(&opts.backgroundFile, "background-file", "B", "", "optional requirement/business context from a Markdown file (combined with --background; inline value appears first when both are set)") a.StringVar(&opts.model, "model", "", "override LLM model for this review (e.g., claude-opus-4-6)") a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file (0 = template default; min 10)") a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses") @@ -214,22 +216,28 @@ Examples: ocr review --preview ocr review -c abc123 -p + # Provide requirement/business context inline, from a Markdown file, or both + ocr review --background "Adding rate limiting to the login API" + ocr review --background-file ./docs/requirements.md + ocr review --background "Focus on auth" --background-file ./docs/requirements.md + Flags: - --audience string output audience: human (show progress) or agent (summary only) (default "human") - -b, --background string optional requirement/business context for the review - -c, --commit string single commit hash or tag to review (vs its parent) - -f, --format string output format: text or json (default "text") - --concurrency int max concurrent file reviews (default 8) - --max-git-procs int max concurrent git subprocesses (default 16) - --from string source ref to start diff from (e.g., 'main') - --max-tools int max tool call rounds per file (0 = template default; min 10) - --model string override LLM model for this review (e.g., claude-opus-4-6) - -p, --preview preview which files will be reviewed 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) - --to string target ref to end diff at (e.g., 'feature-branch') - --tools string path to JSON tools config file (default: embedded)`) + --audience string output audience: human (show progress) or agent (summary only) (default "human") + -b, --background string optional requirement/business context for the review + -B, --background-file string path to a Markdown file used as review background (combined with --background; inline value appears first when both are set) + -c, --commit string single commit hash or tag to review (vs its parent) + -f, --format string output format: text or json (default "text") + --concurrency int max concurrent file reviews (default 8) + --max-git-procs int max concurrent git subprocesses (default 16) + --from string source ref to start diff from (e.g., 'main') + --max-tools int max tool call rounds per file (0 = template default; min 10) + --model string override LLM model for this review (e.g., claude-opus-4-6) + -p, --preview preview which files will be reviewed 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) + --to string target ref to end diff at (e.g., 'feature-branch') + --tools string path to JSON tools config file (default: embedded)`) } // --- config subcommand --- diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go index 15701ca..0514e98 100644 --- a/cmd/opencodereview/flags_test.go +++ b/cmd/opencodereview/flags_test.go @@ -5,6 +5,20 @@ import ( "time" ) +func TestParseReviewFlagsBackgroundFile(t *testing.T) { + for _, flag := range []string{"--background-file", "-B"} { + t.Run(flag, func(t *testing.T) { + opts, err := parseReviewFlags([]string{flag, "./docs/req.md"}) + if err != nil { + t.Fatalf("parseReviewFlags: %v", err) + } + if opts.backgroundFile != "./docs/req.md" { + t.Errorf("backgroundFile = %q, want %q", opts.backgroundFile, "./docs/req.md") + } + }) + } +} + func TestParseReviewFlagsModelOverride(t *testing.T) { opts, err := parseReviewFlags([]string{"--model", "claude-opus-4-6"}) if err != nil { diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index 47a026d..adf553c 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -46,6 +46,17 @@ func runReview(args []string) error { } } + // Only touch the background when --background-file is set, so the existing + // --background behaviour (raw, unsanitised) is preserved for users who do + // not opt into the file-based context. + if opts.backgroundFile != "" { + fileBackground, err := loadBackgroundFile(opts.backgroundFile) + if err != nil { + return err + } + opts.background = mergeBackground(opts.background, fileBackground) + } + if opts.preview { return runPreview(cc, opts) } diff --git a/plugins/open-code-review/commands/review.md b/plugins/open-code-review/commands/review.md index 82a1e99..1e2e19a 100644 --- a/plugins/open-code-review/commands/review.md +++ b/plugins/open-code-review/commands/review.md @@ -17,6 +17,7 @@ ocr review --audience agent [user-args] - If the user provides `--commit` or `--c`: pass through as-is. - If the user provides `--from` and `--to`: pass through as-is. - (Optional) Provide `--background "requirement context"` to review whether the requirements are correctly implemented. +- (Optional) Provide `--background-file ./requirements.md` to load the same context from a Markdown file (sanitised and limited to 8000 characters). Combined with `--background` the inline value is given first. - Capture full stdout. Set a 5-minute timeout. - If the `ocr` command is not found, install it by running `npm i -g @alibaba-group/open-code-review`. @@ -32,4 +33,4 @@ Silently discard low-confidence comments. Display the remaining comments. ### Step 3: Fix -Automatically fix issues and suggestions that are worth adopting. \ No newline at end of file +Automatically fix issues and suggestions that are worth adopting.