|
| 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