gwm/github.rs
1//! Issue ↔ PR ↔ branch link storage, and the **GitHub backend** for the
2//! [`crate::forge::Forge`] trait (via the `gh` CLI).
3//!
4//! Storage lives in git branch config: `branch.<name>.gwm-issue` and
5//! `branch.<name>.gwm-pr`. Issue numbers are auto-detected from the
6//! `<type>/#<N>-<slug>` branch convention when no explicit override is set.
7//! Those `branch.<x>.gwm-*` keys are **forge-neutral** and deliberately stay
8//! shared rather than moving behind the trait (issue #419): a GitLab worktree
9//! reads and writes exactly the same keys.
10//!
11//! Fetch shells out to `gh` and parses its JSON output. The parsing functions
12//! (`parse_issue_json`, `parse_pr_json`) are exposed publicly so tests can
13//! cover the JSON contract without depending on a real `gh` binary.
14
15use crate::error::{GwmError, Result};
16use crate::forge::{self, Forge, ForgeKind};
17use crate::labels::{LabelSpec, RemoteLabel};
18use crate::milestones::{MilestoneSpec, MilestoneState, RemoteMilestone};
19use crate::naming::BranchParser;
20use git2::Repository;
21use serde::Deserialize;
22use std::ffi::{OsStr, OsString};
23use std::sync::LazyLock;
24
25// The parsed shapes are forge-agnostic and now live in `forge`; re-exported
26// here so the many `github::PrStatus` / `github::CiState` imports across the
27// TUI and CLI keep resolving unchanged.
28pub use crate::forge::{cli_command_line as gh_command_line, repo_slug};
29pub use crate::forge::{
30 CheckOutcome, CiState, CreatedIssue, CreatedPr, IssueCreateRequest, IssueState, IssueStatus, PrCheck,
31 PrCreateRequest, PrHead, PrState, PrStatus,
32};
33
34static ISSUE_URL_RE: LazyLock<regex::Regex> =
35 LazyLock::new(|| regex::Regex::new(r"/issues/(\d+)(?:\b|$)").expect("static issue URL regex compiles"));
36static PR_URL_RE: LazyLock<regex::Regex> =
37 LazyLock::new(|| regex::Regex::new(r"/pull/(\d+)(?:\b|$)").expect("static PR URL regex compiles"));
38
39const ISSUE_CONFIG_KEY: &str = "gwm-issue";
40const PR_CONFIG_KEY: &str = "gwm-pr";
41/// Persisted home of an auto-detected PR (issue #283). Kept distinct from
42/// the explicit [`PR_CONFIG_KEY`] so [`read_link`] can resolve it as
43/// [`LinkSource::Detected`] (not `Explicit`) — the pane needs that
44/// distinction for its `detected` badge, and the explicit override must
45/// still win.
46const DETECTED_PR_CONFIG_KEY: &str = "gwm-pr-detected";
47/// The forge instance a persisted link belongs to, as `<host>/<path>`
48/// taken from `origin` at write time (Codex review #458).
49///
50/// The link keys themselves are deliberately forge-neutral — that was
51/// the point of keeping them out of the [`crate::forge::Forge`] trait in
52/// issue #419 — but the *numbers* they hold are not. PR #128 on
53/// github.com and MR !128 on gitlab.com are unrelated objects, so once
54/// the forge became switchable a stored number could be reinterpreted
55/// against a different instance and silently link the worktree to a
56/// stranger's merge request. Stamping the origin lets [`read_link`]
57/// recognise a number that came from somewhere else and ignore it.
58const LINK_ORIGIN_CONFIG_KEY: &str = "gwm-link-origin";
59/// The backend half of the same guard. Per branch, like every other key
60/// here: `.gwm.toml` is a versioned file, so two worktrees of one repo
61/// legitimately resolve different backends, and a repo-level record made
62/// each of them wipe the other's links. See [`reconcile_link_forge`].
63const LINK_FORGE_CONFIG_KEY: &str = "gwm-link-forge";
64/// What an absent [`LINK_FORGE_CONFIG_KEY`] means. Not "whatever is
65/// resolving now" — pre-#419 gwm rejected every origin that was not
66/// `github.com`, so nothing else can have written those numbers.
67const LINK_FORGE_BEFORE_THE_KEY: &str = "github";
68const ISSUE_TITLE_CONFIG_KEY: &str = "gwm-issue-title";
69const PR_TITLE_CONFIG_KEY: &str = "gwm-pr-title";
70const DETECTED_PR_TITLE_CONFIG_KEY: &str = "gwm-pr-detected-title";
71const ISSUE_STATE_CONFIG_KEY: &str = "gwm-issue-state";
72const PR_STATE_CONFIG_KEY: &str = "gwm-pr-state";
73const DETECTED_PR_STATE_CONFIG_KEY: &str = "gwm-pr-detected-state";
74/// Manual agent-session pin (issue #408 US4): the session id the user
75/// attached to this branch's worktree with `gwm agents attach`. One pin per
76/// worktree; auto-detection stays the default and the pin only adds.
77const AGENT_PIN_CONFIG_KEY: &str = "gwm-agent-pin";
78
79/// Where the issue or PR number came from.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum LinkSource {
82 /// No link known (no branch-name match and no explicit override).
83 None,
84 /// Inferred from a branch following `<type>/#<N>-<slug>`.
85 BranchName,
86 /// Explicit override set via `gwm link …` (lives in git branch config).
87 Explicit,
88 /// Auto-detected from GitHub: a PR whose head ref is this branch was
89 /// found via `gh pr list --head <branch>` (issue #181). May be persisted
90 /// to the `gwm-pr-detected` branch-config key (issue #283) so the
91 /// no-fetch table read path surfaces it on every row; an explicit
92 /// `gwm link --pr` still always wins on the next read.
93 Detected,
94}
95
96/// Resolved link for one branch: which issue (if any), which PR (if any),
97/// and where each number came from.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct BranchLink {
100 pub issue: Option<u64>,
101 pub pr: Option<u64>,
102 pub issue_title: Option<String>,
103 pub pr_title: Option<String>,
104 pub issue_state: Option<IssueState>,
105 pub pr_state: Option<PrState>,
106 pub issue_source: LinkSource,
107 pub pr_source: LinkSource,
108}
109
110impl BranchLink {
111 pub fn empty() -> Self {
112 Self {
113 issue: None,
114 pr: None,
115 issue_title: None,
116 pr_title: None,
117 issue_state: None,
118 pr_state: None,
119 issue_source: LinkSource::None,
120 pr_source: LinkSource::None,
121 }
122 }
123
124 /// One-line human-readable rendering for the CLI / TUI status bar.
125 ///
126 /// `pr_noun` comes from [`crate::forge::Forge::pr_noun`] — "PR" on
127 /// GitHub, "MR" on GitLab (issue #419). Passed in rather than read from
128 /// a global so `BranchLink` stays a plain data struct.
129 pub fn summary(&self, pr_noun: &str) -> String {
130 match (self.issue, self.pr) {
131 (None, None) => "no link".into(),
132 (Some(i), None) => format!("issue #{i}"),
133 (None, Some(p)) => format!("{pr_noun} #{p}"),
134 (Some(i), Some(p)) => format!("issue #{i} · {pr_noun} #{p}"),
135 }
136 }
137}
138
139/// Read the link for `branch`. Explicit overrides win over branch-name auto-detect.
140///
141/// The branch-name half is read with a parser compiled from this repo's own
142/// `worktree.branch_pattern` (issue #417), which is what keeps auto-linking
143/// alive in a repo that customised it. Deriving the parser reads `.gwm.toml`
144/// and compiles a regex, so anything looping over branches should hoist that
145/// out and call [`read_link_with`] instead.
146pub fn read_link(repo: &Repository, branch: &str) -> Result<BranchLink> {
147 read_link_with(repo, branch, &BranchParser::for_repo(repo))
148}
149
150/// [`read_link`] with the branch parser supplied by the caller, for loops that
151/// would otherwise re-read `.gwm.toml` once per branch.
152pub fn read_link_with(repo: &Repository, branch: &str, parser: &BranchParser) -> Result<BranchLink> {
153 // Numbers stamped against another instance are dropped before they are
154 // resolved (Codex review #458). Only the *persisted* values go: the
155 // issue parsed out of the branch name is the user's own naming and
156 // stays valid wherever the repo now points.
157 let foreign = match read_branch_string(repo, branch, LINK_ORIGIN_CONFIG_KEY)? {
158 Some(stored) => link_origin_is_foreign(repo)(&stored),
159 // No stamp means the link predates the key. Invalidating those would
160 // wipe every existing link on upgrade, so they are adopted by the
161 // origin the repo has right now — which is almost always the one
162 // that wrote them, and makes a *later* move invalidate properly
163 // instead of leaving them unscoped forever (Codex review #458).
164 //
165 // Adoption is a one-time write per branch, guarded on there being
166 // something to adopt, so the per-row read path does not touch git
167 // config on every listing. Best-effort: read-only repos keep working
168 // and simply stay unstamped.
169 None => {
170 // Any persisted link value, not just the numbers: an issue derived
171 // from the branch name stores no number at all, only a cached
172 // title and state, and keying adoption on numbers left those
173 // branches unstamped forever (Codex review #458).
174 let mut has_link = false;
175 for key in [
176 ISSUE_CONFIG_KEY,
177 PR_CONFIG_KEY,
178 DETECTED_PR_CONFIG_KEY,
179 ISSUE_TITLE_CONFIG_KEY,
180 ISSUE_STATE_CONFIG_KEY,
181 PR_TITLE_CONFIG_KEY,
182 PR_STATE_CONFIG_KEY,
183 DETECTED_PR_TITLE_CONFIG_KEY,
184 DETECTED_PR_STATE_CONFIG_KEY,
185 ] {
186 if read_branch_string(repo, branch, key)?.is_some() {
187 has_link = true;
188 break;
189 }
190 }
191 if has_link {
192 if let Some(id) = origin_identity(repo) {
193 let _ = write_branch_string(repo, branch, LINK_ORIGIN_CONFIG_KEY, &id);
194 }
195 }
196 false
197 }
198 };
199 let explicit_issue = if foreign {
200 None
201 } else {
202 read_branch_u64(repo, branch, ISSUE_CONFIG_KEY)?
203 };
204 let explicit_pr = if foreign {
205 None
206 } else {
207 read_branch_u64(repo, branch, PR_CONFIG_KEY)?
208 };
209
210 let (issue, issue_source) = match explicit_issue {
211 Some(n) => (Some(n), LinkSource::Explicit),
212 None => match parser.parse(branch).and_then(|s| s.issue.parse::<u64>().ok()) {
213 Some(n) => (Some(n), LinkSource::BranchName),
214 None => (None, LinkSource::None),
215 },
216 };
217
218 // PR resolution order (issue #283): an explicit `gwm link --pr` wins,
219 // then a persisted auto-detection (`gwm-pr-detected`), then nothing. The
220 // persisted-detected branch is what lets the no-fetch table read path
221 // colour the PR pastille on every row without a per-row `gh` shell-out.
222 let (pr, pr_source) = match explicit_pr {
223 Some(n) => (Some(n), LinkSource::Explicit),
224 None if foreign => (None, LinkSource::None),
225 None => match read_branch_u64(repo, branch, DETECTED_PR_CONFIG_KEY)? {
226 Some(n) => (Some(n), LinkSource::Detected),
227 None => (None, LinkSource::None),
228 },
229 };
230 // The cached title and state are as instance-scoped as the numbers.
231 // The issue survives a foreign stamp when the branch name carries it,
232 // and reading the previous tenant's metadata onto that number showed
233 // one instance's issue under the other's title until the next write
234 // purged it — which offline or read-only never comes (Codex review
235 // #458).
236 let issue_title = match issue {
237 Some(_) if !foreign => read_branch_string(repo, branch, ISSUE_TITLE_CONFIG_KEY)?,
238 _ => None,
239 };
240 let issue_state = match issue {
241 Some(_) if !foreign => read_branch_issue_state(repo, branch)?,
242 _ => None,
243 };
244 let pr_title = match pr_source {
245 LinkSource::Explicit => read_branch_string(repo, branch, PR_TITLE_CONFIG_KEY)?,
246 LinkSource::Detected => read_branch_string(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?,
247 LinkSource::BranchName | LinkSource::None => None,
248 };
249 let pr_state = match pr_source {
250 LinkSource::Explicit => read_branch_pr_state(repo, branch, PR_STATE_CONFIG_KEY)?,
251 LinkSource::Detected => read_branch_pr_state(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)?,
252 LinkSource::BranchName | LinkSource::None => None,
253 };
254
255 Ok(BranchLink {
256 issue,
257 pr,
258 issue_title,
259 pr_title,
260 issue_state,
261 pr_state,
262 issue_source,
263 pr_source,
264 })
265}
266
267/// Stamp an auto-detected PR number onto `link` when no PR is already
268/// linked. Pure helper (issue #181): the caller supplies the detection
269/// result — typically `find_pr_for_branch(slug, branch).ok().flatten()` —
270/// and this decides whether to apply it.
271///
272/// An explicit (or previously-detected) PR always wins: when `link.pr`
273/// is already `Some`, this is a no-op so a `gwm link --pr` override is
274/// never clobbered. The applied number is marked [`LinkSource::Detected`].
275/// This function only mutates the in-memory [`BranchLink`]; call
276/// [`persist_detected_pr`] separately to write it to the git config so the
277/// table read path (issue #283) picks it up.
278pub fn apply_detected_pr(link: &mut BranchLink, detected: Option<u64>) {
279 if link.pr.is_none() {
280 if let Some(n) = detected {
281 link.pr = Some(n);
282 link.pr_source = LinkSource::Detected;
283 link.pr_title = None;
284 link.pr_state = None;
285 }
286 }
287}
288
289/// Resolve the link for `branch` and, unless a PR is *explicitly* linked,
290/// auto-detect the branch's PR from GitHub via `gh` (issue #181). The
291/// detected PR is marked [`LinkSource::Detected`].
292///
293/// A persisted auto-detection (`gwm-pr-detected`, issue #283) does NOT pin
294/// the result here: this is the live-detection path (`gwm status` /
295/// `gwm list --detect-pr`), so it re-runs `gh pr list` to reflect a PR that
296/// was opened / closed / replaced since the last detection, rather than
297/// echoing a stale stored number (Codex review #284). Only an explicit
298/// `gwm link --pr` short-circuits the probe.
299///
300/// On a successful probe this also **reconciles the persisted cache**
301/// (`gwm-pr-detected`): it rewrites the stored number to the fresh result,
302/// or clears it when the PR vanished, so the no-fetch consumers (`read_link`,
303/// the TUI table at startup, `gwm open pr`) don't resurrect a stale number
304/// after this path saw it change (Codex review #284). The cache write is
305/// best-effort — a read-only repo must not turn `gwm status` into an error.
306///
307/// Detection is best-effort: a `gh` failure (not installed, no network)
308/// leaves the link untouched — a persisted detection survives the failed
309/// probe rather than being wiped — and the local link is still returned.
310/// This shells out, so callers on hot paths (per-worktree listing) must opt
311/// in deliberately rather than route every read through here.
312pub fn read_link_with_pr_detection(repo: &Repository, branch: &str, forge: &dyn Forge) -> Result<BranchLink> {
313 let mut link = read_link(repo, branch)?;
314 if link.pr_source != LinkSource::Explicit {
315 // Re-resolve live. On success, the fresh result replaces any persisted
316 // detection (a vanished PR clears it); on a CLI failure, keep whatever
317 // `read_link` already resolved (possibly a persisted detection).
318 if let Ok(detected) = forge.find_pr_for_branch(branch) {
319 let previous_pr = link.pr;
320 let previous_pr_source = link.pr_source;
321 let previous_pr_title = link.pr_title.clone();
322 let previous_pr_state = link.pr_state;
323 link.pr = detected;
324 link.pr_source = match detected {
325 Some(_) => LinkSource::Detected,
326 None => LinkSource::None,
327 };
328 link.pr_title = if previous_pr_source == LinkSource::Detected && detected == previous_pr {
329 previous_pr_title
330 } else {
331 None
332 };
333 link.pr_state = if previous_pr_source == LinkSource::Detected && detected == previous_pr {
334 previous_pr_state
335 } else {
336 None
337 };
338 // Reconcile the persisted cache (issue #283 / Codex review #284) so the
339 // no-fetch consumers (`read_link`, the TUI table at startup,
340 // `gwm open pr`) don't resurrect a stale number after this live path
341 // saw it change or vanish. Best-effort: a read-only repo must not turn
342 // `gwm status` into an error, so a write failure is discarded.
343 let _ = match detected {
344 Some(n) => persist_detected_pr(repo, branch, n),
345 None => clear_persisted_detected_pr(repo, branch),
346 };
347 }
348 }
349 Ok(link)
350}
351
352/// `<web origin>/<path>` of the repo's `origin`, or `None` when there is
353/// no origin or it does not parse. A local-only repo therefore stamps
354/// nothing and is never invalidated — there is no second instance for
355/// its numbers to be confused with.
356///
357/// Covers a change of *instance*, not a change of *backend*: flipping
358/// `forge = "gitlab"` in `.gwm.toml` over an unchanged remote leaves
359/// this identity untouched, so existing numbers are reinterpreted by
360/// the other backend (Codex review #458). Catching it means threading
361/// the resolved forge through `link_issue` / `link_pr` /
362/// `persist_detected_pr` and into `read_link`, which has no `Config` —
363/// deferred as churn out of proportion to a case that needs the backend
364/// switched on a remote that did not move.
365///
366/// The web origin rather than the bare host, because it carries the
367/// scheme and the port: two self-hosted instances on one hostname behind
368/// different ports are different instances, and `host/path` collapsed
369/// them into one stamp (Codex review #458). It also keeps `ssh://` and
370/// `https://` spellings of the same repo on the same stamp, so switching
371/// remote protocol does not throw the links away.
372fn origin_identity(repo: &Repository) -> Option<String> {
373 let remote = repo.find_remote("origin").ok()?;
374 let parsed = forge::parse_remote_url(remote.url().ok()?).ok()?;
375 Some(format!("{}/{}", parsed.web_origin, parsed.path))
376}
377
378/// Stamp the current origin on the branch's links, dropping anything the
379/// previous origin left behind.
380///
381/// The eager purge is what makes the stamp trustworthy. One stamp covers
382/// the issue, the explicit PR and the detected PR, so writing just one of
383/// them after a move would rewrite the stamp and silently re-bless the
384/// other two — and the lazy check in [`read_link`] would never fire
385/// again, because the stamp now matches (Codex review #458).
386///
387/// Best-effort throughout: a read-only repo must not turn a successful
388/// `gwm pr` into an error.
389fn stamp_link_origin(repo: &Repository, branch: &str) {
390 let Some(id) = origin_identity(repo) else { return };
391 if let Ok(Some(stored)) = read_branch_string(repo, branch, LINK_ORIGIN_CONFIG_KEY) {
392 if stored != id && drop_branch_links(repo, branch).is_err() {
393 // Same rule as `reconcile_link_forge`: no purge, no new stamp.
394 return;
395 }
396 }
397 let _ = write_branch_string(repo, branch, LINK_ORIGIN_CONFIG_KEY, &id);
398}
399
400/// Every number, title and state the link layer persists for `branch`.
401/// One list, because a purge that forgets a key silently re-blesses it.
402fn drop_branch_links(repo: &Repository, branch: &str) -> Result<()> {
403 for key in [
404 ISSUE_CONFIG_KEY,
405 ISSUE_TITLE_CONFIG_KEY,
406 ISSUE_STATE_CONFIG_KEY,
407 PR_CONFIG_KEY,
408 PR_TITLE_CONFIG_KEY,
409 PR_STATE_CONFIG_KEY,
410 DETECTED_PR_CONFIG_KEY,
411 DETECTED_PR_TITLE_CONFIG_KEY,
412 DETECTED_PR_STATE_CONFIG_KEY,
413 ] {
414 remove_branch_key(repo, branch, key)?;
415 }
416 Ok(())
417}
418
419/// Drop every persisted link when the repo changes **backend**.
420///
421/// [`origin_identity`] covers a change of instance and cannot cover this
422/// one: flipping `forge = "gitlab"` in `.gwm.toml` leaves the remote,
423/// and therefore `<web origin>/<path>`, exactly as it was. The numbers
424/// survive and the other backend reads them as its own — issue #42
425/// resurfaces as merge request !42, a real page and the wrong one
426/// (Codex review #458).
427///
428/// Called from [`crate::forge::resolve`] rather than from the readers.
429/// The busiest reader is [`crate::worktree::list`], which has no
430/// `Config` and is threaded through most of the test suite; `resolve` is
431/// the single place that decides a repo's backend and already holds
432/// both halves. The cost in the steady state is one config read.
433///
434/// An absent record **adopts**: links written before this key existed
435/// must survive the upgrade that introduces it, exactly as an absent
436/// origin stamp is not treated as a mismatch.
437///
438/// # Two invariants, and where every caller sits against them
439///
440/// **Atomicity — the marker only advances when the purge fully
441/// succeeded.** Advancing it after a failed removal re-blesses the old
442/// numbers *permanently*: the mismatch never fires again and the other
443/// backend reads them as its own. Same rule [`stamp_link_origin`]
444/// states for the origin stamp. The removals and the marker write share
445/// one config lock, so they fail together anyway — the guard is what
446/// makes that a property of the code rather than a coincidence.
447///
448/// **Scope — one branch, the one at HEAD.** The marker started
449/// repo-level, on the reasoning that a backend is a property of a repo.
450/// It is not: `.gwm.toml` is versioned, so two worktrees of one repo
451/// legitimately carry different `forge` values, and the purge swept
452/// *every* local branch — running gwm in each in turn wiped the other's
453/// links, both ways, forever (Codex review #458). Repo-wide data loss
454/// out of a per-worktree setting. Per-branch is also the scope every
455/// other key here already uses.
456///
457/// The cost of that scoping, named: `gwm open --worktree <other>`
458/// reconciles the branch at HEAD rather than the target's, so the
459/// target keeps a stale number for that one command. Same class as the
460/// `worktree::list` gap below — a stale read, never a wrong write.
461///
462/// **Ordering — every link read or write happens *after* a reconcile,
463/// never before.** Read too early and the stale number is served one
464/// more time; write too early and the write lands under the outgoing
465/// marker, so the next reconcile deletes what the user just did. Both
466/// happened (Codex review #458), which is why the whole surface is
467/// enumerated here rather than fixed one call site per round:
468///
469/// | site | reconciles | why |
470/// |---|---|---|
471/// | `cli::cmd_open` | resolves, then reads | fixed: it resolved after `read_link` |
472/// | `cli::cmd_status` | resolves, then reads | already correct |
473/// | `cli::cmd_link` | [`crate::forge::reconcile_links`] | fixed: it never resolved |
474/// | `cli::cmd_unlink` | no | removes keys; a later purge takes no more than it would |
475/// | `cli::cmd_pr` | `resolve_or_default` before `link_pr` | already correct |
476/// | `cli::cmd_review` | `resolve` before `review::materialize` | already correct |
477/// | `tui::GitHubFetch::reread_link` | resolves, then reads | fixed: it read first |
478/// | `tui::App` link prompt | via `reread_link` on selection | already correct |
479/// | [`crate::worktree::list`] | **no** | no `Config`; display only, and it self-heals on the next resolve |
480///
481/// `worktree::list` is the one deliberate gap: it is a pure reader with
482/// no `Config`, so a flip leaves its badges stale until any command or
483/// TUI selection resolves. Nothing is written from there, so a stale
484/// badge is the whole of the damage.
485///
486/// Best-effort throughout — a read-only repo must not turn a resolve
487/// into an error.
488pub(crate) fn reconcile_link_forge(repo: &Repository, kind: crate::forge::ForgeKind) {
489 let now = kind.as_str();
490 let Ok(head) = repo.head() else { return };
491 let Some(branch) = pinnable_branch(head.shorthand().ok()).map(str::to_string) else {
492 return;
493 };
494 // An absent record is not "adopt whatever is resolving now": pre-#419
495 // gwm rejected every origin that was not `github.com`, so any number
496 // already on the branch is a GitHub number. Reading absent as
497 // adoption re-blessed all of them as GitLab iids on the first resolve
498 // after an upgrade (Codex review #458).
499 let stored = read_branch_string(repo, &branch, LINK_FORGE_CONFIG_KEY)
500 .ok()
501 .flatten()
502 .unwrap_or_else(|| LINK_FORGE_BEFORE_THE_KEY.to_string());
503 if stored == now {
504 return;
505 }
506 if drop_branch_links(repo, &branch).is_err() || remove_branch_key(repo, &branch, LINK_ORIGIN_CONFIG_KEY).is_err() {
507 return;
508 }
509 let _ = write_branch_string(repo, &branch, LINK_FORGE_CONFIG_KEY, now);
510}
511
512/// `true` when persisted numbers on this branch were written against a
513/// different origin than the repo has now.
514///
515/// An absent stamp is **not** a mismatch: links written before this key
516/// existed, and links in local-only repos, stay readable.
517fn link_origin_is_foreign(repo: &Repository) -> impl Fn(&str) -> bool + '_ {
518 let current = origin_identity(repo);
519 move |stored: &str| match ¤t {
520 Some(now) => stored != now,
521 None => false,
522 }
523}
524
525pub fn link_issue(repo: &Repository, branch: &str, number: u64) -> Result<()> {
526 stamp_link_origin(repo, branch);
527 write_branch_u64(repo, branch, ISSUE_CONFIG_KEY, number)?;
528 remove_branch_key(repo, branch, ISSUE_TITLE_CONFIG_KEY)?;
529 remove_branch_key(repo, branch, ISSUE_STATE_CONFIG_KEY)
530}
531
532pub fn link_pr(repo: &Repository, branch: &str, number: u64) -> Result<()> {
533 stamp_link_origin(repo, branch);
534 write_branch_u64(repo, branch, PR_CONFIG_KEY, number)?;
535 remove_branch_key(repo, branch, PR_TITLE_CONFIG_KEY)?;
536 remove_branch_key(repo, branch, PR_STATE_CONFIG_KEY)
537}
538
539pub fn unlink_issue(repo: &Repository, branch: &str) -> Result<()> {
540 remove_branch_key(repo, branch, ISSUE_CONFIG_KEY)?;
541 remove_branch_key(repo, branch, ISSUE_TITLE_CONFIG_KEY)?;
542 remove_branch_key(repo, branch, ISSUE_STATE_CONFIG_KEY)
543}
544
545pub fn unlink_pr(repo: &Repository, branch: &str) -> Result<()> {
546 // Drop both the explicit link and any persisted auto-detection (#283),
547 // otherwise unlinking would leave a stale `gwm-pr-detected` number that
548 // `read_link` would resurface as a `Detected` PR on the next read.
549 remove_branch_key(repo, branch, PR_CONFIG_KEY)?;
550 remove_branch_key(repo, branch, PR_TITLE_CONFIG_KEY)?;
551 remove_branch_key(repo, branch, PR_STATE_CONFIG_KEY)?;
552 remove_branch_key(repo, branch, DETECTED_PR_CONFIG_KEY)?;
553 remove_branch_key(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?;
554 remove_branch_key(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)
555}
556
557/// Persist an auto-detected PR number to its own branch-config key
558/// (`gwm-pr-detected`, issue #283), distinct from the explicit `gwm-pr`.
559/// This lets the no-fetch table read path surface the detected PR on every
560/// row without a per-row `gh` shell-out, while keeping the
561/// detected/explicit distinction the pane badge needs. An explicit
562/// `gwm link --pr` still wins in [`read_link`]. Re-detection overwrites the
563/// stored value and clears a cached title only when the detected number
564/// actually changed.
565pub fn persist_detected_pr(repo: &Repository, branch: &str, number: u64) -> Result<()> {
566 stamp_link_origin(repo, branch);
567 let previous = read_branch_u64(repo, branch, DETECTED_PR_CONFIG_KEY)?;
568 write_branch_u64(repo, branch, DETECTED_PR_CONFIG_KEY, number)?;
569 if previous == Some(number) {
570 Ok(())
571 } else {
572 remove_branch_key(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?;
573 remove_branch_key(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)
574 }
575}
576
577/// Drop a persisted auto-detection (issue #283). A no-op when no detected
578/// PR was stored. Used when a detection no longer holds (the branch's PR
579/// went away) so a stale number doesn't linger in the config.
580pub fn clear_persisted_detected_pr(repo: &Repository, branch: &str) -> Result<()> {
581 remove_branch_key(repo, branch, DETECTED_PR_CONFIG_KEY)?;
582 remove_branch_key(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?;
583 remove_branch_key(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)
584}
585
586// Every one of these stamps first. They persist metadata fetched from
587// the origin the repo has *now*, and `read_link` suppresses anything the
588// stamp says came from somewhere else — so writing without stamping left
589// a fresh title permanently suppressed. It only shows on the path where
590// the number comes from the branch name rather than from config, because
591// nothing else re-links it and no other writer restamps (Codex review
592// #458). `persist_detected_pr` stamped from the start; these did not.
593
594pub fn persist_issue_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
595 stamp_link_origin(repo, branch);
596 write_branch_string(repo, branch, ISSUE_TITLE_CONFIG_KEY, title)
597}
598
599pub fn persist_pr_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
600 stamp_link_origin(repo, branch);
601 write_branch_string(repo, branch, PR_TITLE_CONFIG_KEY, title)
602}
603
604pub fn persist_detected_pr_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
605 stamp_link_origin(repo, branch);
606 write_branch_string(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY, title)
607}
608
609pub fn persist_issue_state(repo: &Repository, branch: &str, state: IssueState) -> Result<()> {
610 stamp_link_origin(repo, branch);
611 write_branch_string(repo, branch, ISSUE_STATE_CONFIG_KEY, issue_state_config_value(state))
612}
613
614pub fn persist_pr_state(repo: &Repository, branch: &str, state: PrState) -> Result<()> {
615 stamp_link_origin(repo, branch);
616 write_branch_string(repo, branch, PR_STATE_CONFIG_KEY, pr_state_config_value(state))
617}
618
619pub fn persist_detected_pr_state(repo: &Repository, branch: &str, state: PrState) -> Result<()> {
620 stamp_link_origin(repo, branch);
621 write_branch_string(repo, branch, DETECTED_PR_STATE_CONFIG_KEY, pr_state_config_value(state))
622}
623
624fn config_key(branch: &str, leaf: &str) -> String {
625 format!("branch.{}.{}", branch, leaf)
626}
627
628fn read_branch_u64(repo: &Repository, branch: &str, leaf: &str) -> Result<Option<u64>> {
629 let cfg = repo.config()?;
630 let key = config_key(branch, leaf);
631 match cfg.get_string(&key) {
632 Ok(s) => s
633 .trim()
634 .parse::<u64>()
635 .map(Some)
636 .map_err(|_| GwmError::Other(format!("config '{}' is not a valid number: {}", key, s))),
637 Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
638 Err(e) => Err(GwmError::Git(e)),
639 }
640}
641
642fn read_branch_string(repo: &Repository, branch: &str, leaf: &str) -> Result<Option<String>> {
643 let cfg = repo.config()?;
644 let key = config_key(branch, leaf);
645 match cfg.get_string(&key) {
646 Ok(s) => Ok(Some(s)),
647 Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
648 Err(e) => Err(GwmError::Git(e)),
649 }
650}
651
652fn read_branch_issue_state(repo: &Repository, branch: &str) -> Result<Option<IssueState>> {
653 Ok(
654 read_branch_string(repo, branch, ISSUE_STATE_CONFIG_KEY)?
655 .as_deref()
656 .and_then(parse_issue_state_config_value),
657 )
658}
659
660fn read_branch_pr_state(repo: &Repository, branch: &str, leaf: &str) -> Result<Option<PrState>> {
661 Ok(
662 read_branch_string(repo, branch, leaf)?
663 .as_deref()
664 .and_then(parse_pr_state_config_value),
665 )
666}
667
668fn parse_issue_state_config_value(value: &str) -> Option<IssueState> {
669 match value.trim().to_ascii_lowercase().as_str() {
670 "open" => Some(IssueState::Open),
671 "closed" => Some(IssueState::Closed),
672 _ => None,
673 }
674}
675
676fn parse_pr_state_config_value(value: &str) -> Option<PrState> {
677 match value.trim().to_ascii_lowercase().as_str() {
678 "open" => Some(PrState::Open),
679 "draft" => Some(PrState::Draft),
680 "closed" => Some(PrState::Closed),
681 "merged" => Some(PrState::Merged),
682 _ => None,
683 }
684}
685
686fn issue_state_config_value(state: IssueState) -> &'static str {
687 match state {
688 IssueState::Open => "open",
689 IssueState::Closed => "closed",
690 }
691}
692
693fn pr_state_config_value(state: PrState) -> &'static str {
694 match state {
695 PrState::Open => "open",
696 PrState::Draft => "draft",
697 PrState::Closed => "closed",
698 PrState::Merged => "merged",
699 }
700}
701
702fn write_branch_u64(repo: &Repository, branch: &str, leaf: &str, value: u64) -> Result<()> {
703 let mut cfg = repo.config()?;
704 cfg.set_str(&config_key(branch, leaf), &value.to_string())?;
705 Ok(())
706}
707
708fn write_branch_string(repo: &Repository, branch: &str, leaf: &str, value: &str) -> Result<()> {
709 let mut cfg = repo.config()?;
710 cfg.set_str(&config_key(branch, leaf), value)?;
711 Ok(())
712}
713
714/// Normalise a worktree's branch for pin storage (issue #408): libgit2
715/// surfaces a detached HEAD either as `None` or as the literal `"HEAD"`
716/// (the same trap the statusline handles), and a `branch.HEAD.*` config key
717/// would silently share one pin across every detached worktree. Every pin
718/// read/write goes through this guard.
719pub fn pinnable_branch(branch: Option<&str>) -> Option<&str> {
720 match branch {
721 None | Some("HEAD") => None,
722 other => other,
723 }
724}
725
726/// Every manual agent-session pin on `branch` (issue #408 US4). The key is
727/// **multi-valued** (user feedback 2026-07-22): several agents can work one
728/// worktree at once, so attach accumulates instead of replacing.
729pub fn agent_pins(repo: &Repository, branch: &str) -> Result<Vec<String>> {
730 let cfg = repo.config()?;
731 let key = config_key(branch, AGENT_PIN_CONFIG_KEY);
732 let mut out = Vec::new();
733 let result = match cfg.multivar(&key, None) {
734 Ok(entries) => {
735 entries
736 .for_each(|e| {
737 if let Ok(v) = e.value() {
738 out.push(v.to_string());
739 }
740 })
741 .map_err(GwmError::Git)?;
742 Ok(out)
743 }
744 Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(out),
745 Err(e) => Err(GwmError::Git(e)),
746 };
747 result
748}
749
750/// Pin `session_id` to `branch`'s worktree (`gwm agents attach`). Appends
751/// to the multi-valued key; re-attaching an already-pinned id is a no-op.
752pub fn add_agent_pin(repo: &Repository, branch: &str, session_id: &str) -> Result<()> {
753 if agent_pins(repo, branch)?.iter().any(|p| p == session_id) {
754 return Ok(());
755 }
756 let mut cfg = repo.config()?;
757 // The never-matching regex makes libgit2 append a new value instead of
758 // replacing an existing one (the documented multivar-append idiom).
759 cfg.set_multivar(&config_key(branch, AGENT_PIN_CONFIG_KEY), "^$", session_id)?;
760 Ok(())
761}
762
763/// Remove exactly the `session_id` pin (`gwm agents detach <wt> <id>` / `d`
764/// on a pinned row). Returns whether it was present; absent is not an error.
765pub fn remove_agent_pin(repo: &Repository, branch: &str, session_id: &str) -> Result<bool> {
766 if !agent_pins(repo, branch)?.iter().any(|p| p == session_id) {
767 return Ok(false);
768 }
769 let mut cfg = repo.config()?;
770 // Escape regex metacharacters so an id is matched literally, anchored.
771 let escaped: String = session_id
772 .chars()
773 .flat_map(|c| {
774 if c.is_ascii_alphanumeric() {
775 vec![c]
776 } else {
777 vec!['\\', c]
778 }
779 })
780 .collect();
781 cfg.remove_multivar(&config_key(branch, AGENT_PIN_CONFIG_KEY), &format!("^{escaped}$"))?;
782 Ok(true)
783}
784
785/// Remove every pin on `branch` (bare `gwm agents detach <wt>`). A no-op
786/// when none is set.
787pub fn clear_agent_pins(repo: &Repository, branch: &str) -> Result<()> {
788 let mut cfg = repo.config()?;
789 match cfg.remove_multivar(&config_key(branch, AGENT_PIN_CONFIG_KEY), ".*") {
790 Ok(()) => Ok(()),
791 Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(()),
792 Err(e) => Err(GwmError::Git(e)),
793 }
794}
795
796fn remove_branch_key(repo: &Repository, branch: &str, leaf: &str) -> Result<()> {
797 let mut cfg = repo.config()?;
798 let key = config_key(branch, leaf);
799 match cfg.remove(&key) {
800 Ok(_) => Ok(()),
801 Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(()),
802 Err(e) => Err(GwmError::Git(e)),
803 }
804}
805
806// ---- Issue / PR status ---------------------------------------------------
807
808#[derive(Deserialize)]
809struct RawIssue {
810 number: u64,
811 title: String,
812 state: String,
813 url: String,
814 #[serde(default)]
815 labels: Vec<RawLabel>,
816 #[serde(rename = "updatedAt", default)]
817 updated_at: String,
818}
819
820#[derive(Deserialize)]
821struct RawLabel {
822 name: String,
823}
824
825#[derive(Deserialize)]
826struct RawPr {
827 number: u64,
828 title: String,
829 state: String,
830 #[serde(rename = "isDraft", default)]
831 is_draft: bool,
832 url: String,
833 #[serde(rename = "updatedAt", default)]
834 updated_at: String,
835 #[serde(rename = "statusCheckRollup", default)]
836 status_check_rollup: Vec<RawCheck>,
837}
838
839/// One `statusCheckRollup` entry. GitHub returns two shapes here: a
840/// `CheckRun` (the Checks API — carries `status` + `conclusion`) and a
841/// legacy `StatusContext` (the commit-status API — carries `state`). We
842/// deserialize all three so both shapes classify correctly.
843#[derive(Deserialize)]
844struct RawCheck {
845 #[serde(default)]
846 status: String,
847 #[serde(default)]
848 conclusion: Option<String>,
849 #[serde(default)]
850 state: String,
851 // Per-check identity + link, kept for the CI checks overlay (issue #436).
852 // `name` + `detailsUrl` on the `CheckRun` shape; `context` + `targetUrl`
853 // on the legacy `StatusContext` shape.
854 #[serde(default)]
855 name: String,
856 #[serde(rename = "detailsUrl", default)]
857 details_url: Option<String>,
858 #[serde(default)]
859 context: String,
860 #[serde(rename = "targetUrl", default)]
861 target_url: Option<String>,
862 // Run metadata (CheckRun shape), kept for the overlay's detail column.
863 #[serde(rename = "workflowName", default)]
864 workflow_name: Option<String>,
865 #[serde(rename = "startedAt", default)]
866 started_at: Option<String>,
867 #[serde(rename = "completedAt", default)]
868 completed_at: Option<String>,
869}
870
871pub fn parse_issue_json(s: &str) -> Result<IssueStatus> {
872 let raw: RawIssue = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
873 kind: "issue",
874 source: e,
875 })?;
876 let state = match raw.state.as_str() {
877 "OPEN" | "open" => IssueState::Open,
878 "CLOSED" | "closed" => IssueState::Closed,
879 other => return Err(GwmError::Other(format!("unknown issue state '{}'", other))),
880 };
881 Ok(IssueStatus {
882 number: raw.number,
883 title: raw.title,
884 state,
885 url: raw.url,
886 labels: raw.labels.into_iter().map(|l| l.name).collect(),
887 updated_at: raw.updated_at,
888 })
889}
890
891pub fn parse_pr_json(s: &str) -> Result<PrStatus> {
892 let raw: RawPr = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse { kind: "pr", source: e })?;
893 let state = match (raw.state.as_str(), raw.is_draft) {
894 ("MERGED" | "merged", _) => PrState::Merged,
895 ("CLOSED" | "closed", _) => PrState::Closed,
896 ("OPEN" | "open", true) => PrState::Draft,
897 ("OPEN" | "open", false) => PrState::Open,
898 (other, _) => return Err(GwmError::Other(format!("unknown PR state '{}'", other))),
899 };
900 let checks_total = raw.status_check_rollup.len() as u32;
901 // Count the same "accepted" terminals the CI state treats as green, so the
902 // `N/M` shown next to the indicator stays consistent with its label — a
903 // rollup of SUCCESS + NEUTRAL + SKIPPED reads "passing 3/3", not "1/3"
904 // (Codex review #302).
905 let checks_passed = raw
906 .status_check_rollup
907 .iter()
908 .filter(|c| matches!(classify_check(c), CheckOutcome::Passing))
909 .count() as u32;
910 let ci = derive_ci_state(&raw.status_check_rollup);
911 let checks = raw
912 .status_check_rollup
913 .iter()
914 .map(|c| PrCheck {
915 name: if c.name.is_empty() {
916 c.context.clone()
917 } else {
918 c.name.clone()
919 },
920 outcome: classify_check(c),
921 url: c.details_url.clone().or_else(|| c.target_url.clone()),
922 workflow_name: c.workflow_name.clone(),
923 started_at: c.started_at.clone(),
924 completed_at: c.completed_at.clone(),
925 })
926 .collect();
927 Ok(PrStatus {
928 number: raw.number,
929 title: raw.title,
930 state,
931 url: raw.url,
932 updated_at: raw.updated_at,
933 checks_passed,
934 checks_total,
935 ci,
936 checks,
937 })
938}
939
940/// Classify one rollup entry, handling both the `CheckRun` shape
941/// (`status` + `conclusion`) and the legacy `StatusContext` shape
942/// (`state`). A `CheckRun` is only green for an *accepted* terminal
943/// conclusion (SUCCESS / NEUTRAL / SKIPPED, or a completed check with no
944/// conclusion); every other terminal conclusion — FAILURE, CANCELLED,
945/// TIMED_OUT, ACTION_REQUIRED, STARTUP_FAILURE, STALE, … — reads as failing
946/// rather than silently falling through to green (Codex review #302).
947fn classify_check(c: &RawCheck) -> CheckOutcome {
948 // `CheckRun`: `status` is populated (QUEUED / IN_PROGRESS / COMPLETED).
949 if !c.status.is_empty() {
950 if !c.status.eq_ignore_ascii_case("COMPLETED") {
951 return CheckOutcome::Running;
952 }
953 return match c.conclusion.as_deref() {
954 Some(s) if is_accepted_conclusion(s) => CheckOutcome::Passing,
955 // A completed check with no conclusion is treated leniently (green) so
956 // missing data never paints a false red.
957 None => CheckOutcome::Passing,
958 Some(_) => CheckOutcome::Failing,
959 };
960 }
961 // Legacy `StatusContext`: classify by `state`.
962 match c.state.to_ascii_uppercase().as_str() {
963 "SUCCESS" => CheckOutcome::Passing,
964 "FAILURE" | "ERROR" => CheckOutcome::Failing,
965 // PENDING / EXPECTED / unknown — not yet conclusive.
966 _ => CheckOutcome::Running,
967 }
968}
969
970/// Terminal `CheckRun` conclusions that count as green.
971fn is_accepted_conclusion(conclusion: &str) -> bool {
972 matches!(
973 conclusion.to_ascii_uppercase().as_str(),
974 "SUCCESS" | "NEUTRAL" | "SKIPPED"
975 )
976}
977
978/// Collapse a `statusCheckRollup` into a single [`CiState`]. The
979/// aggregation rule itself is shared with the GitLab backend since #419 —
980/// see [`forge::aggregate_ci_state`].
981fn derive_ci_state(checks: &[RawCheck]) -> CiState {
982 forge::aggregate_ci_state(checks.iter().map(classify_check))
983}
984
985// ---- gh CLI invocation ---------------------------------------------------
986
987const ISSUE_JSON_FIELDS: &str = "number,title,state,url,labels,updatedAt";
988const PR_JSON_FIELDS: &str = "number,title,state,isDraft,url,updatedAt,statusCheckRollup";
989
990/// Run `gh issue view <n> --repo <slug> --json …` and parse the result.
991pub fn fetch_issue(slug: &str, number: u64) -> Result<IssueStatus> {
992 fetch_issue_with(&gh_program(), slug, number)
993}
994
995/// [`fetch_issue`] with an explicitly resolved `gh` program path. Used by
996/// the TUI's off-thread fetch (issue #217): the program is resolved on the
997/// main thread via [`gh_program`] and handed to the worker thread, so the
998/// thread never touches `GWM_GH` / the process environment concurrently
999/// with env-mutating callers.
1000pub fn fetch_issue_with(program: &OsStr, slug: &str, number: u64) -> Result<IssueStatus> {
1001 parse_issue_json(&run_gh_with(program, issue_view_argv(slug, number))?)
1002}
1003
1004/// `--repo <slug>`, or nothing when the slug is empty.
1005///
1006/// Mirrors [`crate::gitlab::repo_flag`]. An empty slug is the caller
1007/// asking `gh` to resolve the repository from the directory it is
1008/// spawned in, which is also where it infers the host from.
1009fn repo_flag(slug: &str) -> Vec<String> {
1010 if slug.is_empty() {
1011 Vec::new()
1012 } else {
1013 vec!["--repo".into(), slug.into()]
1014 }
1015}
1016
1017/// `repos/<slug>` for a REST path, or `repos/{owner}/{repo}` when the
1018/// slug is empty.
1019///
1020/// `gh api` documents `{owner}`, `{repo}` and `{branch}` as placeholders
1021/// "replaced with values from the repository of the current directory".
1022/// This is the counterpart to glab's `projects/:fullpath`, and round 16
1023/// of the #458 review asserted — wrongly, from a stale code comment
1024/// rather than the docs — that no such thing existed.
1025fn repo_api_path(slug: &str) -> String {
1026 if slug.is_empty() {
1027 "repos/{owner}/{repo}".to_string()
1028 } else {
1029 format!("repos/{slug}")
1030 }
1031}
1032
1033/// Argv for `gh issue view <n> --repo <slug> --json …`.
1034pub fn issue_view_argv(slug: &str, number: u64) -> Vec<String> {
1035 let mut argv: Vec<String> = vec!["issue".into(), "view".into(), number.to_string()];
1036 argv.extend(repo_flag(slug));
1037 argv.extend(["--json".into(), ISSUE_JSON_FIELDS.into()]);
1038 argv
1039}
1040
1041/// Resolve the `gh` program to invoke: `$GWM_GH` when set (test / override
1042/// hook), else `gh` on `PATH`. Read once on the calling thread so off-thread
1043/// fetches can capture it without re-reading the environment.
1044pub fn gh_program() -> OsString {
1045 std::env::var_os("GWM_GH").unwrap_or_else(|| "gh".into())
1046}
1047
1048pub fn create_issue(slug: &str, req: &IssueCreateRequest<'_>) -> Result<CreatedIssue> {
1049 parse_created_issue(&run_gh(issue_create_argv(slug, req))?)
1050}
1051
1052/// Argv for `gh issue create …`.
1053pub fn issue_create_argv(slug: &str, req: &IssueCreateRequest<'_>) -> Vec<OsString> {
1054 let mut args: Vec<OsString> = Vec::with_capacity(8 + 2 * req.labels.len());
1055 args.push("issue".into());
1056 args.push("create".into());
1057 args.push("--title".into());
1058 args.push(req.title.into());
1059 args.push("--body-file".into());
1060 args.push(req.body_file.as_os_str().to_owned());
1061 for label in req.labels {
1062 args.push("--label".into());
1063 args.push(label.into());
1064 }
1065 // An empty slug means `origin` was unresolvable; `gh` then infers the
1066 // repo from the local git context, which is the pre-#419 behaviour this
1067 // path has always relied on.
1068 if !slug.is_empty() {
1069 args.push("--repo".into());
1070 args.push(slug.into());
1071 }
1072 args
1073}
1074
1075/// Recover the created issue from the URL `gh issue create` prints.
1076pub fn parse_created_issue(stdout: &str) -> Result<CreatedIssue> {
1077 let stdout = stdout.trim().to_string();
1078 let Some(caps) = ISSUE_URL_RE.captures(&stdout) else {
1079 return Err(GwmError::CommandFailed(format!(
1080 "gh issue create did not print an issue URL containing a number: {}",
1081 stdout
1082 )));
1083 };
1084 let number = caps
1085 .get(1)
1086 .and_then(|m| m.as_str().parse::<u64>().ok())
1087 .ok_or_else(|| GwmError::CommandFailed(format!("failed to parse issue number from gh output: {}", stdout)))?;
1088 Ok(CreatedIssue { number, url: stdout })
1089}
1090
1091/// Shell out to `gh pr create` with a body file already rendered by
1092/// [`crate::pr_templates::render_pr_body`]. Parses the URL printed by
1093/// gh on success to extract the PR number.
1094pub fn create_pr(slug: &str, req: &PrCreateRequest<'_>) -> Result<CreatedPr> {
1095 parse_created_pr(&run_gh(pr_create_argv(slug, req))?)
1096}
1097
1098/// Argv for `gh pr create …`.
1099pub fn pr_create_argv(slug: &str, req: &PrCreateRequest<'_>) -> Vec<OsString> {
1100 let mut args: Vec<OsString> =
1101 Vec::with_capacity(10 + if req.draft { 1 } else { 0 } + if req.base.is_some() { 2 } else { 0 });
1102 args.push("pr".into());
1103 args.push("create".into());
1104 args.push("--title".into());
1105 args.push(req.title.into());
1106 args.push("--body-file".into());
1107 args.push(req.body_file.as_os_str().to_owned());
1108 args.push("--head".into());
1109 args.push(req.head.into());
1110 if let Some(base) = req.base {
1111 args.push("--base".into());
1112 args.push(base.into());
1113 }
1114 if req.draft {
1115 args.push("--draft".into());
1116 }
1117 // An empty slug means `origin` was unresolvable; `gh` then infers the
1118 // repo from the local git context, which is the pre-#419 behaviour this
1119 // path has always relied on.
1120 if !slug.is_empty() {
1121 args.push("--repo".into());
1122 args.push(slug.into());
1123 }
1124 args
1125}
1126
1127/// Recover the created PR from the URL `gh pr create` prints.
1128pub fn parse_created_pr(stdout: &str) -> Result<CreatedPr> {
1129 let stdout = stdout.trim().to_string();
1130 let Some(caps) = PR_URL_RE.captures(&stdout) else {
1131 return Err(GwmError::CommandFailed(format!(
1132 "gh pr create did not print a PR URL containing a number: {}",
1133 stdout
1134 )));
1135 };
1136 let number = caps
1137 .get(1)
1138 .and_then(|m| m.as_str().parse::<u64>().ok())
1139 .ok_or_else(|| GwmError::CommandFailed(format!("failed to parse PR number from gh output: {}", stdout)))?;
1140 Ok(CreatedPr { number, url: stdout })
1141}
1142
1143/// Run `gh pr view <n> --repo <slug> --json …` and parse the result.
1144pub fn fetch_pr(slug: &str, number: u64) -> Result<PrStatus> {
1145 fetch_pr_with(&gh_program(), slug, number)
1146}
1147
1148/// [`fetch_pr`] with an explicitly resolved `gh` program path — PR-side
1149/// counterpart to [`fetch_issue_with`], used by the TUI off-thread fetch
1150/// (issue #217).
1151pub fn fetch_pr_with(program: &OsStr, slug: &str, number: u64) -> Result<PrStatus> {
1152 parse_pr_json(&run_gh_with(program, pr_view_argv(slug, number))?)
1153}
1154
1155/// Argv for `gh pr view <n> --repo <slug> --json …`.
1156pub fn pr_view_argv(slug: &str, number: u64) -> Vec<String> {
1157 let mut argv: Vec<String> = vec!["pr".into(), "view".into(), number.to_string()];
1158 argv.extend(repo_flag(slug));
1159 argv.extend(["--json".into(), PR_JSON_FIELDS.into()]);
1160 argv
1161}
1162
1163#[derive(Deserialize)]
1164struct RawPrHead {
1165 number: u64,
1166 // `Option` (not just `#[serde(default)]`) so an explicit `"author": null`
1167 // — a deleted GitHub account — deserialises to `None` instead of erroring;
1168 // `default` alone only covers a *missing* key.
1169 #[serde(default)]
1170 author: Option<RawAuthor>,
1171 #[serde(rename = "headRefName", default)]
1172 head_ref_name: String,
1173 #[serde(rename = "baseRefName", default)]
1174 base_ref_name: String,
1175}
1176
1177#[derive(Deserialize, Default)]
1178struct RawAuthor {
1179 #[serde(default)]
1180 login: String,
1181}
1182
1183const PR_HEAD_JSON_FIELDS: &str = "number,author,headRefName,baseRefName";
1184
1185/// Parse the JSON from `gh pr view <n> --json number,author,headRefName,baseRefName`.
1186/// Kept pure + `pub` so its shape is unit-testable without spawning `gh`.
1187pub fn parse_pr_head_json(s: &str) -> Result<PrHead> {
1188 let raw: RawPrHead = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
1189 kind: "pr head",
1190 source: e,
1191 })?;
1192 Ok(PrHead {
1193 number: raw.number,
1194 author: raw.author.unwrap_or_default().login,
1195 head_ref_name: raw.head_ref_name,
1196 base_ref_name: raw.base_ref_name,
1197 })
1198}
1199
1200/// Run `gh pr view <n> --repo <slug> --json …` and parse the head metadata
1201/// `gwm review` needs (author / head ref / base ref). Works for PRs in any
1202/// state — open, draft, closed, or merged.
1203pub fn fetch_pr_head(slug: &str, number: u64) -> Result<PrHead> {
1204 parse_pr_head_json(&run_gh(pr_head_argv(slug, number))?)
1205}
1206
1207/// Argv for `gh pr view <n> --repo <slug> --json number,author,headRefName,baseRefName`.
1208pub fn pr_head_argv(slug: &str, number: u64) -> Vec<String> {
1209 let mut argv: Vec<String> = vec!["pr".into(), "view".into(), number.to_string()];
1210 argv.extend(repo_flag(slug));
1211 argv.extend(["--json".into(), PR_HEAD_JSON_FIELDS.into()]);
1212 argv
1213}
1214
1215/// Find the most recent PR opened from `branch` (head ref) on the given
1216/// repo, regardless of state. Returns `Ok(Some(N))` if at least one PR
1217/// exists (open, draft, closed, or merged — `gh pr list --state all`),
1218/// `Ok(None)` otherwise. Callers that need state-aware filtering should
1219/// pair this with `fetch_pr` to inspect `PrState` afterwards.
1220pub fn find_pr_for_branch(slug: &str, branch: &str) -> Result<Option<u64>> {
1221 let stdout = run_gh(find_pr_argv(slug, branch))?;
1222 parse_pr_list_number(&stdout)
1223}
1224
1225/// Argv for `gh pr list --repo <slug> --head <branch> --state all --json
1226/// number --limit 1`. Extracted so the test suite can pin the `gh`
1227/// contract without shelling out; [`find_pr_for_branch`] is the caller
1228/// that actually invokes it. `--state all` is the load-bearing bit: a
1229/// closed or merged PR for the branch is still detected (its `PrState`
1230/// is resolved later via [`fetch_pr`]).
1231pub fn find_pr_argv(slug: &str, branch: &str) -> Vec<String> {
1232 let mut argv: Vec<String> = vec!["pr".into(), "list".into()];
1233 argv.extend(repo_flag(slug));
1234 argv.extend([
1235 "--head".into(),
1236 branch.into(),
1237 "--state".into(),
1238 "all".into(),
1239 "--json".into(),
1240 // `isCrossRepository` is GitHub's own marker for "opened from a
1241 // fork"; `parse_pr_list_number` ranks on it (Codex review #458).
1242 "number,isCrossRepository".into(),
1243 // More than one row on purpose: `--head` matches the branch NAME
1244 // only, so a fork carrying the same name can appear.
1245 "--limit".into(),
1246 "20".into(),
1247 ]);
1248 argv
1249}
1250
1251/// Parse the JSON array printed by `gh pr list --json number --limit 1`,
1252/// returning the first PR number if any. Exposed for unit tests so the
1253/// parse contract is covered without a `gh` shell-out.
1254pub fn parse_pr_list_number(s: &str) -> Result<Option<u64>> {
1255 #[derive(Deserialize)]
1256 struct PrRef {
1257 number: u64,
1258 /// GitHub's marker for a PR opened from a fork. `--head <branch>`
1259 /// matches the branch NAME only, so a fork sharing the name lands in
1260 /// the same list — and its number would be persisted as this
1261 /// branch's detected PR (Codex review #458). Absent is treated as
1262 /// same-repo so an older payload still detects.
1263 #[serde(rename = "isCrossRepository", default)]
1264 is_cross_repository: Option<bool>,
1265 }
1266 let arr: Vec<PrRef> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
1267 kind: "pr list",
1268 source: e,
1269 })?;
1270 // Prefer a same-repo PR; fall back to a fork's rather than reporting
1271 // nothing. Filtering forks out entirely also removed the standard
1272 // contributor workflow — branch locally, push to your own fork, open
1273 // the PR against upstream — which is cross-repository by definition
1274 // and had been detected before (Codex review #458).
1275 //
1276 // What is left ambiguous: no same-repo PR *and* a fork PR that might
1277 // not be yours. Resolving that needs `headRepositoryOwner` matched
1278 // against the repo's configured remotes, which is new machinery in
1279 // round 27 of a review — filed as issue #461. Until then this is
1280 // still strictly better than the pre-filter behaviour, which took the
1281 // first row whatever it was.
1282 Ok(
1283 arr
1284 .iter()
1285 .find(|p| !p.is_cross_repository.unwrap_or(false))
1286 .or_else(|| arr.first())
1287 .map(|p| p.number),
1288 )
1289}
1290
1291fn run_gh<I, S>(args: I) -> Result<String>
1292where
1293 I: IntoIterator<Item = S>,
1294 S: AsRef<OsStr>,
1295{
1296 run_gh_with(&gh_program(), args)
1297}
1298
1299/// [`run_gh`] against an explicitly resolved `gh` program. Lets callers on
1300/// a worker thread (issue #217) avoid re-reading `GWM_GH` / the process
1301/// environment concurrently with env-mutating code on other threads. The
1302/// spawn + logging + error shape is shared with the GitLab backend since
1303/// #419 — see [`forge::run_cli`].
1304fn run_gh_with<I, S>(program: &OsStr, args: I) -> Result<String>
1305where
1306 I: IntoIterator<Item = S>,
1307 S: AsRef<OsStr>,
1308{
1309 forge::run_cli(program, args)
1310}
1311
1312// ---- Labels (issue #81) -------------------------------------------------
1313
1314const LABEL_JSON_FIELDS: &str = "name,color,description";
1315const LABEL_LIST_LIMIT: &str = "1000";
1316
1317#[derive(Deserialize)]
1318struct RawLabel2 {
1319 name: String,
1320 /// `color` is a documented gh-CLI invariant — every label always
1321 /// carries one. We deliberately do NOT mark this `#[serde(default)]`:
1322 /// if a future gh contract change drops the field, we want a hard
1323 /// parse error rather than a silent empty-string that would flag
1324 /// every remote label as a colour mismatch in the diff. (Copilot
1325 /// review on PR #90.)
1326 color: String,
1327 #[serde(default)]
1328 description: Option<String>,
1329}
1330
1331/// Parse the JSON returned by `gh label list --json name,color,description`.
1332/// Exposed publicly so unit tests can cover the contract without
1333/// shelling out. Two normalisations happen here so callers get a
1334/// uniformly-shaped `RemoteLabel`:
1335///
1336/// - **`color`** is lowercased. GitHub serialises hex colours in
1337/// either case; the diff engine expects the lowercase form, and
1338/// normalising at the parse boundary means downstream code never
1339/// has to think about it.
1340/// - **`description`** is left as-is. An empty `""` from GitHub
1341/// round-trips as `Some("")`; the labels-diff module collapses
1342/// empty strings to `None` on its own.
1343pub fn parse_labels_json(s: &str) -> Result<Vec<RemoteLabel>> {
1344 let raw: Vec<RawLabel2> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
1345 kind: "labels",
1346 source: e,
1347 })?;
1348 Ok(
1349 raw
1350 .into_iter()
1351 .map(|r| RemoteLabel {
1352 name: r.name,
1353 description: r.description,
1354 color: r.color.to_ascii_lowercase(),
1355 })
1356 .collect(),
1357 )
1358}
1359
1360/// Argv for `gh label list --repo <slug> --json name,color,description --limit 1000`.
1361/// Extracted so the test suite can pin the contract; callers should
1362/// prefer `fetch_remote_labels` which actually shells out.
1363pub fn label_list_argv(slug: &str) -> Vec<String> {
1364 let mut argv: Vec<String> = vec!["label".into(), "list".into()];
1365 argv.extend(repo_flag(slug));
1366 argv.extend([
1367 "--json".into(),
1368 LABEL_JSON_FIELDS.into(),
1369 "--limit".into(),
1370 LABEL_LIST_LIMIT.into(),
1371 ]);
1372 argv
1373}
1374
1375/// Argv for `gh label create <name> --color <hex> [--description <desc>] --force --repo <slug>`.
1376/// The `--force` flag is the key contract bit: GitHub's CLI uses it
1377/// to mean "create OR update", which is exactly what `gwm labels
1378/// push` needs (no separate "edit" call). When `description` is
1379/// `None` we omit the flag entirely rather than pass `""` — gh would
1380/// otherwise wipe an existing description that the user didn't intend
1381/// to touch.
1382pub fn label_create_argv(slug: &str, spec: &LabelSpec) -> Vec<String> {
1383 let mut argv: Vec<String> = vec!["label".into(), "create".into(), spec.name.clone()];
1384 argv.extend(repo_flag(slug));
1385 argv.extend(["--color".into(), spec.color.clone(), "--force".into()]);
1386 if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
1387 argv.push("--description".into());
1388 argv.push(desc.clone());
1389 }
1390 argv
1391}
1392
1393/// Argv for `gh label delete <name> --repo <slug> --yes`. The `--yes`
1394/// flag bypasses the interactive confirm prompt; without it gh blocks
1395/// on a TTY read and `gwm labels push --prune` hangs.
1396pub fn label_delete_argv(slug: &str, name: &str) -> Vec<String> {
1397 let mut argv: Vec<String> = vec!["label".into(), "delete".into(), name.into()];
1398 argv.extend(repo_flag(slug));
1399 argv.push("--yes".into());
1400 argv
1401}
1402
1403/// Run `gh label list --repo <slug> --json …` and parse the result.
1404/// Returns an empty vec when the remote has no labels (which is
1405/// distinct from "gh not installed" — that surfaces as
1406/// `CommandFailed`).
1407pub fn fetch_remote_labels(slug: &str) -> Result<Vec<RemoteLabel>> {
1408 let argv = label_list_argv(slug);
1409 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1410 let stdout = run_gh(&args)?;
1411 parse_labels_json(&stdout)
1412}
1413
1414/// Push one label upstream via `gh label create --force`. Returns
1415/// `Ok(())` on success; the caller is responsible for tracking which
1416/// label was created vs. updated (the diff already knows).
1417pub fn push_label(slug: &str, spec: &LabelSpec) -> Result<()> {
1418 let argv = label_create_argv(slug, spec);
1419 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1420 run_gh(&args)?;
1421 Ok(())
1422}
1423
1424/// Delete one label on the remote via `gh label delete --yes`. Used
1425/// by `gwm labels push --prune` for labels declared on the remote but
1426/// not in `.gwm.toml`.
1427///
1428/// Validates `name` through [`crate::labels::validate_label_name`]
1429/// BEFORE shelling out (issue #100). The argv-injection vector that
1430/// motivates `validate_label_name` for declared labels (config side)
1431/// applies equally to the prune path: `gh label delete <name>` takes
1432/// the name positionally, so a remote label whose name starts with
1433/// `-` (planted by an attacker who can edit the upstream label set,
1434/// or by an unrelated tool predating the validator) would be parsed
1435/// as a flag — `-h` no-ops the delete with a help banner, `--repo
1436/// other/repo` retargets the operation. We refuse the prune with a
1437/// scoped error instead of running the risky argv.
1438pub fn delete_label(slug: &str, name: &str) -> Result<()> {
1439 validate_remote_label_name(name)?;
1440 let argv = label_delete_argv(slug, name);
1441 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1442 run_gh(&args)?;
1443 Ok(())
1444}
1445
1446/// Refuse a hostile remote label name before it reaches an argv slot
1447/// (issue #100). `gh label delete <name>` takes the name positionally, so
1448/// a remote label starting with `-` would be parsed as a flag: `-h` no-ops
1449/// the delete with a help banner, `--repo other/repo` retargets it.
1450fn validate_remote_label_name(name: &str) -> Result<()> {
1451 crate::labels::validate_label_name(name).map_err(|e| {
1452 let inner = match e {
1453 GwmError::Config(msg) => msg,
1454 other => other.to_string(),
1455 };
1456 GwmError::Config(format!(
1457 "labels (remote): {} — refusing to delete via `gh label delete`",
1458 inner
1459 ))
1460 })
1461}
1462
1463// ---- Milestones (issue #82) ---------------------------------------------
1464
1465const MILESTONE_PER_PAGE: &str = "100";
1466
1467#[derive(Deserialize)]
1468struct RawMilestone {
1469 number: u64,
1470 title: String,
1471 /// Always present in the documented schema. Like `RawLabel2.color`
1472 /// for labels, we deliberately do NOT mark this `#[serde(default)]`:
1473 /// a contract change would surface as a hard parse error rather than
1474 /// silently flagging every remote milestone as a state mismatch.
1475 state: String,
1476 #[serde(default)]
1477 description: Option<String>,
1478 #[serde(default)]
1479 due_on: Option<String>,
1480}
1481
1482/// Parse the JSON returned by `gh api repos/:owner/:repo/milestones?state=all`.
1483/// Exposed publicly so unit tests can cover the contract without
1484/// shelling out. The `state` field is mapped to the strict
1485/// `MilestoneState` enum — an unknown value is a hard error rather
1486/// than a silent third state on the diff side.
1487pub fn parse_milestones_json(s: &str) -> Result<Vec<RemoteMilestone>> {
1488 let raw: Vec<RawMilestone> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
1489 kind: "milestones",
1490 source: e,
1491 })?;
1492 raw
1493 .into_iter()
1494 .map(|r| {
1495 let state = match r.state.as_str() {
1496 "open" => MilestoneState::Open,
1497 "closed" => MilestoneState::Closed,
1498 other => {
1499 return Err(GwmError::Other(format!(
1500 "milestone '{}' has unknown state '{}': expected 'open' or 'closed'",
1501 r.title, other
1502 )))
1503 }
1504 };
1505 Ok(RemoteMilestone {
1506 number: r.number,
1507 title: r.title,
1508 description: r.description,
1509 due_on: r.due_on,
1510 state,
1511 })
1512 })
1513 .collect()
1514}
1515
1516/// Argv for `gh api --paginate repos/<slug>/milestones?state=all&per_page=100`.
1517///
1518/// Two contract bits worth pinning:
1519/// - `state=all` — without it, the default endpoint only lists `open`
1520/// milestones and `gwm milestones push --prune` would silently
1521/// leave closed ones in place.
1522/// - `--paginate` — GitHub caps `per_page` at 100. Without paginating
1523/// we'd diff against a truncated remote set for repos with more
1524/// than 100 milestones, leading to bogus `create` rows and a
1525/// dangerously confusing `--prune` (Copilot review on PR #92).
1526pub fn milestone_list_argv(slug: &str) -> Vec<String> {
1527 vec![
1528 "api".into(),
1529 "--paginate".into(),
1530 format!(
1531 "{}/milestones?state=all&per_page={}",
1532 repo_api_path(slug),
1533 MILESTONE_PER_PAGE
1534 ),
1535 ]
1536}
1537
1538/// Argv for `gh api -X POST repos/<slug>/milestones -f title=… [-f
1539/// description=…] [-f due_on=…] -f state=…`. Each optional field is
1540/// omitted entirely when absent — `gh` would otherwise wipe the
1541/// existing remote value.
1542pub fn milestone_create_argv(slug: &str, spec: &MilestoneSpec) -> Vec<String> {
1543 let mut argv = vec![
1544 "api".into(),
1545 "-X".into(),
1546 "POST".into(),
1547 format!("{}/milestones", repo_api_path(slug)),
1548 "-f".into(),
1549 format!("title={}", spec.title),
1550 "-f".into(),
1551 format!("state={}", spec.state.as_str()),
1552 ];
1553 if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
1554 argv.push("-f".into());
1555 argv.push(format!("description={}", desc));
1556 }
1557 if let Some(due) = spec.due_on.as_ref().filter(|s| !s.is_empty()) {
1558 argv.push("-f".into());
1559 argv.push(format!("due_on={}", due));
1560 }
1561 argv
1562}
1563
1564/// Argv for `gh api -X PATCH repos/<slug>/milestones/<number> -f …`.
1565/// Same omission rules as `milestone_create_argv`: absent optionals
1566/// are skipped so the remote value isn't wiped.
1567pub fn milestone_update_argv(slug: &str, number: u64, spec: &MilestoneSpec) -> Vec<String> {
1568 let mut argv = vec![
1569 "api".into(),
1570 "-X".into(),
1571 "PATCH".into(),
1572 format!("{}/milestones/{}", repo_api_path(slug), number),
1573 "-f".into(),
1574 format!("title={}", spec.title),
1575 "-f".into(),
1576 format!("state={}", spec.state.as_str()),
1577 ];
1578 if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
1579 argv.push("-f".into());
1580 argv.push(format!("description={}", desc));
1581 }
1582 if let Some(due) = spec.due_on.as_ref().filter(|s| !s.is_empty()) {
1583 argv.push("-f".into());
1584 argv.push(format!("due_on={}", due));
1585 }
1586 argv
1587}
1588
1589/// Argv for `gh api -X DELETE repos/<slug>/milestones/<number>`.
1590/// `gh api -X DELETE` is non-interactive by construction (no TTY
1591/// confirm), so there's no `--yes` equivalent to add.
1592pub fn milestone_delete_argv(slug: &str, number: u64) -> Vec<String> {
1593 vec![
1594 "api".into(),
1595 "-X".into(),
1596 "DELETE".into(),
1597 format!("{}/milestones/{}", repo_api_path(slug), number),
1598 ]
1599}
1600
1601/// Run `gh api repos/<slug>/milestones?state=all` and parse the
1602/// result. Returns an empty vec when the remote has no milestones.
1603pub fn fetch_remote_milestones(slug: &str) -> Result<Vec<RemoteMilestone>> {
1604 let argv = milestone_list_argv(slug);
1605 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1606 let stdout = run_gh(&args)?;
1607 parse_milestones_json(&stdout)
1608}
1609
1610/// Create one milestone upstream via `gh api -X POST`. Returns
1611/// `Ok(())` — the caller already has the spec; we don't bother
1612/// parsing the response back into a `RemoteMilestone`.
1613pub fn create_milestone(slug: &str, spec: &MilestoneSpec) -> Result<()> {
1614 let argv = milestone_create_argv(slug, spec);
1615 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1616 run_gh(&args)?;
1617 Ok(())
1618}
1619
1620/// Update one milestone upstream via `gh api -X PATCH`. `number` is
1621/// the GitHub-issued identifier carried through `MilestoneUpdate`.
1622pub fn update_milestone(slug: &str, number: u64, spec: &MilestoneSpec) -> Result<()> {
1623 let argv = milestone_update_argv(slug, number, spec);
1624 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1625 run_gh(&args)?;
1626 Ok(())
1627}
1628
1629/// Delete one milestone on the remote via `gh api -X DELETE`. Used
1630/// by `gwm milestones push --prune` for milestones declared on the
1631/// remote but not in `.gwm.toml`.
1632pub fn delete_milestone(slug: &str, number: u64) -> Result<()> {
1633 let argv = milestone_delete_argv(slug, number);
1634 let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
1635 run_gh(&args)?;
1636 Ok(())
1637}
1638
1639// ---- The Forge backend (issue #419) -------------------------------------
1640
1641/// GitHub implementation of [`Forge`], shelling out to `gh`.
1642///
1643/// A thin binding over the free functions above rather than a rewrite:
1644/// they were already the GitHub backend in all but name, and keeping them
1645/// `pub` means the extraction reads as a no-op for the existing tests
1646/// that pin the `gh` argv contract.
1647#[derive(Debug, Clone)]
1648pub struct GitHubForge {
1649 origin: forge::RemoteRef,
1650 program: OsString,
1651 env: Vec<(String, String)>,
1652 env_remove: Vec<&'static str>,
1653 workdir: Option<std::path::PathBuf>,
1654}
1655
1656impl GitHubForge {
1657 /// Resolves `$GWM_GH` **now**, on the calling thread, so a forge handed
1658 /// to the TUI's fetch worker never re-reads the process environment
1659 /// concurrently with env-mutating code (issue #217).
1660 pub fn new(origin: forge::RemoteRef, workdir: Option<std::path::PathBuf>) -> Self {
1661 Self {
1662 env: gh_env(&origin),
1663 env_remove: gh_env_remove(&origin, workdir.is_some()),
1664 origin,
1665 program: gh_program(),
1666 workdir,
1667 }
1668 }
1669
1670 fn run<I, S>(&self, args: I) -> Result<String>
1671 where
1672 I: IntoIterator<Item = S>,
1673 S: AsRef<OsStr>,
1674 {
1675 forge::run_cli_with(
1676 &self.program,
1677 args,
1678 &forge::CliSpawn {
1679 env: &self.env,
1680 cwd: self.workdir.as_deref(),
1681 env_remove: &self.env_remove,
1682 redact_after: &[],
1683 redact_output: false,
1684 // `gh` takes bodies via `--body-file`, so nothing sensitive ever
1685 // needs stdin on this backend (contrast `glab`, issue #459).
1686 stdin: None,
1687 },
1688 )
1689 }
1690}
1691
1692/// Environment pinned on every `gh` spawn (Codex review #458).
1693///
1694/// `$GH_HOST` selects the GitHub instance. Before #419 the slug parser
1695/// rejected anything that was not github.com, so a GitHub Enterprise host
1696/// could not reach this code at all; host-agnostic parsing opened that
1697/// door, and without the pin `gh` would silently target github.com and
1698/// could read a same-named repo on the wrong tenant.
1699///
1700/// github.com is pinned like any other host, deliberately: the child
1701/// inherits gwm's environment, so a user's ambient `GH_HOST` — routine for
1702/// enterprise users — would otherwise retarget a github.com repo, since
1703/// the argv only ever carries `--repo owner/repo` and never a hostname
1704/// (Codex review #458, round 3).
1705///
1706/// The host is pinned whenever a slug is known — including github.com,
1707/// and including a **guessed** (SSH) origin. Both were exempted at some
1708/// point and both exemptions were wrong (Codex review #458):
1709///
1710/// - The child inherits gwm's environment, so an ambient `GH_HOST` —
1711/// routine for enterprise users — retargets every call, since the argv
1712/// only ever carries `--repo owner/repo` and never a hostname. Knowing
1713/// the repo is on github.com, gwm says so rather than letting the
1714/// environment decide.
1715/// - `gh` cannot be steered any other way: `gh api repos/<slug>/…` bakes
1716/// the slug into the request path, so unlike `glab` it has no working
1717/// directory to fall back to. This is the one place the two backends
1718/// diverge — see [`crate::gitlab::glab_env`], where a guessed origin is
1719/// deliberately left alone because a distinct SSH hostname *is* a
1720/// documented GitLab pattern.
1721///
1722/// Nothing is pinned only when the slug is empty: that is the caller
1723/// asking `gh` to infer the project locally.
1724/// Inherited variables that would redirect `gh` at another repository.
1725///
1726/// `$GH_REPO` names a whole `[HOST/]OWNER/REPO` and wins over both the
1727/// working directory and any inference, so an exported one silently
1728/// retargets every call. It is cleared whenever gwm supplies a project
1729/// of its own — a slug, or a repo to spawn the child inside.
1730///
1731/// It is **not** cleared when gwm supplies neither. That case is real
1732/// and is the one `$GH_REPO` exists for: `resolve_or_default` builds a
1733/// forge for a repo with no `origin`, deliberately passing no slug and
1734/// carrying no workdir, so `gwm new` / `gwm pr` still work there. gh
1735/// cannot infer a project either, and taking the variable away left the
1736/// user no way to name one (Codex review #458). Tier 1's premise —
1737/// "gwm always knows the project" — was false in exactly that spot.
1738///
1739/// The rule this applies, shared with [`crate::gitlab::glab_env_remove`]
1740/// and stated once so it stops being rediscovered one variable per
1741/// review round:
1742///
1743/// 1. **Project selectors are cleared when gwm supplies a project.**
1744/// A slug, or a working directory for the CLI to infer from.
1745/// 2. **Host overrides are cleared only when gwm has an authoritative
1746/// value to replace them with.** gwm sometimes knows the host.
1747/// 3. **Authentication and config location are never touched.** gwm
1748/// never knows better than the user which identity they meant to use
1749/// or where they keep their credentials.
1750///
1751/// Tier 3 raises the obvious objection — gwm pins a host read from
1752/// `origin` and leaves the global tokens in place, so a repo whose
1753/// remote points at a hostile server gets a bearer token sent to it.
1754///
1755/// An earlier revision of this comment answered "unchanged by any of
1756/// this, the pin carries the host `gh` would have resolved unaided".
1757/// That was **false**, and it is recorded rather than deleted because
1758/// the same mistake produced the `$GITLAB_API_HOST` cycle in
1759/// [`crate::gitlab::glab_env_remove`]. Before this PR
1760/// `github::repo_slug` accepted `git@github.com:` and
1761/// `https://github.com/` and nothing else — every other origin was
1762/// rejected with "is not a github URL", so gwm never made an
1763/// authenticated call against an arbitrary host at all. Verifying the
1764/// mechanism is not verifying the baseline.
1765///
1766/// Closed where it is actually opened: [`crate::forge::resolve`] no
1767/// longer treats an unrecognised host as GitHub by default. The
1768/// residual hole is stated there.
1769///
1770/// Audited against gh's documented environment. Tier 1: `$GH_REPO`.
1771/// Tier 2: none — `$GH_HOST` is pinned by [`gh_env`] and gh publishes
1772/// neither an alias for it nor a separate API endpoint override, so
1773/// there is nothing to close behind the pin (unlike `glab`). Tier 3:
1774/// `$GH_TOKEN` / `$GITHUB_TOKEN`, `$GH_ENTERPRISE_TOKEN` /
1775/// `$GITHUB_ENTERPRISE_TOKEN`, `$GH_CONFIG_DIR`. Everything else gh
1776/// reads is presentation (`$GH_PAGER`, `$GH_EDITOR`, `$GH_BROWSER`,
1777/// `$GH_FORCE_TTY`, `$GH_MDWIDTH`, `$NO_COLOR`), diagnostics
1778/// (`$GH_DEBUG`), or telemetry — none of it can retarget a call.
1779pub fn gh_env_remove(origin: &forge::RemoteRef, has_workdir: bool) -> Vec<&'static str> {
1780 if origin.path.is_empty() && !has_workdir {
1781 return Vec::new();
1782 }
1783 vec!["GH_REPO"]
1784}
1785
1786pub fn gh_env(origin: &forge::RemoteRef) -> Vec<(String, String)> {
1787 // Same rule as [`crate::gitlab::glab_env`]. An SSH remote carries no
1788 // web scheme or port, so `https://<ssh-host>` is a guess, and pinning
1789 // it as `$GH_HOST` broke a GHE whose SSH endpoint is not its API host.
1790 //
1791 // Rounds 4, 5 and 7 pinned harder each time, to stop an ambient
1792 // `$GH_HOST` retargeting the call; round 16 refused to stop, on the
1793 // claim that `gh api` had no way to resolve a repo from the working
1794 // directory. That claim was read off a stale code comment and is
1795 // wrong. gh documents `{owner}` / `{repo}` as endpoint placeholders
1796 // "replaced with values from the repository of the current directory",
1797 // and documents `$GH_HOST` as applying only "where a hostname has not
1798 // been provided, or cannot be inferred from the context of a local Git
1799 // repository". The child is spawned inside the repo, so delegating
1800 // closes the retargeting hazard rather than reopening it — the slug
1801 // goes away with the pin (see `repo_selector` and `repo_api_path`).
1802 //
1803 // `$GH_HOST` also cannot carry everything a remote URL can. gh's own
1804 // `HostnameValidator` rejects any hostname containing `:`, and
1805 // `RESTPrefix` / `GraphQLEndpoint` always build `https://` for
1806 // anything but the hardcoded `github.localhost`
1807 // (`internal/ghinstance/host.go`). So a non-default port and a plain
1808 // http origin are both inexpressible — and mis-pinning them is worse
1809 // than a 404, because `IsEnterprise` means "not github.com":
1810 // `GH_HOST=github.com:443` reads as Enterprise, so gh picks
1811 // `$GH_ENTERPRISE_TOKEN` and sends it to github.com while calling
1812 // `/api/v3/`. Round 2 flagged the port as undocumented and passed it
1813 // anyway; the answer is no (Codex review #458). Where gwm cannot
1814 // express the origin it pins nothing and delegates, which is the same
1815 // path a guessed origin already takes.
1816 if origin.trust != forge::OriginTrust::FromUrl || origin.path.is_empty() {
1817 return Vec::new();
1818 }
1819 let Some(host) = gh_pinnable_host(origin) else {
1820 return Vec::new();
1821 };
1822 vec![("GH_HOST".to_string(), host)]
1823}
1824
1825/// The origin as a hostname gh will accept, or `None` when it cannot be
1826/// expressed. A default port is dropped rather than disqualifying —
1827/// `github.com:443` and `github.com` are the same endpoint, and only the
1828/// first one reads as Enterprise.
1829fn gh_pinnable_host(origin: &forge::RemoteRef) -> Option<String> {
1830 let (scheme, rest) = origin.web_origin.split_once("://")?;
1831 if !scheme.eq_ignore_ascii_case("https") {
1832 return None;
1833 }
1834 let authority = rest.trim_end_matches('/');
1835 match authority.rsplit_once(':') {
1836 Some((h, "443")) => Some(h.to_string()),
1837 Some(_) => None,
1838 None => Some(authority.to_string()),
1839 }
1840}
1841
1842impl Forge for GitHubForge {
1843 fn kind(&self) -> ForgeKind {
1844 ForgeKind::GitHub
1845 }
1846
1847 fn slug(&self) -> &str {
1848 // Identity, not the CLI selector: this feeds display and URLs, which
1849 // need the real path even when `repo_selector` deliberately returns
1850 // nothing. Same as the GitLab backend.
1851 &self.origin.path
1852 }
1853
1854 fn web_origin(&self) -> &str {
1855 &self.origin.web_origin
1856 }
1857
1858 fn workdir(&self) -> Option<&std::path::Path> {
1859 self.workdir.as_deref()
1860 }
1861
1862 fn origin_is_authoritative(&self) -> bool {
1863 self.origin.trust == forge::OriginTrust::FromUrl
1864 }
1865
1866 /// Always the slug: `gh` is pinned by `$GH_HOST` even for a guessed
1867 /// origin (see [`gh_env`]), so there is no ambiguity to defer to the
1868 /// working directory — and `gh api repos/<slug>/…` could not defer
1869 /// anyway, the slug being part of the request path.
1870 fn repo_selector(&self) -> &str {
1871 // The slug and the host pin move together, or the slug resolves
1872 // against the wrong instance. Two ways to have no pin: a guessed
1873 // origin (round 18), and — since round 27 — an origin `gh` cannot
1874 // express, a non-default port or plain http. The second was missed,
1875 // so `--repo owner/repo` went out with no `$GH_HOST` and `gh`
1876 // resolved it against github.com or an ambient one: a same-named
1877 // repo on another tenant, read and pruned (Codex review #458).
1878 //
1879 // `github.com` is the exception that needs no pin, being gh's own
1880 // default instance.
1881 let pinned = !gh_env(&self.origin).is_empty() || self.origin.host.eq_ignore_ascii_case("github.com");
1882 if !pinned && self.workdir.is_some() {
1883 return "";
1884 }
1885 &self.origin.path
1886 }
1887
1888 fn issue_url(&self, number: u64) -> String {
1889 format!("{}/{}/issues/{}", self.origin.web_origin, self.origin.path, number)
1890 }
1891
1892 fn pr_url(&self, number: u64) -> String {
1893 format!("{}/{}/pull/{}", self.origin.web_origin, self.origin.path, number)
1894 }
1895
1896 fn pr_head_refspec(&self, number: u64) -> String {
1897 format!("pull/{number}/head")
1898 }
1899
1900 // Every method below goes through `self.run`, never the free functions,
1901 // so `$GH_HOST` reaches the child (Codex review #458). The free functions
1902 // stay for the argv/parse contract the test suite pins.
1903
1904 fn fetch_issue(&self, number: u64) -> Result<IssueStatus> {
1905 parse_issue_json(&self.run(issue_view_argv(self.repo_selector(), number))?)
1906 }
1907
1908 fn fetch_pr(&self, number: u64) -> Result<PrStatus> {
1909 parse_pr_json(&self.run(pr_view_argv(self.repo_selector(), number))?)
1910 }
1911
1912 fn fetch_pr_head(&self, number: u64) -> Result<PrHead> {
1913 parse_pr_head_json(&self.run(pr_head_argv(self.repo_selector(), number))?)
1914 }
1915
1916 fn find_pr_for_branch(&self, branch: &str) -> Result<Option<u64>> {
1917 parse_pr_list_number(&self.run(find_pr_argv(self.repo_selector(), branch))?)
1918 }
1919
1920 fn create_issue(&self, req: &IssueCreateRequest<'_>) -> Result<CreatedIssue> {
1921 parse_created_issue(&self.run(issue_create_argv(self.repo_selector(), req))?)
1922 }
1923
1924 fn create_pr(&self, req: &PrCreateRequest<'_>) -> Result<CreatedPr> {
1925 parse_created_pr(&self.run(pr_create_argv(self.repo_selector(), req))?)
1926 }
1927
1928 fn fetch_remote_labels(&self) -> Result<Vec<RemoteLabel>> {
1929 parse_labels_json(&self.run(label_list_argv(self.repo_selector()))?)
1930 }
1931
1932 fn create_label(&self, spec: &LabelSpec) -> Result<()> {
1933 // `gh label create --force` means "create OR update", so both halves
1934 // of the trait's create/update split land on the same call here. The
1935 // split exists for GitLab, which has no such flag.
1936 self.run(label_create_argv(self.repo_selector(), spec))?;
1937 Ok(())
1938 }
1939
1940 fn update_label(&self, spec: &LabelSpec) -> Result<()> {
1941 self.create_label(spec)
1942 }
1943
1944 fn delete_label(&self, name: &str) -> Result<()> {
1945 validate_remote_label_name(name)?;
1946 self.run(label_delete_argv(self.repo_selector(), name))?;
1947 Ok(())
1948 }
1949
1950 fn fetch_remote_milestones(&self) -> Result<Vec<RemoteMilestone>> {
1951 parse_milestones_json(&self.run(milestone_list_argv(self.repo_selector()))?)
1952 }
1953
1954 fn create_milestone(&self, spec: &MilestoneSpec) -> Result<()> {
1955 self.run(milestone_create_argv(self.repo_selector(), spec))?;
1956 Ok(())
1957 }
1958
1959 fn update_milestone(&self, number: u64, spec: &MilestoneSpec) -> Result<()> {
1960 self.run(milestone_update_argv(self.repo_selector(), number, spec))?;
1961 Ok(())
1962 }
1963
1964 fn delete_milestone(&self, number: u64) -> Result<()> {
1965 self.run(milestone_delete_argv(self.repo_selector(), number))?;
1966 Ok(())
1967 }
1968}