1use serde::Deserialize;
7
8use crate::error::{Error, Result};
9use crate::model::{Task, TaskState};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct PrRef {
17 pub url: String,
19 pub host: String,
20 pub owner: String,
21 pub repo: String,
22 pub number: u64,
23}
24
25impl PrRef {
26 pub fn parse(input: &str) -> Result<PrRef> {
32 let s = input.trim();
33 if s.is_empty() {
34 return Err(Error::Invalid("a PR reference is required".into()));
35 }
36
37 if let Some((repo_part, num_part)) = s.split_once('#')
39 && !repo_part.contains("://")
40 && repo_part.matches('/').count() == 1
41 {
42 let (owner, repo) = repo_part.split_once('/').unwrap();
43 let number = parse_number(num_part)?;
44 return PrRef::build("github.com", owner, repo, number);
45 }
46
47 let no_scheme = s
50 .split_once("://")
51 .map(|(_, rest)| rest)
52 .unwrap_or(s)
53 .trim_start_matches('/');
54 let path = no_scheme
55 .split(['?', '#'])
56 .next()
57 .unwrap_or(no_scheme)
58 .trim_end_matches('/');
59 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
60
61 if let Some(pos) = segments.iter().position(|s| *s == "pull" || *s == "pulls")
63 && pos >= 3
64 && let Some(num) = segments.get(pos + 1)
65 {
66 let host = segments[pos - 3];
67 let owner = segments[pos - 2];
68 let repo = segments[pos - 1];
69 let number = parse_number(num)?;
70 return PrRef::build(host, owner, repo, number);
71 }
72
73 Err(Error::Invalid(format!(
74 "'{input}' is not a GitHub PR reference — expected a URL like \
75 https://github.com/owner/repo/pull/12 or the shorthand owner/repo#12"
76 )))
77 }
78
79 fn build(host: &str, owner: &str, repo: &str, number: u64) -> Result<PrRef> {
80 if host.is_empty() || owner.is_empty() || repo.is_empty() {
81 return Err(Error::Invalid(
82 "a PR reference needs a host, owner, and repo".into(),
83 ));
84 }
85 Ok(PrRef {
86 url: format!("https://{host}/{owner}/{repo}/pull/{number}"),
87 host: host.to_string(),
88 owner: owner.to_string(),
89 repo: repo.to_string(),
90 number,
91 })
92 }
93
94 pub fn nwo(&self) -> String {
96 format!("{}/{}", self.owner, self.repo)
97 }
98
99 pub fn api_path(&self, resource: &str) -> String {
102 format!("repos/{}/pulls/{}/{resource}", self.nwo(), self.number)
103 }
104}
105
106fn parse_number(raw: &str) -> Result<u64> {
107 raw.trim()
108 .parse()
109 .map_err(|_| Error::Invalid(format!("'{raw}' is not a PR number")))
110}
111
112#[derive(Debug, Clone, Deserialize)]
113struct GhUser {
114 #[serde(default)]
115 login: String,
116}
117
118#[derive(Debug, Clone, Deserialize)]
122struct PrReview {
123 #[serde(default)]
124 body: String,
125 #[serde(default)]
126 state: String,
127 #[serde(default)]
128 user: Option<GhUser>,
129}
130
131#[derive(Debug, Clone, Deserialize)]
133struct PrReviewComment {
134 #[serde(default)]
135 body: String,
136 #[serde(default)]
137 path: Option<String>,
138 #[serde(default)]
139 line: Option<i64>,
140 #[serde(default)]
141 user: Option<GhUser>,
142}
143
144fn login(user: &Option<GhUser>) -> &str {
145 user.as_ref()
146 .map(|u| u.login.as_str())
147 .filter(|l| !l.is_empty())
148 .unwrap_or("unknown")
149}
150
151pub fn format_review_feedback(
156 pr: &PrRef,
157 reviews_json: &str,
158 comments_json: &str,
159) -> Result<String> {
160 let reviews: Vec<PrReview> = serde_json::from_str(reviews_json)
161 .map_err(|e| Error::Invalid(format!("invalid PR reviews JSON: {e}")))?;
162 let comments: Vec<PrReviewComment> = serde_json::from_str(comments_json)
163 .map_err(|e| Error::Invalid(format!("invalid PR comments JSON: {e}")))?;
164
165 let mut sections: Vec<String> = Vec::new();
166 for r in &reviews {
167 if r.body.trim().is_empty() {
168 continue;
169 }
170 let state = if r.state.is_empty() {
171 String::new()
172 } else {
173 format!(" ({})", r.state.to_lowercase())
174 };
175 sections.push(format!("@{}{state}: {}", login(&r.user), r.body.trim()));
176 }
177 for c in &comments {
178 if c.body.trim().is_empty() {
179 continue;
180 }
181 let loc = match (&c.path, c.line) {
182 (Some(path), Some(line)) => format!("`{path}:{line}` — "),
183 (Some(path), None) => format!("`{path}` — "),
184 _ => String::new(),
185 };
186 sections.push(format!("{loc}@{}: {}", login(&c.user), c.body.trim()));
187 }
188
189 if sections.is_empty() {
190 return Ok(String::new());
191 }
192 let mut body = format!("Review feedback from {}\n", pr.url);
193 for section in sections {
194 body.push('\n');
195 body.push_str(§ion);
196 body.push('\n');
197 }
198 Ok(body)
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct PrPlan {
206 pub branch: String,
207 pub title: String,
208 pub body: String,
209}
210
211pub fn plan_pr(task: &Task, latest_summary: Option<&str>) -> Result<PrPlan> {
217 if task.state != TaskState::Review {
218 return Err(Error::Invalid(format!(
219 "only a review task can have a PR opened from its summary; task {} is {}",
220 task.id, task.state
221 )));
222 }
223 let branch = task
224 .branch
225 .as_deref()
226 .map(str::trim)
227 .filter(|b| !b.is_empty())
228 .ok_or_else(|| {
229 Error::Invalid(format!(
230 "task {} has no branch to push — record one with `voro done --branch` or \
231 `voro set --branch`",
232 task.id
233 ))
234 })?;
235 let body = latest_summary
236 .map(str::trim)
237 .filter(|s| !s.is_empty())
238 .ok_or_else(|| {
239 Error::Invalid(format!(
240 "task {} has no completion summary for the PR body — record one with \
241 `voro set --summary`",
242 task.id
243 ))
244 })?;
245 Ok(PrPlan {
246 branch: branch.to_string(),
247 title: task.title.clone(),
248 body: body.to_string(),
249 })
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn parses_a_full_url() {
258 let pr = PrRef::parse("https://github.com/acme/widget/pull/42").unwrap();
259 assert_eq!(pr.host, "github.com");
260 assert_eq!(pr.owner, "acme");
261 assert_eq!(pr.repo, "widget");
262 assert_eq!(pr.number, 42);
263 assert_eq!(pr.nwo(), "acme/widget");
264 assert_eq!(pr.url, "https://github.com/acme/widget/pull/42");
265 assert_eq!(
266 pr.api_path("comments"),
267 "repos/acme/widget/pulls/42/comments"
268 );
269 }
270
271 #[test]
272 fn parses_url_without_scheme_and_with_extra_segments() {
273 let pr = PrRef::parse("github.com/acme/widget/pull/42/files?w=1#discussion").unwrap();
274 assert_eq!(pr.number, 42);
275 assert_eq!(pr.url, "https://github.com/acme/widget/pull/42");
276 }
277
278 #[test]
279 fn parses_the_owner_repo_shorthand() {
280 let pr = PrRef::parse("acme/widget#7").unwrap();
281 assert_eq!(pr.owner, "acme");
282 assert_eq!(pr.repo, "widget");
283 assert_eq!(pr.number, 7);
284 assert_eq!(pr.url, "https://github.com/acme/widget/pull/7");
285 }
286
287 #[test]
288 fn preserves_an_enterprise_host() {
289 let pr = PrRef::parse("https://git.example.com/acme/widget/pull/3").unwrap();
290 assert_eq!(pr.host, "git.example.com");
291 assert_eq!(pr.url, "https://git.example.com/acme/widget/pull/3");
292 }
293
294 #[test]
295 fn rejects_non_pr_references() {
296 assert!(PrRef::parse("https://github.com/acme/widget/issues/42").is_err());
297 assert!(PrRef::parse("https://github.com/acme/widget").is_err());
298 assert!(PrRef::parse("not a url").is_err());
299 assert!(PrRef::parse("acme/widget/extra#1").is_err());
300 assert!(PrRef::parse("").is_err());
301 assert!(PrRef::parse("https://github.com/acme/widget/pull/notanumber").is_err());
302 }
303
304 fn pr() -> PrRef {
305 PrRef::parse("https://github.com/acme/widget/pull/42").unwrap()
306 }
307
308 #[test]
309 fn formats_reviews_and_inline_comments() {
310 let reviews = r#"[
311 {"user": {"login": "alice"}, "state": "CHANGES_REQUESTED", "body": "Please fix the parser"},
312 {"user": {"login": "bob"}, "state": "APPROVED", "body": ""}
313 ]"#;
314 let comments = r#"[
315 {"user": {"login": "alice"}, "path": "src/lib.rs", "line": 12, "body": "off-by-one here"}
316 ]"#;
317 let body = format_review_feedback(&pr(), reviews, comments).unwrap();
318 assert!(body.contains("Review feedback from https://github.com/acme/widget/pull/42"));
319 assert!(body.contains("@alice (changes_requested): Please fix the parser"));
320 assert!(!body.contains("@bob"));
322 assert!(body.contains("`src/lib.rs:12` — @alice: off-by-one here"));
323 }
324
325 #[test]
326 fn empty_when_there_is_nothing_to_relay() {
327 let body = format_review_feedback(&pr(), "[]", "[]").unwrap();
328 assert!(body.is_empty());
329 let reviews = r#"[{"user": {"login": "bob"}, "state": "APPROVED", "body": ""}]"#;
331 assert!(
332 format_review_feedback(&pr(), reviews, "[]")
333 .unwrap()
334 .is_empty()
335 );
336 }
337
338 #[test]
339 fn tolerates_missing_optional_fields() {
340 let comments = r#"[{"body": "a general note"}]"#;
342 let body = format_review_feedback(&pr(), "[]", comments).unwrap();
343 assert!(body.contains("@unknown: a general note"));
344 }
345
346 #[test]
347 fn rejects_malformed_json() {
348 assert!(format_review_feedback(&pr(), "not json", "[]").is_err());
349 assert!(format_review_feedback(&pr(), "[]", "not json").is_err());
350 }
351
352 use crate::model::Priority;
355
356 fn task(state: TaskState, branch: Option<&str>) -> Task {
357 Task {
358 id: 82,
359 project_id: 1,
360 title: "Extend pr to create the PR".into(),
361 body: String::new(),
362 priority: Priority::P1,
363 state,
364 agent: None,
365 human: false,
366 question: None,
367 pr_url: None,
368 branch: branch.map(str::to_string),
369 state_since: "2026-07-10 00:00:00".into(),
370 created_at: "2026-07-10 00:00:00".into(),
371 closed_at: None,
372 }
373 }
374
375 #[test]
376 fn plans_a_pr_from_a_review_task_with_branch_and_summary() {
377 let plan = plan_pr(
378 &task(TaskState::Review, Some("feat/pr")),
379 Some("Did the thing"),
380 )
381 .unwrap();
382 assert_eq!(plan.branch, "feat/pr");
383 assert_eq!(plan.title, "Extend pr to create the PR");
384 assert_eq!(plan.body, "Did the thing");
385 }
386
387 #[test]
388 fn plan_requires_the_review_state() {
389 for state in [
390 TaskState::Ready,
391 TaskState::Running,
392 TaskState::NeedsInput,
393 TaskState::Done,
394 ] {
395 let err = plan_pr(&task(state, Some("feat/pr")), Some("summary"))
396 .unwrap_err()
397 .to_string();
398 assert!(err.contains("review"), "{state}: {err}");
399 }
400 }
401
402 #[test]
403 fn plan_names_a_missing_branch() {
404 let err = plan_pr(&task(TaskState::Review, None), Some("summary"))
405 .unwrap_err()
406 .to_string();
407 assert!(err.contains("branch"), "{err}");
408 let err = plan_pr(&task(TaskState::Review, Some(" ")), Some("summary"))
410 .unwrap_err()
411 .to_string();
412 assert!(err.contains("branch"), "{err}");
413 }
414
415 #[test]
416 fn plan_names_a_missing_summary() {
417 let err = plan_pr(&task(TaskState::Review, Some("feat/pr")), None)
418 .unwrap_err()
419 .to_string();
420 assert!(err.contains("summary"), "{err}");
421 let err = plan_pr(&task(TaskState::Review, Some("feat/pr")), Some(" "))
422 .unwrap_err()
423 .to_string();
424 assert!(err.contains("summary"), "{err}");
425 }
426}