1use git2::{FetchOptions, Repository};
82use log::debug;
83
84use crate::{
85 error::{PrError, Result},
86 get_remote_callbacks,
87};
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct PullRequest {
92 pub number: u32,
94 pub remote: Option<String>,
96}
97
98#[derive(Debug, Clone)]
100pub struct PrMetadata {
101 pub number: u32,
103 pub title: String,
105 pub author: String,
107 pub head_ref: String,
109 pub base_ref: String,
111 pub is_fork: bool,
113 pub fork_owner: Option<String>,
115 pub fork_url: Option<String>,
117}
118
119pub fn parse_pr_reference(input: &str) -> Result<Option<PullRequest>> {
131 if let Some(num_str) = input.strip_prefix('#') {
133 return parse_number(num_str, input).map(|num| {
134 Some(PullRequest {
135 number: num,
136 remote: None,
137 })
138 });
139 }
140
141 if let Some(num_str) = input.strip_prefix("pr#") {
143 return parse_number(num_str, input).map(|num| {
144 Some(PullRequest {
145 number: num,
146 remote: None,
147 })
148 });
149 }
150
151 if let Some(num_str) = input.strip_prefix("pr-") {
153 return parse_number(num_str, input).map(|num| {
154 Some(PullRequest {
155 number: num,
156 remote: None,
157 })
158 });
159 }
160
161 if input.contains("github.com") && input.contains("/pull/") {
163 return parse_github_url(input);
164 }
165
166 if input.contains("/pull/") && input.ends_with("/head") {
168 return parse_remote_ref(input);
169 }
170
171 Ok(None)
173}
174
175fn parse_number(num_str: &str, original_input: &str) -> Result<u32> {
177 num_str.parse::<u32>().map_err(|_| {
178 PrError::InvalidReference {
179 input: original_input.to_string(),
180 }
181 .into()
182 })
183}
184
185fn parse_github_url(url: &str) -> Result<Option<PullRequest>> {
187 let parts: Vec<&str> = url.split('/').collect();
189
190 for (i, &part) in parts.iter().enumerate() {
192 if part == "pull" && i + 1 < parts.len() {
193 let num_str = parts[i + 1];
194 let number = parse_number(num_str, url)?;
195 return Ok(Some(PullRequest {
196 number,
197 remote: None,
198 }));
199 }
200 }
201
202 Err(PrError::InvalidReference {
203 input: url.to_string(),
204 }
205 .into())
206}
207
208fn parse_remote_ref(ref_str: &str) -> Result<Option<PullRequest>> {
210 let parts: Vec<&str> = ref_str.split('/').collect();
212
213 if parts.len() >= 4 && parts[parts.len() - 3] == "pull" && parts[parts.len() - 1] == "head" {
214 let num_str = parts[parts.len() - 2];
215 let number = parse_number(num_str, ref_str)?;
216 return Ok(Some(PullRequest {
217 number,
218 remote: None,
219 }));
220 }
221
222 Err(PrError::InvalidReference {
223 input: ref_str.to_string(),
224 }
225 .into())
226}
227
228pub fn check_gh_available() -> Result<()> {
232 std::process::Command::new("gh")
233 .arg("--version")
234 .output()
235 .map_err(|_| PrError::GhNotInstalled)?;
236 Ok(())
237}
238
239pub fn fetch_pr_metadata(pr_number: u32) -> Result<PrMetadata> {
244 check_gh_available()?;
246
247 let output = std::process::Command::new("gh")
249 .args([
250 "pr",
251 "view",
252 &pr_number.to_string(),
253 "--json",
254 "number,title,author,headRefName,baseRefName,isCrossRepository,headRepository",
255 ])
256 .output()
257 .map_err(|e| PrError::GhFetchFailed {
258 message: e.to_string(),
259 })?;
260
261 if !output.status.success() {
262 let stderr = String::from_utf8_lossy(&output.stderr);
263 return Err(PrError::GhFetchFailed {
264 message: stderr.to_string(),
265 }
266 .into());
267 }
268
269 let json_str = String::from_utf8_lossy(&output.stdout);
271 let json: serde_json::Value =
272 serde_json::from_str(&json_str).map_err(|e| PrError::GhJsonParseFailed {
273 message: e.to_string(),
274 })?;
275
276 let number = json["number"]
278 .as_u64()
279 .ok_or_else(|| PrError::GhJsonParseFailed {
280 message: "Missing 'number' field".to_string(),
281 })? as u32;
282
283 let title = json["title"]
284 .as_str()
285 .ok_or_else(|| PrError::GhJsonParseFailed {
286 message: "Missing 'title' field".to_string(),
287 })?
288 .to_string();
289
290 let author = json["author"]["login"]
291 .as_str()
292 .ok_or_else(|| PrError::GhJsonParseFailed {
293 message: "Missing 'author.login' field".to_string(),
294 })?
295 .to_string();
296
297 let head_ref = json["headRefName"]
298 .as_str()
299 .ok_or_else(|| PrError::GhJsonParseFailed {
300 message: "Missing 'headRefName' field".to_string(),
301 })?
302 .to_string();
303
304 let base_ref = json["baseRefName"]
305 .as_str()
306 .ok_or_else(|| PrError::GhJsonParseFailed {
307 message: "Missing 'baseRefName' field".to_string(),
308 })?
309 .to_string();
310
311 let is_fork = json["isCrossRepository"].as_bool().unwrap_or(false);
312
313 let (fork_owner, fork_url) = if is_fork {
314 let owner = json["headRepository"]["owner"]["login"]
315 .as_str()
316 .ok_or(PrError::MissingForkOwner)?
317 .to_string();
318 let url = json["headRepository"]["url"]
319 .as_str()
320 .map(|s| s.to_string());
321 (Some(owner), url)
322 } else {
323 (None, None)
324 };
325
326 Ok(PrMetadata {
327 number,
328 title,
329 author,
330 head_ref,
331 base_ref,
332 is_fork,
333 fork_owner,
334 fork_url,
335 })
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct MergedPr {
341 pub number: u32,
342 pub head_oid: String,
343}
344
345pub fn find_merged_pr(branch: &str) -> Result<Option<MergedPr>> {
359 let output = std::process::Command::new("gh")
360 .args([
361 "pr",
362 "list",
363 "--head",
364 branch,
365 "--state",
366 "merged",
367 "--json",
368 "number,mergedAt,headRefOid",
369 "--limit",
370 "1",
371 ])
372 .output()
373 .map_err(|e| PrError::GhFetchFailed {
374 message: e.to_string(),
375 })?;
376
377 if !output.status.success() {
378 let stderr = String::from_utf8_lossy(&output.stderr);
379 return Err(PrError::GhFetchFailed {
380 message: stderr.to_string(),
381 }
382 .into());
383 }
384
385 let json_str = String::from_utf8_lossy(&output.stdout);
386 Ok(parse_merged_pr(&json_str))
387}
388
389fn parse_merged_pr(json: &str) -> Option<MergedPr> {
398 let json: serde_json::Value = serde_json::from_str(json).ok()?;
399 let entry = json.as_array()?.first()?;
400 let number = entry.get("number")?.as_u64()? as u32;
401 let head_oid = entry.get("headRefOid")?.as_str()?.to_string();
402 Some(MergedPr { number, head_oid })
403}
404
405fn sanitize_for_branch_name(s: &str) -> String {
407 let sanitized = s
408 .chars()
409 .map(|c| match c {
410 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
411 ' ' | '/' => '-',
412 _ => '-',
413 })
414 .collect::<String>()
415 .to_lowercase();
416
417 let mut result = String::new();
419 let mut last_was_dash = false;
420 for c in sanitized.chars() {
421 if c == '-' {
422 if !last_was_dash {
423 result.push(c);
424 }
425 last_was_dash = true;
426 } else {
427 result.push(c);
428 last_was_dash = false;
429 }
430 }
431
432 result.trim_matches(|c| c == '-' || c == '_').to_string()
433}
434
435pub fn format_pr_name_with_metadata(format: &str, metadata: &PrMetadata) -> String {
440 format
441 .replace("{number}", &metadata.number.to_string())
442 .replace("{title}", &sanitize_for_branch_name(&metadata.title))
443 .replace("{author}", &sanitize_for_branch_name(&metadata.author))
444 .replace("{branch}", &sanitize_for_branch_name(&metadata.head_ref))
445}
446
447pub fn is_pr_reference(input: &str) -> bool {
451 parse_pr_reference(input).ok().flatten().is_some()
452}
453
454pub fn remote_priority(remote: &str) -> usize {
461 match remote {
462 "upstream" => 0,
463 "origin" => 1,
464 _ => 2,
465 }
466}
467
468pub fn preferred_remote_order(repo: &Repository) -> Vec<String> {
471 let Ok(remotes) = repo.remotes() else {
472 return vec![];
473 };
474 let mut all: Vec<String> = remotes
475 .iter()
476 .flatten()
477 .flatten()
478 .map(str::to_string)
479 .collect();
480 all.sort_by_key(|r| remote_priority(r));
481 all
482}
483
484pub fn has_github_remote(repo: &Repository) -> bool {
491 let Ok(remotes) = repo.remotes() else {
492 return false;
493 };
494 remotes.iter().flatten().flatten().any(|name| {
495 repo.find_remote(name)
496 .ok()
497 .and_then(|r| r.url().ok().map(str::to_string))
498 .and_then(|url| crate::ssh_config::extract_host_from_url(&url))
499 .is_some_and(|host| host.eq_ignore_ascii_case("github.com"))
500 })
501}
502
503pub fn detect_pr_remote(repo: &Repository) -> Result<String> {
508 preferred_remote_order(repo)
509 .into_iter()
510 .next()
511 .ok_or_else(|| PrError::NoRemoteConfigured.into())
512}
513
514pub fn setup_fork_remote(repo: &Repository, metadata: &PrMetadata) -> Result<String> {
520 if !metadata.is_fork {
521 return detect_pr_remote(repo);
523 }
524
525 let _fork_owner = metadata
527 .fork_owner
528 .as_ref()
529 .ok_or(PrError::MissingForkOwner)?;
530
531 let fork_url = metadata
532 .fork_url
533 .as_ref()
534 .ok_or(PrError::MissingForkOwner)?;
535
536 let fork_remote_name = format!("pr-{}-fork", metadata.number);
538
539 if repo.find_remote(&fork_remote_name).is_ok() {
540 debug!("Fork remote {} already exists", fork_remote_name);
541 return Ok(fork_remote_name);
542 }
543
544 debug!("Adding fork remote: {} -> {}", fork_remote_name, fork_url);
546 repo.remote(&fork_remote_name, fork_url)
547 .map_err(|e| PrError::FetchFailed {
548 remote: fork_remote_name.clone(),
549 message: format!("Failed to add fork remote: {}", e),
550 })?;
551
552 Ok(fork_remote_name)
553}
554
555pub fn fetch_branch(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> {
562 let branch_ref = format!("refs/remotes/{}/{}", remote_name, branch);
564 if repo.find_reference(&branch_ref).is_ok() {
565 debug!("Branch ref {} already exists", branch_ref);
566 return Ok(());
567 }
568
569 debug!("Fetching branch {} from remote {}", branch, remote_name);
570
571 let refspec = format!(
572 "+refs/heads/{}:refs/remotes/{}/{}",
573 branch, remote_name, branch
574 );
575
576 let remote_url = repo
577 .find_remote(remote_name)
578 .ok()
579 .and_then(|r| r.url().ok().map(str::to_string));
580 let auth = get_remote_callbacks(repo, remote_url.as_deref())?;
581 let mut fetch_options = FetchOptions::new();
582 fetch_options.remote_callbacks(auth.callbacks());
583
584 repo.find_remote(remote_name)?
585 .fetch(
586 &[refspec.as_str()],
587 Some(&mut fetch_options),
588 Some("Fetching PR branch"),
589 )
590 .map_err(|e| PrError::FetchFailed {
591 remote: remote_name.to_string(),
592 message: e.message().to_string(),
593 })?;
594
595 debug!("Successfully fetched branch {}", branch);
596 Ok(())
597}
598
599pub fn format_pr_name(format: &str, pr_number: u32) -> String {
603 format.replace("{number}", &pr_number.to_string())
604}
605
606pub fn prepare_pr_worktree(
617 repo: &Repository,
618 pr_number: u32,
619 pr_format: &str,
620) -> Result<(String, String, String)> {
621 debug!("Preparing PR worktree for PR #{}", pr_number);
622
623 let metadata = fetch_pr_metadata(pr_number)?;
625 debug!(
626 "Fetched metadata: title='{}', author='{}', is_fork={}",
627 metadata.title, metadata.author, metadata.is_fork
628 );
629
630 let remote_name = if metadata.is_fork {
634 setup_fork_remote(repo, &metadata)?
635 } else {
636 detect_pr_remote(repo)?
637 };
638
639 fetch_branch(repo, &remote_name, &metadata.head_ref)?;
641
642 let worktree_name = format_pr_name_with_metadata(pr_format, &metadata);
644 debug!("Worktree name: {}", worktree_name);
645
646 let remote_ref = format!("{}/{}", remote_name, metadata.head_ref);
648 debug!("Remote ref: {}", remote_ref);
649
650 Ok((worktree_name, remote_ref, metadata.base_ref))
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
658 fn test_parse_hash_number() {
659 let pr = parse_pr_reference("#123").unwrap().unwrap();
660 assert_eq!(pr.number, 123);
661 assert_eq!(pr.remote, None);
662 }
663
664 #[test]
665 fn test_parse_pr_hash_number() {
666 let pr = parse_pr_reference("pr#456").unwrap().unwrap();
667 assert_eq!(pr.number, 456);
668 assert_eq!(pr.remote, None);
669 }
670
671 #[test]
672 fn test_parse_pr_dash_number() {
673 let pr = parse_pr_reference("pr-789").unwrap().unwrap();
674 assert_eq!(pr.number, 789);
675 assert_eq!(pr.remote, None);
676 }
677
678 #[test]
679 fn test_parse_github_url() {
680 let pr = parse_pr_reference("https://github.com/owner/repo/pull/999")
681 .unwrap()
682 .unwrap();
683 assert_eq!(pr.number, 999);
684 assert_eq!(pr.remote, None);
685 }
686
687 #[test]
688 fn test_parse_remote_ref() {
689 let pr = parse_pr_reference("origin/pull/111/head").unwrap().unwrap();
690 assert_eq!(pr.number, 111);
691 assert_eq!(pr.remote, None);
692 }
693
694 #[test]
695 fn test_parse_regular_branch_name() {
696 let result = parse_pr_reference("my-feature-branch").unwrap();
697 assert!(result.is_none());
698 }
699
700 #[test]
701 fn test_parse_invalid_number() {
702 let result = parse_pr_reference("#abc");
703 assert!(result.is_err());
704 }
705
706 #[test]
707 fn test_is_pr_reference_true() {
708 assert!(is_pr_reference("#123"));
709 assert!(is_pr_reference("pr#456"));
710 assert!(is_pr_reference("pr-789"));
711 assert!(is_pr_reference("https://github.com/owner/repo/pull/999"));
712 }
713
714 #[test]
715 fn test_is_pr_reference_false() {
716 assert!(!is_pr_reference("my-branch"));
717 assert!(!is_pr_reference("feature"));
718 }
719
720 #[test]
721 fn test_format_pr_name() {
722 assert_eq!(format_pr_name("pr-{number}", 123), "pr-123");
723 assert_eq!(format_pr_name("review-{number}", 456), "review-456");
724 assert_eq!(format_pr_name("{number}-test", 789), "789-test");
725 }
726
727 #[test]
728 fn test_sanitize_branch_name() {
729 assert_eq!(sanitize_for_branch_name("Fix Bug #123"), "fix-bug-123");
730 assert_eq!(
731 sanitize_for_branch_name("Add Feature (v2)"),
732 "add-feature-v2"
733 );
734 assert_eq!(sanitize_for_branch_name("john-smith"), "john-smith");
735 assert_eq!(
736 sanitize_for_branch_name("Fix: Authentication Issue"),
737 "fix-authentication-issue"
738 );
739 assert_eq!(sanitize_for_branch_name("Test@#$%"), "test");
740 }
741
742 #[test]
743 fn test_format_with_metadata() {
744 let metadata = PrMetadata {
745 number: 123,
746 title: "Fix Authentication Bug".to_string(),
747 author: "john-smith".to_string(),
748 head_ref: "feature/fix-auth".to_string(),
749 base_ref: "main".to_string(),
750 is_fork: false,
751 fork_owner: None,
752 fork_url: None,
753 };
754
755 assert_eq!(
756 format_pr_name_with_metadata("pr-{number}", &metadata),
757 "pr-123"
758 );
759 assert_eq!(
760 format_pr_name_with_metadata("{number}-{title}", &metadata),
761 "123-fix-authentication-bug"
762 );
763 assert_eq!(
764 format_pr_name_with_metadata("{author}/pr-{number}", &metadata),
765 "john-smith/pr-123"
766 );
767 assert_eq!(
768 format_pr_name_with_metadata("{branch}-{number}", &metadata),
769 "feature-fix-auth-123"
770 );
771 }
772
773 #[test]
774 fn test_parse_merged_pr_empty_array() {
775 assert_eq!(parse_merged_pr("[]"), None);
776 }
777
778 #[test]
779 fn test_parse_merged_pr_populated_array() {
780 let json = r#"[{"number":66,"mergedAt":"2024-01-01T00:00:00Z","headRefOid":"abc123"}]"#;
781 assert_eq!(
782 parse_merged_pr(json),
783 Some(MergedPr {
784 number: 66,
785 head_oid: "abc123".to_string()
786 })
787 );
788 }
789
790 #[test]
791 fn test_parse_merged_pr_number_missing() {
792 let json = r#"[{"mergedAt":"2024-01-01T00:00:00Z","headRefOid":"abc123"}]"#;
793 assert_eq!(parse_merged_pr(json), None);
794 }
795
796 #[test]
797 fn test_parse_merged_pr_number_non_numeric() {
798 let json = r#"[{"number":"not-a-number","mergedAt":"2024-01-01T00:00:00Z","headRefOid":"abc123"}]"#;
799 assert_eq!(parse_merged_pr(json), None);
800 }
801
802 #[test]
803 fn test_parse_merged_pr_head_oid_missing() {
804 let json = r#"[{"number":66,"mergedAt":"2024-01-01T00:00:00Z"}]"#;
805 assert_eq!(parse_merged_pr(json), None);
806 }
807
808 #[test]
809 fn test_parse_merged_pr_non_array_payload() {
810 assert_eq!(parse_merged_pr(r#"{"number":66}"#), None);
811 }
812
813 #[test]
814 fn test_parse_merged_pr_malformed_json() {
815 assert_eq!(parse_merged_pr("not json"), None);
816 }
817
818 #[test]
820 #[ignore]
821 fn test_gh_cli_available() {
822 check_gh_available().expect("gh CLI should be installed");
823 }
824
825 #[test]
826 #[ignore]
827 fn test_fetch_real_pr_metadata() {
828 let metadata = fetch_pr_metadata(1).expect("Failed to fetch PR metadata");
832 assert_eq!(metadata.number, 1);
833 assert!(!metadata.title.is_empty());
834 assert!(!metadata.author.is_empty());
835 }
836}