Skip to content

Commit 1f95c5c

Browse files
committed
feat(mcp): add reusable readiness and fix-loop prompts
1 parent 42d6ce1 commit 1f95c5c

4 files changed

Lines changed: 337 additions & 5 deletions

File tree

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
120120
75. [x] Add a "trigger re-review" API that reuses existing PR metadata and loop policy.
121121
76. [x] Add APIs for comment resolution and lifecycle updates, not just thumbs.
122122
77. [x] Add an MCP server for DiffScope with review, analytics, and rule-management tools.
123-
78. [ ] Add reusable agent skills/workflows for checking PR readiness and running fix loops.
123+
78. [x] Add reusable agent skills/workflows for checking PR readiness and running fix loops.
124124
79. [ ] Add signed webhook or event-stream integration for downstream automation consumers.
125125
80. [ ] Add rate-limited API auth and audit trails for automation-heavy deployments.
126126

src/server/mcp.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#[path = "mcp/prompts.rs"]
2+
mod prompts;
13
#[path = "mcp/protocol.rs"]
24
mod protocol;
35
#[path = "mcp/stdio.rs"]

src/server/mcp/prompts.rs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
use anyhow::{Context, Result};
2+
use serde::de::DeserializeOwned;
3+
use serde::{Deserialize, Serialize};
4+
use serde_json::Value;
5+
6+
#[derive(Clone, Serialize)]
7+
pub(super) struct PromptArgumentSpec {
8+
pub name: &'static str,
9+
pub description: &'static str,
10+
#[serde(default)]
11+
pub required: bool,
12+
}
13+
14+
#[derive(Clone, Serialize)]
15+
pub(super) struct PromptSpec {
16+
pub name: &'static str,
17+
pub description: &'static str,
18+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
19+
pub arguments: Vec<PromptArgumentSpec>,
20+
}
21+
22+
#[derive(Clone, Serialize)]
23+
pub(super) struct PromptTextContent {
24+
#[serde(rename = "type")]
25+
pub kind: &'static str,
26+
pub text: String,
27+
}
28+
29+
#[derive(Clone, Serialize)]
30+
pub(super) struct PromptMessage {
31+
pub role: &'static str,
32+
pub content: PromptTextContent,
33+
}
34+
35+
#[derive(Clone, Serialize)]
36+
pub(super) struct PromptResult {
37+
pub description: &'static str,
38+
pub messages: Vec<PromptMessage>,
39+
}
40+
41+
#[derive(Deserialize)]
42+
struct CheckPrReadinessArgs {
43+
repo: String,
44+
pr_number: u32,
45+
#[serde(default)]
46+
rerun_if_stale: bool,
47+
}
48+
49+
#[derive(Deserialize)]
50+
struct FixUntilCleanArgs {
51+
repo: String,
52+
pr_number: u32,
53+
#[serde(default = "default_max_iterations")]
54+
max_iterations: usize,
55+
}
56+
57+
fn default_max_iterations() -> usize {
58+
3
59+
}
60+
61+
pub(super) fn prompt_specs() -> Vec<PromptSpec> {
62+
vec![
63+
PromptSpec {
64+
name: "check_pr_readiness",
65+
description: "Guide an agent through DiffScope PR readiness checks, reruns, and blocker triage.",
66+
arguments: vec![
67+
PromptArgumentSpec {
68+
name: "repo",
69+
description: "GitHub repo in owner/repo format.",
70+
required: true,
71+
},
72+
PromptArgumentSpec {
73+
name: "pr_number",
74+
description: "GitHub pull request number.",
75+
required: true,
76+
},
77+
PromptArgumentSpec {
78+
name: "rerun_if_stale",
79+
description: "Whether to rerun the PR review when DiffScope marks the latest review stale.",
80+
required: false,
81+
},
82+
],
83+
},
84+
PromptSpec {
85+
name: "fix_until_clean",
86+
description: "Guide an agent through an iterative DiffScope fix loop until PR blockers are cleared or the iteration budget is exhausted.",
87+
arguments: vec![
88+
PromptArgumentSpec {
89+
name: "repo",
90+
description: "GitHub repo in owner/repo format.",
91+
required: true,
92+
},
93+
PromptArgumentSpec {
94+
name: "pr_number",
95+
description: "GitHub pull request number.",
96+
required: true,
97+
},
98+
PromptArgumentSpec {
99+
name: "max_iterations",
100+
description: "Maximum fix-loop iterations before stopping.",
101+
required: false,
102+
},
103+
],
104+
},
105+
]
106+
}
107+
108+
pub(super) fn render_prompt(name: &str, arguments: Value) -> Result<PromptResult> {
109+
match name {
110+
"check_pr_readiness" => render_check_pr_readiness(arguments),
111+
"fix_until_clean" => render_fix_until_clean(arguments),
112+
_ => anyhow::bail!("Unknown DiffScope prompt: {name}"),
113+
}
114+
}
115+
116+
fn render_check_pr_readiness(arguments: Value) -> Result<PromptResult> {
117+
let args: CheckPrReadinessArgs = parse_arguments(arguments)?;
118+
let rerun_guidance = if args.rerun_if_stale {
119+
"If the latest review is stale (`NeedsReReview`, stale head SHA, or no fresh latest review), call `rerun_pr_review` with the latest review id and then poll `get_review` until it reaches `Complete` or `Failed`."
120+
} else {
121+
"If the latest review is stale, do not rerun automatically; report the stale state and explain why a rerun is recommended."
122+
};
123+
124+
Ok(PromptResult {
125+
description: "Run a reusable DiffScope PR readiness workflow.",
126+
messages: vec![PromptMessage {
127+
role: "user",
128+
content: PromptTextContent {
129+
kind: "text",
130+
text: format!(
131+
"You are checking DiffScope readiness for GitHub PR `{repo}#{pr_number}`.\n\nUse these steps:\n1. Call `get_pr_readiness` with `repo={repo}` and `pr_number={pr_number}`.\n2. If there is no `latest_review`, call `review_pr` with `post_results=false`, then poll `get_review` until the new review reaches `Complete` or `Failed`.\n3. {rerun_guidance}\n4. Once you have a fresh completed review, call `get_pr_findings` with `group_by=severity` and `get_pr_comments` with `status=open` to inspect unresolved blockers and the most urgent findings.\n5. Summarize the final `merge_readiness`, `open_blockers`, `readiness_reasons`, the freshest review id, and the top files or rules that still need attention.\n6. If the latest review fails, stop and report the failure payload instead of guessing.\n\nAlways quote concrete evidence from DiffScope fields such as `rule_id`, `file_path`, `line_number`, `content`, and `suggestion` when describing blockers.",
132+
repo = args.repo,
133+
pr_number = args.pr_number,
134+
rerun_guidance = rerun_guidance,
135+
),
136+
},
137+
}],
138+
})
139+
}
140+
141+
fn render_fix_until_clean(arguments: Value) -> Result<PromptResult> {
142+
let args: FixUntilCleanArgs = parse_arguments(arguments)?;
143+
let max_iterations = args.max_iterations.max(1);
144+
145+
Ok(PromptResult {
146+
description: "Run a reusable DiffScope fix loop workflow.",
147+
messages: vec![PromptMessage {
148+
role: "user",
149+
content: PromptTextContent {
150+
kind: "text",
151+
text: format!(
152+
"You are driving a DiffScope fix loop for GitHub PR `{repo}#{pr_number}` with an iteration budget of {max_iterations}.\n\nLoop instructions:\n1. Start by calling `get_pr_readiness`. If there is no completed review, call `review_pr` with `post_results=false`; otherwise reuse the freshest review id.\n2. If the latest review is stale or incomplete, call `rerun_pr_review` and poll `get_review` until the rerun completes.\n3. Inspect the current findings with `get_pr_findings` (`group_by=file`) and `get_pr_comments` (`status=open`). When a file needs more detail, call `get_review` and extract each finding's `rule_id`, `file_path`, `line_number`, `content`, and `suggestion` as the handoff contract for your code edits.\n4. Use your normal workspace edit/test tools to fix the highest-signal unresolved blockers first. Run the repository validators after each meaningful edit batch.\n5. Call `rerun_pr_review` on the freshest review id, wait for completion with `get_review`, and compare blocker counts and readiness against the previous iteration.\n6. Stop early when `merge_readiness` is `Ready`, `open_blockers` is `0`, there are no unresolved comments, the review fails, or two consecutive iterations show no improvement.\n\nEvery loop summary must include the review id, validator outcome, blocker delta, and the remaining files/rules still preventing merge readiness.",
153+
repo = args.repo,
154+
pr_number = args.pr_number,
155+
max_iterations = max_iterations,
156+
),
157+
},
158+
}],
159+
})
160+
}
161+
162+
fn parse_arguments<T>(arguments: Value) -> Result<T>
163+
where
164+
T: DeserializeOwned,
165+
{
166+
serde_json::from_value(arguments).context("invalid MCP prompt arguments")
167+
}
168+
169+
#[cfg(test)]
170+
mod tests {
171+
use super::*;
172+
use serde_json::json;
173+
174+
#[test]
175+
fn prompt_catalog_lists_readiness_and_fix_loop_workflows() {
176+
let prompts = prompt_specs();
177+
let names: Vec<&str> = prompts.iter().map(|prompt| prompt.name).collect();
178+
assert!(names.contains(&"check_pr_readiness"));
179+
assert!(names.contains(&"fix_until_clean"));
180+
}
181+
182+
#[test]
183+
fn readiness_prompt_mentions_rerun_flow_and_findings() {
184+
let prompt = render_prompt(
185+
"check_pr_readiness",
186+
json!({
187+
"repo": "owner/repo",
188+
"pr_number": 42,
189+
"rerun_if_stale": true,
190+
}),
191+
)
192+
.unwrap();
193+
194+
let text = &prompt.messages[0].content.text;
195+
assert!(text.contains("get_pr_readiness"));
196+
assert!(text.contains("rerun_pr_review"));
197+
assert!(text.contains("get_pr_findings"));
198+
assert!(text.contains("owner/repo#42"));
199+
}
200+
201+
#[test]
202+
fn fix_loop_prompt_uses_iteration_budget_and_stop_conditions() {
203+
let prompt = render_prompt(
204+
"fix_until_clean",
205+
json!({
206+
"repo": "owner/repo",
207+
"pr_number": 7,
208+
"max_iterations": 5,
209+
}),
210+
)
211+
.unwrap();
212+
213+
let text = &prompt.messages[0].content.text;
214+
assert!(text.contains("iteration budget of 5"));
215+
assert!(text.contains("rerun_pr_review"));
216+
assert!(text.contains("merge_readiness"));
217+
assert!(text.contains("no improvement"));
218+
}
219+
}

0 commit comments

Comments
 (0)