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};
15
16pub fn parse_pr_reference(
36 reference: &str,
37 repo_context: Option<&str>,
38) -> Result<(String, String, u64)> {
39 parse_github_reference(ReferenceKind::Pull, reference, repo_context)
40}
41
42#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
61pub async fn fetch_pr_details(
62 client: &Octocrab,
63 owner: &str,
64 repo: &str,
65 number: u64,
66) -> Result<PrDetails> {
67 debug!("Fetching PR details");
68
69 let pr = match client.pulls(owner, repo).get(number).await {
71 Ok(pr) => pr,
72 Err(e) => {
73 if let octocrab::Error::GitHub { source, .. } = &e
75 && source.status_code == 404
76 {
77 if (client.issues(owner, repo).get(number).await).is_ok() {
79 return Err(AptuError::TypeMismatch {
80 number,
81 expected: ResourceType::PullRequest,
82 actual: ResourceType::Issue,
83 }
84 .into());
85 }
86 }
88 return Err(e)
89 .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
90 }
91 };
92
93 let files = client
95 .pulls(owner, repo)
96 .list_files(number)
97 .await
98 .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
99
100 let pr_files: Vec<PrFile> = files
102 .items
103 .into_iter()
104 .map(|f| PrFile {
105 filename: f.filename,
106 status: format!("{:?}", f.status),
107 additions: f.additions,
108 deletions: f.deletions,
109 patch: f.patch,
110 })
111 .collect();
112
113 let labels: Vec<String> = pr
114 .labels
115 .iter()
116 .flat_map(|labels_vec| labels_vec.iter().map(|l| l.name.clone()))
117 .collect();
118
119 let details = PrDetails {
120 owner: owner.to_string(),
121 repo: repo.to_string(),
122 number,
123 title: pr.title.unwrap_or_default(),
124 body: pr.body.unwrap_or_default(),
125 base_branch: pr.base.ref_field,
126 head_branch: pr.head.ref_field,
127 head_sha: pr.head.sha,
128 files: pr_files,
129 url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
130 labels,
131 };
132
133 debug!(
134 file_count = details.files.len(),
135 "PR details fetched successfully"
136 );
137
138 Ok(details)
139}
140
141#[allow(clippy::too_many_arguments)]
165#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
166pub async fn post_pr_review(
167 client: &Octocrab,
168 owner: &str,
169 repo: &str,
170 number: u64,
171 body: &str,
172 event: ReviewEvent,
173 comments: &[PrReviewComment],
174 commit_id: &str,
175) -> Result<u64> {
176 debug!("Posting PR review");
177
178 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
179
180 let inline_comments: Vec<serde_json::Value> = comments
182 .iter()
183 .filter_map(|c| {
185 c.line.map(|line| {
186 serde_json::json!({
187 "path": c.file,
188 "line": line,
189 "side": "RIGHT",
193 "body": c.comment,
194 })
195 })
196 })
197 .collect();
198
199 let mut payload = serde_json::json!({
200 "body": body,
201 "event": event.to_string(),
202 "comments": inline_comments,
203 });
204
205 if !commit_id.is_empty() {
207 payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
208 }
209
210 #[derive(serde::Deserialize)]
211 struct ReviewResponse {
212 id: u64,
213 }
214
215 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
216 format!(
217 "Failed to post review to PR #{number} in {owner}/{repo}. \
218 Check that you have write access to the repository."
219 )
220 })?;
221
222 debug!(review_id = response.id, "PR review posted successfully");
223
224 Ok(response.id)
225}
226
227#[must_use]
239pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
240 let mut labels = std::collections::HashSet::new();
241
242 let prefix = title
245 .split(':')
246 .next()
247 .unwrap_or("")
248 .split('(')
249 .next()
250 .unwrap_or("")
251 .trim();
252
253 let type_label = match prefix {
255 "feat" | "perf" => Some("enhancement"),
256 "fix" => Some("bug"),
257 "docs" => Some("documentation"),
258 "refactor" => Some("refactor"),
259 _ => None,
260 };
261
262 if let Some(label) = type_label {
263 labels.insert(label.to_string());
264 }
265
266 for path in file_paths {
268 let scope = if path.starts_with("crates/aptu-cli/") {
269 Some("cli")
270 } else if path.starts_with("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
271 Some("ios")
272 } else if path.starts_with("docs/") {
273 Some("documentation")
274 } else if path.starts_with("snap/") {
275 Some("distribution")
276 } else {
277 None
278 };
279
280 if let Some(label) = scope {
281 labels.insert(label.to_string());
282 }
283 }
284
285 labels.into_iter().collect()
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::ai::types::CommentSeverity;
292
293 fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
300 comments
301 .iter()
302 .filter_map(|c| {
303 c.line.map(|line| {
304 serde_json::json!({
305 "path": c.file,
306 "line": line,
307 "side": "RIGHT",
308 "body": c.comment,
309 })
310 })
311 })
312 .collect()
313 }
314
315 #[test]
316 fn test_post_pr_review_payload_with_comments() {
317 let comments = vec![PrReviewComment {
319 file: "src/main.rs".to_string(),
320 line: Some(42),
321 comment: "Consider using a match here.".to_string(),
322 severity: CommentSeverity::Suggestion,
323 }];
324
325 let inline = build_inline_comments(&comments);
327
328 assert_eq!(inline.len(), 1);
330 assert_eq!(inline[0]["path"], "src/main.rs");
331 assert_eq!(inline[0]["line"], 42);
332 assert_eq!(inline[0]["side"], "RIGHT");
333 assert_eq!(inline[0]["body"], "Consider using a match here.");
334 }
335
336 #[test]
337 fn test_post_pr_review_skips_none_line_comments() {
338 let comments = vec![
340 PrReviewComment {
341 file: "src/lib.rs".to_string(),
342 line: None,
343 comment: "General file comment.".to_string(),
344 severity: CommentSeverity::Info,
345 },
346 PrReviewComment {
347 file: "src/lib.rs".to_string(),
348 line: Some(10),
349 comment: "Inline comment.".to_string(),
350 severity: CommentSeverity::Warning,
351 },
352 ];
353
354 let inline = build_inline_comments(&comments);
356
357 assert_eq!(inline.len(), 1);
359 assert_eq!(inline[0]["line"], 10);
360 }
361
362 #[test]
363 fn test_post_pr_review_empty_comments() {
364 let comments: Vec<PrReviewComment> = vec![];
366
367 let inline = build_inline_comments(&comments);
369
370 assert!(inline.is_empty());
372 let serialized = serde_json::to_string(&inline).unwrap();
373 assert_eq!(serialized, "[]");
374 }
375
376 #[test]
383 fn test_parse_pr_reference_delegates_to_shared() {
384 let (owner, repo, number) =
385 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
386 assert_eq!(owner, "block");
387 assert_eq!(repo, "goose");
388 assert_eq!(number, 123);
389 }
390
391 #[test]
392 fn test_title_prefix_to_label_mapping() {
393 let cases = vec![
394 (
395 "feat: add new feature",
396 vec!["enhancement"],
397 "feat should map to enhancement",
398 ),
399 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
400 (
401 "docs: update readme",
402 vec!["documentation"],
403 "docs should map to documentation",
404 ),
405 (
406 "refactor: improve code",
407 vec!["refactor"],
408 "refactor should map to refactor",
409 ),
410 (
411 "perf: optimize",
412 vec!["enhancement"],
413 "perf should map to enhancement",
414 ),
415 (
416 "chore: update deps",
417 vec![],
418 "chore should produce no labels",
419 ),
420 ];
421
422 for (title, expected_labels, msg) in cases {
423 let labels = labels_from_pr_metadata(title, &[]);
424 for expected in &expected_labels {
425 assert!(
426 labels.contains(&expected.to_string()),
427 "{msg}: expected '{expected}' in {labels:?}",
428 );
429 }
430 if expected_labels.is_empty() {
431 assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}",);
432 }
433 }
434 }
435
436 #[test]
437 fn test_file_path_to_scope_mapping() {
438 let cases = vec![
439 (
440 "feat: cli",
441 vec!["crates/aptu-cli/src/main.rs"],
442 vec!["enhancement", "cli"],
443 "cli path should map to cli scope",
444 ),
445 (
446 "feat: ios",
447 vec!["crates/aptu-ffi/src/lib.rs"],
448 vec!["enhancement", "ios"],
449 "ffi path should map to ios scope",
450 ),
451 (
452 "feat: ios",
453 vec!["AptuApp/ContentView.swift"],
454 vec!["enhancement", "ios"],
455 "app path should map to ios scope",
456 ),
457 (
458 "feat: docs",
459 vec!["docs/GITHUB_ACTION.md"],
460 vec!["enhancement", "documentation"],
461 "docs path should map to documentation scope",
462 ),
463 (
464 "feat: snap",
465 vec!["snap/snapcraft.yaml"],
466 vec!["enhancement", "distribution"],
467 "snap path should map to distribution scope",
468 ),
469 (
470 "feat: workflow",
471 vec![".github/workflows/test.yml"],
472 vec!["enhancement"],
473 "workflow path should be ignored",
474 ),
475 ];
476
477 for (title, paths, expected_labels, msg) in cases {
478 let labels = labels_from_pr_metadata(
479 title,
480 &paths
481 .iter()
482 .map(std::string::ToString::to_string)
483 .collect::<Vec<_>>(),
484 );
485 for expected in expected_labels {
486 assert!(
487 labels.contains(&expected.to_string()),
488 "{msg}: expected '{expected}' in {labels:?}",
489 );
490 }
491 }
492 }
493
494 #[test]
495 fn test_combined_title_and_paths() {
496 let labels = labels_from_pr_metadata(
497 "feat: multi",
498 &[
499 "crates/aptu-cli/src/main.rs".to_string(),
500 "docs/README.md".to_string(),
501 ],
502 );
503 assert!(
504 labels.contains(&"enhancement".to_string()),
505 "should include enhancement from feat prefix"
506 );
507 assert!(
508 labels.contains(&"cli".to_string()),
509 "should include cli from path"
510 );
511 assert!(
512 labels.contains(&"documentation".to_string()),
513 "should include documentation from path"
514 );
515 }
516
517 #[test]
518 fn test_no_match_returns_empty() {
519 let cases = vec![
520 (
521 "Random title",
522 vec![],
523 "unrecognized prefix should return empty",
524 ),
525 (
526 "chore: update",
527 vec![],
528 "ignored prefix should return empty",
529 ),
530 ];
531
532 for (title, paths, msg) in cases {
533 let labels = labels_from_pr_metadata(title, &paths);
534 assert!(labels.is_empty(), "{msg}: got {labels:?}");
535 }
536 }
537
538 #[test]
539 fn test_scoped_prefix_extracts_type() {
540 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
541 assert!(
542 labels.contains(&"enhancement".to_string()),
543 "scoped prefix should extract type from feat(cli)"
544 );
545 }
546
547 #[test]
548 fn test_duplicate_labels_deduplicated() {
549 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
550 assert_eq!(
551 labels.len(),
552 1,
553 "should have exactly one label when title and path both map to documentation"
554 );
555 assert!(
556 labels.contains(&"documentation".to_string()),
557 "should contain documentation label"
558 );
559 }
560}