1use anyhow::{Context, Result, bail};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22#[allow(dead_code)]
23pub(crate) enum MergeMethod {
24 #[default]
25 Squash,
26 Merge,
27 Rebase,
28}
29use serde::Deserialize;
30use std::path::Path;
31use std::process::Command;
32
33#[derive(Debug, Clone)]
34#[allow(dead_code)]
35pub(crate) struct PrInfo {
36 pub number: u32,
37 #[allow(dead_code)]
38 pub url: String,
39 #[allow(dead_code)]
40 pub head: String,
41 #[allow(dead_code)]
42 pub base: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub(crate) enum MergeState {
47 Clean,
48 Dirty,
49 Other(String),
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub(crate) struct PrMergeStatus {
54 pub merge_state: MergeState,
55 pub is_draft: bool,
56}
57
58#[derive(Deserialize)]
59#[allow(dead_code)]
60struct PrViewJson {
61 #[serde(rename = "mergeStateStatus")]
62 merge_state_status: String,
63 number: Option<u32>,
64 url: Option<String>,
65 #[serde(rename = "headRefName")]
66 head: Option<String>,
67 #[serde(rename = "baseRefName")]
68 base: Option<String>,
69 #[serde(rename = "isDraft")]
70 is_draft: Option<bool>,
71 state: Option<String>,
72 #[serde(rename = "merged")]
73 is_merged: Option<bool>,
74 #[serde(rename = "mergedAt")]
75 merged_at: Option<String>,
76}
77
78#[derive(Deserialize)]
79#[allow(dead_code)]
80struct RepoViewNameWithOwnerJson {
81 #[serde(rename = "nameWithOwner")]
82 name_with_owner: String,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub(crate) enum PrLifecycle {
88 Open,
89 Closed,
90 Merged,
91 Unknown(String),
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub(crate) struct PrLifecycleStatus {
97 pub lifecycle: PrLifecycle,
98 pub is_merged: bool,
99}
100
101#[allow(dead_code)]
102pub(crate) fn create_pr(
103 repo_root: &Path,
104 title: &str,
105 body: &str,
106 head: &str,
107 base: &str,
108 draft: bool,
109) -> Result<PrInfo> {
110 let safe_title = title.trim();
111 if safe_title.is_empty() {
112 bail!("PR title must be non-empty");
113 }
114
115 let body = if body.trim().is_empty() {
116 "Automated by Ralph.".to_string()
117 } else {
118 body.to_string()
119 };
120
121 let mut cmd = Command::new("gh");
122 cmd.current_dir(repo_root);
123 cmd.arg("pr")
124 .arg("create")
125 .arg("--title")
126 .arg(safe_title)
127 .arg("--body")
128 .arg(body)
129 .arg("--head")
130 .arg(head)
131 .arg("--base")
132 .arg(base);
133 if draft {
134 cmd.arg("--draft");
135 }
136
137 let output = cmd
138 .output()
139 .with_context(|| format!("run gh pr create in {}", repo_root.display()))?;
140
141 if !output.status.success() {
142 let stderr = String::from_utf8_lossy(&output.stderr);
143 bail!("gh pr create failed: {}", stderr.trim());
144 }
145
146 let stdout = String::from_utf8_lossy(&output.stdout);
147 let pr_url = extract_pr_url(&stdout).ok_or_else(|| {
148 anyhow::anyhow!(
149 "Unable to parse PR URL from gh output. Output: {}",
150 stdout.trim()
151 )
152 })?;
153
154 pr_view(repo_root, &pr_url)
155}
156
157#[allow(dead_code)]
158pub(crate) fn merge_pr(
159 repo_root: &Path,
160 pr_number: u32,
161 method: MergeMethod,
162 delete_branch: bool,
163) -> Result<()> {
164 let repo_name_with_owner = gh_repo_name_with_owner(repo_root)?;
165
166 let mut cmd = Command::new("gh");
167 cmd.current_dir(std::env::temp_dir());
170 cmd.arg("pr")
171 .arg("merge")
172 .arg(pr_number.to_string())
173 .arg("--repo")
174 .arg(&repo_name_with_owner)
175 .arg(merge_method_flag(method));
176
177 if delete_branch {
178 cmd.arg("--delete-branch");
179 }
180
181 let output = cmd.output().with_context(|| {
182 format!(
183 "run gh pr merge --repo {} in isolated cwd",
184 repo_name_with_owner
185 )
186 })?;
187
188 if !output.status.success() {
189 let stderr = String::from_utf8_lossy(&output.stderr);
190 bail!("gh pr merge failed: {}", stderr.trim());
191 }
192
193 Ok(())
194}
195
196#[allow(dead_code)]
197fn merge_method_flag(method: MergeMethod) -> &'static str {
198 match method {
199 MergeMethod::Squash => "--squash",
200 MergeMethod::Merge => "--merge",
201 MergeMethod::Rebase => "--rebase",
202 }
203}
204
205#[allow(dead_code)]
206fn gh_repo_name_with_owner(repo_root: &Path) -> Result<String> {
207 let output = Command::new("gh")
208 .current_dir(repo_root)
209 .arg("repo")
210 .arg("view")
211 .arg("--json")
212 .arg("nameWithOwner")
213 .output()
214 .with_context(|| format!("run gh repo view in {}", repo_root.display()))?;
215
216 if !output.status.success() {
217 let stderr = String::from_utf8_lossy(&output.stderr);
218 bail!("gh repo view failed: {}", stderr.trim());
219 }
220
221 parse_name_with_owner_from_repo_view_json(&output.stdout)
222}
223
224#[allow(dead_code)]
225fn parse_name_with_owner_from_repo_view_json(payload: &[u8]) -> Result<String> {
226 let repo: RepoViewNameWithOwnerJson =
227 serde_json::from_slice(payload).context("parse gh repo view json")?;
228 let trimmed = repo.name_with_owner.trim();
229 if trimmed.is_empty() {
230 bail!("gh repo view returned empty nameWithOwner");
231 }
232 Ok(trimmed.to_string())
233}
234
235#[allow(dead_code)]
236pub(crate) fn pr_merge_status(repo_root: &Path, pr_number: u32) -> Result<PrMergeStatus> {
237 let json = pr_view_json(repo_root, &pr_number.to_string())?;
238 Ok(pr_merge_status_from_view(&json))
239}
240
241#[allow(dead_code)]
243pub(crate) fn pr_lifecycle_status(repo_root: &Path, pr_number: u32) -> Result<PrLifecycleStatus> {
244 let json = pr_view_json(repo_root, &pr_number.to_string())?;
245 Ok(pr_lifecycle_status_from_view(&json))
246}
247
248#[allow(dead_code)]
249fn pr_lifecycle_status_from_view(json: &PrViewJson) -> PrLifecycleStatus {
250 let state = json.state.as_deref().unwrap_or("UNKNOWN");
251 let merged_flag = json.is_merged.unwrap_or(false) || json.merged_at.as_ref().is_some();
252
253 let lifecycle = match state {
254 "OPEN" => PrLifecycle::Open,
255 "CLOSED" => {
256 if merged_flag {
257 PrLifecycle::Merged
258 } else {
259 PrLifecycle::Closed
260 }
261 }
262 "MERGED" => PrLifecycle::Merged,
263 other => PrLifecycle::Unknown(other.to_string()),
264 };
265
266 let is_merged_final = merged_flag || matches!(lifecycle, PrLifecycle::Merged);
267
268 PrLifecycleStatus {
269 lifecycle,
270 is_merged: is_merged_final,
271 }
272}
273
274#[allow(dead_code)]
275fn pr_view(repo_root: &Path, selector: &str) -> Result<PrInfo> {
276 let json = pr_view_json(repo_root, selector)?;
277 let number = json
278 .number
279 .ok_or_else(|| anyhow::anyhow!("Missing PR number in gh response"))?;
280 let url = json
281 .url
282 .ok_or_else(|| anyhow::anyhow!("Missing PR url in gh response"))?;
283 let head = json
284 .head
285 .ok_or_else(|| anyhow::anyhow!("Missing PR head in gh response"))?;
286 let base = json
287 .base
288 .ok_or_else(|| anyhow::anyhow!("Missing PR base in gh response"))?;
289
290 Ok(PrInfo {
291 number,
292 url,
293 head,
294 base,
295 })
296}
297
298#[allow(dead_code)]
299fn pr_view_json(repo_root: &Path, selector: &str) -> Result<PrViewJson> {
300 let primary_fields = "mergeStateStatus,number,url,headRefName,baseRefName,isDraft,state,merged";
301 match run_gh_pr_view(repo_root, selector, primary_fields) {
302 Ok(json) => Ok(json),
303 Err(err) => {
304 let err_msg = err.to_string();
305 if err_msg.contains("Unknown JSON field: \"merged\"") {
306 let fallback_fields =
307 "mergeStateStatus,number,url,headRefName,baseRefName,isDraft,state,mergedAt";
308 return run_gh_pr_view(repo_root, selector, fallback_fields).with_context(|| {
309 "gh pr view failed after falling back to mergedAt field".to_string()
310 });
311 }
312 Err(err)
313 }
314 }
315}
316
317#[allow(dead_code)]
318fn run_gh_pr_view(repo_root: &Path, selector: &str, fields: &str) -> Result<PrViewJson> {
319 let output = Command::new("gh")
320 .current_dir(repo_root)
321 .arg("pr")
322 .arg("view")
323 .arg(selector)
324 .arg("--json")
325 .arg(fields)
326 .output()
327 .with_context(|| format!("run gh pr view in {}", repo_root.display()))?;
328
329 if !output.status.success() {
330 let stderr = String::from_utf8_lossy(&output.stderr);
331 bail!("gh pr view failed: {}", stderr.trim());
332 }
333
334 let json: PrViewJson =
335 serde_json::from_slice(&output.stdout).context("parse gh pr view json")?;
336 Ok(json)
337}
338
339#[allow(dead_code)]
340fn pr_merge_status_from_view(json: &PrViewJson) -> PrMergeStatus {
341 let merge_state = match json.merge_state_status.as_str() {
342 "CLEAN" => MergeState::Clean,
343 "DIRTY" => MergeState::Dirty,
344 other => MergeState::Other(other.to_string()),
345 };
346 PrMergeStatus {
347 merge_state,
348 is_draft: json.is_draft.unwrap_or(false),
349 }
350}
351
352#[allow(dead_code)]
353fn extract_pr_url(output: &str) -> Option<String> {
354 output
355 .lines()
356 .map(str::trim)
357 .find(|line| line.starts_with("http://") || line.starts_with("https://"))
358 .map(|line| line.to_string())
359}
360
361fn run_gh_with_no_update(args: &[&str]) -> Result<std::process::Output> {
363 std::process::Command::new("gh")
364 .args(args)
365 .env("GH_NO_UPDATE_NOTIFIER", "1")
366 .output()
367 .with_context(|| format!("run gh {}", args.join(" ")))
368}
369
370pub(crate) fn check_gh_available() -> Result<()> {
378 check_gh_available_with(run_gh_with_no_update)
379}
380
381fn check_gh_available_with<F>(run_gh: F) -> Result<()>
383where
384 F: Fn(&[&str]) -> Result<std::process::Output>,
385{
386 let version_output = run_gh(&["--version"]).with_context(|| {
388 "GitHub CLI (`gh`) not found on PATH. Install it from https://cli.github.com/ and re-run."
389 .to_string()
390 })?;
391
392 if !version_output.status.success() {
393 let stderr = String::from_utf8_lossy(&version_output.stderr);
394 bail!(
395 "`gh --version` failed (gh is not usable). Details: {}. Install/repair `gh` from https://cli.github.com/ and re-run.",
396 stderr.trim()
397 );
398 }
399
400 let auth_output = run_gh(&["auth", "status"]).with_context(|| {
402 "Failed to run `gh auth status`. Ensure `gh` is properly installed.".to_string()
403 })?;
404
405 if !auth_output.status.success() {
406 let stdout = String::from_utf8_lossy(&auth_output.stdout);
407 let stderr = String::from_utf8_lossy(&auth_output.stderr);
408 let details = if !stderr.is_empty() {
409 stderr.trim()
410 } else {
411 stdout.trim()
412 };
413 bail!(
414 "GitHub CLI (`gh`) is not authenticated. Run `gh auth login` and re-run. Details: {}",
415 details
416 );
417 }
418
419 Ok(())
420}
421
422#[cfg(test)]
423mod tests {
424 use super::{MergeMethod, MergeState, PrLifecycle, check_gh_available_with, extract_pr_url};
425 use super::{
426 PrViewJson, merge_method_flag, parse_name_with_owner_from_repo_view_json,
427 pr_lifecycle_status_from_view, pr_merge_status_from_view,
428 };
429
430 #[test]
431 fn extract_pr_url_picks_first_url_line() {
432 let output = "Creating pull request for feature...\nhttps://github.com/org/repo/pull/5\n";
433 let url = extract_pr_url(output).expect("url");
434 assert_eq!(url, "https://github.com/org/repo/pull/5");
435 }
436
437 #[test]
438 fn pr_merge_status_from_view_tracks_draft_flag() {
439 let json = PrViewJson {
440 merge_state_status: "CLEAN".to_string(),
441 number: Some(1),
442 url: Some("https://example.com/pr/1".to_string()),
443 head: Some("ralph/RQ-0001".to_string()),
444 base: Some("main".to_string()),
445 is_draft: Some(true),
446 state: Some("OPEN".to_string()),
447 is_merged: Some(false),
448 merged_at: None,
449 };
450
451 let status = pr_merge_status_from_view(&json);
452 assert_eq!(status.merge_state, MergeState::Clean);
453 assert!(status.is_draft);
454 }
455
456 #[test]
457 fn pr_merge_status_from_view_defaults_draft_false() {
458 let json = PrViewJson {
459 merge_state_status: "DIRTY".to_string(),
460 number: Some(2),
461 url: Some("https://example.com/pr/2".to_string()),
462 head: Some("ralph/RQ-0002".to_string()),
463 base: Some("main".to_string()),
464 is_draft: None,
465 state: Some("OPEN".to_string()),
466 is_merged: Some(false),
467 merged_at: None,
468 };
469
470 let status = pr_merge_status_from_view(&json);
471 assert_eq!(status.merge_state, MergeState::Dirty);
472 assert!(!status.is_draft);
473 }
474
475 #[test]
476 fn pr_merge_status_from_view_handles_unknown_state() {
477 let json = PrViewJson {
478 merge_state_status: "BLOCKED".to_string(),
479 number: Some(3),
480 url: Some("https://example.com/pr/3".to_string()),
481 head: Some("ralph/RQ-0003".to_string()),
482 base: Some("main".to_string()),
483 is_draft: Some(false),
484 state: Some("OPEN".to_string()),
485 is_merged: Some(false),
486 merged_at: None,
487 };
488
489 let status = pr_merge_status_from_view(&json);
490 assert_eq!(status.merge_state, MergeState::Other("BLOCKED".to_string()));
491 assert!(!status.is_draft);
492 }
493
494 #[test]
495 fn pr_lifecycle_status_from_view_open() {
496 let json = PrViewJson {
497 merge_state_status: "CLEAN".to_string(),
498 number: Some(1),
499 url: Some("https://example.com/pr/1".to_string()),
500 head: Some("ralph/RQ-0001".to_string()),
501 base: Some("main".to_string()),
502 is_draft: Some(false),
503 state: Some("OPEN".to_string()),
504 is_merged: Some(false),
505 merged_at: None,
506 };
507
508 let status = pr_lifecycle_status_from_view(&json);
509 assert!(matches!(status.lifecycle, PrLifecycle::Open));
510 assert!(!status.is_merged);
511 }
512
513 #[test]
514 fn pr_lifecycle_status_from_view_closed_not_merged() {
515 let json = PrViewJson {
516 merge_state_status: "CLEAN".to_string(),
517 number: Some(2),
518 url: Some("https://example.com/pr/2".to_string()),
519 head: Some("ralph/RQ-0002".to_string()),
520 base: Some("main".to_string()),
521 is_draft: Some(false),
522 state: Some("CLOSED".to_string()),
523 is_merged: Some(false),
524 merged_at: None,
525 };
526
527 let status = pr_lifecycle_status_from_view(&json);
528 assert!(matches!(status.lifecycle, PrLifecycle::Closed));
529 assert!(!status.is_merged);
530 }
531
532 #[test]
533 fn pr_lifecycle_status_from_view_closed_merged_at() {
534 let json = PrViewJson {
535 merge_state_status: "CLEAN".to_string(),
536 number: Some(3),
537 url: Some("https://example.com/pr/3".to_string()),
538 head: Some("ralph/RQ-0003".to_string()),
539 base: Some("main".to_string()),
540 is_draft: Some(false),
541 state: Some("CLOSED".to_string()),
542 is_merged: None,
543 merged_at: Some("2026-01-19T00:00:00Z".to_string()),
544 };
545
546 let status = pr_lifecycle_status_from_view(&json);
547 assert!(matches!(status.lifecycle, PrLifecycle::Merged));
548 assert!(status.is_merged);
549 }
550
551 #[test]
552 fn pr_lifecycle_status_from_view_closed_merged() {
553 let json = PrViewJson {
554 merge_state_status: "CLEAN".to_string(),
555 number: Some(3),
556 url: Some("https://example.com/pr/3".to_string()),
557 head: Some("ralph/RQ-0003".to_string()),
558 base: Some("main".to_string()),
559 is_draft: Some(false),
560 state: Some("CLOSED".to_string()),
561 is_merged: Some(true),
562 merged_at: None,
563 };
564
565 let status = pr_lifecycle_status_from_view(&json);
566 assert!(matches!(status.lifecycle, PrLifecycle::Merged));
567 assert!(status.is_merged);
568 }
569
570 #[test]
571 fn pr_lifecycle_status_from_view_merged_state() {
572 let json = PrViewJson {
573 merge_state_status: "CLEAN".to_string(),
574 number: Some(4),
575 url: Some("https://example.com/pr/4".to_string()),
576 head: Some("ralph/RQ-0004".to_string()),
577 base: Some("main".to_string()),
578 is_draft: Some(false),
579 state: Some("MERGED".to_string()),
580 is_merged: Some(true),
581 merged_at: None,
582 };
583
584 let status = pr_lifecycle_status_from_view(&json);
585 assert!(matches!(status.lifecycle, PrLifecycle::Merged));
586 assert!(status.is_merged);
587 }
588
589 #[test]
590 fn pr_lifecycle_status_from_view_unknown_state() {
591 let json = PrViewJson {
592 merge_state_status: "CLEAN".to_string(),
593 number: Some(5),
594 url: Some("https://example.com/pr/5".to_string()),
595 head: Some("ralph/RQ-0005".to_string()),
596 base: Some("main".to_string()),
597 is_draft: Some(false),
598 state: Some("WEIRD".to_string()),
599 is_merged: Some(false),
600 merged_at: None,
601 };
602
603 let status = pr_lifecycle_status_from_view(&json);
604 assert!(matches!(status.lifecycle, PrLifecycle::Unknown(s) if s == "WEIRD"));
605 assert!(!status.is_merged);
606 }
607
608 #[test]
609 fn check_gh_available_fails_when_gh_not_found() {
610 let run_gh = |_args: &[&str]| -> anyhow::Result<std::process::Output> {
612 Err(anyhow::anyhow!(std::io::Error::new(
613 std::io::ErrorKind::NotFound,
614 "No such file or directory"
615 )))
616 };
617
618 let result = check_gh_available_with(run_gh);
619 assert!(result.is_err());
620 let msg = result.unwrap_err().to_string();
621 assert!(msg.contains("GitHub CLI (`gh`) not found on PATH"));
622 assert!(msg.contains("https://cli.github.com/"));
623 }
624
625 #[test]
626 fn check_gh_available_fails_when_version_fails() {
627 let fail_status = std::process::Command::new("false")
630 .status()
631 .expect("'false' command should exist");
632
633 let run_gh = |args: &[&str]| -> anyhow::Result<std::process::Output> {
634 if args == ["--version"] {
635 Ok(std::process::Output {
636 status: fail_status,
637 stdout: vec![],
638 stderr: b"gh: command not recognized".to_vec(),
639 })
640 } else {
641 Ok(std::process::Output {
642 status: std::process::ExitStatus::default(),
643 stdout: vec![],
644 stderr: vec![],
645 })
646 }
647 };
648
649 let result = check_gh_available_with(run_gh);
650 assert!(result.is_err());
651 let msg = result.unwrap_err().to_string();
652 assert!(msg.contains("`gh --version` failed"));
653 assert!(msg.contains("gh is not usable"));
654 }
655
656 #[test]
657 fn check_gh_available_fails_when_auth_fails() {
658 let fail_status = std::process::Command::new("false")
661 .status()
662 .expect("'false' command should exist");
663
664 let run_gh = |args: &[&str]| -> anyhow::Result<std::process::Output> {
665 if args == ["--version"] {
666 Ok(std::process::Output {
667 status: std::process::ExitStatus::default(),
668 stdout: b"gh version 2.40.0".to_vec(),
669 stderr: vec![],
670 })
671 } else if args == ["auth", "status"] {
672 Ok(std::process::Output {
673 status: fail_status,
674 stdout: vec![],
675 stderr: b"You are not logged into any GitHub hosts".to_vec(),
676 })
677 } else {
678 Ok(std::process::Output {
679 status: std::process::ExitStatus::default(),
680 stdout: vec![],
681 stderr: vec![],
682 })
683 }
684 };
685
686 let result = check_gh_available_with(run_gh);
687 assert!(result.is_err());
688 let msg = result.unwrap_err().to_string();
689 assert!(msg.contains("GitHub CLI (`gh`) is not authenticated"));
690 assert!(msg.contains("gh auth login"));
691 }
692
693 #[test]
694 fn check_gh_available_succeeds_when_both_checks_pass() {
695 let run_gh = |args: &[&str]| -> anyhow::Result<std::process::Output> {
697 if args == ["--version"] {
698 Ok(std::process::Output {
699 status: std::process::ExitStatus::default(),
700 stdout: b"gh version 2.40.0".to_vec(),
701 stderr: vec![],
702 })
703 } else if args == ["auth", "status"] {
704 Ok(std::process::Output {
705 status: std::process::ExitStatus::default(),
706 stdout: b"Logged in to github.com as user".to_vec(),
707 stderr: vec![],
708 })
709 } else {
710 Ok(std::process::Output {
711 status: std::process::ExitStatus::default(),
712 stdout: vec![],
713 stderr: vec![],
714 })
715 }
716 };
717
718 let result = check_gh_available_with(run_gh);
719 assert!(result.is_ok());
720 }
721
722 #[test]
723 fn parse_name_with_owner_from_repo_view_json_accepts_valid_payload() {
724 let payload = br#"{ "nameWithOwner": "org/repo" }"#;
725 let result = parse_name_with_owner_from_repo_view_json(payload).expect("repo");
726 assert_eq!(result, "org/repo");
727 }
728
729 #[test]
730 fn parse_name_with_owner_from_repo_view_json_rejects_empty_value() {
731 let payload = br#"{ "nameWithOwner": " " }"#;
732 let err = parse_name_with_owner_from_repo_view_json(payload).unwrap_err();
733 assert!(
734 err.to_string().contains("empty nameWithOwner"),
735 "unexpected error: {}",
736 err
737 );
738 }
739
740 #[test]
741 fn merge_method_flag_maps_all_variants() {
742 assert_eq!(merge_method_flag(MergeMethod::Squash), "--squash");
743 assert_eq!(merge_method_flag(MergeMethod::Merge), "--merge");
744 assert_eq!(merge_method_flag(MergeMethod::Rebase), "--rebase");
745 }
746}