Skip to content

Commit 80e2ec4

Browse files
committed
feat: add PR readiness query surfaces
1 parent d5a095e commit 80e2ec4

File tree

8 files changed

+603
-132
lines changed

8 files changed

+603
-132
lines changed

TODO.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
4444
17. [x] Add "critical blockers" summary cards for unresolved `Error` and `Warning` comments.
4545
18. [ ] Add per-PR readiness timelines showing when a review became mergeable.
4646
19. [ ] Store resolution timestamps for findings so mean-time-to-fix can be measured.
47-
20. [ ] Add CLI and API surfaces to query PR readiness without opening the web UI.
47+
20. [x] Add CLI and API surfaces to query PR readiness without opening the web UI.
4848

4949
## 3. Agentic Validation Loops
5050

@@ -114,7 +114,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
114114
## 8. APIs, Automation, and MCP-Like Surfaces
115115

116116
71. [ ] Expose unresolved/resolved comment search through the HTTP API.
117-
72. [ ] Expose PR readiness through the HTTP API for CI and agent integrations.
117+
72. [x] Expose PR readiness through the HTTP API for CI and agent integrations.
118118
73. [ ] Add API endpoints to fetch learned rules, attention gaps, and top rejected patterns.
119119
74. [ ] Add machine-friendly APIs to fetch findings grouped by severity, file, and lifecycle state.
120120
75. [ ] Add a "trigger re-review" API that reuses existing PR metadata and loop policy.
@@ -159,4 +159,5 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
159159
- [x] Make merge readiness verification-aware and surface stale PR reviews as needs re-review in history/detail views.
160160
- [x] Make stale-review detection compare PR head SHAs so same-head reruns do not look stale.
161161
- [x] Split open findings into blocking vs informational buckets and surface critical blocker cards in review detail.
162+
- [x] Add PR readiness query surfaces in the CLI and HTTP API for non-UI workflows.
162163
- [ ] Commit and push each validated checkpoint before moving to the next epic.

src/commands/pr.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ mod comments;
77
mod context;
88
#[path = "pr/gh.rs"]
99
mod gh;
10+
#[path = "pr/readiness.rs"]
11+
mod readiness;
1012
#[path = "pr/review_flow.rs"]
1113
mod review_flow;
1214
#[path = "pr/summary_flow.rs"]
@@ -16,6 +18,7 @@ use crate::config;
1618
use crate::output::OutputFormat;
1719

1820
use context::prepare_pr_context;
21+
use readiness::run_pr_readiness_flow;
1922
use review_flow::run_pr_review_flow;
2023
use summary_flow::run_pr_summary_flow;
2124

@@ -24,9 +27,14 @@ pub async fn pr_command(
2427
repo: Option<String>,
2528
post_comments: bool,
2629
summary: bool,
30+
readiness: bool,
2731
config: config::Config,
2832
format: OutputFormat,
2933
) -> Result<()> {
34+
if readiness {
35+
return run_pr_readiness_flow(number, repo.as_deref(), config, format).await;
36+
}
37+
3038
let context = prepare_pr_context(number, repo.as_deref())?;
3139

3240
info!("Reviewing PR #{}", context.pr_number);

src/commands/pr/gh/metadata.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::process::Command;
44

55
#[derive(Debug, Deserialize)]
66
pub(in super::super) struct GhPrMetadata {
7+
pub(in super::super) number: u32,
78
#[serde(rename = "headRefOid")]
89
pub(in super::super) head_ref_oid: String,
910
#[serde(rename = "baseRepository")]
@@ -25,7 +26,7 @@ pub(in super::super) fn fetch_pr_metadata(
2526
"view".to_string(),
2627
pr_number.to_string(),
2728
"--json".to_string(),
28-
"headRefOid,baseRepository".to_string(),
29+
"number,headRefOid,baseRepository".to_string(),
2930
];
3031
if let Some(repo) = repo {
3132
args.push("--repo".to_string());

src/commands/pr/readiness.rs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
use anyhow::Result;
2+
use std::sync::Arc;
3+
4+
use crate::config;
5+
use crate::output::OutputFormat;
6+
use crate::server::pr_readiness::{get_pr_readiness_snapshot, PrReadinessSnapshot};
7+
use crate::server::state::AppState;
8+
9+
use super::gh::{fetch_pr_metadata, resolve_pr_number};
10+
11+
struct PrReadinessTarget {
12+
repo: String,
13+
pr_number: u32,
14+
current_head_sha: Option<String>,
15+
}
16+
17+
pub(super) async fn run_pr_readiness_flow(
18+
number: Option<u32>,
19+
repo: Option<&str>,
20+
config: config::Config,
21+
format: OutputFormat,
22+
) -> Result<()> {
23+
let target = resolve_pr_readiness_target(number, repo)?;
24+
let state = Arc::new(AppState::new(config).await?);
25+
let snapshot = get_pr_readiness_snapshot(
26+
&state,
27+
&target.repo,
28+
target.pr_number,
29+
target.current_head_sha.as_deref(),
30+
)
31+
.await;
32+
33+
match format {
34+
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&snapshot)?),
35+
OutputFormat::Markdown | OutputFormat::Patch => {
36+
println!("{}", format_pr_readiness_markdown(&snapshot))
37+
}
38+
}
39+
40+
Ok(())
41+
}
42+
43+
fn resolve_pr_readiness_target(
44+
number: Option<u32>,
45+
repo: Option<&str>,
46+
) -> Result<PrReadinessTarget> {
47+
match (number, repo) {
48+
(Some(pr_number), Some(repo)) => {
49+
let current_head_sha = fetch_pr_metadata(&pr_number.to_string(), Some(repo))
50+
.ok()
51+
.map(|metadata| metadata.head_ref_oid);
52+
Ok(PrReadinessTarget {
53+
repo: repo.to_string(),
54+
pr_number,
55+
current_head_sha,
56+
})
57+
}
58+
_ => {
59+
let pr_number = resolve_pr_number(number, repo)?;
60+
let metadata = fetch_pr_metadata(&pr_number, repo)?;
61+
Ok(PrReadinessTarget {
62+
repo: repo
63+
.map(str::to_string)
64+
.unwrap_or(metadata.base_repository.name_with_owner),
65+
pr_number: metadata.number,
66+
current_head_sha: Some(metadata.head_ref_oid),
67+
})
68+
}
69+
}
70+
}
71+
72+
fn format_pr_readiness_markdown(snapshot: &PrReadinessSnapshot) -> String {
73+
let mut output = String::new();
74+
output.push_str("# PR Readiness\n\n");
75+
output.push_str(&format!(
76+
"- PR: `{}#{}`\n",
77+
snapshot.repo, snapshot.pr_number
78+
));
79+
if let Some(current_head_sha) = snapshot.current_head_sha.as_deref() {
80+
output.push_str(&format!(
81+
"- Current head: `{}`\n",
82+
short_sha(current_head_sha)
83+
));
84+
}
85+
86+
match &snapshot.latest_review {
87+
Some(review) => {
88+
output.push_str(&format!(
89+
"- Latest DiffScope review: `{}` ({:?})\n",
90+
review.id, review.status
91+
));
92+
if let Some(reviewed_head_sha) = review.reviewed_head_sha.as_deref() {
93+
output.push_str(&format!(
94+
"- Reviewed head: `{}`\n",
95+
short_sha(reviewed_head_sha)
96+
));
97+
}
98+
if let Some(summary) = review.summary.as_ref() {
99+
output.push_str(&format!("- Merge readiness: {}\n", summary.merge_readiness));
100+
output.push_str(&format!("- Open blockers: {}\n", summary.open_blockers));
101+
output.push_str(&format!(
102+
"- Lifecycle: {} open · {} resolved · {} dismissed\n",
103+
summary.open_comments, summary.resolved_comments, summary.dismissed_comments
104+
));
105+
output.push_str(&format!("- Verification: {}\n", summary.verification.state));
106+
if !summary.readiness_reasons.is_empty() {
107+
output.push_str("- Readiness reasons:\n");
108+
for reason in &summary.readiness_reasons {
109+
output.push_str(&format!(" - {}\n", reason));
110+
}
111+
}
112+
} else {
113+
output.push_str("- State: readiness summary is not available yet\n");
114+
}
115+
}
116+
None => {
117+
output.push_str("- Latest DiffScope review: none\n");
118+
output.push_str("- State: no stored PR readiness summary found\n");
119+
}
120+
}
121+
122+
output
123+
}
124+
125+
fn short_sha(sha: &str) -> &str {
126+
sha.get(..12).unwrap_or(sha)
127+
}
128+
129+
#[cfg(test)]
130+
mod tests {
131+
use super::*;
132+
use crate::core::comment::{MergeReadiness, ReviewVerificationState};
133+
134+
#[test]
135+
fn markdown_output_includes_summary_fields() {
136+
let mut summary = crate::core::CommentSynthesizer::generate_summary(&[]);
137+
summary.merge_readiness = MergeReadiness::NeedsAttention;
138+
summary.open_blockers = 2;
139+
summary.open_comments = 3;
140+
summary.resolved_comments = 1;
141+
summary.dismissed_comments = 1;
142+
summary.verification.state = ReviewVerificationState::Inconclusive;
143+
summary.readiness_reasons = vec!["new commits landed after this review".to_string()];
144+
let snapshot = PrReadinessSnapshot {
145+
repo: "owner/repo".to_string(),
146+
pr_number: 42,
147+
diff_source: "pr:owner/repo#42".to_string(),
148+
current_head_sha: Some("0123456789abcdef".to_string()),
149+
latest_review: Some(crate::server::pr_readiness::PrReadinessReview {
150+
id: "review-1".to_string(),
151+
status: crate::server::state::ReviewStatus::Complete,
152+
started_at: 10,
153+
completed_at: Some(11),
154+
reviewed_head_sha: Some("fedcba9876543210".to_string()),
155+
summary: Some(summary),
156+
files_reviewed: 2,
157+
comment_count: 4,
158+
error: None,
159+
}),
160+
};
161+
162+
let output = format_pr_readiness_markdown(&snapshot);
163+
assert!(output.contains("# PR Readiness"));
164+
assert!(output.contains("PR: `owner/repo#42`"));
165+
assert!(output.contains("Current head: `0123456789ab`"));
166+
assert!(output.contains("Merge readiness: Needs attention"));
167+
assert!(output.contains("Open blockers: 2"));
168+
assert!(output.contains("new commits landed after this review"));
169+
}
170+
171+
#[test]
172+
fn markdown_output_handles_missing_reviews() {
173+
let snapshot = PrReadinessSnapshot {
174+
repo: "owner/repo".to_string(),
175+
pr_number: 42,
176+
diff_source: "pr:owner/repo#42".to_string(),
177+
current_head_sha: None,
178+
latest_review: None,
179+
};
180+
181+
let output = format_pr_readiness_markdown(&snapshot);
182+
assert!(output.contains("Latest DiffScope review: none"));
183+
assert!(output.contains("no stored PR readiness summary found"));
184+
}
185+
}

src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,13 @@ enum Commands {
189189

190190
#[arg(long)]
191191
summary: bool,
192+
193+
#[arg(
194+
long,
195+
conflicts_with_all = ["post_comments", "summary"],
196+
help = "Show the latest stored DiffScope readiness summary for this PR"
197+
)]
198+
readiness: bool,
192199
},
193200
Compare {
194201
#[arg(long)]
@@ -634,12 +641,14 @@ async fn main() -> Result<()> {
634641
repo,
635642
post_comments,
636643
summary,
644+
readiness,
637645
} => {
638646
commands::pr_command(
639647
number,
640648
repo,
641649
post_comments,
642650
summary,
651+
readiness,
643652
config,
644653
cli.output_format,
645654
)

0 commit comments

Comments
 (0)