Skip to main content

gwm/
github.rs

1//! Issue ↔ PR ↔ branch link storage + GitHub API fetch (via `gh` CLI).
2//!
3//! Storage lives in git branch config: `branch.<name>.gwm-issue` and
4//! `branch.<name>.gwm-pr`. Issue numbers are auto-detected from the
5//! `<type>/#<N>-<slug>` branch convention when no explicit override is set.
6//!
7//! Fetch shells out to `gh` and parses its JSON output. The parsing functions
8//! (`parse_issue_json`, `parse_pr_json`) are exposed publicly so tests can
9//! cover the JSON contract without depending on a real `gh` binary.
10
11use 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";
29/// Persisted home of an auto-detected PR (issue #283). Kept distinct from
30/// the explicit [`PR_CONFIG_KEY`] so [`read_link`] can resolve it as
31/// [`LinkSource::Detected`] (not `Explicit`) — the pane needs that
32/// distinction for its `detected` badge, and the explicit override must
33/// still win.
34const 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/// Where the issue or PR number came from.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum LinkSource {
45  /// No link known (no branch-name match and no explicit override).
46  None,
47  /// Inferred from a branch following `<type>/#<N>-<slug>`.
48  BranchName,
49  /// Explicit override set via `gwm link …` (lives in git branch config).
50  Explicit,
51  /// Auto-detected from GitHub: a PR whose head ref is this branch was
52  /// found via `gh pr list --head <branch>` (issue #181). May be persisted
53  /// to the `gwm-pr-detected` branch-config key (issue #283) so the
54  /// no-fetch table read path surfaces it on every row; an explicit
55  /// `gwm link --pr` still always wins on the next read.
56  Detected,
57}
58
59/// Resolved link for one branch: which issue (if any), which PR (if any),
60/// and where each number came from.
61#[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  /// One-line human-readable rendering for the CLI / TUI status bar.
88  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
98/// Read the link for `branch`. Explicit overrides win over branch-name auto-detect.
99pub 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  // PR resolution order (issue #283): an explicit `gwm link --pr` wins,
112  // then a persisted auto-detection (`gwm-pr-detected`), then nothing. The
113  // persisted-detected branch is what lets the no-fetch table read path
114  // colour the PR pastille on every row without a per-row `gh` shell-out.
115  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
153/// Stamp an auto-detected PR number onto `link` when no PR is already
154/// linked. Pure helper (issue #181): the caller supplies the detection
155/// result — typically `find_pr_for_branch(slug, branch).ok().flatten()` —
156/// and this decides whether to apply it.
157///
158/// An explicit (or previously-detected) PR always wins: when `link.pr`
159/// is already `Some`, this is a no-op so a `gwm link --pr` override is
160/// never clobbered. The applied number is marked [`LinkSource::Detected`].
161/// This function only mutates the in-memory [`BranchLink`]; call
162/// [`persist_detected_pr`] separately to write it to the git config so the
163/// table read path (issue #283) picks it up.
164pub 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
175/// Resolve the link for `branch` and, unless a PR is *explicitly* linked,
176/// auto-detect the branch's PR from GitHub via `gh` (issue #181). The
177/// detected PR is marked [`LinkSource::Detected`].
178///
179/// A persisted auto-detection (`gwm-pr-detected`, issue #283) does NOT pin
180/// the result here: this is the live-detection path (`gwm status` /
181/// `gwm list --detect-pr`), so it re-runs `gh pr list` to reflect a PR that
182/// was opened / closed / replaced since the last detection, rather than
183/// echoing a stale stored number (Codex review #284). Only an explicit
184/// `gwm link --pr` short-circuits the probe.
185///
186/// On a successful probe this also **reconciles the persisted cache**
187/// (`gwm-pr-detected`): it rewrites the stored number to the fresh result,
188/// or clears it when the PR vanished, so the no-fetch consumers (`read_link`,
189/// the TUI table at startup, `gwm open pr`) don't resurrect a stale number
190/// after this path saw it change (Codex review #284). The cache write is
191/// best-effort — a read-only repo must not turn `gwm status` into an error.
192///
193/// Detection is best-effort: a `gh` failure (not installed, no network)
194/// leaves the link untouched — a persisted detection survives the failed
195/// probe rather than being wiped — and the local link is still returned.
196/// This shells out, so callers on hot paths (per-worktree listing) must opt
197/// in deliberately rather than route every read through here.
198pub 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    // Re-resolve live. On success, the fresh result replaces any persisted
202    // detection (a vanished PR clears it); on a `gh` failure, keep whatever
203    // `read_link` already resolved (possibly a persisted detection).
204    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      // Reconcile the persisted cache (issue #283 / Codex review #284) so the
225      // no-fetch consumers (`read_link`, the TUI table at startup,
226      // `gwm open pr`) don't resurrect a stale number after this live path
227      // saw it change or vanish. Best-effort: a read-only repo must not turn
228      // `gwm status` into an error, so a write failure is discarded.
229      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  // Drop both the explicit link and any persisted auto-detection (#283),
258  // otherwise unlinking would leave a stale `gwm-pr-detected` number that
259  // `read_link` would resurface as a `Detected` PR on the next read.
260  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
268/// Persist an auto-detected PR number to its own branch-config key
269/// (`gwm-pr-detected`, issue #283), distinct from the explicit `gwm-pr`.
270/// This lets the no-fetch table read path surface the detected PR on every
271/// row without a per-row `gh` shell-out, while keeping the
272/// detected/explicit distinction the pane badge needs. An explicit
273/// `gwm link --pr` still wins in [`read_link`]. Re-detection overwrites the
274/// stored value and clears a cached title only when the detected number
275/// actually changed.
276pub 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
287/// Drop a persisted auto-detection (issue #283). A no-op when no detected
288/// PR was stored. Used when a detection no longer holds (the branch's PR
289/// went away) so a stale number doesn't linger in the config.
290pub 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
420// ---- Repo slug from origin remote --------------------------------------
421
422/// Extract the `owner/repo` slug from the `origin` remote URL.
423/// Supports the two GitHub URL flavours: `git@github.com:owner/repo(.git)?`
424/// and `https://github.com/owner/repo(.git)?`.
425pub 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  // SSH: git@github.com:owner/repo(.git)?
439  if let Some(rest) = url.strip_prefix("git@github.com:") {
440    return Ok(trim_git_suffix(rest).to_string());
441  }
442  // HTTPS: https://github.com/owner/repo(.git)?
443  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  // Normalise trailing slashes first so `owner/repo.git/` becomes
456  // `owner/repo.git` before the `.git` strip kicks in. Pre-fix this
457  // returned `owner/repo.git` because `.git` was sought with a trailing
458  // `/` still attached (Copilot PR #68 review).
459  let trimmed = s.trim_end_matches('/');
460  trimmed.strip_suffix(".git").unwrap_or(trimmed)
461}
462
463// ---- Issue / PR status ---------------------------------------------------
464
465#[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/// Overall CI outcome derived from a PR's `statusCheckRollup` (issue #299).
520/// A single ordered signal so the sidebar can render pass/fail/running at a
521/// glance instead of a bare `N/M` count. Priority is **failing > running >
522/// passing**: the most actionable state always wins, so a red check is never
523/// hidden behind an in-flight one.
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525pub enum CiState {
526  /// The PR has no checks at all — render nothing.
527  None,
528  /// Every check completed successfully (counting `NEUTRAL` / `SKIPPED`).
529  Passing,
530  /// At least one check is still in flight and none has failed.
531  Running,
532  /// At least one check completed with a failing conclusion
533  /// (`FAILURE` / `CANCELLED` / `TIMED_OUT` / `ACTION_REQUIRED`).
534  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  /// Overall CI state derived from the same rollup that feeds
547  /// `checks_passed` / `checks_total` — no extra GitHub request.
548  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/// One `statusCheckRollup` entry. GitHub returns two shapes here: a
583/// `CheckRun` (the Checks API — carries `status` + `conclusion`) and a
584/// legacy `StatusContext` (the commit-status API — carries `state`). We
585/// deserialize all three so both shapes classify correctly.
586#[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  // Count the same "accepted" terminals the CI state treats as green, so the
627  // `N/M` shown next to the indicator stays consistent with its label — a
628  // rollup of SUCCESS + NEUTRAL + SKIPPED reads "passing 3/3", not "1/3"
629  // (Codex review #302).
630  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/// The outcome of a single rollup entry, before the per-PR aggregation.
649#[derive(Debug, Clone, Copy, PartialEq, Eq)]
650enum CheckOutcome {
651  Passing,
652  Running,
653  Failing,
654}
655
656/// Classify one rollup entry, handling both the `CheckRun` shape
657/// (`status` + `conclusion`) and the legacy `StatusContext` shape
658/// (`state`). A `CheckRun` is only green for an *accepted* terminal
659/// conclusion (SUCCESS / NEUTRAL / SKIPPED, or a completed check with no
660/// conclusion); every other terminal conclusion — FAILURE, CANCELLED,
661/// TIMED_OUT, ACTION_REQUIRED, STARTUP_FAILURE, STALE, … — reads as failing
662/// rather than silently falling through to green (Codex review #302).
663fn classify_check(c: &RawCheck) -> CheckOutcome {
664  // `CheckRun`: `status` is populated (QUEUED / IN_PROGRESS / COMPLETED).
665  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      // A completed check with no conclusion is treated leniently (green) so
672      // missing data never paints a false red.
673      None => CheckOutcome::Passing,
674      Some(_) => CheckOutcome::Failing,
675    };
676  }
677  // Legacy `StatusContext`: classify by `state`.
678  match c.state.to_ascii_uppercase().as_str() {
679    "SUCCESS" => CheckOutcome::Passing,
680    "FAILURE" | "ERROR" => CheckOutcome::Failing,
681    // PENDING / EXPECTED / unknown — not yet conclusive.
682    _ => CheckOutcome::Running,
683  }
684}
685
686/// Terminal `CheckRun` conclusions that count as green.
687fn is_accepted_conclusion(conclusion: &str) -> bool {
688  matches!(
689    conclusion.to_ascii_uppercase().as_str(),
690    "SUCCESS" | "NEUTRAL" | "SKIPPED"
691  )
692}
693
694/// Collapse a `statusCheckRollup` into a single [`CiState`] with the
695/// priority **failing > running > passing** (issue #299). A failing check
696/// wins immediately; any still-pending check downgrades an otherwise-green
697/// rollup to `Running`; an empty rollup is `None`.
698fn 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      // Failing outranks everything — short-circuit so a red check is never
706      // masked by a later in-flight one.
707      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
719// ---- gh CLI invocation ---------------------------------------------------
720
721const ISSUE_JSON_FIELDS: &str = "number,title,state,url,labels,updatedAt";
722const PR_JSON_FIELDS: &str = "number,title,state,isDraft,url,updatedAt,statusCheckRollup";
723
724/// Run `gh issue view <n> --repo <slug> --json …` and parse the result.
725pub fn fetch_issue(slug: &str, number: u64) -> Result<IssueStatus> {
726  fetch_issue_with(&gh_program(), slug, number)
727}
728
729/// [`fetch_issue`] with an explicitly resolved `gh` program path. Used by
730/// the TUI's off-thread fetch (issue #217): the program is resolved on the
731/// main thread via [`gh_program`] and handed to the worker thread, so the
732/// thread never touches `GWM_GH` / the process environment concurrently
733/// with env-mutating callers.
734pub 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
750/// Resolve the `gh` program to invoke: `$GWM_GH` when set (test / override
751/// hook), else `gh` on `PATH`. Read once on the calling thread so off-thread
752/// fetches can capture it without re-reading the environment.
753pub 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
788/// Shell out to `gh pr create` with a body file already rendered by
789/// [`crate::pr_templates::render_pr_body`]. Parses the URL printed by
790/// gh on success to extract the PR number.
791pub 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
829/// Run `gh pr view <n> --repo <slug> --json …` and parse the result.
830pub fn fetch_pr(slug: &str, number: u64) -> Result<PrStatus> {
831  fetch_pr_with(&gh_program(), slug, number)
832}
833
834/// [`fetch_pr`] with an explicitly resolved `gh` program path — PR-side
835/// counterpart to [`fetch_issue_with`], used by the TUI off-thread fetch
836/// (issue #217).
837pub 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/// The slice of PR metadata `gwm review` needs to materialise a worktree:
854/// the head ref name (slug source), the author login (path component), and
855/// the base ref (diff base). Distinct from [`PrStatus`] so the TUI's
856/// status/CI path stays untouched.
857#[derive(Debug, Clone, PartialEq, Eq)]
858pub struct PrHead {
859  pub number: u64,
860  /// Author login, e.g. `alice` (`dependabot[bot]` for bot PRs).
861  pub author: String,
862  /// The PR's head branch name, e.g. `feat/spike-x`.
863  pub head_ref_name: String,
864  /// The PR's base branch name, e.g. `main`.
865  pub base_ref_name: String,
866}
867
868#[derive(Deserialize)]
869struct RawPrHead {
870  number: u64,
871  // `Option` (not just `#[serde(default)]`) so an explicit `"author": null`
872  // — a deleted GitHub account — deserialises to `None` instead of erroring;
873  // `default` alone only covers a *missing* key.
874  #[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
890/// Parse the JSON from `gh pr view <n> --json number,author,headRefName,baseRefName`.
891/// Kept pure + `pub` so its shape is unit-testable without spawning `gh`.
892pub 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
905/// Run `gh pr view <n> --repo <slug> --json …` and parse the head metadata
906/// `gwm review` needs (author / head ref / base ref). Works for PRs in any
907/// state — open, draft, closed, or merged.
908pub 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
921/// Find the most recent PR opened from `branch` (head ref) on the given
922/// repo, regardless of state. Returns `Ok(Some(N))` if at least one PR
923/// exists (open, draft, closed, or merged — `gh pr list --state all`),
924/// `Ok(None)` otherwise. Callers that need state-aware filtering should
925/// pair this with `fetch_pr` to inspect `PrState` afterwards.
926pub 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
931/// Argv for `gh pr list --repo <slug> --head <branch> --state all --json
932/// number --limit 1`. Extracted so the test suite can pin the `gh`
933/// contract without shelling out; [`find_pr_for_branch`] is the caller
934/// that actually invokes it. `--state all` is the load-bearing bit: a
935/// closed or merged PR for the branch is still detected (its `PrState`
936/// is resolved later via [`fetch_pr`]).
937pub 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
954/// Parse the JSON array printed by `gh pr list --json number --limit 1`,
955/// returning the first PR number if any. Exposed for unit tests so the
956/// parse contract is covered without a `gh` shell-out.
957pub 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
977/// Build the human-readable command line stored on the Command Logs
978/// transcript (issue #226) for a `gh` invocation: the program's *file name*
979/// (so a `GWM_GH=/usr/bin/gh` override still reads as `gh issue view …`
980/// rather than leaking the full path) followed by the resolved args. Kept
981/// pure and `pub` so its argv format is unit-testable without spawning `gh`
982/// (which CI runners do not have).
983pub 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
996/// [`run_gh`] against an explicitly resolved `gh` program. Lets callers on
997/// a worker thread (issue #217) avoid re-reading `GWM_GH` / the process
998/// environment concurrently with env-mutating code on other threads.
999fn run_gh_with<I, S>(program: &OsStr, args: I) -> Result<String>
1000where
1001  I: IntoIterator<Item = S>,
1002  S: AsRef<OsStr>,
1003{
1004  // Collect the args once so they can both drive the spawn and build the
1005  // human-readable command line stored on the Command Logs transcript
1006  // (issue #226): the resolved `gh <args…>`, not an opaque handle.
1007  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
1023/// Build the canonical GitHub URL for an issue, given the repo slug.
1024pub fn issue_url(slug: &str, number: u64) -> String {
1025  format!("https://github.com/{}/issues/{}", slug, number)
1026}
1027
1028/// Build the canonical GitHub URL for a PR, given the repo slug.
1029pub fn pr_url(slug: &str, number: u64) -> String {
1030  format!("https://github.com/{}/pull/{}", slug, number)
1031}
1032
1033// ---- Labels (issue #81) -------------------------------------------------
1034
1035const LABEL_JSON_FIELDS: &str = "name,color,description";
1036const LABEL_LIST_LIMIT: &str = "1000";
1037
1038#[derive(Deserialize)]
1039struct RawLabel2 {
1040  name: String,
1041  /// `color` is a documented gh-CLI invariant — every label always
1042  /// carries one. We deliberately do NOT mark this `#[serde(default)]`:
1043  /// if a future gh contract change drops the field, we want a hard
1044  /// parse error rather than a silent empty-string that would flag
1045  /// every remote label as a colour mismatch in the diff. (Copilot
1046  /// review on PR #90.)
1047  color: String,
1048  #[serde(default)]
1049  description: Option<String>,
1050}
1051
1052/// Parse the JSON returned by `gh label list --json name,color,description`.
1053/// Exposed publicly so unit tests can cover the contract without
1054/// shelling out. Two normalisations happen here so callers get a
1055/// uniformly-shaped `RemoteLabel`:
1056///
1057/// - **`color`** is lowercased. GitHub serialises hex colours in
1058///   either case; the diff engine expects the lowercase form, and
1059///   normalising at the parse boundary means downstream code never
1060///   has to think about it.
1061/// - **`description`** is left as-is. An empty `""` from GitHub
1062///   round-trips as `Some("")`; the labels-diff module collapses
1063///   empty strings to `None` on its own.
1064pub 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
1081/// Argv for `gh label list --repo <slug> --json name,color,description --limit 1000`.
1082/// Extracted so the test suite can pin the contract; callers should
1083/// prefer `fetch_remote_labels` which actually shells out.
1084pub 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
1097/// Argv for `gh label create <name> --color <hex> [--description <desc>] --force --repo <slug>`.
1098/// The `--force` flag is the key contract bit: GitHub's CLI uses it
1099/// to mean "create OR update", which is exactly what `gwm labels
1100/// push` needs (no separate "edit" call). When `description` is
1101/// `None` we omit the flag entirely rather than pass `""` — gh would
1102/// otherwise wipe an existing description that the user didn't intend
1103/// to touch.
1104pub 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
1122/// Argv for `gh label delete <name> --repo <slug> --yes`. The `--yes`
1123/// flag bypasses the interactive confirm prompt; without it gh blocks
1124/// on a TTY read and `gwm labels push --prune` hangs.
1125pub 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
1136/// Run `gh label list --repo <slug> --json …` and parse the result.
1137/// Returns an empty vec when the remote has no labels (which is
1138/// distinct from "gh not installed" — that surfaces as
1139/// `CommandFailed`).
1140pub 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
1147/// Push one label upstream via `gh label create --force`. Returns
1148/// `Ok(())` on success; the caller is responsible for tracking which
1149/// label was created vs. updated (the diff already knows).
1150pub 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
1157/// Delete one label on the remote via `gh label delete --yes`. Used
1158/// by `gwm labels push --prune` for labels declared on the remote but
1159/// not in `.gwm.toml`.
1160///
1161/// Validates `name` through [`crate::labels::validate_label_name`]
1162/// BEFORE shelling out (issue #100). The argv-injection vector that
1163/// motivates `validate_label_name` for declared labels (config side)
1164/// applies equally to the prune path: `gh label delete <name>` takes
1165/// the name positionally, so a remote label whose name starts with
1166/// `-` (planted by an attacker who can edit the upstream label set,
1167/// or by an unrelated tool predating the validator) would be parsed
1168/// as a flag — `-h` no-ops the delete with a help banner, `--repo
1169/// other/repo` retargets the operation. We refuse the prune with a
1170/// scoped error instead of running the risky argv.
1171pub 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
1188// ---- Milestones (issue #82) ---------------------------------------------
1189
1190const MILESTONE_PER_PAGE: &str = "100";
1191
1192#[derive(Deserialize)]
1193struct RawMilestone {
1194  number: u64,
1195  title: String,
1196  /// Always present in the documented schema. Like `RawLabel2.color`
1197  /// for labels, we deliberately do NOT mark this `#[serde(default)]`:
1198  /// a contract change would surface as a hard parse error rather than
1199  /// silently flagging every remote milestone as a state mismatch.
1200  state: String,
1201  #[serde(default)]
1202  description: Option<String>,
1203  #[serde(default)]
1204  due_on: Option<String>,
1205}
1206
1207/// Parse the JSON returned by `gh api repos/:owner/:repo/milestones?state=all`.
1208/// Exposed publicly so unit tests can cover the contract without
1209/// shelling out. The `state` field is mapped to the strict
1210/// `MilestoneState` enum — an unknown value is a hard error rather
1211/// than a silent third state on the diff side.
1212pub 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
1241/// Argv for `gh api --paginate repos/<slug>/milestones?state=all&per_page=100`.
1242///
1243/// Two contract bits worth pinning:
1244/// - `state=all` — without it, the default endpoint only lists `open`
1245///   milestones and `gwm milestones push --prune` would silently
1246///   leave closed ones in place.
1247/// - `--paginate` — GitHub caps `per_page` at 100. Without paginating
1248///   we'd diff against a truncated remote set for repos with more
1249///   than 100 milestones, leading to bogus `create` rows and a
1250///   dangerously confusing `--prune` (Copilot review on PR #92).
1251pub 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
1259/// Argv for `gh api -X POST repos/<slug>/milestones -f title=… [-f
1260/// description=…] [-f due_on=…] -f state=…`. Each optional field is
1261/// omitted entirely when absent — `gh` would otherwise wipe the
1262/// existing remote value.
1263pub 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
1285/// Argv for `gh api -X PATCH repos/<slug>/milestones/<number> -f …`.
1286/// Same omission rules as `milestone_create_argv`: absent optionals
1287/// are skipped so the remote value isn't wiped.
1288pub 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
1310/// Argv for `gh api -X DELETE repos/<slug>/milestones/<number>`.
1311/// `gh api -X DELETE` is non-interactive by construction (no TTY
1312/// confirm), so there's no `--yes` equivalent to add.
1313pub 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
1322/// Run `gh api repos/<slug>/milestones?state=all` and parse the
1323/// result. Returns an empty vec when the remote has no milestones.
1324pub 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
1331/// Create one milestone upstream via `gh api -X POST`. Returns
1332/// `Ok(())` — the caller already has the spec; we don't bother
1333/// parsing the response back into a `RemoteMilestone`.
1334pub 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
1341/// Update one milestone upstream via `gh api -X PATCH`. `number` is
1342/// the GitHub-issued identifier carried through `MilestoneUpdate`.
1343pub 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
1350/// Delete one milestone on the remote via `gh api -X DELETE`. Used
1351/// by `gwm milestones push --prune` for milestones declared on the
1352/// remote but not in `.gwm.toml`.
1353pub 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}