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 = client
70 .pulls(owner, repo)
71 .get(number)
72 .await
73 .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"))?;
74
75 let files = client
77 .pulls(owner, repo)
78 .list_files(number)
79 .await
80 .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
81
82 let pr_files: Vec<PrFile> = files
84 .items
85 .into_iter()
86 .map(|f| PrFile {
87 filename: f.filename,
88 status: format!("{:?}", f.status),
89 additions: f.additions,
90 deletions: f.deletions,
91 patch: f.patch,
92 })
93 .collect();
94
95 let details = PrDetails {
96 owner: owner.to_string(),
97 repo: repo.to_string(),
98 number,
99 title: pr.title.unwrap_or_default(),
100 body: pr.body.unwrap_or_default(),
101 base_branch: pr.base.ref_field,
102 head_branch: pr.head.ref_field,
103 files: pr_files,
104 url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
105 };
106
107 debug!(
108 file_count = details.files.len(),
109 "PR details fetched successfully"
110 );
111
112 Ok(details)
113}
114
115#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number, event = %event))]
137pub async fn post_pr_review(
138 client: &Octocrab,
139 owner: &str,
140 repo: &str,
141 number: u64,
142 body: &str,
143 event: ReviewEvent,
144) -> Result<u64> {
145 debug!("Posting PR review");
146
147 let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
148
149 let payload = serde_json::json!({
150 "body": body,
151 "event": event.to_string(),
152 });
153
154 #[derive(serde::Deserialize)]
155 struct ReviewResponse {
156 id: u64,
157 }
158
159 let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
160 format!(
161 "Failed to post review to PR #{number} in {owner}/{repo}. \
162 Check that you have write access to the repository."
163 )
164 })?;
165
166 debug!(review_id = response.id, "PR review posted successfully");
167
168 Ok(response.id)
169}
170
171#[must_use]
183pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
184 let mut labels = std::collections::HashSet::new();
185
186 let prefix = title
189 .split(':')
190 .next()
191 .unwrap_or("")
192 .split('(')
193 .next()
194 .unwrap_or("")
195 .trim();
196
197 let type_label = match prefix {
199 "feat" | "perf" => Some("enhancement"),
200 "fix" => Some("bug"),
201 "docs" => Some("documentation"),
202 "refactor" => Some("refactor"),
203 _ => None,
204 };
205
206 if let Some(label) = type_label {
207 labels.insert(label.to_string());
208 }
209
210 for path in file_paths {
212 let scope = if path.starts_with("crates/aptu-cli/") {
213 Some("cli")
214 } else if path.starts_with("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
215 Some("ios")
216 } else if path.starts_with("docs/") {
217 Some("documentation")
218 } else if path.starts_with("snap/") {
219 Some("distribution")
220 } else {
221 None
222 };
223
224 if let Some(label) = scope {
225 labels.insert(label.to_string());
226 }
227 }
228
229 labels.into_iter().collect()
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
239 fn test_parse_pr_reference_delegates_to_shared() {
240 let (owner, repo, number) =
241 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
242 assert_eq!(owner, "block");
243 assert_eq!(repo, "goose");
244 assert_eq!(number, 123);
245 }
246
247 #[test]
248 fn test_title_prefix_to_label_mapping() {
249 let cases = vec![
250 (
251 "feat: add new feature",
252 vec!["enhancement"],
253 "feat should map to enhancement",
254 ),
255 ("fix: resolve bug", vec!["bug"], "fix should map to bug"),
256 (
257 "docs: update readme",
258 vec!["documentation"],
259 "docs should map to documentation",
260 ),
261 (
262 "refactor: improve code",
263 vec!["refactor"],
264 "refactor should map to refactor",
265 ),
266 (
267 "perf: optimize",
268 vec!["enhancement"],
269 "perf should map to enhancement",
270 ),
271 (
272 "chore: update deps",
273 vec![],
274 "chore should produce no labels",
275 ),
276 ];
277
278 for (title, expected_labels, msg) in cases {
279 let labels = labels_from_pr_metadata(title, &[]);
280 for expected in &expected_labels {
281 assert!(
282 labels.contains(&expected.to_string()),
283 "{}: expected '{}' in {:?}",
284 msg,
285 expected,
286 labels
287 );
288 }
289 if expected_labels.is_empty() {
290 assert!(
291 labels.is_empty(),
292 "{}: expected empty, got {:?}",
293 msg,
294 labels
295 );
296 }
297 }
298 }
299
300 #[test]
301 fn test_file_path_to_scope_mapping() {
302 let cases = vec![
303 (
304 "feat: cli",
305 vec!["crates/aptu-cli/src/main.rs"],
306 vec!["enhancement", "cli"],
307 "cli path should map to cli scope",
308 ),
309 (
310 "feat: ios",
311 vec!["crates/aptu-ffi/src/lib.rs"],
312 vec!["enhancement", "ios"],
313 "ffi path should map to ios scope",
314 ),
315 (
316 "feat: ios",
317 vec!["AptuApp/ContentView.swift"],
318 vec!["enhancement", "ios"],
319 "app path should map to ios scope",
320 ),
321 (
322 "feat: docs",
323 vec!["docs/GITHUB_ACTION.md"],
324 vec!["enhancement", "documentation"],
325 "docs path should map to documentation scope",
326 ),
327 (
328 "feat: snap",
329 vec!["snap/snapcraft.yaml"],
330 vec!["enhancement", "distribution"],
331 "snap path should map to distribution scope",
332 ),
333 (
334 "feat: workflow",
335 vec![".github/workflows/test.yml"],
336 vec!["enhancement"],
337 "workflow path should be ignored",
338 ),
339 ];
340
341 for (title, paths, expected_labels, msg) in cases {
342 let labels = labels_from_pr_metadata(
343 title,
344 &paths.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
345 );
346 for expected in expected_labels {
347 assert!(
348 labels.contains(&expected.to_string()),
349 "{}: expected '{}' in {:?}",
350 msg,
351 expected,
352 labels
353 );
354 }
355 }
356 }
357
358 #[test]
359 fn test_combined_title_and_paths() {
360 let labels = labels_from_pr_metadata(
361 "feat: multi",
362 &[
363 "crates/aptu-cli/src/main.rs".to_string(),
364 "docs/README.md".to_string(),
365 ],
366 );
367 assert!(
368 labels.contains(&"enhancement".to_string()),
369 "should include enhancement from feat prefix"
370 );
371 assert!(
372 labels.contains(&"cli".to_string()),
373 "should include cli from path"
374 );
375 assert!(
376 labels.contains(&"documentation".to_string()),
377 "should include documentation from path"
378 );
379 }
380
381 #[test]
382 fn test_no_match_returns_empty() {
383 let cases = vec![
384 (
385 "Random title",
386 vec![],
387 "unrecognized prefix should return empty",
388 ),
389 (
390 "chore: update",
391 vec![],
392 "ignored prefix should return empty",
393 ),
394 ];
395
396 for (title, paths, msg) in cases {
397 let labels = labels_from_pr_metadata(title, &paths);
398 assert!(labels.is_empty(), "{}: got {:?}", msg, labels);
399 }
400 }
401
402 #[test]
403 fn test_scoped_prefix_extracts_type() {
404 let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
405 assert!(
406 labels.contains(&"enhancement".to_string()),
407 "scoped prefix should extract type from feat(cli)"
408 );
409 }
410
411 #[test]
412 fn test_duplicate_labels_deduplicated() {
413 let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
414 assert_eq!(
415 labels.len(),
416 1,
417 "should have exactly one label when title and path both map to documentation"
418 );
419 assert!(
420 labels.contains(&"documentation".to_string()),
421 "should contain documentation label"
422 );
423 }
424}