1use crate::error::{GwmError, Result};
12use crate::labels::{LabelSpec, RemoteLabel};
13use crate::milestones::{MilestoneSpec, MilestoneState, RemoteMilestone};
14use crate::naming::parse_branch;
15use git2::Repository;
16use serde::Deserialize;
17use std::ffi::{OsStr, OsString};
18use std::path::Path;
19use std::process::Command;
20use std::sync::LazyLock;
21
22static ISSUE_URL_RE: LazyLock<regex::Regex> =
23 LazyLock::new(|| regex::Regex::new(r"/issues/(\d+)(?:\b|$)").expect("static issue URL regex compiles"));
24static PR_URL_RE: LazyLock<regex::Regex> =
25 LazyLock::new(|| regex::Regex::new(r"/pull/(\d+)(?:\b|$)").expect("static PR URL regex compiles"));
26
27const ISSUE_CONFIG_KEY: &str = "gwm-issue";
28const PR_CONFIG_KEY: &str = "gwm-pr";
29const DETECTED_PR_CONFIG_KEY: &str = "gwm-pr-detected";
35const ISSUE_TITLE_CONFIG_KEY: &str = "gwm-issue-title";
36const PR_TITLE_CONFIG_KEY: &str = "gwm-pr-title";
37const DETECTED_PR_TITLE_CONFIG_KEY: &str = "gwm-pr-detected-title";
38const ISSUE_STATE_CONFIG_KEY: &str = "gwm-issue-state";
39const PR_STATE_CONFIG_KEY: &str = "gwm-pr-state";
40const DETECTED_PR_STATE_CONFIG_KEY: &str = "gwm-pr-detected-state";
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum LinkSource {
45 None,
47 BranchName,
49 Explicit,
51 Detected,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct BranchLink {
63 pub issue: Option<u64>,
64 pub pr: Option<u64>,
65 pub issue_title: Option<String>,
66 pub pr_title: Option<String>,
67 pub issue_state: Option<IssueState>,
68 pub pr_state: Option<PrState>,
69 pub issue_source: LinkSource,
70 pub pr_source: LinkSource,
71}
72
73impl BranchLink {
74 pub fn empty() -> Self {
75 Self {
76 issue: None,
77 pr: None,
78 issue_title: None,
79 pr_title: None,
80 issue_state: None,
81 pr_state: None,
82 issue_source: LinkSource::None,
83 pr_source: LinkSource::None,
84 }
85 }
86
87 pub fn summary(&self) -> String {
89 match (self.issue, self.pr) {
90 (None, None) => "no link".into(),
91 (Some(i), None) => format!("issue #{i}"),
92 (None, Some(p)) => format!("PR #{p}"),
93 (Some(i), Some(p)) => format!("issue #{i} · PR #{p}"),
94 }
95 }
96}
97
98pub fn read_link(repo: &Repository, branch: &str) -> Result<BranchLink> {
100 let explicit_issue = read_branch_u64(repo, branch, ISSUE_CONFIG_KEY)?;
101 let explicit_pr = read_branch_u64(repo, branch, PR_CONFIG_KEY)?;
102
103 let (issue, issue_source) = match explicit_issue {
104 Some(n) => (Some(n), LinkSource::Explicit),
105 None => match parse_branch(branch).and_then(|s| s.issue.parse::<u64>().ok()) {
106 Some(n) => (Some(n), LinkSource::BranchName),
107 None => (None, LinkSource::None),
108 },
109 };
110
111 let (pr, pr_source) = match explicit_pr {
116 Some(n) => (Some(n), LinkSource::Explicit),
117 None => match read_branch_u64(repo, branch, DETECTED_PR_CONFIG_KEY)? {
118 Some(n) => (Some(n), LinkSource::Detected),
119 None => (None, LinkSource::None),
120 },
121 };
122 let issue_title = match issue {
123 Some(_) => read_branch_string(repo, branch, ISSUE_TITLE_CONFIG_KEY)?,
124 None => None,
125 };
126 let issue_state = match issue {
127 Some(_) => read_branch_issue_state(repo, branch)?,
128 None => None,
129 };
130 let pr_title = match pr_source {
131 LinkSource::Explicit => read_branch_string(repo, branch, PR_TITLE_CONFIG_KEY)?,
132 LinkSource::Detected => read_branch_string(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?,
133 LinkSource::BranchName | LinkSource::None => None,
134 };
135 let pr_state = match pr_source {
136 LinkSource::Explicit => read_branch_pr_state(repo, branch, PR_STATE_CONFIG_KEY)?,
137 LinkSource::Detected => read_branch_pr_state(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)?,
138 LinkSource::BranchName | LinkSource::None => None,
139 };
140
141 Ok(BranchLink {
142 issue,
143 pr,
144 issue_title,
145 pr_title,
146 issue_state,
147 pr_state,
148 issue_source,
149 pr_source,
150 })
151}
152
153pub fn apply_detected_pr(link: &mut BranchLink, detected: Option<u64>) {
165 if link.pr.is_none() {
166 if let Some(n) = detected {
167 link.pr = Some(n);
168 link.pr_source = LinkSource::Detected;
169 link.pr_title = None;
170 link.pr_state = None;
171 }
172 }
173}
174
175pub fn read_link_with_pr_detection(repo: &Repository, branch: &str, slug: &str) -> Result<BranchLink> {
199 let mut link = read_link(repo, branch)?;
200 if link.pr_source != LinkSource::Explicit {
201 if let Ok(detected) = find_pr_for_branch(slug, branch) {
205 let previous_pr = link.pr;
206 let previous_pr_source = link.pr_source;
207 let previous_pr_title = link.pr_title.clone();
208 let previous_pr_state = link.pr_state;
209 link.pr = detected;
210 link.pr_source = match detected {
211 Some(_) => LinkSource::Detected,
212 None => LinkSource::None,
213 };
214 link.pr_title = if previous_pr_source == LinkSource::Detected && detected == previous_pr {
215 previous_pr_title
216 } else {
217 None
218 };
219 link.pr_state = if previous_pr_source == LinkSource::Detected && detected == previous_pr {
220 previous_pr_state
221 } else {
222 None
223 };
224 let _ = match detected {
230 Some(n) => persist_detected_pr(repo, branch, n),
231 None => clear_persisted_detected_pr(repo, branch),
232 };
233 }
234 }
235 Ok(link)
236}
237
238pub fn link_issue(repo: &Repository, branch: &str, number: u64) -> Result<()> {
239 write_branch_u64(repo, branch, ISSUE_CONFIG_KEY, number)?;
240 remove_branch_key(repo, branch, ISSUE_TITLE_CONFIG_KEY)?;
241 remove_branch_key(repo, branch, ISSUE_STATE_CONFIG_KEY)
242}
243
244pub fn link_pr(repo: &Repository, branch: &str, number: u64) -> Result<()> {
245 write_branch_u64(repo, branch, PR_CONFIG_KEY, number)?;
246 remove_branch_key(repo, branch, PR_TITLE_CONFIG_KEY)?;
247 remove_branch_key(repo, branch, PR_STATE_CONFIG_KEY)
248}
249
250pub fn unlink_issue(repo: &Repository, branch: &str) -> Result<()> {
251 remove_branch_key(repo, branch, ISSUE_CONFIG_KEY)?;
252 remove_branch_key(repo, branch, ISSUE_TITLE_CONFIG_KEY)?;
253 remove_branch_key(repo, branch, ISSUE_STATE_CONFIG_KEY)
254}
255
256pub fn unlink_pr(repo: &Repository, branch: &str) -> Result<()> {
257 remove_branch_key(repo, branch, PR_CONFIG_KEY)?;
261 remove_branch_key(repo, branch, PR_TITLE_CONFIG_KEY)?;
262 remove_branch_key(repo, branch, PR_STATE_CONFIG_KEY)?;
263 remove_branch_key(repo, branch, DETECTED_PR_CONFIG_KEY)?;
264 remove_branch_key(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?;
265 remove_branch_key(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)
266}
267
268pub fn persist_detected_pr(repo: &Repository, branch: &str, number: u64) -> Result<()> {
277 let previous = read_branch_u64(repo, branch, DETECTED_PR_CONFIG_KEY)?;
278 write_branch_u64(repo, branch, DETECTED_PR_CONFIG_KEY, number)?;
279 if previous == Some(number) {
280 Ok(())
281 } else {
282 remove_branch_key(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?;
283 remove_branch_key(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)
284 }
285}
286
287pub fn clear_persisted_detected_pr(repo: &Repository, branch: &str) -> Result<()> {
291 remove_branch_key(repo, branch, DETECTED_PR_CONFIG_KEY)?;
292 remove_branch_key(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?;
293 remove_branch_key(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)
294}
295
296pub fn persist_issue_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
297 write_branch_string(repo, branch, ISSUE_TITLE_CONFIG_KEY, title)
298}
299
300pub fn persist_pr_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
301 write_branch_string(repo, branch, PR_TITLE_CONFIG_KEY, title)
302}
303
304pub fn persist_detected_pr_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
305 write_branch_string(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY, title)
306}
307
308pub fn persist_issue_state(repo: &Repository, branch: &str, state: IssueState) -> Result<()> {
309 write_branch_string(repo, branch, ISSUE_STATE_CONFIG_KEY, issue_state_config_value(state))
310}
311
312pub fn persist_pr_state(repo: &Repository, branch: &str, state: PrState) -> Result<()> {
313 write_branch_string(repo, branch, PR_STATE_CONFIG_KEY, pr_state_config_value(state))
314}
315
316pub fn persist_detected_pr_state(repo: &Repository, branch: &str, state: PrState) -> Result<()> {
317 write_branch_string(repo, branch, DETECTED_PR_STATE_CONFIG_KEY, pr_state_config_value(state))
318}
319
320fn config_key(branch: &str, leaf: &str) -> String {
321 format!("branch.{}.{}", branch, leaf)
322}
323
324fn read_branch_u64(repo: &Repository, branch: &str, leaf: &str) -> Result<Option<u64>> {
325 let cfg = repo.config()?;
326 let key = config_key(branch, leaf);
327 match cfg.get_string(&key) {
328 Ok(s) => s
329 .trim()
330 .parse::<u64>()
331 .map(Some)
332 .map_err(|_| GwmError::Other(format!("config '{}' is not a valid number: {}", key, s))),
333 Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
334 Err(e) => Err(GwmError::Git(e)),
335 }
336}
337
338fn read_branch_string(repo: &Repository, branch: &str, leaf: &str) -> Result<Option<String>> {
339 let cfg = repo.config()?;
340 let key = config_key(branch, leaf);
341 match cfg.get_string(&key) {
342 Ok(s) => Ok(Some(s)),
343 Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
344 Err(e) => Err(GwmError::Git(e)),
345 }
346}
347
348fn read_branch_issue_state(repo: &Repository, branch: &str) -> Result<Option<IssueState>> {
349 Ok(
350 read_branch_string(repo, branch, ISSUE_STATE_CONFIG_KEY)?
351 .as_deref()
352 .and_then(parse_issue_state_config_value),
353 )
354}
355
356fn read_branch_pr_state(repo: &Repository, branch: &str, leaf: &str) -> Result<Option<PrState>> {
357 Ok(
358 read_branch_string(repo, branch, leaf)?
359 .as_deref()
360 .and_then(parse_pr_state_config_value),
361 )
362}
363
364fn parse_issue_state_config_value(value: &str) -> Option<IssueState> {
365 match value.trim().to_ascii_lowercase().as_str() {
366 "open" => Some(IssueState::Open),
367 "closed" => Some(IssueState::Closed),
368 _ => None,
369 }
370}
371
372fn parse_pr_state_config_value(value: &str) -> Option<PrState> {
373 match value.trim().to_ascii_lowercase().as_str() {
374 "open" => Some(PrState::Open),
375 "draft" => Some(PrState::Draft),
376 "closed" => Some(PrState::Closed),
377 "merged" => Some(PrState::Merged),
378 _ => None,
379 }
380}
381
382fn issue_state_config_value(state: IssueState) -> &'static str {
383 match state {
384 IssueState::Open => "open",
385 IssueState::Closed => "closed",
386 }
387}
388
389fn pr_state_config_value(state: PrState) -> &'static str {
390 match state {
391 PrState::Open => "open",
392 PrState::Draft => "draft",
393 PrState::Closed => "closed",
394 PrState::Merged => "merged",
395 }
396}
397
398fn write_branch_u64(repo: &Repository, branch: &str, leaf: &str, value: u64) -> Result<()> {
399 let mut cfg = repo.config()?;
400 cfg.set_str(&config_key(branch, leaf), &value.to_string())?;
401 Ok(())
402}
403
404fn write_branch_string(repo: &Repository, branch: &str, leaf: &str, value: &str) -> Result<()> {
405 let mut cfg = repo.config()?;
406 cfg.set_str(&config_key(branch, leaf), value)?;
407 Ok(())
408}
409
410fn remove_branch_key(repo: &Repository, branch: &str, leaf: &str) -> Result<()> {
411 let mut cfg = repo.config()?;
412 let key = config_key(branch, leaf);
413 match cfg.remove(&key) {
414 Ok(_) => Ok(()),
415 Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(()),
416 Err(e) => Err(GwmError::Git(e)),
417 }
418}
419
420pub fn repo_slug(repo: &Repository) -> Result<String> {
426 let remote = repo
427 .find_remote("origin")
428 .map_err(|_| GwmError::Other("no 'origin' remote configured".into()))?;
429 let url = remote
430 .url()
431 .ok()
432 .ok_or_else(|| GwmError::Other("origin remote has no URL (non-utf8?)".into()))?
433 .to_string();
434 parse_github_slug(&url)
435}
436
437fn parse_github_slug(url: &str) -> Result<String> {
438 if let Some(rest) = url.strip_prefix("git@github.com:") {
440 return Ok(trim_git_suffix(rest).to_string());
441 }
442 for prefix in ["https://github.com/", "http://github.com/"] {
444 if let Some(rest) = url.strip_prefix(prefix) {
445 return Ok(trim_git_suffix(rest).to_string());
446 }
447 }
448 Err(GwmError::Other(format!(
449 "origin '{}' is not a github URL (expected git@github.com:… or https://github.com/…)",
450 url
451 )))
452}
453
454fn trim_git_suffix(s: &str) -> &str {
455 let trimmed = s.trim_end_matches('/');
460 trimmed.strip_suffix(".git").unwrap_or(trimmed)
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
466pub enum IssueState {
467 Open,
468 Closed,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct IssueStatus {
473 pub number: u64,
474 pub title: String,
475 pub state: IssueState,
476 pub url: String,
477 pub labels: Vec<String>,
478 pub updated_at: String,
479}
480
481#[derive(Debug, Clone)]
482pub struct IssueCreateRequest<'a> {
483 pub title: &'a str,
484 pub body_file: &'a std::path::Path,
485 pub labels: &'a [String],
486 pub repo: Option<&'a str>,
487}
488
489#[derive(Debug, Clone)]
490pub struct CreatedIssue {
491 pub number: u64,
492 pub url: String,
493}
494
495#[derive(Debug, Clone)]
496pub struct PrCreateRequest<'a> {
497 pub title: &'a str,
498 pub body_file: &'a std::path::Path,
499 pub head: &'a str,
500 pub base: Option<&'a str>,
501 pub draft: bool,
502 pub repo: Option<&'a str>,
503}
504
505#[derive(Debug, Clone)]
506pub struct CreatedPr {
507 pub number: u64,
508 pub url: String,
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub enum PrState {
513 Open,
514 Draft,
515 Closed,
516 Merged,
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525pub enum CiState {
526 None,
528 Passing,
530 Running,
532 Failing,
535}
536
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct PrStatus {
539 pub number: u64,
540 pub title: String,
541 pub state: PrState,
542 pub url: String,
543 pub updated_at: String,
544 pub checks_passed: u32,
545 pub checks_total: u32,
546 pub ci: CiState,
549}
550
551#[derive(Deserialize)]
552struct RawIssue {
553 number: u64,
554 title: String,
555 state: String,
556 url: String,
557 #[serde(default)]
558 labels: Vec<RawLabel>,
559 #[serde(rename = "updatedAt", default)]
560 updated_at: String,
561}
562
563#[derive(Deserialize)]
564struct RawLabel {
565 name: String,
566}
567
568#[derive(Deserialize)]
569struct RawPr {
570 number: u64,
571 title: String,
572 state: String,
573 #[serde(rename = "isDraft", default)]
574 is_draft: bool,
575 url: String,
576 #[serde(rename = "updatedAt", default)]
577 updated_at: String,
578 #[serde(rename = "statusCheckRollup", default)]
579 status_check_rollup: Vec<RawCheck>,
580}
581
582#[derive(Deserialize)]
587struct RawCheck {
588 #[serde(default)]
589 status: String,
590 #[serde(default)]
591 conclusion: Option<String>,
592 #[serde(default)]
593 state: String,
594}
595
596pub fn parse_issue_json(s: &str) -> Result<IssueStatus> {
597 let raw: RawIssue = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
598 kind: "issue",
599 source: e,
600 })?;
601 let state = match raw.state.as_str() {
602 "OPEN" | "open" => IssueState::Open,
603 "CLOSED" | "closed" => IssueState::Closed,
604 other => return Err(GwmError::Other(format!("unknown issue state '{}'", other))),
605 };
606 Ok(IssueStatus {
607 number: raw.number,
608 title: raw.title,
609 state,
610 url: raw.url,
611 labels: raw.labels.into_iter().map(|l| l.name).collect(),
612 updated_at: raw.updated_at,
613 })
614}
615
616pub fn parse_pr_json(s: &str) -> Result<PrStatus> {
617 let raw: RawPr = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse { kind: "pr", source: e })?;
618 let state = match (raw.state.as_str(), raw.is_draft) {
619 ("MERGED" | "merged", _) => PrState::Merged,
620 ("CLOSED" | "closed", _) => PrState::Closed,
621 ("OPEN" | "open", true) => PrState::Draft,
622 ("OPEN" | "open", false) => PrState::Open,
623 (other, _) => return Err(GwmError::Other(format!("unknown PR state '{}'", other))),
624 };
625 let checks_total = raw.status_check_rollup.len() as u32;
626 let checks_passed = raw
631 .status_check_rollup
632 .iter()
633 .filter(|c| matches!(classify_check(c), CheckOutcome::Passing))
634 .count() as u32;
635 let ci = derive_ci_state(&raw.status_check_rollup);
636 Ok(PrStatus {
637 number: raw.number,
638 title: raw.title,
639 state,
640 url: raw.url,
641 updated_at: raw.updated_at,
642 checks_passed,
643 checks_total,
644 ci,
645 })
646}
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
650enum CheckOutcome {
651 Passing,
652 Running,
653 Failing,
654}
655
656fn classify_check(c: &RawCheck) -> CheckOutcome {
664 if !c.status.is_empty() {
666 if !c.status.eq_ignore_ascii_case("COMPLETED") {
667 return CheckOutcome::Running;
668 }
669 return match c.conclusion.as_deref() {
670 Some(s) if is_accepted_conclusion(s) => CheckOutcome::Passing,
671 None => CheckOutcome::Passing,
674 Some(_) => CheckOutcome::Failing,
675 };
676 }
677 match c.state.to_ascii_uppercase().as_str() {
679 "SUCCESS" => CheckOutcome::Passing,
680 "FAILURE" | "ERROR" => CheckOutcome::Failing,
681 _ => CheckOutcome::Running,
683 }
684}
685
686fn is_accepted_conclusion(conclusion: &str) -> bool {
688 matches!(
689 conclusion.to_ascii_uppercase().as_str(),
690 "SUCCESS" | "NEUTRAL" | "SKIPPED"
691 )
692}
693
694fn derive_ci_state(checks: &[RawCheck]) -> CiState {
699 if checks.is_empty() {
700 return CiState::None;
701 }
702 let mut any_running = false;
703 for c in checks {
704 match classify_check(c) {
705 CheckOutcome::Failing => return CiState::Failing,
708 CheckOutcome::Running => any_running = true,
709 CheckOutcome::Passing => {}
710 }
711 }
712 if any_running {
713 CiState::Running
714 } else {
715 CiState::Passing
716 }
717}
718
719const ISSUE_JSON_FIELDS: &str = "number,title,state,url,labels,updatedAt";
722const PR_JSON_FIELDS: &str = "number,title,state,isDraft,url,updatedAt,statusCheckRollup";
723
724pub fn fetch_issue(slug: &str, number: u64) -> Result<IssueStatus> {
726 fetch_issue_with(&gh_program(), slug, number)
727}
728
729pub fn fetch_issue_with(program: &OsStr, slug: &str, number: u64) -> Result<IssueStatus> {
735 let stdout = run_gh_with(
736 program,
737 [
738 "issue",
739 "view",
740 &number.to_string(),
741 "--repo",
742 slug,
743 "--json",
744 ISSUE_JSON_FIELDS,
745 ],
746 )?;
747 parse_issue_json(&stdout)
748}
749
750pub fn gh_program() -> OsString {
754 std::env::var_os("GWM_GH").unwrap_or_else(|| "gh".into())
755}
756
757pub fn create_issue(req: &IssueCreateRequest<'_>) -> Result<CreatedIssue> {
758 let mut args: Vec<OsString> = Vec::with_capacity(6 + 2 * req.labels.len() + if req.repo.is_some() { 2 } else { 0 });
759 args.push("issue".into());
760 args.push("create".into());
761 args.push("--title".into());
762 args.push(req.title.into());
763 args.push("--body-file".into());
764 args.push(req.body_file.as_os_str().to_owned());
765 for label in req.labels {
766 args.push("--label".into());
767 args.push(label.into());
768 }
769 if let Some(repo) = req.repo {
770 args.push("--repo".into());
771 args.push(repo.into());
772 }
773 let stdout = run_gh(&args)?;
774 let stdout = stdout.trim().to_string();
775 let Some(caps) = ISSUE_URL_RE.captures(&stdout) else {
776 return Err(GwmError::CommandFailed(format!(
777 "gh issue create did not print an issue URL containing a number: {}",
778 stdout
779 )));
780 };
781 let number = caps
782 .get(1)
783 .and_then(|m| m.as_str().parse::<u64>().ok())
784 .ok_or_else(|| GwmError::CommandFailed(format!("failed to parse issue number from gh output: {}", stdout)))?;
785 Ok(CreatedIssue { number, url: stdout })
786}
787
788pub fn create_pr(req: &PrCreateRequest<'_>) -> Result<CreatedPr> {
792 let mut args: Vec<OsString> = Vec::with_capacity(
793 8 + if req.draft { 1 } else { 0 } + if req.base.is_some() { 2 } else { 0 } + if req.repo.is_some() { 2 } else { 0 },
794 );
795 args.push("pr".into());
796 args.push("create".into());
797 args.push("--title".into());
798 args.push(req.title.into());
799 args.push("--body-file".into());
800 args.push(req.body_file.as_os_str().to_owned());
801 args.push("--head".into());
802 args.push(req.head.into());
803 if let Some(base) = req.base {
804 args.push("--base".into());
805 args.push(base.into());
806 }
807 if req.draft {
808 args.push("--draft".into());
809 }
810 if let Some(repo) = req.repo {
811 args.push("--repo".into());
812 args.push(repo.into());
813 }
814 let stdout = run_gh(&args)?;
815 let stdout = stdout.trim().to_string();
816 let Some(caps) = PR_URL_RE.captures(&stdout) else {
817 return Err(GwmError::CommandFailed(format!(
818 "gh pr create did not print a PR URL containing a number: {}",
819 stdout
820 )));
821 };
822 let number = caps
823 .get(1)
824 .and_then(|m| m.as_str().parse::<u64>().ok())
825 .ok_or_else(|| GwmError::CommandFailed(format!("failed to parse PR number from gh output: {}", stdout)))?;
826 Ok(CreatedPr { number, url: stdout })
827}
828
829pub fn fetch_pr(slug: &str, number: u64) -> Result<PrStatus> {
831 fetch_pr_with(&gh_program(), slug, number)
832}
833
834pub fn fetch_pr_with(program: &OsStr, slug: &str, number: u64) -> Result<PrStatus> {
838 let stdout = run_gh_with(
839 program,
840 [
841 "pr",
842 "view",
843 &number.to_string(),
844 "--repo",
845 slug,
846 "--json",
847 PR_JSON_FIELDS,
848 ],
849 )?;
850 parse_pr_json(&stdout)
851}
852
853#[derive(Debug, Clone, PartialEq, Eq)]
858pub struct PrHead {
859 pub number: u64,
860 pub author: String,
862 pub head_ref_name: String,
864 pub base_ref_name: String,
866}
867
868#[derive(Deserialize)]
869struct RawPrHead {
870 number: u64,
871 #[serde(default)]
875 author: Option<RawAuthor>,
876 #[serde(rename = "headRefName", default)]
877 head_ref_name: String,
878 #[serde(rename = "baseRefName", default)]
879 base_ref_name: String,
880}
881
882#[derive(Deserialize, Default)]
883struct RawAuthor {
884 #[serde(default)]
885 login: String,
886}
887
888const PR_HEAD_JSON_FIELDS: &str = "number,author,headRefName,baseRefName";
889
890pub fn parse_pr_head_json(s: &str) -> Result<PrHead> {
893 let raw: RawPrHead = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
894 kind: "pr head",
895 source: e,
896 })?;
897 Ok(PrHead {
898 number: raw.number,
899 author: raw.author.unwrap_or_default().login,
900 head_ref_name: raw.head_ref_name,
901 base_ref_name: raw.base_ref_name,
902 })
903}
904
905pub fn fetch_pr_head(slug: &str, number: u64) -> Result<PrHead> {
909 let stdout = run_gh([
910 "pr",
911 "view",
912 &number.to_string(),
913 "--repo",
914 slug,
915 "--json",
916 PR_HEAD_JSON_FIELDS,
917 ])?;
918 parse_pr_head_json(&stdout)
919}
920
921pub fn find_pr_for_branch(slug: &str, branch: &str) -> Result<Option<u64>> {
927 let stdout = run_gh(find_pr_argv(slug, branch))?;
928 parse_pr_list_number(&stdout)
929}
930
931pub fn find_pr_argv(slug: &str, branch: &str) -> Vec<String> {
938 vec![
939 "pr".into(),
940 "list".into(),
941 "--repo".into(),
942 slug.into(),
943 "--head".into(),
944 branch.into(),
945 "--state".into(),
946 "all".into(),
947 "--json".into(),
948 "number".into(),
949 "--limit".into(),
950 "1".into(),
951 ]
952}
953
954pub fn parse_pr_list_number(s: &str) -> Result<Option<u64>> {
958 #[derive(Deserialize)]
959 struct PrRef {
960 number: u64,
961 }
962 let arr: Vec<PrRef> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
963 kind: "pr list",
964 source: e,
965 })?;
966 Ok(arr.into_iter().next().map(|p| p.number))
967}
968
969fn run_gh<I, S>(args: I) -> Result<String>
970where
971 I: IntoIterator<Item = S>,
972 S: AsRef<OsStr>,
973{
974 run_gh_with(&gh_program(), args)
975}
976
977pub fn gh_command_line(program: &OsStr, args: &[OsString]) -> String {
984 let name = Path::new(program)
985 .file_name()
986 .map(|n| n.to_string_lossy().into_owned())
987 .unwrap_or_else(|| program.to_string_lossy().into_owned());
988 let mut line = name;
989 for arg in args {
990 line.push(' ');
991 line.push_str(&arg.to_string_lossy());
992 }
993 line
994}
995
996fn run_gh_with<I, S>(program: &OsStr, args: I) -> Result<String>
1000where
1001 I: IntoIterator<Item = S>,
1002 S: AsRef<OsStr>,
1003{
1004 let collected: Vec<OsString> = args.into_iter().map(|a| a.as_ref().to_os_string()).collect();
1008 let cmdline = gh_command_line(program, &collected);
1009 let mut cmd = Command::new(program);
1010 cmd.args(&collected);
1011 let output = crate::command_log::run_logged(&mut cmd, cmdline)
1012 .map_err(|e| GwmError::CommandFailed(format!("gh: failed to spawn ({}). Is `gh` installed and on PATH?", e)))?;
1013 if !output.status.success() {
1014 return Err(GwmError::CommandFailed(format!(
1015 "gh exited {}: {}",
1016 output.status,
1017 String::from_utf8_lossy(&output.stderr).trim()
1018 )));
1019 }
1020 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1021}
1022
1023pub fn issue_url(slug: &str, number: u64) -> String {
1025 format!("https://github.com/{}/issues/{}", slug, number)
1026}
1027
1028pub fn pr_url(slug: &str, number: u64) -> String {
1030 format!("https://github.com/{}/pull/{}", slug, number)
1031}
1032
1033const LABEL_JSON_FIELDS: &str = "name,color,description";
1036const LABEL_LIST_LIMIT: &str = "1000";
1037
1038#[derive(Deserialize)]
1039struct RawLabel2 {
1040 name: String,
1041 color: String,
1048 #[serde(default)]
1049 description: Option<String>,
1050}
1051
1052pub fn parse_labels_json(s: &str) -> Result<Vec<RemoteLabel>> {
1065 let raw: Vec<RawLabel2> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
1066 kind: "labels",
1067 source: e,
1068 })?;
1069 Ok(
1070 raw
1071 .into_iter()
1072 .map(|r| RemoteLabel {
1073 name: r.name,
1074 description: r.description,
1075 color: r.color.to_ascii_lowercase(),
1076 })
1077 .collect(),
1078 )
1079}
1080
1081pub fn label_list_argv(slug: &str) -> Vec<String> {
1085 vec![
1086 "label".into(),
1087 "list".into(),
1088 "--repo".into(),
1089 slug.into(),
1090 "--json".into(),
1091 LABEL_JSON_FIELDS.into(),
1092 "--limit".into(),
1093 LABEL_LIST_LIMIT.into(),
1094 ]
1095}
1096
1097pub fn label_create_argv(slug: &str, spec: &LabelSpec) -> Vec<String> {
1105 let mut argv = vec![
1106 "label".into(),
1107 "create".into(),
1108 spec.name.clone(),
1109 "--repo".into(),
1110 slug.into(),
1111 "--color".into(),
1112 spec.color.clone(),
1113 "--force".into(),
1114 ];
1115 if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
1116 argv.push("--description".into());
1117 argv.push(desc.clone());
1118 }
1119 argv
1120}
1121
1122pub fn label_delete_argv(slug: &str, name: &str) -> Vec<String> {
1126 vec![
1127 "label".into(),
1128 "delete".into(),
1129 name.into(),
1130 "--repo".into(),
1131 slug.into(),
1132 "--yes".into(),
1133 ]
1134}
1135
1136pub fn fetch_remote_labels(slug: &str) -> Result<Vec<RemoteLabel>> {
1141 let argv = label_list_argv(slug);
1142 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1143 let stdout = run_gh(&args)?;
1144 parse_labels_json(&stdout)
1145}
1146
1147pub fn push_label(slug: &str, spec: &LabelSpec) -> Result<()> {
1151 let argv = label_create_argv(slug, spec);
1152 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1153 run_gh(&args)?;
1154 Ok(())
1155}
1156
1157pub fn delete_label(slug: &str, name: &str) -> Result<()> {
1172 crate::labels::validate_label_name(name).map_err(|e| {
1173 let inner = match e {
1174 GwmError::Config(msg) => msg,
1175 other => other.to_string(),
1176 };
1177 GwmError::Config(format!(
1178 "labels (remote): {} — refusing to delete via `gh label delete`",
1179 inner
1180 ))
1181 })?;
1182 let argv = label_delete_argv(slug, name);
1183 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1184 run_gh(&args)?;
1185 Ok(())
1186}
1187
1188const MILESTONE_PER_PAGE: &str = "100";
1191
1192#[derive(Deserialize)]
1193struct RawMilestone {
1194 number: u64,
1195 title: String,
1196 state: String,
1201 #[serde(default)]
1202 description: Option<String>,
1203 #[serde(default)]
1204 due_on: Option<String>,
1205}
1206
1207pub fn parse_milestones_json(s: &str) -> Result<Vec<RemoteMilestone>> {
1213 let raw: Vec<RawMilestone> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
1214 kind: "milestones",
1215 source: e,
1216 })?;
1217 raw
1218 .into_iter()
1219 .map(|r| {
1220 let state = match r.state.as_str() {
1221 "open" => MilestoneState::Open,
1222 "closed" => MilestoneState::Closed,
1223 other => {
1224 return Err(GwmError::Other(format!(
1225 "milestone '{}' has unknown state '{}': expected 'open' or 'closed'",
1226 r.title, other
1227 )))
1228 }
1229 };
1230 Ok(RemoteMilestone {
1231 number: r.number,
1232 title: r.title,
1233 description: r.description,
1234 due_on: r.due_on,
1235 state,
1236 })
1237 })
1238 .collect()
1239}
1240
1241pub fn milestone_list_argv(slug: &str) -> Vec<String> {
1252 vec![
1253 "api".into(),
1254 "--paginate".into(),
1255 format!("repos/{}/milestones?state=all&per_page={}", slug, MILESTONE_PER_PAGE),
1256 ]
1257}
1258
1259pub fn milestone_create_argv(slug: &str, spec: &MilestoneSpec) -> Vec<String> {
1264 let mut argv = vec![
1265 "api".into(),
1266 "-X".into(),
1267 "POST".into(),
1268 format!("repos/{}/milestones", slug),
1269 "-f".into(),
1270 format!("title={}", spec.title),
1271 "-f".into(),
1272 format!("state={}", spec.state.as_str()),
1273 ];
1274 if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
1275 argv.push("-f".into());
1276 argv.push(format!("description={}", desc));
1277 }
1278 if let Some(due) = spec.due_on.as_ref().filter(|s| !s.is_empty()) {
1279 argv.push("-f".into());
1280 argv.push(format!("due_on={}", due));
1281 }
1282 argv
1283}
1284
1285pub fn milestone_update_argv(slug: &str, number: u64, spec: &MilestoneSpec) -> Vec<String> {
1289 let mut argv = vec![
1290 "api".into(),
1291 "-X".into(),
1292 "PATCH".into(),
1293 format!("repos/{}/milestones/{}", slug, number),
1294 "-f".into(),
1295 format!("title={}", spec.title),
1296 "-f".into(),
1297 format!("state={}", spec.state.as_str()),
1298 ];
1299 if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
1300 argv.push("-f".into());
1301 argv.push(format!("description={}", desc));
1302 }
1303 if let Some(due) = spec.due_on.as_ref().filter(|s| !s.is_empty()) {
1304 argv.push("-f".into());
1305 argv.push(format!("due_on={}", due));
1306 }
1307 argv
1308}
1309
1310pub fn milestone_delete_argv(slug: &str, number: u64) -> Vec<String> {
1314 vec![
1315 "api".into(),
1316 "-X".into(),
1317 "DELETE".into(),
1318 format!("repos/{}/milestones/{}", slug, number),
1319 ]
1320}
1321
1322pub fn fetch_remote_milestones(slug: &str) -> Result<Vec<RemoteMilestone>> {
1325 let argv = milestone_list_argv(slug);
1326 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1327 let stdout = run_gh(&args)?;
1328 parse_milestones_json(&stdout)
1329}
1330
1331pub fn create_milestone(slug: &str, spec: &MilestoneSpec) -> Result<()> {
1335 let argv = milestone_create_argv(slug, spec);
1336 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1337 run_gh(&args)?;
1338 Ok(())
1339}
1340
1341pub fn update_milestone(slug: &str, number: u64, spec: &MilestoneSpec) -> Result<()> {
1344 let argv = milestone_update_argv(slug, number, spec);
1345 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1346 run_gh(&args)?;
1347 Ok(())
1348}
1349
1350pub fn delete_milestone(slug: &str, number: u64) -> Result<()> {
1354 let argv = milestone_delete_argv(slug, number);
1355 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1356 run_gh(&args)?;
1357 Ok(())
1358}