1use anyhow::{Context, Result};
9use octocrab::Octocrab;
10use tracing::{debug, instrument};
11
12use super::{ReferenceKind, parse_github_reference};
13use crate::ai::types::{PrDetails, PrFile, ReviewEvent};
14
15pub fn parse_pr_reference(
35 reference: &str,
36 repo_context: Option<&str>,
37) -> Result<(String, String, u64)> {
38 parse_github_reference(ReferenceKind::Pull, reference, repo_context)
39}
40
41#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
60pub async fn fetch_pr_details(
61 client: &Octocrab,
62 owner: &str,
63 repo: &str,
64 number: u64,
65) -> Result<PrDetails> {
66 debug!("Fetching PR details");
67
68 let pr = match client.pulls(owner, repo).get(number).await {
70 Ok(pr) => pr,
71 Err(e) => {
72 if let octocrab::Error::GitHub { source, .. } = &e
74 && source.status_code == 404
75 {
76 if (client.issues(owner, repo).get(number).await).is_ok() {
78 return Err(anyhow::anyhow!("#{number} is an issue, not a pull request"));
79 }
80 }
82 return Err(e)
83 .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
84 }
85 };
86
87 let files = client
89 .pulls(owner, repo)
90 .list_files(number)
91 .await
92 .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
93
94 let pr_files: Vec<PrFile> = files
96 .items
97 .into_iter()
98 .map(|f| PrFile {
99 filename: f.filename,
100 status: format!("{:?}", f.status),
101 additions: f.additions,
102 deletions: f.deletions,
103 patch: f.patch,
104 })
105 .collect();
106
107 let labels: Vec<String> = pr
108 .labels
109 .iter()
110 .flat_map(|labels_vec| labels_vec.iter().map(|l| l.name.clone()))
111 .collect();
112
113 let details = PrDetails {
114 owner: owner.to_string(),
115 repo: repo.to_string(),
116 number,
117 title: pr.title.unwrap_or_default(),
118 body: pr.body.unwrap_or_default(),
119 base_branch: pr.base.ref_field,
120 head_branch: pr.head.ref_field,
121 files: pr_files,
122 url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
123 labels,
124 };
125
126 debug!(
127 file_count = details.files.len(),
128 "PR details fetched successfully"
129 );
130
131 Ok(details)
132}
133
134#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number, event = %event))]
156pub async fn post_pr_review(
157 client: &Octocrab,
158 owner: &str,
159 repo: &str,
160 number: u64,
161 body: &str,
162 event: ReviewEvent,
163) -> Result<u64> {
164 debug!("Posting PR review");
165
166 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
167
168 let payload = serde_json::json!({
169 "body": body,
170 "event": event.to_string(),
171 });
172
173 #[derive(serde::Deserialize)]
174 struct ReviewResponse {
175 id: u64,
176 }
177
178 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
179 format!(
180 "Failed to post review to PR #{number} in {owner}/{repo}. \
181 Check that you have write access to the repository."
182 )
183 })?;
184
185 debug!(review_id = response.id, "PR review posted successfully");
186
187 Ok(response.id)
188}
189
190#[must_use]
202pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
203 let mut labels = std::collections::HashSet::new();
204
205 let prefix = title
208 .split(':')
209 .next()
210 .unwrap_or("")
211 .split('(')
212 .next()
213 .unwrap_or("")
214 .trim();
215
216 let type_label = match prefix {
218 "feat" | "perf" => Some("enhancement"),
219 "fix" => Some("bug"),
220 "docs" => Some("documentation"),
221 "refactor" => Some("refactor"),
222 _ => None,
223 };
224
225 if let Some(label) = type_label {
226 labels.insert(label.to_string());
227 }
228
229 for path in file_paths {
231 let scope = if path.starts_with("crates/aptu-cli/") {
232 Some("cli")
233 } else if path.starts_with("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
234 Some("ios")
235 } else if path.starts_with("docs/") {
236 Some("documentation")
237 } else if path.starts_with("snap/") {
238 Some("distribution")
239 } else {
240 None
241 };
242
243 if let Some(label) = scope {
244 labels.insert(label.to_string());
245 }
246 }
247
248 labels.into_iter().collect()
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
258 fn test_parse_pr_reference_delegates_to_shared() {
259 let (owner, repo, number) =
260 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
261 assert_eq!(owner, "block");
262 assert_eq!(repo, "goose");
263 assert_eq!(number, 123);
264 }
265
266 #[test]
267 fn test_title_prefix_to_label_mapping() {
268 let cases = vec![
269 (
270 "feat: add new feature",
271 vec!["enhancement"],
272 "feat should map to enhancement",
273 ),
274 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
275 (
276 "docs: update readme",
277 vec!["documentation"],
278 "docs should map to documentation",
279 ),
280 (
281 "refactor: improve code",
282 vec!["refactor"],
283 "refactor should map to refactor",
284 ),
285 (
286 "perf: optimize",
287 vec!["enhancement"],
288 "perf should map to enhancement",
289 ),
290 (
291 "chore: update deps",
292 vec![],
293 "chore should produce no labels",
294 ),
295 ];
296
297 for (title, expected_labels, msg) in cases {
298 let labels = labels_from_pr_metadata(title, &[]);
299 for expected in &expected_labels {
300 assert!(
301 labels.contains(&expected.to_string()),
302 "{}: expected '{}' in {:?}",
303 msg,
304 expected,
305 labels
306 );
307 }
308 if expected_labels.is_empty() {
309 assert!(
310 labels.is_empty(),
311 "{}: expected empty, got {:?}",
312 msg,
313 labels
314 );
315 }
316 }
317 }
318
319 #[test]
320 fn test_file_path_to_scope_mapping() {
321 let cases = vec![
322 (
323 "feat: cli",
324 vec!["crates/aptu-cli/src/main.rs"],
325 vec!["enhancement", "cli"],
326 "cli path should map to cli scope",
327 ),
328 (
329 "feat: ios",
330 vec!["crates/aptu-ffi/src/lib.rs"],
331 vec!["enhancement", "ios"],
332 "ffi path should map to ios scope",
333 ),
334 (
335 "feat: ios",
336 vec!["AptuApp/ContentView.swift"],
337 vec!["enhancement", "ios"],
338 "app path should map to ios scope",
339 ),
340 (
341 "feat: docs",
342 vec!["docs/GITHUB_ACTION.md"],
343 vec!["enhancement", "documentation"],
344 "docs path should map to documentation scope",
345 ),
346 (
347 "feat: snap",
348 vec!["snap/snapcraft.yaml"],
349 vec!["enhancement", "distribution"],
350 "snap path should map to distribution scope",
351 ),
352 (
353 "feat: workflow",
354 vec![".github/workflows/test.yml"],
355 vec!["enhancement"],
356 "workflow path should be ignored",
357 ),
358 ];
359
360 for (title, paths, expected_labels, msg) in cases {
361 let labels = labels_from_pr_metadata(
362 title,
363 &paths.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
364 );
365 for expected in expected_labels {
366 assert!(
367 labels.contains(&expected.to_string()),
368 "{}: expected '{}' in {:?}",
369 msg,
370 expected,
371 labels
372 );
373 }
374 }
375 }
376
377 #[test]
378 fn test_combined_title_and_paths() {
379 let labels = labels_from_pr_metadata(
380 "feat: multi",
381 &[
382 "crates/aptu-cli/src/main.rs".to_string(),
383 "docs/README.md".to_string(),
384 ],
385 );
386 assert!(
387 labels.contains(&"enhancement".to_string()),
388 "should include enhancement from feat prefix"
389 );
390 assert!(
391 labels.contains(&"cli".to_string()),
392 "should include cli from path"
393 );
394 assert!(
395 labels.contains(&"documentation".to_string()),
396 "should include documentation from path"
397 );
398 }
399
400 #[test]
401 fn test_no_match_returns_empty() {
402 let cases = vec![
403 (
404 "Random title",
405 vec![],
406 "unrecognized prefix should return empty",
407 ),
408 (
409 "chore: update",
410 vec![],
411 "ignored prefix should return empty",
412 ),
413 ];
414
415 for (title, paths, msg) in cases {
416 let labels = labels_from_pr_metadata(title, &paths);
417 assert!(labels.is_empty(), "{}: got {:?}", msg, labels);
418 }
419 }
420
421 #[test]
422 fn test_scoped_prefix_extracts_type() {
423 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
424 assert!(
425 labels.contains(&"enhancement".to_string()),
426 "scoped prefix should extract type from feat(cli)"
427 );
428 }
429
430 #[test]
431 fn test_duplicate_labels_deduplicated() {
432 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
433 assert_eq!(
434 labels.len(),
435 1,
436 "should have exactly one label when title and path both map to documentation"
437 );
438 assert!(
439 labels.contains(&"documentation".to_string()),
440 "should contain documentation label"
441 );
442 }
443}