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};
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 files: pr_files,
128 url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
129 labels,
130 };
131
132 debug!(
133 file_count = details.files.len(),
134 "PR details fetched successfully"
135 );
136
137 Ok(details)
138}
139
140#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number, event = %event))]
162pub async fn post_pr_review(
163 client: &Octocrab,
164 owner: &str,
165 repo: &str,
166 number: u64,
167 body: &str,
168 event: ReviewEvent,
169) -> Result<u64> {
170 debug!("Posting PR review");
171
172 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
173
174 let payload = serde_json::json!({
175 "body": body,
176 "event": event.to_string(),
177 });
178
179 #[derive(serde::Deserialize)]
180 struct ReviewResponse {
181 id: u64,
182 }
183
184 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
185 format!(
186 "Failed to post review to PR #{number} in {owner}/{repo}. \
187 Check that you have write access to the repository."
188 )
189 })?;
190
191 debug!(review_id = response.id, "PR review posted successfully");
192
193 Ok(response.id)
194}
195
196#[must_use]
208pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
209 let mut labels = std::collections::HashSet::new();
210
211 let prefix = title
214 .split(':')
215 .next()
216 .unwrap_or("")
217 .split('(')
218 .next()
219 .unwrap_or("")
220 .trim();
221
222 let type_label = match prefix {
224 "feat" | "perf" => Some("enhancement"),
225 "fix" => Some("bug"),
226 "docs" => Some("documentation"),
227 "refactor" => Some("refactor"),
228 _ => None,
229 };
230
231 if let Some(label) = type_label {
232 labels.insert(label.to_string());
233 }
234
235 for path in file_paths {
237 let scope = if path.starts_with("crates/aptu-cli/") {
238 Some("cli")
239 } else if path.starts_with("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
240 Some("ios")
241 } else if path.starts_with("docs/") {
242 Some("documentation")
243 } else if path.starts_with("snap/") {
244 Some("distribution")
245 } else {
246 None
247 };
248
249 if let Some(label) = scope {
250 labels.insert(label.to_string());
251 }
252 }
253
254 labels.into_iter().collect()
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
264 fn test_parse_pr_reference_delegates_to_shared() {
265 let (owner, repo, number) =
266 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
267 assert_eq!(owner, "block");
268 assert_eq!(repo, "goose");
269 assert_eq!(number, 123);
270 }
271
272 #[test]
273 fn test_title_prefix_to_label_mapping() {
274 let cases = vec![
275 (
276 "feat: add new feature",
277 vec!["enhancement"],
278 "feat should map to enhancement",
279 ),
280 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
281 (
282 "docs: update readme",
283 vec!["documentation"],
284 "docs should map to documentation",
285 ),
286 (
287 "refactor: improve code",
288 vec!["refactor"],
289 "refactor should map to refactor",
290 ),
291 (
292 "perf: optimize",
293 vec!["enhancement"],
294 "perf should map to enhancement",
295 ),
296 (
297 "chore: update deps",
298 vec![],
299 "chore should produce no labels",
300 ),
301 ];
302
303 for (title, expected_labels, msg) in cases {
304 let labels = labels_from_pr_metadata(title, &[]);
305 for expected in &expected_labels {
306 assert!(
307 labels.contains(&expected.to_string()),
308 "{msg}: expected '{expected}' in {labels:?}",
309 );
310 }
311 if expected_labels.is_empty() {
312 assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}",);
313 }
314 }
315 }
316
317 #[test]
318 fn test_file_path_to_scope_mapping() {
319 let cases = vec![
320 (
321 "feat: cli",
322 vec!["crates/aptu-cli/src/main.rs"],
323 vec!["enhancement", "cli"],
324 "cli path should map to cli scope",
325 ),
326 (
327 "feat: ios",
328 vec!["crates/aptu-ffi/src/lib.rs"],
329 vec!["enhancement", "ios"],
330 "ffi path should map to ios scope",
331 ),
332 (
333 "feat: ios",
334 vec!["AptuApp/ContentView.swift"],
335 vec!["enhancement", "ios"],
336 "app path should map to ios scope",
337 ),
338 (
339 "feat: docs",
340 vec!["docs/GITHUB_ACTION.md"],
341 vec!["enhancement", "documentation"],
342 "docs path should map to documentation scope",
343 ),
344 (
345 "feat: snap",
346 vec!["snap/snapcraft.yaml"],
347 vec!["enhancement", "distribution"],
348 "snap path should map to distribution scope",
349 ),
350 (
351 "feat: workflow",
352 vec![".github/workflows/test.yml"],
353 vec!["enhancement"],
354 "workflow path should be ignored",
355 ),
356 ];
357
358 for (title, paths, expected_labels, msg) in cases {
359 let labels = labels_from_pr_metadata(
360 title,
361 &paths
362 .iter()
363 .map(std::string::ToString::to_string)
364 .collect::<Vec<_>>(),
365 );
366 for expected in expected_labels {
367 assert!(
368 labels.contains(&expected.to_string()),
369 "{msg}: expected '{expected}' in {labels:?}",
370 );
371 }
372 }
373 }
374
375 #[test]
376 fn test_combined_title_and_paths() {
377 let labels = labels_from_pr_metadata(
378 "feat: multi",
379 &[
380 "crates/aptu-cli/src/main.rs".to_string(),
381 "docs/README.md".to_string(),
382 ],
383 );
384 assert!(
385 labels.contains(&"enhancement".to_string()),
386 "should include enhancement from feat prefix"
387 );
388 assert!(
389 labels.contains(&"cli".to_string()),
390 "should include cli from path"
391 );
392 assert!(
393 labels.contains(&"documentation".to_string()),
394 "should include documentation from path"
395 );
396 }
397
398 #[test]
399 fn test_no_match_returns_empty() {
400 let cases = vec![
401 (
402 "Random title",
403 vec![],
404 "unrecognized prefix should return empty",
405 ),
406 (
407 "chore: update",
408 vec![],
409 "ignored prefix should return empty",
410 ),
411 ];
412
413 for (title, paths, msg) in cases {
414 let labels = labels_from_pr_metadata(title, &paths);
415 assert!(labels.is_empty(), "{msg}: got {labels:?}");
416 }
417 }
418
419 #[test]
420 fn test_scoped_prefix_extracts_type() {
421 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
422 assert!(
423 labels.contains(&"enhancement".to_string()),
424 "scoped prefix should extract type from feat(cli)"
425 );
426 }
427
428 #[test]
429 fn test_duplicate_labels_deduplicated() {
430 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
431 assert_eq!(
432 labels.len(),
433 1,
434 "should have exactly one label when title and path both map to documentation"
435 );
436 assert!(
437 labels.contains(&"documentation".to_string()),
438 "should contain documentation label"
439 );
440 }
441}