1use anyhow::{Context, Result};
9use octocrab::Octocrab;
10use tracing::{debug, instrument};
11
12use super::{ReferenceKind, parse_github_reference};
13use crate::ai::types::{PrDetails, PrFile, PrReviewComment, ReviewEvent};
14use crate::error::{AptuError, ResourceType};
15use crate::triage::render_pr_review_comment_body;
16
17#[derive(Debug, serde::Serialize)]
19pub struct PrCreateResult {
20 pub pr_number: u64,
22 pub url: String,
24 pub branch: String,
26 pub base: String,
28 pub title: String,
30 pub draft: bool,
32 pub files_changed: u32,
34 pub additions: u64,
36 pub deletions: u64,
38}
39
40pub fn parse_pr_reference(
60 reference: &str,
61 repo_context: Option<&str>,
62) -> Result<(String, String, u64)> {
63 parse_github_reference(ReferenceKind::Pull, reference, repo_context)
64}
65
66#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
85pub async fn fetch_pr_details(
86 client: &Octocrab,
87 owner: &str,
88 repo: &str,
89 number: u64,
90) -> Result<PrDetails> {
91 debug!("Fetching PR details");
92
93 let pr = match client.pulls(owner, repo).get(number).await {
95 Ok(pr) => pr,
96 Err(e) => {
97 if let octocrab::Error::GitHub { source, .. } = &e
99 && source.status_code == 404
100 {
101 if (client.issues(owner, repo).get(number).await).is_ok() {
103 return Err(AptuError::TypeMismatch {
104 number,
105 expected: ResourceType::PullRequest,
106 actual: ResourceType::Issue,
107 }
108 .into());
109 }
110 }
112 return Err(e)
113 .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
114 }
115 };
116
117 let files = client
119 .pulls(owner, repo)
120 .list_files(number)
121 .await
122 .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
123
124 let pr_files: Vec<PrFile> = files
126 .items
127 .into_iter()
128 .map(|f| PrFile {
129 filename: f.filename,
130 status: format!("{:?}", f.status),
131 additions: f.additions,
132 deletions: f.deletions,
133 patch: f.patch,
134 })
135 .collect();
136
137 let labels: Vec<String> = pr
138 .labels
139 .iter()
140 .flat_map(|labels_vec| labels_vec.iter().map(|l| l.name.clone()))
141 .collect();
142
143 let details = PrDetails {
144 owner: owner.to_string(),
145 repo: repo.to_string(),
146 number,
147 title: pr.title.unwrap_or_default(),
148 body: pr.body.unwrap_or_default(),
149 base_branch: pr.base.ref_field,
150 head_branch: pr.head.ref_field,
151 head_sha: pr.head.sha,
152 files: pr_files,
153 url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
154 labels,
155 };
156
157 debug!(
158 file_count = details.files.len(),
159 "PR details fetched successfully"
160 );
161
162 Ok(details)
163}
164
165#[allow(clippy::too_many_arguments)]
189#[instrument(skip(client, comments), 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 comments: &[PrReviewComment],
198 commit_id: &str,
199) -> Result<u64> {
200 debug!("Posting PR review");
201
202 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
203
204 let inline_comments: Vec<serde_json::Value> = comments
206 .iter()
207 .filter_map(|c| {
209 c.line.map(|line| {
210 serde_json::json!({
211 "path": c.file,
212 "line": line,
213 "side": "RIGHT",
217 "body": render_pr_review_comment_body(c),
218 })
219 })
220 })
221 .collect();
222
223 let mut payload = serde_json::json!({
224 "body": body,
225 "event": event.to_string(),
226 "comments": inline_comments,
227 });
228
229 if !commit_id.is_empty() {
231 payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
232 }
233
234 #[derive(serde::Deserialize)]
235 struct ReviewResponse {
236 id: u64,
237 }
238
239 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
240 format!(
241 "Failed to post review to PR #{number} in {owner}/{repo}. \
242 Check that you have write access to the repository."
243 )
244 })?;
245
246 debug!(review_id = response.id, "PR review posted successfully");
247
248 Ok(response.id)
249}
250
251#[must_use]
263pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
264 let mut labels = std::collections::HashSet::new();
265
266 let prefix = title
269 .split(':')
270 .next()
271 .unwrap_or("")
272 .split('(')
273 .next()
274 .unwrap_or("")
275 .trim();
276
277 let type_label = match prefix {
279 "feat" | "perf" => Some("enhancement"),
280 "fix" => Some("bug"),
281 "docs" => Some("documentation"),
282 "refactor" => Some("refactor"),
283 _ => None,
284 };
285
286 if let Some(label) = type_label {
287 labels.insert(label.to_string());
288 }
289
290 for path in file_paths {
292 let scope = if path.starts_with("crates/aptu-cli/") {
293 Some("cli")
294 } else if path.starts_with("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
295 Some("ios")
296 } else if path.starts_with("docs/") {
297 Some("documentation")
298 } else if path.starts_with("snap/") {
299 Some("distribution")
300 } else {
301 None
302 };
303
304 if let Some(label) = scope {
305 labels.insert(label.to_string());
306 }
307 }
308
309 labels.into_iter().collect()
310}
311
312#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
332pub async fn create_pull_request(
333 client: &Octocrab,
334 owner: &str,
335 repo: &str,
336 title: &str,
337 head_branch: &str,
338 base_branch: &str,
339 body: Option<&str>,
340) -> anyhow::Result<PrCreateResult> {
341 debug!("Creating pull request");
342
343 let pr = client
344 .pulls(owner, repo)
345 .create(title, head_branch, base_branch)
346 .body(body.unwrap_or_default())
347 .draft(false)
348 .send()
349 .await
350 .with_context(|| {
351 format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
352 })?;
353
354 let result = PrCreateResult {
355 pr_number: pr.number,
356 url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
357 branch: pr.head.ref_field,
358 base: pr.base.ref_field,
359 title: pr.title.unwrap_or_default(),
360 draft: pr.draft.unwrap_or(false),
361 files_changed: u32::try_from(pr.changed_files.unwrap_or_default()).unwrap_or(u32::MAX),
362 additions: pr.additions.unwrap_or_default(),
363 deletions: pr.deletions.unwrap_or_default(),
364 };
365
366 debug!(
367 pr_number = result.pr_number,
368 "Pull request created successfully"
369 );
370
371 Ok(result)
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use crate::ai::types::CommentSeverity;
378
379 #[test]
380 fn test_pr_create_result_fields() {
381 let result = PrCreateResult {
383 pr_number: 42,
384 url: "https://github.com/owner/repo/pull/42".to_string(),
385 branch: "feat/my-feature".to_string(),
386 base: "main".to_string(),
387 title: "feat: add feature".to_string(),
388 draft: false,
389 files_changed: 3,
390 additions: 100,
391 deletions: 10,
392 };
393
394 assert_eq!(result.pr_number, 42);
396 assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
397 assert_eq!(result.branch, "feat/my-feature");
398 assert_eq!(result.base, "main");
399 assert_eq!(result.title, "feat: add feature");
400 assert!(!result.draft);
401 assert_eq!(result.files_changed, 3);
402 assert_eq!(result.additions, 100);
403 assert_eq!(result.deletions, 10);
404 }
405
406 fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
413 comments
414 .iter()
415 .filter_map(|c| {
416 c.line.map(|line| {
417 serde_json::json!({
418 "path": c.file,
419 "line": line,
420 "side": "RIGHT",
421 "body": render_pr_review_comment_body(c),
422 })
423 })
424 })
425 .collect()
426 }
427
428 #[test]
429 fn test_post_pr_review_payload_with_comments() {
430 let comments = vec![PrReviewComment {
432 file: "src/main.rs".to_string(),
433 line: Some(42),
434 comment: "Consider using a match here.".to_string(),
435 severity: CommentSeverity::Suggestion,
436 suggested_code: None,
437 }];
438
439 let inline = build_inline_comments(&comments);
441
442 assert_eq!(inline.len(), 1);
444 assert_eq!(inline[0]["path"], "src/main.rs");
445 assert_eq!(inline[0]["line"], 42);
446 assert_eq!(inline[0]["side"], "RIGHT");
447 assert_eq!(inline[0]["body"], "Consider using a match here.");
448 }
449
450 #[test]
451 fn test_post_pr_review_skips_none_line_comments() {
452 let comments = vec![
454 PrReviewComment {
455 file: "src/lib.rs".to_string(),
456 line: None,
457 comment: "General file comment.".to_string(),
458 severity: CommentSeverity::Info,
459 suggested_code: None,
460 },
461 PrReviewComment {
462 file: "src/lib.rs".to_string(),
463 line: Some(10),
464 comment: "Inline comment.".to_string(),
465 severity: CommentSeverity::Warning,
466 suggested_code: None,
467 },
468 ];
469
470 let inline = build_inline_comments(&comments);
472
473 assert_eq!(inline.len(), 1);
475 assert_eq!(inline[0]["line"], 10);
476 }
477
478 #[test]
479 fn test_post_pr_review_empty_comments() {
480 let comments: Vec<PrReviewComment> = vec![];
482
483 let inline = build_inline_comments(&comments);
485
486 assert!(inline.is_empty());
488 let serialized = serde_json::to_string(&inline).unwrap();
489 assert_eq!(serialized, "[]");
490 }
491
492 #[test]
499 fn test_parse_pr_reference_delegates_to_shared() {
500 let (owner, repo, number) =
501 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
502 assert_eq!(owner, "block");
503 assert_eq!(repo, "goose");
504 assert_eq!(number, 123);
505 }
506
507 #[test]
508 fn test_title_prefix_to_label_mapping() {
509 let cases = vec![
510 (
511 "feat: add new feature",
512 vec!["enhancement"],
513 "feat should map to enhancement",
514 ),
515 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
516 (
517 "docs: update readme",
518 vec!["documentation"],
519 "docs should map to documentation",
520 ),
521 (
522 "refactor: improve code",
523 vec!["refactor"],
524 "refactor should map to refactor",
525 ),
526 (
527 "perf: optimize",
528 vec!["enhancement"],
529 "perf should map to enhancement",
530 ),
531 (
532 "chore: update deps",
533 vec![],
534 "chore should produce no labels",
535 ),
536 ];
537
538 for (title, expected_labels, msg) in cases {
539 let labels = labels_from_pr_metadata(title, &[]);
540 for expected in &expected_labels {
541 assert!(
542 labels.contains(&expected.to_string()),
543 "{msg}: expected '{expected}' in {labels:?}",
544 );
545 }
546 if expected_labels.is_empty() {
547 assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}",);
548 }
549 }
550 }
551
552 #[test]
553 fn test_file_path_to_scope_mapping() {
554 let cases = vec![
555 (
556 "feat: cli",
557 vec!["crates/aptu-cli/src/main.rs"],
558 vec!["enhancement", "cli"],
559 "cli path should map to cli scope",
560 ),
561 (
562 "feat: ios",
563 vec!["crates/aptu-ffi/src/lib.rs"],
564 vec!["enhancement", "ios"],
565 "ffi path should map to ios scope",
566 ),
567 (
568 "feat: ios",
569 vec!["AptuApp/ContentView.swift"],
570 vec!["enhancement", "ios"],
571 "app path should map to ios scope",
572 ),
573 (
574 "feat: docs",
575 vec!["docs/GITHUB_ACTION.md"],
576 vec!["enhancement", "documentation"],
577 "docs path should map to documentation scope",
578 ),
579 (
580 "feat: snap",
581 vec!["snap/snapcraft.yaml"],
582 vec!["enhancement", "distribution"],
583 "snap path should map to distribution scope",
584 ),
585 (
586 "feat: workflow",
587 vec![".github/workflows/test.yml"],
588 vec!["enhancement"],
589 "workflow path should be ignored",
590 ),
591 ];
592
593 for (title, paths, expected_labels, msg) in cases {
594 let labels = labels_from_pr_metadata(
595 title,
596 &paths
597 .iter()
598 .map(std::string::ToString::to_string)
599 .collect::<Vec<_>>(),
600 );
601 for expected in expected_labels {
602 assert!(
603 labels.contains(&expected.to_string()),
604 "{msg}: expected '{expected}' in {labels:?}",
605 );
606 }
607 }
608 }
609
610 #[test]
611 fn test_combined_title_and_paths() {
612 let labels = labels_from_pr_metadata(
613 "feat: multi",
614 &[
615 "crates/aptu-cli/src/main.rs".to_string(),
616 "docs/README.md".to_string(),
617 ],
618 );
619 assert!(
620 labels.contains(&"enhancement".to_string()),
621 "should include enhancement from feat prefix"
622 );
623 assert!(
624 labels.contains(&"cli".to_string()),
625 "should include cli from path"
626 );
627 assert!(
628 labels.contains(&"documentation".to_string()),
629 "should include documentation from path"
630 );
631 }
632
633 #[test]
634 fn test_no_match_returns_empty() {
635 let cases = vec![
636 (
637 "Random title",
638 vec![],
639 "unrecognized prefix should return empty",
640 ),
641 (
642 "chore: update",
643 vec![],
644 "ignored prefix should return empty",
645 ),
646 ];
647
648 for (title, paths, msg) in cases {
649 let labels = labels_from_pr_metadata(title, &paths);
650 assert!(labels.is_empty(), "{msg}: got {labels:?}");
651 }
652 }
653
654 #[test]
655 fn test_scoped_prefix_extracts_type() {
656 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
657 assert!(
658 labels.contains(&"enhancement".to_string()),
659 "scoped prefix should extract type from feat(cli)"
660 );
661 }
662
663 #[test]
664 fn test_duplicate_labels_deduplicated() {
665 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
666 assert_eq!(
667 labels.len(),
668 1,
669 "should have exactly one label when title and path both map to documentation"
670 );
671 assert!(
672 labels.contains(&"documentation".to_string()),
673 "should contain documentation label"
674 );
675 }
676}