Skip to content

Commit bdf91ef

Browse files
committed
fix: address pr-review set review findings
1 parent b33593a commit bdf91ef

9 files changed

Lines changed: 136 additions & 26 deletions

File tree

crates/gather-step-cli/src/commands/mod.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,26 @@ mod tests {
396396
}
397397
}
398398

399+
#[test]
400+
fn rejects_zero_pr_review_parallelism_during_parse() {
401+
let error = Cli::try_parse_from([
402+
"gather-step",
403+
"pr-review",
404+
"--pr-set",
405+
"examples/pr-set/cross-repo-feature.yaml",
406+
"--parallelism",
407+
"0",
408+
])
409+
.expect_err("zero parallelism should be rejected by clap");
410+
411+
assert!(
412+
error
413+
.to_string()
414+
.contains("--parallelism must be an integer greater than or equal to 1"),
415+
"unexpected error for zero parallelism: {error}"
416+
);
417+
}
418+
399419
#[test]
400420
fn parses_top_level_serve_args() {
401421
let cli = Cli::parse_from([

crates/gather-step-cli/src/commands/pr_review.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ pub struct PrReviewArgs {
103103
pub set_id: Option<String>,
104104

105105
/// Number of independent PR-set entries to review in parallel.
106-
#[arg(long, value_name = "N", default_value_t = 1)]
106+
#[arg(long, value_name = "N", default_value_t = 1, value_parser = parse_positive_usize)]
107107
pub parallelism: usize,
108108

109109
/// Include PRs whose GitHub repo is not listed in the workspace config
@@ -329,6 +329,17 @@ pub enum ReviewEngine {
329329
TempIndex,
330330
}
331331

332+
fn parse_positive_usize(value: &str) -> std::result::Result<usize, String> {
333+
let parsed = value
334+
.parse::<usize>()
335+
.map_err(|_| "--parallelism must be an integer greater than or equal to 1.".to_owned())?;
336+
if parsed == 0 {
337+
Err("--parallelism must be an integer greater than or equal to 1.".to_owned())
338+
} else {
339+
Ok(parsed)
340+
}
341+
}
342+
332343
// ─── Handler ──────────────────────────────────────────────────────────────────
333344

334345
pub fn run(app: &AppContext, args: PrReviewArgs) -> Result<u8> {
@@ -340,9 +351,6 @@ pub fn run(app: &AppContext, args: PrReviewArgs) -> Result<u8> {
340351
run_init_set(app, &args, init_args).map(|()| 0)
341352
}
342353
None => {
343-
if args.parallelism == 0 {
344-
anyhow::bail!("--parallelism must be at least 1.");
345-
}
346354
if let Some(pr_set) = args.pr_set.as_ref() {
347355
let set_args = PrSetRunArgs {
348356
manifest_path: pr_set.clone(),
@@ -1690,6 +1698,7 @@ pub fn run_inner(app: &AppContext, args: &PrReviewRunArgs) -> Result<(String, bo
16901698
run_id: artifact_root.run_id.clone(),
16911699
cleanup_policy,
16921700
cache_key,
1701+
config_hash: cache_key_struct.config_hash.clone(),
16931702
},
16941703
changed_files: changed_files_display,
16951704
changed_files_truncated,
@@ -4939,6 +4948,7 @@ indexing:
49394948
run_id: "test-run".to_owned(),
49404949
cleanup_policy: CleanupPolicy::RemoveOnExit,
49414950
cache_key: "hash:aaa:bbb".to_owned(),
4951+
config_hash: "cfg".to_owned(),
49424952
},
49434953
changed_files: vec![],
49444954
changed_files_truncated: false,

crates/gather-step-cli/src/pr_review/delta_report.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,9 @@ pub struct SafetyMetadata {
631631
/// Composite key that uniquely identifies this review state.
632632
/// Format: `"<workspace_hash>:<base_sha>:<head_sha>"`.
633633
pub cache_key: String,
634+
/// First 16 hex chars of blake3(`gather-step.config.yaml` bytes) used by
635+
/// the review cache identity.
636+
pub config_hash: String,
634637
}
635638

636639
/// Whether the review artifact root is kept or removed after the run.
@@ -2436,6 +2439,7 @@ mod tests {
24362439
run_id: "test-run".to_owned(),
24372440
cleanup_policy: CleanupPolicy::RemoveOnExit,
24382441
cache_key: "hash:aaa:bbb".to_owned(),
2442+
config_hash: "cfg".to_owned(),
24392443
},
24402444
changed_files: vec![],
24412445
changed_files_truncated: false,
@@ -2908,6 +2912,7 @@ mod tests {
29082912
run_id: "test-run-full".to_owned(),
29092913
cleanup_policy: CleanupPolicy::KeepCache,
29102914
cache_key: "hash:aaa:bbb".to_owned(),
2915+
config_hash: "cfg".to_owned(),
29112916
},
29122917
changed_files: vec!["backend/src/routes.ts".to_owned()],
29132918
changed_files_truncated: false,

crates/gather-step-cli/src/pr_review/multi_pr/coordinator.rs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,7 @@ pub fn run_pr_set(app: &AppContext, args: &PrSetRunArgs) -> Result<(String, bool
7979
blocked_ids.insert(entry.id.clone());
8080
errors.push(skipped_entry(
8181
&entry,
82-
format!(
83-
"Skipped because dependency review did not complete successfully: {}.",
84-
blocked_by.join(", ")
85-
),
82+
skipped_dependency_message(&entry, &blocked_by),
8683
));
8784
}
8885
}
@@ -294,6 +291,18 @@ fn skipped_entry(entry: &PrEntry, message: String) -> ErroredPrReview {
294291
}
295292
}
296293

294+
fn skipped_dependency_message(entry: &PrEntry, blocked_by: &[String]) -> String {
295+
let dependency_ids = if blocked_by.is_empty() {
296+
&entry.depends_on
297+
} else {
298+
blocked_by
299+
};
300+
format!(
301+
"Skipped because dependency review did not complete successfully: {}.",
302+
dependency_ids.join(", ")
303+
)
304+
}
305+
297306
fn dependency_levels(manifest: &PrSetManifest) -> Vec<Vec<PrEntry>> {
298307
let mut entries: BTreeMap<String, PrEntry> = manifest
299308
.prs
@@ -335,7 +344,7 @@ fn dependency_levels(manifest: &PrSetManifest) -> Vec<Vec<PrEntry>> {
335344

336345
#[cfg(test)]
337346
mod tests {
338-
use super::{dependency_levels, resolve_entry_workspace};
347+
use super::{dependency_levels, resolve_entry_workspace, skipped_dependency_message};
339348
use crate::pr_review::multi_pr::manifest::{PrEntry, PrSetManifest};
340349

341350
fn entry(id: &str, depends_on: Vec<&str>) -> PrEntry {
@@ -380,4 +389,16 @@ mod tests {
380389

381390
assert!(resolve_entry_workspace(temp.path(), "api").is_none());
382391
}
392+
393+
#[test]
394+
fn skipped_dependency_message_falls_back_to_all_dependencies() {
395+
let entry = entry("web", vec!["api", "contracts"]);
396+
397+
let message = skipped_dependency_message(&entry, &[]);
398+
399+
assert_eq!(
400+
message,
401+
"Skipped because dependency review did not complete successfully: api, contracts."
402+
);
403+
}
383404
}

crates/gather-step-cli/src/pr_review/multi_pr/delta_report.rs

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ pub enum PrReviewSetEntryStatus {
7272
Skipped,
7373
}
7474

75+
impl PrReviewSetEntryStatus {
76+
pub fn as_str(self) -> &'static str {
77+
match self {
78+
Self::Failed => "failed",
79+
Self::Skipped => "skipped",
80+
}
81+
}
82+
}
83+
7584
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7685
pub struct CrossPrFindings {
7786
pub contract_drifts: Vec<CrossPrContractDrift>,
@@ -242,8 +251,13 @@ impl MultiPrDeltaReport {
242251
.map_or_else(|| error.id.clone(), |pr| format!("#{pr}"));
243252
let _ = writeln!(
244253
buf,
245-
"| {:?} | {} | `{}` | `{}`..`{}` | {} |",
246-
error.status, pr, error.repo, error.base, error.head, error.message
254+
"| {} | {} | `{}` | `{}`..`{}` | {} |",
255+
error.status.as_str(),
256+
pr,
257+
error.repo,
258+
error.base,
259+
error.head,
260+
error.message
247261
);
248262
}
249263
}
@@ -506,7 +520,13 @@ fn set_fingerprint_for_report(manifest: &PrSetManifest, prs: &[PerPrDeltaReport]
506520
.and_then(Value::as_str)
507521
.unwrap_or(pr.head.as_str())
508522
.to_owned(),
509-
config_hash: String::new(),
523+
config_hash: pr
524+
.delta_report
525+
.get("safety")
526+
.and_then(|safety| safety.get("config_hash"))
527+
.and_then(Value::as_str)
528+
.unwrap_or_default()
529+
.to_owned(),
510530
gather_step_version: env!("CARGO_PKG_VERSION").to_owned(),
511531
})
512532
.collect();
@@ -546,6 +566,10 @@ mod tests {
546566
use crate::pr_review::multi_pr::manifest::{PrEntry, PrSetManifest};
547567

548568
fn pr(id: &str, side: &str) -> PerPrDeltaReport {
569+
pr_with_config_hash(id, side, "cfg")
570+
}
571+
572+
fn pr_with_config_hash(id: &str, side: &str, config_hash: &str) -> PerPrDeltaReport {
549573
PerPrDeltaReport {
550574
id: id.to_owned(),
551575
repo: id.to_owned(),
@@ -559,7 +583,8 @@ mod tests {
559583
"head_sha": "2222222222222222222222222222222222222222"
560584
},
561585
"safety": {
562-
"cache_key": "workspace:base:head"
586+
"cache_key": "workspace:base:head",
587+
"config_hash": config_hash
563588
},
564589
"changed_files": ["src/app.ts"],
565590
"routes": { "added": [], "removed": [], "changed": [] },
@@ -644,6 +669,42 @@ mod tests {
644669
assert_eq!(report.metadata.completed_prs, 1);
645670
assert_eq!(report.metadata.failed_prs, 0);
646671
assert_eq!(report.metadata.skipped_prs, 1);
647-
assert!(report.render_markdown().contains("Cross-PR findings"));
672+
let markdown = report.render_markdown();
673+
assert!(markdown.contains("Cross-PR findings"));
674+
assert!(
675+
markdown.contains("| skipped | #2 |"),
676+
"markdown should use the stable serialized status label: {markdown}"
677+
);
678+
}
679+
680+
#[test]
681+
fn set_fingerprint_includes_child_config_hash() {
682+
let manifest = PrSetManifest {
683+
version: 0,
684+
id: "checkout-refresh".to_owned(),
685+
title: None,
686+
prs: vec![PrEntry {
687+
id: "api".to_owned(),
688+
repo: "api".to_owned(),
689+
base: "main".to_owned(),
690+
head: "feature/api".to_owned(),
691+
pr: Some(1),
692+
depends_on: vec![],
693+
}],
694+
};
695+
696+
let first = super::set_fingerprint_for_report(
697+
&manifest,
698+
&[pr_with_config_hash("api", "producer", "cfg-a")],
699+
);
700+
let second = super::set_fingerprint_for_report(
701+
&manifest,
702+
&[pr_with_config_hash("api", "producer", "cfg-b")],
703+
);
704+
705+
assert_ne!(
706+
first, second,
707+
"set fingerprint must change when a child review config hash changes"
708+
);
648709
}
649710
}

crates/gather-step-cli/src/pr_review/parity.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ mod tests {
409409
run_id: "test-run".to_owned(),
410410
cleanup_policy: CleanupPolicy::RemoveOnExit,
411411
cache_key: "hash:aaa:bbb".to_owned(),
412+
config_hash: "cfg".to_owned(),
412413
},
413414
changed_files: vec![],
414415
changed_files_truncated: false,

crates/gather-step-mcp/src/tools/pr_review.rs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
3535
use std::io::Read;
3636
use std::process::{Command, Stdio};
37-
use std::sync::mpsc;
3837
use std::thread;
3938
use std::time::{Duration, Instant};
4039

@@ -428,19 +427,12 @@ fn wait_with_timeout(
428427
child: &mut std::process::Child,
429428
timeout: Duration,
430429
) -> Result<std::process::ExitStatus, String> {
431-
let (tx, rx) = mpsc::channel::<Result<std::process::ExitStatus, std::io::Error>>();
432-
// We cannot move `child` into the waiter thread (we still need its
433-
// pipes and `kill` handle on this thread), so the waiter polls
434-
// `try_wait` from its own thread. A short cadence keeps the timeout
435-
// honest without busy-spinning.
430+
// A short polling cadence keeps the timeout honest without busy-spinning.
436431
let pid = child.id();
437432
let deadline = Instant::now() + timeout;
438433
loop {
439434
match child.try_wait() {
440-
Ok(Some(status)) => {
441-
let _ = (tx, rx, pid);
442-
return Ok(status);
443-
}
435+
Ok(Some(status)) => return Ok(status),
444436
Ok(None) => {
445437
if Instant::now() >= deadline {
446438
let _ = child.kill();

website/src/content/docs/guides/pr-review.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ in that surface category.
162162
| Section | What it shows |
163163
|---|---|
164164
| `metadata` | Base/head SHAs, checkout mode, indexed repos, elapsed time, warnings |
165-
| `safety` | Review storage path, run ID, cleanup policy, cache key |
165+
| `safety` | Review storage path, run ID, cleanup policy, cache key, config hash |
166166
| `changed_files` | Repo-relative paths changed in `merge_base..head` |
167167
| `evidence` | Canonical evidence rows with closed kind/source enums and structured citations |
168168
| `routes` | Added / removed / changed HTTP routes. Removed routes carry downstream impact summaries. |

website/src/content/docs/reference/mcp-tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ Used automatically when the user asks to review a pull request, do a structural
173173
**Returns.** A JSON `DeltaReport` (`schema_version: 1`) with these top-level sections:
174174

175175
- `metadata` — base/head SHAs, checkout mode, indexed repos, elapsed time, warnings (e.g., baseline-vs-base mismatch).
176-
- `safety` — review storage path, run id, cleanup policy, cache key.
176+
- `safety` — review storage path, run id, cleanup policy, cache key, config hash.
177177
- `changed_files` — list of repo-relative paths changed in `merge_base..head`.
178178
- `evidence` — canonical evidence rows computed from the typed delta surfaces at query time.
179179
- `routes` — added / removed / changed HTTP routes by `(method, canonical_path)`. Carry handler info via `Serves` edges and downstream impact summaries.

0 commit comments

Comments
 (0)