feat(cli): add goose review local code review command (#9114)
Signed-off-by: Joah Gerstenberg <joahg@squareup.com> Signed-off-by: Joah Gerstenberg <joah@squareup.com> Co-authored-by: Joah Gerstenberg <joahg@squareup.com> Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -988,6 +988,104 @@ enum Command {
|
||||
bin_name: String,
|
||||
},
|
||||
|
||||
/// Local code review.
|
||||
///
|
||||
/// Discovers `**/.agents/checks/*.md` subagent reviewers and
|
||||
/// `**/.agents/REVIEW.md` scoped prompt overrides, builds a review
|
||||
/// request from the working tree (or an explicit diff range), and
|
||||
/// runs the review through goose.
|
||||
#[command(about = "Review the current diff using goose")]
|
||||
Review {
|
||||
/// Diff range to review (e.g. "main...HEAD"). Defaults to the working
|
||||
/// tree vs HEAD.
|
||||
#[arg(value_name = "RANGE")]
|
||||
range: Option<String>,
|
||||
|
||||
/// Path to a Markdown file with a custom base review prompt. Replaces
|
||||
/// the embedded default prompt.
|
||||
#[arg(long = "prompt", value_name = "FILE")]
|
||||
prompt: Option<PathBuf>,
|
||||
|
||||
/// Default model used for the main review agent and for any check
|
||||
/// that does not declare its own `model:` in frontmatter.
|
||||
#[arg(long = "model", value_name = "MODEL")]
|
||||
model: Option<String>,
|
||||
|
||||
/// Provider for the main review agent.
|
||||
#[arg(long = "provider", value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
|
||||
/// Force every discovered check to use this model, regardless of
|
||||
/// the check's own `model:` field.
|
||||
#[arg(long = "override-model", value_name = "MODEL")]
|
||||
override_model: Option<String>,
|
||||
|
||||
/// Default `turn-limit` applied to checks that do not declare their
|
||||
/// own.
|
||||
#[arg(long = "turn-limit", value_name = "N")]
|
||||
turn_limit: Option<usize>,
|
||||
|
||||
/// Print the assembled review prompt and discovered checks instead of
|
||||
/// running the review.
|
||||
#[arg(long = "dry-run")]
|
||||
dry_run: bool,
|
||||
|
||||
/// Suppress non-result output from the underlying agent.
|
||||
#[arg(long, short = 'q')]
|
||||
quiet: bool,
|
||||
|
||||
/// Disable the Rust-driven parallel orchestrator and fall back to
|
||||
/// the single-prompt path that asks the main agent to delegate
|
||||
/// each check via `delegate(... async: true ...)`. The default
|
||||
/// orchestrator dispatches one `goose run` subprocess per check
|
||||
/// (capped at 4 concurrent), bounding wall-clock to the slowest
|
||||
/// single check rather than waiting on the model to issue
|
||||
/// dispatches.
|
||||
#[arg(long = "no-orchestrate")]
|
||||
no_orchestrate: bool,
|
||||
|
||||
/// Additional free-form instructions to prepend to the review
|
||||
/// (e.g. PR intent, commit-message context, "this is a refactor,
|
||||
/// flag any behavior change"). Mirrors `amp review --instructions`
|
||||
/// for drop-in compatibility with existing reviewer wrappers.
|
||||
#[arg(long = "instructions", short = 'i', value_name = "TEXT")]
|
||||
instructions: Option<String>,
|
||||
|
||||
/// Restrict the review to a specific set of files. Other files in
|
||||
/// the diff are still passed to the agent for context but are
|
||||
/// excluded from the assembled diff sent to checks. Mirrors
|
||||
/// `amp review --files`.
|
||||
#[arg(long = "files", short = 'f', value_name = "FILE", num_args = 1..)]
|
||||
files: Vec<String>,
|
||||
|
||||
/// Only run checks whose `name` matches one of these. Other
|
||||
/// discovered checks are skipped. Mirrors `amp review --check-filter`.
|
||||
#[arg(long = "check-filter", short = 'c', value_name = "NAME", num_args = 1..)]
|
||||
check_filter: Vec<String>,
|
||||
|
||||
/// Alternate directory to search for `.agents/checks/*.md` instead
|
||||
/// of the repo root. Mirrors `amp review --check-scope`.
|
||||
#[arg(long = "check-scope", short = 's', value_name = "DIR")]
|
||||
check_scope: Option<PathBuf>,
|
||||
|
||||
/// Skip the main correctness pass and only run check subagents.
|
||||
/// Mirrors `amp review --checks-only`.
|
||||
#[arg(long = "checks-only")]
|
||||
checks_only: bool,
|
||||
|
||||
/// Print only the diff summary; skip the full review.
|
||||
/// Mirrors `amp review --summary-only`.
|
||||
#[arg(long = "summary-only")]
|
||||
summary_only: bool,
|
||||
|
||||
/// Minimum severity to display. Findings below this rank are
|
||||
/// dropped from the output. Default is `medium`, matching
|
||||
/// Amp's CLI which hides `low` from review output. Pass
|
||||
/// `--severity low` to surface every finding.
|
||||
#[arg(long = "severity", value_name = "LEVEL", default_value = "medium")]
|
||||
severity: String,
|
||||
},
|
||||
|
||||
#[command(
|
||||
name = "validate-extensions",
|
||||
about = "Validate a bundled-extensions.json file",
|
||||
@@ -1157,6 +1255,7 @@ fn get_command_name(command: &Option<Command>) -> &'static str {
|
||||
#[cfg(feature = "local-inference")]
|
||||
Some(Command::LocalModels { .. }) => "local-models",
|
||||
Some(Command::Completion { .. }) => "completion",
|
||||
Some(Command::Review { .. }) => "review",
|
||||
Some(Command::ValidateExtensions { .. }) => "validate-extensions",
|
||||
None => "default_session",
|
||||
}
|
||||
@@ -1990,6 +2089,45 @@ pub async fn cli() -> anyhow::Result<()> {
|
||||
Some(Command::Term { command }) => handle_term_subcommand(command).await,
|
||||
#[cfg(feature = "local-inference")]
|
||||
Some(Command::LocalModels { command }) => handle_local_models_command(command).await,
|
||||
Some(Command::Review {
|
||||
range,
|
||||
prompt,
|
||||
model,
|
||||
provider,
|
||||
override_model,
|
||||
turn_limit,
|
||||
dry_run,
|
||||
quiet,
|
||||
no_orchestrate,
|
||||
instructions,
|
||||
files,
|
||||
check_filter,
|
||||
check_scope,
|
||||
checks_only,
|
||||
summary_only,
|
||||
severity,
|
||||
}) => {
|
||||
use crate::commands::review::{handle_review, ReviewOptions};
|
||||
handle_review(ReviewOptions {
|
||||
range,
|
||||
prompt_file: prompt,
|
||||
default_model: model,
|
||||
provider,
|
||||
override_model,
|
||||
default_turn_limit: turn_limit,
|
||||
dry_run,
|
||||
quiet,
|
||||
no_orchestrate,
|
||||
instructions,
|
||||
files,
|
||||
check_filter,
|
||||
check_scope,
|
||||
checks_only,
|
||||
summary_only,
|
||||
severity,
|
||||
})
|
||||
.await
|
||||
}
|
||||
Some(Command::ValidateExtensions { file }) => {
|
||||
use goose::agents::validate_extensions::validate_bundled_extensions;
|
||||
match validate_bundled_extensions(&file) {
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod info;
|
||||
pub mod plugin;
|
||||
pub mod project;
|
||||
pub mod recipe;
|
||||
pub mod review;
|
||||
pub mod schedule;
|
||||
pub mod session;
|
||||
pub mod term;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
You are reviewing a code change for **correctness bugs**, security issues,
|
||||
performance problems, and style violations. Be precise and concrete; cite the
|
||||
exact line(s) and explain the failure mode.
|
||||
|
||||
## Output
|
||||
|
||||
For every issue you find, emit a single JSON object on its own line with the
|
||||
fields:
|
||||
|
||||
- `severity` — one of `low`, `medium`, `high`, `critical`
|
||||
- `path` — repo-relative file path
|
||||
- `line_start` — first line the comment applies to (1-indexed)
|
||||
- `line_end` — last line the comment applies to
|
||||
- `summary` — one-paragraph explanation of the issue and the fix
|
||||
- `check` — the `name` of the check that produced the finding, or
|
||||
`main` for findings produced by the main review pass
|
||||
|
||||
If there are no issues, emit a single line containing `[]`.
|
||||
|
||||
## Correctness pass (run this for every diff)
|
||||
|
||||
Before delegating to subagent checks, do a careful correctness pass on the
|
||||
diff yourself. Walk every changed function and look hard for:
|
||||
|
||||
- **Silent error paths.** Missing-key, missing-row, `None`/`null`, and
|
||||
exception cases that produce a default value instead of surfacing the
|
||||
error. Flag every place where a missing record is silently coerced to
|
||||
`0`, `""`, `[]`, etc.
|
||||
- **Off-by-one and boundary errors.** Loop bounds, slice indices,
|
||||
ranges, and inclusive vs. exclusive comparisons.
|
||||
- **Unhandled error returns.** Functions that return `Result`/`Error`/`err`
|
||||
whose return value is dropped or ignored.
|
||||
- **Concurrency hazards.** Shared mutable state without a lock, missing
|
||||
`await`, blocking I/O on async paths, deadlock-prone lock ordering.
|
||||
- **Resource lifecycle.** File handles, sockets, threads, or subprocess
|
||||
handles that are not closed/joined on every path (including error
|
||||
paths).
|
||||
- **Input validation.** Untrusted input flowing into SQL, shell, file
|
||||
paths, deserialization, or template rendering without sanitization.
|
||||
- **State that leaks across requests.** Module-level mutables, default
|
||||
arguments, and singleton caches that retain user data across calls.
|
||||
- **Logic that contradicts the comment, docstring, or function name.**
|
||||
These signal that one of them is wrong; flag the inconsistency.
|
||||
|
||||
Emit findings from this pass with `"check": "main"`.
|
||||
|
||||
## Code-quality pass
|
||||
|
||||
Alongside the correctness pass, walk every changed hunk and call out:
|
||||
|
||||
- **Bugs and hackiness.** Suspicious workarounds, copy-pasted blocks
|
||||
that drifted, anything that looks like a fix-as-you-go.
|
||||
- **Unnecessary code.** Dead branches, unreachable paths, redundant
|
||||
null checks, work that could be deleted without changing behavior.
|
||||
- **Too much shared mutable state.** Module-level singletons, globals,
|
||||
parameters mutated across helpers, structures whose ownership is
|
||||
unclear.
|
||||
- **Abstraction fit, in both directions.** Flag *unnecessary
|
||||
indirection* (factories, wrappers, traits, adapters that have one
|
||||
caller and add no leverage) and *missing abstractions* (the same
|
||||
five-line block repeated across the diff, or hard-coded values that
|
||||
belong behind a name). For each finding, cite concrete locations
|
||||
and recommend exactly one action — only when it improves the
|
||||
current code, not because it is a "best practice".
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Only comment on the diff. Do not flag pre-existing code unless the diff
|
||||
meaningfully changes its behavior.
|
||||
- Prefer high-signal findings over coverage. A small number of correct,
|
||||
actionable comments is better than many low-confidence ones.
|
||||
- Treat style nits as `low` severity; reserve `high`/`critical` for real
|
||||
bugs, regressions, or security issues.
|
||||
|
||||
## Checks
|
||||
|
||||
If the request below lists subagent **checks**, **dispatch them all in
|
||||
parallel** before doing anything else. For each check:
|
||||
|
||||
```
|
||||
delegate(
|
||||
instructions = <check body>,
|
||||
async = true, # IMPORTANT: parallelize
|
||||
model = <check model>,
|
||||
max_turns = <check turn_limit>,
|
||||
)
|
||||
```
|
||||
|
||||
Do NOT pass the check's `tools` value to `extensions`. The `extensions`
|
||||
parameter filters by **extension name** (e.g. `developer`, `summon`),
|
||||
not tool name (e.g. `Read`, `Grep`), so passing a tool list there
|
||||
silently disables every extension and the subagent ends up with no
|
||||
tools at all. Treat the per-check `tools` column in the request as
|
||||
informational guidance for the subagent's prompt, not as an
|
||||
extensions filter.
|
||||
|
||||
This returns a `taskId` immediately. After dispatching every check, call
|
||||
`load(taskId)` once per check to wait for the results. **Do not** issue
|
||||
the next `delegate` call after the previous one has completed — that is
|
||||
sequential and slow; we want every check executing concurrently.
|
||||
|
||||
Run your own correctness pass while the subagents are in flight, so the
|
||||
wall-clock time is bounded by the slowest single check rather than by
|
||||
their sum.
|
||||
|
||||
Each subagent must include the originating check's `name` in the `check`
|
||||
field of every finding so attribution is preserved end-to-end.
|
||||
Aggregate all findings (yours and theirs) into the same JSON output.
|
||||
@@ -0,0 +1,618 @@
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::session::{build_session, SessionBuilderConfig};
|
||||
|
||||
use goose::checks::{discover, DiscoveredReview};
|
||||
|
||||
use super::orchestrator::{
|
||||
emit_findings, run_checks_in_parallel, run_main_pass_in_parallel, Severity,
|
||||
};
|
||||
use super::prompt::{build_review_prompt, DEFAULT_REVIEW_PROMPT};
|
||||
|
||||
/// Options for `goose review`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ReviewOptions {
|
||||
/// Diff range to review (e.g. `main...HEAD`). When `None`, falls back to
|
||||
/// the working tree vs. the inferred merge base / default branch.
|
||||
pub range: Option<String>,
|
||||
/// Path to a markdown file with a custom base review prompt. Overrides the
|
||||
/// embedded default prompt entirely.
|
||||
pub prompt_file: Option<PathBuf>,
|
||||
/// Default model used for the main review agent and for any check that
|
||||
/// does not declare its own `model:`.
|
||||
pub default_model: Option<String>,
|
||||
/// Provider for the main review agent.
|
||||
pub provider: Option<String>,
|
||||
/// Force every discovered check to run with this model, regardless of
|
||||
/// the check's own `model:` field.
|
||||
pub override_model: Option<String>,
|
||||
/// Default `turn-limit` applied to checks that do not declare their own.
|
||||
pub default_turn_limit: Option<usize>,
|
||||
/// Print the assembled prompt and discovered checks instead of dispatching
|
||||
/// the review.
|
||||
pub dry_run: bool,
|
||||
/// Suppress non-result output from the underlying agent.
|
||||
pub quiet: bool,
|
||||
/// Disable the Rust-driven parallel orchestrator and fall back to the
|
||||
/// single-prompt path that asks the main agent to delegate checks via
|
||||
/// `delegate(... async: true ...)`. Useful when comparing against the
|
||||
/// in-process behavior or running on a model that handles dispatch
|
||||
/// reliably on its own.
|
||||
pub no_orchestrate: bool,
|
||||
/// Additional free-form instructions to prepend to the review (PR
|
||||
/// intent, commit-message context, etc.). Surfaced to both the main
|
||||
/// agent and every check subprocess.
|
||||
pub instructions: Option<String>,
|
||||
/// Restrict the review to a specific set of files (repo-relative).
|
||||
/// When non-empty, the diff sent to the agent is filtered to only
|
||||
/// include hunks for these paths.
|
||||
pub files: Vec<String>,
|
||||
/// Only run checks whose `name` is in this list. Empty means run all
|
||||
/// discovered checks (the default).
|
||||
pub check_filter: Vec<String>,
|
||||
/// Alternate directory to search for `.agents/checks/*.md` instead of
|
||||
/// the repo root.
|
||||
pub check_scope: Option<PathBuf>,
|
||||
/// Skip the main correctness pass and only run check subagents.
|
||||
pub checks_only: bool,
|
||||
/// Print only the diff summary; skip the full review.
|
||||
pub summary_only: bool,
|
||||
/// Minimum severity to display from check findings. Defaults to
|
||||
/// `medium`, matching Amp's CLI behavior of hiding `low` from
|
||||
/// the review output.
|
||||
pub severity: String,
|
||||
}
|
||||
|
||||
/// Entry point for the `goose review` subcommand.
|
||||
pub async fn handle_review(opts: ReviewOptions) -> Result<()> {
|
||||
let repo_root = find_repo_root().context("not inside a git repository")?;
|
||||
|
||||
// Validate `--severity` once, up front, so a bogus value fails fast
|
||||
// regardless of which orchestration path we end up taking.
|
||||
let sev_str = if opts.severity.is_empty() {
|
||||
"medium"
|
||||
} else {
|
||||
opts.severity.as_str()
|
||||
};
|
||||
let min_sev: Severity = sev_str
|
||||
.parse()
|
||||
.map_err(|e: String| anyhow!("--severity: {e}"))?;
|
||||
|
||||
let mut touched = touched_files(&repo_root, opts.range.as_deref(), &opts.files)?;
|
||||
let mut diff = collect_diff(&repo_root, opts.range.as_deref(), &opts.files)?;
|
||||
|
||||
// Without an explicit `--range`, `git diff HEAD` excludes untracked
|
||||
// files entirely — brand-new files would silently miss the review.
|
||||
// Synthesize a `new file` diff for each so the main pass and the
|
||||
// checks see them.
|
||||
if opts.range.is_none() {
|
||||
let untracked = untracked_files(&repo_root, &opts.files)?;
|
||||
if !untracked.is_empty() {
|
||||
let untracked_diff = synthesize_untracked_diff(&repo_root, &untracked)?;
|
||||
diff.push_str(&untracked_diff);
|
||||
for u in untracked {
|
||||
if !touched.contains(&u) {
|
||||
touched.push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if diff.trim().is_empty() {
|
||||
eprintln!("goose review: no changes to review");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `--summary-only` short-circuits everything else: print `git
|
||||
// diff --stat` and return without calling the agent. Mirrors
|
||||
// `amp review --summary-only`.
|
||||
if opts.summary_only {
|
||||
let summary = collect_diff_stat(&repo_root, opts.range.as_deref(), &opts.files)?;
|
||||
print!("{}", summary);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `--check-scope` overrides where we look for `.agents/checks/*.md`,
|
||||
// otherwise discovery walks from the repo root + every directory on
|
||||
// the path of a touched file.
|
||||
let discovery_root = opts.check_scope.as_deref().unwrap_or(&repo_root);
|
||||
// `touched` is repo-relative; rebase to discovery_root so candidate
|
||||
// scope walking doesn't double-prefix `<scope>/api/...` for files
|
||||
// already living under the scope.
|
||||
let discovery_touched = rebase_touched_to_scope(&repo_root, discovery_root, &touched);
|
||||
let discovered = discover(discovery_root, &discovery_touched)?;
|
||||
let discovered = filter_checks(discovered, &opts.check_filter);
|
||||
if !opts.quiet {
|
||||
print_discovered_summary(&discovered);
|
||||
}
|
||||
|
||||
let base_prompt = match &opts.prompt_file {
|
||||
Some(path) => fs::read_to_string(path)
|
||||
.with_context(|| format!("read --prompt file {}", path.display()))?,
|
||||
None => DEFAULT_REVIEW_PROMPT.to_string(),
|
||||
};
|
||||
|
||||
let use_orchestrator = !opts.no_orchestrate;
|
||||
|
||||
// Reviewer instructions are also injected into every per-file
|
||||
// main-pass subprocess and every per-check subprocess. To avoid
|
||||
// duplicating them, only prepend to the base prompt for the legacy
|
||||
// single-prompt (`--no-orchestrate`) path.
|
||||
let base_prompt = if use_orchestrator {
|
||||
base_prompt
|
||||
} else {
|
||||
prepend_instructions(&base_prompt, opts.instructions.as_deref())
|
||||
};
|
||||
|
||||
// In orchestrator mode, the main pass runs as N parallel subprocesses
|
||||
// (one per touched file) — checks run as parallel subprocesses too —
|
||||
// so the assembled prompt only matters for the legacy in-process path.
|
||||
let main_prompt_discovered = if use_orchestrator {
|
||||
DiscoveredReview::default()
|
||||
} else {
|
||||
discovered.clone()
|
||||
};
|
||||
let prompt = build_review_prompt(
|
||||
&base_prompt,
|
||||
&main_prompt_discovered,
|
||||
&diff,
|
||||
opts.default_model.as_deref(),
|
||||
opts.override_model.as_deref(),
|
||||
opts.default_turn_limit,
|
||||
);
|
||||
|
||||
if opts.dry_run {
|
||||
println!("{}", prompt);
|
||||
if use_orchestrator {
|
||||
println!(
|
||||
"\n# orchestrator: {} check(s) would run as parallel subprocesses",
|
||||
discovered.checks.len()
|
||||
);
|
||||
println!("# orchestrator: main pass would fan out one subprocess per touched file");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !use_orchestrator {
|
||||
// Legacy in-process path (--no-orchestrate). Useful for comparing
|
||||
// against orchestrated wall clock and for models that handle
|
||||
// delegation reliably on their own.
|
||||
if opts.checks_only {
|
||||
// The legacy path runs everything as a single agent prompt,
|
||||
// so it has no way to "skip the main pass". Fall back to the
|
||||
// orchestrator's check-runner (which IS able to run checks
|
||||
// in isolation) instead of silently no-op'ing.
|
||||
let check_results = run_checks_in_parallel(&discovered.checks, &diff, &opts).await;
|
||||
let mut total_emitted = 0usize;
|
||||
let mut total_seen = 0usize;
|
||||
for findings in &check_results {
|
||||
total_seen += findings.len();
|
||||
total_emitted += emit_findings(findings, min_sev);
|
||||
}
|
||||
if !opts.quiet {
|
||||
let suppressed = total_seen.saturating_sub(total_emitted);
|
||||
eprintln!(
|
||||
"goose review: emitted {total_emitted} finding(s) from {} check(s) ({suppressed} hidden below severity={:?})",
|
||||
discovered.checks.len(),
|
||||
min_sev
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let mut session = build_session(SessionBuilderConfig {
|
||||
session_id: None,
|
||||
no_session: true,
|
||||
no_profile: true,
|
||||
builtins: vec!["developer".to_string(), "summon".to_string()],
|
||||
provider: opts.provider.clone(),
|
||||
model: opts.default_model.clone(),
|
||||
quiet: opts.quiet,
|
||||
output_format: "text".to_string(),
|
||||
..SessionBuilderConfig::default()
|
||||
})
|
||||
.await;
|
||||
return session.headless(prompt).await;
|
||||
}
|
||||
|
||||
// Orchestrated mode: run the main correctness pass (per-file
|
||||
// parallel subprocesses) and the discovered checks (one subprocess
|
||||
// each, capped at MAX_WORKERS) concurrently. Wall clock is bounded
|
||||
// by `max(slowest_main_file, slowest_check)` instead of scaling
|
||||
// with diff size or check count.
|
||||
let main_findings_fut = async {
|
||||
if opts.checks_only {
|
||||
Vec::new()
|
||||
} else {
|
||||
run_main_pass_in_parallel(&diff, &base_prompt, &opts).await
|
||||
}
|
||||
};
|
||||
let checks_fut = run_checks_in_parallel(&discovered.checks, &diff, &opts);
|
||||
let (main_findings, check_results) = tokio::join!(main_findings_fut, checks_fut);
|
||||
|
||||
let mut total_emitted = 0usize;
|
||||
let mut total_seen = main_findings.len();
|
||||
total_emitted += emit_findings(&main_findings, min_sev);
|
||||
for findings in &check_results {
|
||||
total_seen += findings.len();
|
||||
total_emitted += emit_findings(findings, min_sev);
|
||||
}
|
||||
if !opts.quiet {
|
||||
let suppressed = total_seen.saturating_sub(total_emitted);
|
||||
let main_pass_label = if opts.checks_only { "skipped" } else { "ran" };
|
||||
if suppressed == 0 {
|
||||
eprintln!(
|
||||
"goose review: orchestrator emitted {total_emitted} finding(s) from {} check(s) (main: {main_pass_label}, {} finding(s))",
|
||||
discovered.checks.len(),
|
||||
main_findings.len()
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"goose review: orchestrator emitted {total_emitted} finding(s) from {} check(s) (main: {main_pass_label}, {} finding(s); {suppressed} hidden below severity={:?})",
|
||||
discovered.checks.len(),
|
||||
main_findings.len(),
|
||||
min_sev
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restrict a discovered review to the named checks (no-op when the
|
||||
/// filter is empty). Mirrors `amp review --check-filter`.
|
||||
fn filter_checks(discovered: DiscoveredReview, names: &[String]) -> DiscoveredReview {
|
||||
if names.is_empty() {
|
||||
return discovered;
|
||||
}
|
||||
let allow: std::collections::HashSet<&str> = names.iter().map(String::as_str).collect();
|
||||
DiscoveredReview {
|
||||
checks: discovered
|
||||
.checks
|
||||
.into_iter()
|
||||
.filter(|c| allow.contains(c.name.as_str()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepend a free-form `--instructions <text>` block to the base prompt
|
||||
/// so it is visible to both the main agent and (via the orchestrator)
|
||||
/// every per-check subprocess.
|
||||
fn prepend_instructions(base_prompt: &str, instructions: Option<&str>) -> String {
|
||||
match instructions {
|
||||
Some(text) if !text.trim().is_empty() => {
|
||||
format!(
|
||||
"## Reviewer instructions\n\n{}\n\n{}",
|
||||
text.trim(),
|
||||
base_prompt
|
||||
)
|
||||
}
|
||||
_ => base_prompt.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_discovered_summary(d: &DiscoveredReview) {
|
||||
if d.checks.is_empty() {
|
||||
eprintln!("goose review: no checks or REVIEW.md rules discovered");
|
||||
return;
|
||||
}
|
||||
eprintln!("goose review: discovered {} check(s):", d.checks.len());
|
||||
for c in &d.checks {
|
||||
let scope = if c.scope_dir.is_empty() {
|
||||
"<root>"
|
||||
} else {
|
||||
&c.scope_dir
|
||||
};
|
||||
eprintln!(" - {} (scope: {})", c.name, scope);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_repo_root() -> Result<PathBuf> {
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.output()
|
||||
.context("failed to invoke git")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git rev-parse --show-toplevel failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
let path = String::from_utf8(out.stdout)?.trim().to_string();
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
/// Configure a `git` Command to disable quoting of non-ASCII paths.
|
||||
/// Without this, paths containing non-ASCII bytes come back as quoted
|
||||
/// C-style escapes (`"dir/\303\251.txt"`), which downstream parsers
|
||||
/// would have to round-trip-decode just to spell the filename. We turn
|
||||
/// it off everywhere so callers always get clean UTF-8 paths.
|
||||
fn git_command(repo_root: &Path) -> Command {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.current_dir(repo_root)
|
||||
.args(["-c", "core.quotePath=off"]);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn touched_files(repo_root: &Path, range: Option<&str>, files: &[String]) -> Result<Vec<String>> {
|
||||
let mut cmd = git_command(repo_root);
|
||||
cmd.arg("diff").arg("--name-only");
|
||||
match range {
|
||||
Some(r) => {
|
||||
cmd.arg(r);
|
||||
}
|
||||
None => {
|
||||
cmd.arg("HEAD");
|
||||
}
|
||||
}
|
||||
if !files.is_empty() {
|
||||
cmd.arg("--");
|
||||
for f in files {
|
||||
cmd.arg(f);
|
||||
}
|
||||
}
|
||||
let out = cmd.output().context("git diff --name-only failed")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git diff --name-only failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8(out.stdout)?
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| l.to_string())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn collect_diff(repo_root: &Path, range: Option<&str>, files: &[String]) -> Result<String> {
|
||||
let mut cmd = git_command(repo_root);
|
||||
cmd.arg("diff");
|
||||
match range {
|
||||
Some(r) => {
|
||||
cmd.arg(r);
|
||||
}
|
||||
None => {
|
||||
cmd.arg("HEAD");
|
||||
}
|
||||
}
|
||||
if !files.is_empty() {
|
||||
cmd.arg("--");
|
||||
for f in files {
|
||||
cmd.arg(f);
|
||||
}
|
||||
}
|
||||
let out = cmd.output().context("git diff failed")?;
|
||||
if !out.status.success() {
|
||||
bail!("git diff failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
String::from_utf8(out.stdout).map_err(|e| anyhow!("git diff returned non-UTF8 output: {e}"))
|
||||
}
|
||||
|
||||
fn collect_diff_stat(repo_root: &Path, range: Option<&str>, files: &[String]) -> Result<String> {
|
||||
let mut cmd = git_command(repo_root);
|
||||
cmd.arg("diff").arg("--stat");
|
||||
match range {
|
||||
Some(r) => {
|
||||
cmd.arg(r);
|
||||
}
|
||||
None => {
|
||||
cmd.arg("HEAD");
|
||||
}
|
||||
}
|
||||
if !files.is_empty() {
|
||||
cmd.arg("--");
|
||||
for f in files {
|
||||
cmd.arg(f);
|
||||
}
|
||||
}
|
||||
let out = cmd.output().context("git diff --stat failed")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git diff --stat failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
String::from_utf8(out.stdout)
|
||||
.map_err(|e| anyhow!("git diff --stat returned non-UTF8 output: {e}"))
|
||||
}
|
||||
|
||||
/// List untracked-but-not-ignored files in `repo_root`. Used to expose
|
||||
/// brand-new files to the review when no `--range` is given (default
|
||||
/// `git diff HEAD` would silently drop them).
|
||||
fn untracked_files(repo_root: &Path, files: &[String]) -> Result<Vec<String>> {
|
||||
let mut cmd = git_command(repo_root);
|
||||
cmd.args(["ls-files", "--others", "--exclude-standard"]);
|
||||
if !files.is_empty() {
|
||||
cmd.arg("--");
|
||||
for f in files {
|
||||
cmd.arg(f);
|
||||
}
|
||||
}
|
||||
let out = cmd.output().context("git ls-files failed")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"git ls-files failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8(out.stdout)?
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| l.to_string())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Synthesize a unified `new file` diff for each untracked path so
|
||||
/// downstream parsers and the review prompt can treat them as
|
||||
/// additions. Binary or unreadable files are skipped (we cannot
|
||||
/// produce a meaningful textual diff for them).
|
||||
fn synthesize_untracked_diff(repo_root: &Path, paths: &[String]) -> Result<String> {
|
||||
let mut out = String::new();
|
||||
for path in paths {
|
||||
let abs = repo_root.join(path);
|
||||
let content = match fs::read_to_string(&abs) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
out.push_str(&format!("diff --git a/{path} b/{path}\n"));
|
||||
out.push_str("new file mode 100644\n");
|
||||
out.push_str("--- /dev/null\n");
|
||||
out.push_str(&format!("+++ b/{path}\n"));
|
||||
let trailing_newline = content.ends_with('\n');
|
||||
let line_count = if content.is_empty() {
|
||||
0
|
||||
} else if trailing_newline {
|
||||
content.matches('\n').count()
|
||||
} else {
|
||||
content.matches('\n').count() + 1
|
||||
};
|
||||
if line_count > 0 {
|
||||
out.push_str(&format!("@@ -0,0 +1,{line_count} @@\n"));
|
||||
for line in content.split_inclusive('\n') {
|
||||
let body = line.strip_suffix('\n').unwrap_or(line);
|
||||
out.push('+');
|
||||
out.push_str(body);
|
||||
out.push('\n');
|
||||
}
|
||||
if !trailing_newline {
|
||||
out.push_str("\\ No newline at end of file\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Convert repo-relative `touched` paths into paths relative to
|
||||
/// `discovery_root` so [`goose::checks::discover`] doesn't double-
|
||||
/// prefix `<scope>/api/...` when `--check-scope` points at a subtree.
|
||||
/// Files outside the scope are dropped — they cannot affect any
|
||||
/// scoped check inside `discovery_root`.
|
||||
fn rebase_touched_to_scope(
|
||||
repo_root: &Path,
|
||||
discovery_root: &Path,
|
||||
touched: &[String],
|
||||
) -> Vec<String> {
|
||||
if discovery_root == repo_root {
|
||||
return touched.to_vec();
|
||||
}
|
||||
let prefix = match discovery_root.strip_prefix(repo_root) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return touched.to_vec(),
|
||||
};
|
||||
let prefix_str = prefix.to_string_lossy().replace('\\', "/");
|
||||
if prefix_str.is_empty() {
|
||||
return touched.to_vec();
|
||||
}
|
||||
let prefix_with_slash = format!("{prefix_str}/");
|
||||
touched
|
||||
.iter()
|
||||
.filter_map(|p| p.strip_prefix(&prefix_with_slash).map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use goose::checks::Check;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn ck(name: &str) -> Check {
|
||||
Check {
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
model: None,
|
||||
turn_limit: None,
|
||||
tools: None,
|
||||
severity_default: None,
|
||||
path: PathBuf::from(format!("/.agents/checks/{name}.md")),
|
||||
scope_dir: String::new(),
|
||||
body: "body".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_checks_passes_through_when_filter_empty() {
|
||||
let d = DiscoveredReview {
|
||||
checks: vec![ck("perf"), ck("security")],
|
||||
};
|
||||
let out = filter_checks(d, &[]);
|
||||
assert_eq!(out.checks.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_checks_keeps_only_named_checks() {
|
||||
let d = DiscoveredReview {
|
||||
checks: vec![ck("perf"), ck("security"), ck("idempotency")],
|
||||
};
|
||||
let out = filter_checks(d, &["security".to_string(), "idempotency".to_string()]);
|
||||
let names: Vec<&str> = out.checks.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["security", "idempotency"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_instructions_noop_when_none_or_empty() {
|
||||
assert_eq!(prepend_instructions("BASE", None), "BASE");
|
||||
assert_eq!(prepend_instructions("BASE", Some(" ")), "BASE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepend_instructions_adds_block_above_base() {
|
||||
let out = prepend_instructions("BASE", Some("Refactor only — flag any behavior change."));
|
||||
assert!(out.starts_with("## Reviewer instructions\n\nRefactor only"));
|
||||
assert!(out.ends_with("BASE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesize_untracked_diff_emits_new_file_chunk_with_added_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let path = root.join("new/file.txt");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, "alpha\nbeta\ngamma\n").unwrap();
|
||||
|
||||
let diff = synthesize_untracked_diff(root, &["new/file.txt".to_string()]).unwrap();
|
||||
assert!(diff.contains("diff --git a/new/file.txt b/new/file.txt"));
|
||||
assert!(diff.contains("new file mode 100644"));
|
||||
assert!(diff.contains("--- /dev/null"));
|
||||
assert!(diff.contains("+++ b/new/file.txt"));
|
||||
assert!(diff.contains("@@ -0,0 +1,3 @@"));
|
||||
assert!(diff.contains("+alpha\n+beta\n+gamma\n"));
|
||||
assert!(!diff.contains("\\ No newline at end of file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesize_untracked_diff_marks_missing_trailing_newline() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fs::write(root.join("a.txt"), "no-newline").unwrap();
|
||||
|
||||
let diff = synthesize_untracked_diff(root, &["a.txt".to_string()]).unwrap();
|
||||
assert!(diff.contains("@@ -0,0 +1,1 @@"));
|
||||
assert!(diff.contains("+no-newline\n"));
|
||||
assert!(diff.contains("\\ No newline at end of file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_touched_to_scope_strips_scope_prefix() {
|
||||
let repo = PathBuf::from("/repo");
|
||||
let scope = PathBuf::from("/repo/api/v2");
|
||||
let touched = vec![
|
||||
"api/v2/foo.rs".to_string(),
|
||||
"api/v2/bar.rs".to_string(),
|
||||
"frontend/main.tsx".to_string(),
|
||||
];
|
||||
let out = rebase_touched_to_scope(&repo, &scope, &touched);
|
||||
assert_eq!(out, vec!["foo.rs", "bar.rs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_touched_to_scope_passes_through_when_scope_equals_repo() {
|
||||
let repo = PathBuf::from("/repo");
|
||||
let touched = vec!["a.rs".to_string()];
|
||||
let out = rebase_touched_to_scope(&repo, &repo, &touched);
|
||||
assert_eq!(out, touched);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! `goose review` — local code review tool.
|
||||
//!
|
||||
//! Discovers `**/.agents/checks/*.md` subagent reviewers and `**/.agents/REVIEW.md`
|
||||
//! scoped prompt overrides, builds a review request from the working tree (or an
|
||||
//! explicit diff range), and dispatches the review to the configured agent.
|
||||
//!
|
||||
//! Modeled after Amp's `review` command.
|
||||
//!
|
||||
//! Check parsing and discovery live in [`goose::checks`] so they can be reused
|
||||
//! from other entry points (server, ACP) without depending on this CLI.
|
||||
|
||||
pub mod handler;
|
||||
pub mod orchestrator;
|
||||
pub mod prompt;
|
||||
|
||||
pub use handler::{handle_review, ReviewOptions};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
use goose::checks::{Check, DiscoveredReview};
|
||||
|
||||
/// The default review prompt embedded in the binary.
|
||||
pub const DEFAULT_REVIEW_PROMPT: &str = include_str!("default_review_prompt.md");
|
||||
|
||||
/// Build the full prompt sent to the main review agent.
|
||||
///
|
||||
/// Layout:
|
||||
///
|
||||
/// ```text
|
||||
/// <base prompt>
|
||||
///
|
||||
/// ## Checks
|
||||
/// <check table + per-check bodies>
|
||||
///
|
||||
/// ## Diff
|
||||
/// ```
|
||||
///
|
||||
/// `base_prompt` is either the embedded [`DEFAULT_REVIEW_PROMPT`] or a
|
||||
/// caller-supplied prompt loaded from `--prompt`. Findings derived from a
|
||||
/// `**/.agents/REVIEW.md` file appear here as virtual `repo-rules`-prefixed
|
||||
/// checks so the agent can attribute them via the `check` field on each
|
||||
/// JSON finding.
|
||||
pub fn build_review_prompt(
|
||||
base_prompt: &str,
|
||||
discovered: &DiscoveredReview,
|
||||
diff: &str,
|
||||
default_model: Option<&str>,
|
||||
override_model: Option<&str>,
|
||||
default_turn_limit: Option<usize>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(base_prompt.trim_end());
|
||||
out.push_str("\n\n");
|
||||
|
||||
if !discovered.checks.is_empty() {
|
||||
out.push_str("## Checks\n\n");
|
||||
out.push_str("Dispatch one subagent per check below. ");
|
||||
out.push_str(
|
||||
"Use the `model`, `turn_limit`, and `tools` columns when invoking each subagent. ",
|
||||
);
|
||||
out.push_str("`tools = *` means the subagent inherits the agent's full toolset. ");
|
||||
out.push_str(
|
||||
"Set the `check` field on each finding to the check's `name` so the originating \
|
||||
rule can be identified in the output.\n\n",
|
||||
);
|
||||
out.push_str(
|
||||
"| name | scope | model | turn_limit | tools | severity_default | description |\n",
|
||||
);
|
||||
out.push_str(
|
||||
"|------|-------|-------|------------|-------|------------------|-------------|\n",
|
||||
);
|
||||
for check in &discovered.checks {
|
||||
let scope = if check.scope_dir.is_empty() {
|
||||
"<root>".to_string()
|
||||
} else {
|
||||
check.scope_dir.clone()
|
||||
};
|
||||
let model = check
|
||||
.resolved_model(default_model, override_model)
|
||||
.unwrap_or("<agent default>");
|
||||
let turn_limit = check.resolved_turn_limit(default_turn_limit);
|
||||
let tools = match check.tools.as_ref() {
|
||||
Some(t) if !t.is_empty() => t.join(", "),
|
||||
_ => "*".to_string(),
|
||||
};
|
||||
let severity = check.severity_default.as_deref().unwrap_or("");
|
||||
let description = check.description.as_deref().unwrap_or("");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"| {} | {} | {} | {} | {} | {} | {} |",
|
||||
escape_pipe(&check.name),
|
||||
escape_pipe(&scope),
|
||||
escape_pipe(model),
|
||||
turn_limit,
|
||||
escape_pipe(&tools),
|
||||
escape_pipe(severity),
|
||||
escape_pipe(description),
|
||||
);
|
||||
}
|
||||
out.push('\n');
|
||||
for check in &discovered.checks {
|
||||
append_check_body(&mut out, check);
|
||||
}
|
||||
}
|
||||
|
||||
out.push_str("## Diff\n\n");
|
||||
out.push_str("```diff\n");
|
||||
out.push_str(diff.trim_end_matches('\n'));
|
||||
out.push_str("\n```\n");
|
||||
out
|
||||
}
|
||||
|
||||
fn append_check_body(out: &mut String, check: &Check) {
|
||||
let scope = if check.scope_dir.is_empty() {
|
||||
"<root>".to_string()
|
||||
} else {
|
||||
check.scope_dir.clone()
|
||||
};
|
||||
let _ = writeln!(out, "### Check: {} (scope: {})", check.name, scope);
|
||||
out.push_str(check.body.trim());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
fn escape_pipe(s: &str) -> String {
|
||||
s.replace('|', "\\|")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn check(name: &str, scope: &str, model: Option<&str>, turn_limit: Option<usize>) -> Check {
|
||||
Check {
|
||||
name: name.to_string(),
|
||||
description: Some(format!("desc-{name}")),
|
||||
model: model.map(str::to_string),
|
||||
turn_limit,
|
||||
tools: None,
|
||||
severity_default: None,
|
||||
path: PathBuf::from(format!("/r/{scope}/.agents/checks/{name}.md")),
|
||||
scope_dir: scope.to_string(),
|
||||
body: format!("body-{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_checks_with_resolved_model_and_turn_limit() {
|
||||
let discovered = DiscoveredReview {
|
||||
checks: vec![
|
||||
check("perf", "", None, None),
|
||||
check("auth", "api", Some("m1"), Some(7)),
|
||||
],
|
||||
};
|
||||
|
||||
let prompt = build_review_prompt(
|
||||
"BASE",
|
||||
&discovered,
|
||||
"diff content",
|
||||
Some("default-model"),
|
||||
None,
|
||||
Some(20),
|
||||
);
|
||||
|
||||
assert!(prompt.contains("| auth | api | m1 | 7 | * |"));
|
||||
assert!(prompt.contains("| perf | <root> | default-model | 20 | * |"));
|
||||
assert!(prompt.contains("body-auth"));
|
||||
assert!(prompt.contains("```diff\ndiff content\n```"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_model_wins_per_check() {
|
||||
let discovered = DiscoveredReview {
|
||||
checks: vec![check("perf", "", Some("per-check"), None)],
|
||||
};
|
||||
let prompt = build_review_prompt(
|
||||
"BASE",
|
||||
&discovered,
|
||||
"",
|
||||
Some("default"),
|
||||
Some("OVERRIDE"),
|
||||
None,
|
||||
);
|
||||
assert!(prompt.contains("| perf | <root> | OVERRIDE |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_tool_allowlist_and_severity_when_present() {
|
||||
let mut perf = check("perf", "", None, None);
|
||||
perf.tools = Some(vec!["read".to_string(), "grep".to_string()]);
|
||||
perf.severity_default = Some("high".into());
|
||||
let discovered = DiscoveredReview { checks: vec![perf] };
|
||||
let prompt = build_review_prompt("BASE", &discovered, "", None, None, None);
|
||||
assert!(prompt.contains("| perf | <root> | <agent default> | 25 | read, grep | high |"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructs_agent_to_attribute_findings_via_check_field() {
|
||||
let discovered = DiscoveredReview {
|
||||
checks: vec![check("perf", "", None, None)],
|
||||
};
|
||||
let prompt = build_review_prompt("BASE", &discovered, "", None, None, None);
|
||||
assert!(prompt.contains("Set the `check` field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_checks_section_when_empty() {
|
||||
let discovered = DiscoveredReview::default();
|
||||
let prompt = build_review_prompt("BASE", &discovered, "diff", None, None, None);
|
||||
assert!(!prompt.contains("## Checks"));
|
||||
assert!(prompt.contains("## Diff"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user