1use anyhow::{Context, Result, bail};
9use octocrab::Octocrab;
10use tracing::{debug, instrument};
11
12use crate::ai::types::{PrDetails, PrFile, ReviewEvent};
13
14pub fn parse_pr_reference(
34 reference: &str,
35 repo_context: Option<&str>,
36) -> Result<(String, String, u64)> {
37 let reference = reference.trim();
38
39 if reference.starts_with("https://github.com/") || reference.starts_with("http://github.com/") {
42 let path = reference
43 .trim_start_matches("https://github.com/")
44 .trim_start_matches("http://github.com/");
45
46 let parts: Vec<&str> = path.split('/').collect();
47 if parts.len() >= 4 && parts[2] == "pull" {
48 let owner = parts[0].to_string();
49 let repo = parts[1].to_string();
50 let number: u64 = parts[3]
51 .parse()
52 .with_context(|| format!("Invalid PR number in URL: {}", parts[3]))?;
53 return Ok((owner, repo, number));
54 }
55 bail!("Invalid GitHub PR URL format: {reference}");
56 }
57
58 if let Some((repo_part, num_part)) = reference.split_once('#') {
60 if let Some((owner, repo)) = repo_part.split_once('/') {
61 let number: u64 = num_part
62 .parse()
63 .with_context(|| format!("Invalid PR number: {num_part}"))?;
64 return Ok((owner.to_string(), repo.to_string(), number));
65 }
66 if let Some(ctx) = repo_context
68 && let Some((owner, repo)) = ctx.split_once('/')
69 {
70 let number: u64 = num_part
71 .parse()
72 .with_context(|| format!("Invalid PR number: {num_part}"))?;
73 return Ok((owner.to_string(), repo.to_string(), number));
74 }
75 bail!("Invalid PR reference format: {reference}");
76 }
77
78 if let Ok(number) = reference.parse::<u64>() {
80 if let Some(ctx) = repo_context {
81 if let Some((owner, repo)) = ctx.split_once('/') {
82 return Ok((owner.to_string(), repo.to_string(), number));
83 }
84 bail!("Invalid repo_context format, expected 'owner/repo': {ctx}");
85 }
86 bail!("Bare PR number requires --repo flag or default_repo config: {reference}");
87 }
88
89 bail!(
90 "Invalid PR reference format: {reference}. Expected URL, owner/repo#number, or number with --repo"
91 )
92}
93
94#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
113pub async fn fetch_pr_details(
114 client: &Octocrab,
115 owner: &str,
116 repo: &str,
117 number: u64,
118) -> Result<PrDetails> {
119 debug!("Fetching PR details");
120
121 let pr = client
123 .pulls(owner, repo)
124 .get(number)
125 .await
126 .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"))?;
127
128 let files = client
130 .pulls(owner, repo)
131 .list_files(number)
132 .await
133 .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
134
135 let pr_files: Vec<PrFile> = files
137 .items
138 .into_iter()
139 .map(|f| PrFile {
140 filename: f.filename,
141 status: format!("{:?}", f.status),
142 additions: f.additions,
143 deletions: f.deletions,
144 patch: f.patch,
145 })
146 .collect();
147
148 let details = PrDetails {
149 owner: owner.to_string(),
150 repo: repo.to_string(),
151 number,
152 title: pr.title.unwrap_or_default(),
153 body: pr.body.unwrap_or_default(),
154 base_branch: pr.base.ref_field,
155 head_branch: pr.head.ref_field,
156 files: pr_files,
157 url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
158 };
159
160 debug!(
161 file_count = details.files.len(),
162 "PR details fetched successfully"
163 );
164
165 Ok(details)
166}
167
168#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number, event = %event))]
190pub async fn post_pr_review(
191 client: &Octocrab,
192 owner: &str,
193 repo: &str,
194 number: u64,
195 body: &str,
196 event: ReviewEvent,
197) -> Result<u64> {
198 debug!("Posting PR review");
199
200 let route = format!("repos/{owner}/{repo}/pulls/{number}/reviews");
201
202 let payload = serde_json::json!({
203 "body": body,
204 "event": event.to_string(),
205 });
206
207 #[derive(serde::Deserialize)]
208 struct ReviewResponse {
209 id: u64,
210 }
211
212 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
213 format!(
214 "Failed to post review to PR #{number} in {owner}/{repo}. \
215 Check that you have write access to the repository."
216 )
217 })?;
218
219 debug!(review_id = response.id, "PR review posted successfully");
220
221 Ok(response.id)
222}
223
224#[must_use]
236pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
237 let mut labels = Vec::new();
238
239 let prefix = title
242 .split(':')
243 .next()
244 .unwrap_or("")
245 .split('(')
246 .next()
247 .unwrap_or("")
248 .trim();
249
250 let type_label = match prefix {
252 "feat" | "perf" => Some("enhancement"),
253 "fix" => Some("bug"),
254 "docs" => Some("documentation"),
255 "refactor" => Some("refactor"),
256 _ => None,
257 };
258
259 if let Some(label) = type_label {
260 labels.push(label.to_string());
261 }
262
263 let mut scope_labels = std::collections::HashSet::new();
265
266 for path in file_paths {
267 let scope = if path.starts_with("crates/aptu-cli/") {
268 Some("cli")
269 } else if path.starts_with("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
270 Some("ios")
271 } else if path.starts_with("docs/") {
272 Some("documentation")
273 } else if path.starts_with("snap/") {
274 Some("distribution")
275 } else {
276 None
277 };
278
279 if let Some(label) = scope {
280 scope_labels.insert(label.to_string());
281 }
282 }
283
284 labels.extend(scope_labels);
285 labels
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn test_parse_pr_reference_full_url() {
294 let (owner, repo, number) =
295 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
296 assert_eq!(owner, "block");
297 assert_eq!(repo, "goose");
298 assert_eq!(number, 123);
299 }
300
301 #[test]
302 fn test_parse_pr_reference_short_form() {
303 let (owner, repo, number) = parse_pr_reference("block/goose#456", None).unwrap();
304 assert_eq!(owner, "block");
305 assert_eq!(repo, "goose");
306 assert_eq!(number, 456);
307 }
308
309 #[test]
310 fn test_parse_pr_reference_bare_number_with_context() {
311 let (owner, repo, number) = parse_pr_reference("789", Some("block/goose")).unwrap();
312 assert_eq!(owner, "block");
313 assert_eq!(repo, "goose");
314 assert_eq!(number, 789);
315 }
316
317 #[test]
318 fn test_parse_pr_reference_bare_number_without_context() {
319 let result = parse_pr_reference("123", None);
320 assert!(result.is_err());
321 assert!(
322 result
323 .unwrap_err()
324 .to_string()
325 .contains("requires --repo flag")
326 );
327 }
328
329 #[test]
330 fn test_parse_pr_reference_hash_with_context() {
331 let (owner, repo, number) = parse_pr_reference("#42", Some("owner/repo")).unwrap();
332 assert_eq!(owner, "owner");
333 assert_eq!(repo, "repo");
334 assert_eq!(number, 42);
335 }
336
337 #[test]
338 fn test_parse_pr_reference_invalid_url() {
339 let result = parse_pr_reference("https://github.com/invalid", None);
340 assert!(result.is_err());
341 }
342
343 #[test]
344 fn test_parse_pr_reference_invalid_number() {
345 let result = parse_pr_reference("block/goose#abc", None);
346 assert!(result.is_err());
347 }
348
349 #[test]
350 fn test_title_prefix_to_label_mapping() {
351 let cases = vec![
352 (
353 "feat: add new feature",
354 vec!["enhancement"],
355 "feat should map to enhancement",
356 ),
357 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
358 (
359 "docs: update readme",
360 vec!["documentation"],
361 "docs should map to documentation",
362 ),
363 (
364 "refactor: improve code",
365 vec!["refactor"],
366 "refactor should map to refactor",
367 ),
368 (
369 "perf: optimize",
370 vec!["enhancement"],
371 "perf should map to enhancement",
372 ),
373 (
374 "chore: update deps",
375 vec![],
376 "chore should produce no labels",
377 ),
378 ];
379
380 for (title, expected_labels, msg) in cases {
381 let labels = labels_from_pr_metadata(title, &[]);
382 for expected in &expected_labels {
383 assert!(
384 labels.contains(&expected.to_string()),
385 "{}: expected '{}' in {:?}",
386 msg,
387 expected,
388 labels
389 );
390 }
391 if expected_labels.is_empty() {
392 assert!(
393 labels.is_empty(),
394 "{}: expected empty, got {:?}",
395 msg,
396 labels
397 );
398 }
399 }
400 }
401
402 #[test]
403 fn test_file_path_to_scope_mapping() {
404 let cases = vec![
405 (
406 "feat: cli",
407 vec!["crates/aptu-cli/src/main.rs"],
408 vec!["enhancement", "cli"],
409 "cli path should map to cli scope",
410 ),
411 (
412 "feat: ios",
413 vec!["crates/aptu-ffi/src/lib.rs"],
414 vec!["enhancement", "ios"],
415 "ffi path should map to ios scope",
416 ),
417 (
418 "feat: ios",
419 vec!["AptuApp/ContentView.swift"],
420 vec!["enhancement", "ios"],
421 "app path should map to ios scope",
422 ),
423 (
424 "feat: docs",
425 vec!["docs/GITHUB_ACTION.md"],
426 vec!["enhancement", "documentation"],
427 "docs path should map to documentation scope",
428 ),
429 (
430 "feat: snap",
431 vec!["snap/snapcraft.yaml"],
432 vec!["enhancement", "distribution"],
433 "snap path should map to distribution scope",
434 ),
435 (
436 "feat: workflow",
437 vec![".github/workflows/test.yml"],
438 vec!["enhancement"],
439 "workflow path should be ignored",
440 ),
441 ];
442
443 for (title, paths, expected_labels, msg) in cases {
444 let labels = labels_from_pr_metadata(
445 title,
446 &paths.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
447 );
448 for expected in expected_labels {
449 assert!(
450 labels.contains(&expected.to_string()),
451 "{}: expected '{}' in {:?}",
452 msg,
453 expected,
454 labels
455 );
456 }
457 }
458 }
459
460 #[test]
461 fn test_combined_title_and_paths() {
462 let labels = labels_from_pr_metadata(
463 "feat: multi",
464 &[
465 "crates/aptu-cli/src/main.rs".to_string(),
466 "docs/README.md".to_string(),
467 ],
468 );
469 assert!(
470 labels.contains(&"enhancement".to_string()),
471 "should include enhancement from feat prefix"
472 );
473 assert!(
474 labels.contains(&"cli".to_string()),
475 "should include cli from path"
476 );
477 assert!(
478 labels.contains(&"documentation".to_string()),
479 "should include documentation from path"
480 );
481 }
482
483 #[test]
484 fn test_no_match_returns_empty() {
485 let cases = vec![
486 (
487 "Random title",
488 vec![],
489 "unrecognized prefix should return empty",
490 ),
491 (
492 "chore: update",
493 vec![],
494 "ignored prefix should return empty",
495 ),
496 ];
497
498 for (title, paths, msg) in cases {
499 let labels = labels_from_pr_metadata(title, &paths);
500 assert!(labels.is_empty(), "{}: got {:?}", msg, labels);
501 }
502 }
503
504 #[test]
505 fn test_scoped_prefix_extracts_type() {
506 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
507 assert!(
508 labels.contains(&"enhancement".to_string()),
509 "scoped prefix should extract type from feat(cli)"
510 );
511 }
512}