use crate::error::{GwmError, Result};
use crate::forge::{self, Forge, ForgeKind};
use crate::labels::{LabelSpec, RemoteLabel};
use crate::milestones::{MilestoneSpec, MilestoneState, RemoteMilestone};
use crate::naming::BranchParser;
use git2::Repository;
use serde::Deserialize;
use std::ffi::{OsStr, OsString};
use std::sync::LazyLock;
pub use crate::forge::{cli_command_line as gh_command_line, repo_slug};
pub use crate::forge::{
CheckOutcome, CiState, CreatedIssue, CreatedPr, IssueCreateRequest, IssueState, IssueStatus, PrCheck,
PrCreateRequest, PrHead, PrState, PrStatus,
};
static ISSUE_URL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"/issues/(\d+)(?:\b|$)").expect("static issue URL regex compiles"));
static PR_URL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"/pull/(\d+)(?:\b|$)").expect("static PR URL regex compiles"));
const ISSUE_CONFIG_KEY: &str = "gwm-issue";
const PR_CONFIG_KEY: &str = "gwm-pr";
const DETECTED_PR_CONFIG_KEY: &str = "gwm-pr-detected";
const LINK_ORIGIN_CONFIG_KEY: &str = "gwm-link-origin";
const LINK_FORGE_CONFIG_KEY: &str = "gwm-link-forge";
const LINK_FORGE_BEFORE_THE_KEY: &str = "github";
const ISSUE_TITLE_CONFIG_KEY: &str = "gwm-issue-title";
const PR_TITLE_CONFIG_KEY: &str = "gwm-pr-title";
const DETECTED_PR_TITLE_CONFIG_KEY: &str = "gwm-pr-detected-title";
const ISSUE_STATE_CONFIG_KEY: &str = "gwm-issue-state";
const PR_STATE_CONFIG_KEY: &str = "gwm-pr-state";
const DETECTED_PR_STATE_CONFIG_KEY: &str = "gwm-pr-detected-state";
const AGENT_PIN_CONFIG_KEY: &str = "gwm-agent-pin";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkSource {
None,
BranchName,
Explicit,
Detected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchLink {
pub issue: Option<u64>,
pub pr: Option<u64>,
pub issue_title: Option<String>,
pub pr_title: Option<String>,
pub issue_state: Option<IssueState>,
pub pr_state: Option<PrState>,
pub issue_source: LinkSource,
pub pr_source: LinkSource,
}
impl BranchLink {
pub fn empty() -> Self {
Self {
issue: None,
pr: None,
issue_title: None,
pr_title: None,
issue_state: None,
pr_state: None,
issue_source: LinkSource::None,
pr_source: LinkSource::None,
}
}
pub fn summary(&self, pr_noun: &str) -> String {
match (self.issue, self.pr) {
(None, None) => "no link".into(),
(Some(i), None) => format!("issue #{i}"),
(None, Some(p)) => format!("{pr_noun} #{p}"),
(Some(i), Some(p)) => format!("issue #{i} · {pr_noun} #{p}"),
}
}
}
pub fn read_link(repo: &Repository, branch: &str) -> Result<BranchLink> {
read_link_with(repo, branch, &BranchParser::for_repo(repo))
}
pub fn read_link_with(repo: &Repository, branch: &str, parser: &BranchParser) -> Result<BranchLink> {
let foreign = match read_branch_string(repo, branch, LINK_ORIGIN_CONFIG_KEY)? {
Some(stored) => link_origin_is_foreign(repo)(&stored),
None => {
let mut has_link = false;
for key in [
ISSUE_CONFIG_KEY,
PR_CONFIG_KEY,
DETECTED_PR_CONFIG_KEY,
ISSUE_TITLE_CONFIG_KEY,
ISSUE_STATE_CONFIG_KEY,
PR_TITLE_CONFIG_KEY,
PR_STATE_CONFIG_KEY,
DETECTED_PR_TITLE_CONFIG_KEY,
DETECTED_PR_STATE_CONFIG_KEY,
] {
if read_branch_string(repo, branch, key)?.is_some() {
has_link = true;
break;
}
}
if has_link {
if let Some(id) = origin_identity(repo) {
let _ = write_branch_string(repo, branch, LINK_ORIGIN_CONFIG_KEY, &id);
}
}
false
}
};
let explicit_issue = if foreign {
None
} else {
read_branch_u64(repo, branch, ISSUE_CONFIG_KEY)?
};
let explicit_pr = if foreign {
None
} else {
read_branch_u64(repo, branch, PR_CONFIG_KEY)?
};
let (issue, issue_source) = match explicit_issue {
Some(n) => (Some(n), LinkSource::Explicit),
None => match parser.parse(branch).and_then(|s| s.issue.parse::<u64>().ok()) {
Some(n) => (Some(n), LinkSource::BranchName),
None => (None, LinkSource::None),
},
};
let (pr, pr_source) = match explicit_pr {
Some(n) => (Some(n), LinkSource::Explicit),
None if foreign => (None, LinkSource::None),
None => match read_branch_u64(repo, branch, DETECTED_PR_CONFIG_KEY)? {
Some(n) => (Some(n), LinkSource::Detected),
None => (None, LinkSource::None),
},
};
let issue_title = match issue {
Some(_) if !foreign => read_branch_string(repo, branch, ISSUE_TITLE_CONFIG_KEY)?,
_ => None,
};
let issue_state = match issue {
Some(_) if !foreign => read_branch_issue_state(repo, branch)?,
_ => None,
};
let pr_title = match pr_source {
LinkSource::Explicit => read_branch_string(repo, branch, PR_TITLE_CONFIG_KEY)?,
LinkSource::Detected => read_branch_string(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?,
LinkSource::BranchName | LinkSource::None => None,
};
let pr_state = match pr_source {
LinkSource::Explicit => read_branch_pr_state(repo, branch, PR_STATE_CONFIG_KEY)?,
LinkSource::Detected => read_branch_pr_state(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)?,
LinkSource::BranchName | LinkSource::None => None,
};
Ok(BranchLink {
issue,
pr,
issue_title,
pr_title,
issue_state,
pr_state,
issue_source,
pr_source,
})
}
pub fn apply_detected_pr(link: &mut BranchLink, detected: Option<u64>) {
if link.pr.is_none() {
if let Some(n) = detected {
link.pr = Some(n);
link.pr_source = LinkSource::Detected;
link.pr_title = None;
link.pr_state = None;
}
}
}
pub fn read_link_with_pr_detection(repo: &Repository, branch: &str, forge: &dyn Forge) -> Result<BranchLink> {
let mut link = read_link(repo, branch)?;
if link.pr_source != LinkSource::Explicit {
if let Ok(detected) = forge.find_pr_for_branch(branch) {
let previous_pr = link.pr;
let previous_pr_source = link.pr_source;
let previous_pr_title = link.pr_title.clone();
let previous_pr_state = link.pr_state;
link.pr = detected;
link.pr_source = match detected {
Some(_) => LinkSource::Detected,
None => LinkSource::None,
};
link.pr_title = if previous_pr_source == LinkSource::Detected && detected == previous_pr {
previous_pr_title
} else {
None
};
link.pr_state = if previous_pr_source == LinkSource::Detected && detected == previous_pr {
previous_pr_state
} else {
None
};
let _ = match detected {
Some(n) => persist_detected_pr(repo, branch, n),
None => clear_persisted_detected_pr(repo, branch),
};
}
}
Ok(link)
}
fn origin_identity(repo: &Repository) -> Option<String> {
let remote = repo.find_remote("origin").ok()?;
let parsed = forge::parse_remote_url(remote.url().ok()?).ok()?;
Some(format!("{}/{}", parsed.web_origin, parsed.path))
}
fn stamp_link_origin(repo: &Repository, branch: &str) {
let Some(id) = origin_identity(repo) else { return };
if let Ok(Some(stored)) = read_branch_string(repo, branch, LINK_ORIGIN_CONFIG_KEY) {
if stored != id && drop_branch_links(repo, branch).is_err() {
return;
}
}
let _ = write_branch_string(repo, branch, LINK_ORIGIN_CONFIG_KEY, &id);
}
fn drop_branch_links(repo: &Repository, branch: &str) -> Result<()> {
for key in [
ISSUE_CONFIG_KEY,
ISSUE_TITLE_CONFIG_KEY,
ISSUE_STATE_CONFIG_KEY,
PR_CONFIG_KEY,
PR_TITLE_CONFIG_KEY,
PR_STATE_CONFIG_KEY,
DETECTED_PR_CONFIG_KEY,
DETECTED_PR_TITLE_CONFIG_KEY,
DETECTED_PR_STATE_CONFIG_KEY,
] {
remove_branch_key(repo, branch, key)?;
}
Ok(())
}
pub(crate) fn reconcile_link_forge(repo: &Repository, kind: crate::forge::ForgeKind) {
let now = kind.as_str();
let Ok(head) = repo.head() else { return };
let Some(branch) = pinnable_branch(head.shorthand().ok()).map(str::to_string) else {
return;
};
let stored = read_branch_string(repo, &branch, LINK_FORGE_CONFIG_KEY)
.ok()
.flatten()
.unwrap_or_else(|| LINK_FORGE_BEFORE_THE_KEY.to_string());
if stored == now {
return;
}
if drop_branch_links(repo, &branch).is_err() || remove_branch_key(repo, &branch, LINK_ORIGIN_CONFIG_KEY).is_err() {
return;
}
let _ = write_branch_string(repo, &branch, LINK_FORGE_CONFIG_KEY, now);
}
fn link_origin_is_foreign(repo: &Repository) -> impl Fn(&str) -> bool + '_ {
let current = origin_identity(repo);
move |stored: &str| match ¤t {
Some(now) => stored != now,
None => false,
}
}
pub fn link_issue(repo: &Repository, branch: &str, number: u64) -> Result<()> {
stamp_link_origin(repo, branch);
write_branch_u64(repo, branch, ISSUE_CONFIG_KEY, number)?;
remove_branch_key(repo, branch, ISSUE_TITLE_CONFIG_KEY)?;
remove_branch_key(repo, branch, ISSUE_STATE_CONFIG_KEY)
}
pub fn link_pr(repo: &Repository, branch: &str, number: u64) -> Result<()> {
stamp_link_origin(repo, branch);
write_branch_u64(repo, branch, PR_CONFIG_KEY, number)?;
remove_branch_key(repo, branch, PR_TITLE_CONFIG_KEY)?;
remove_branch_key(repo, branch, PR_STATE_CONFIG_KEY)
}
pub fn unlink_issue(repo: &Repository, branch: &str) -> Result<()> {
remove_branch_key(repo, branch, ISSUE_CONFIG_KEY)?;
remove_branch_key(repo, branch, ISSUE_TITLE_CONFIG_KEY)?;
remove_branch_key(repo, branch, ISSUE_STATE_CONFIG_KEY)
}
pub fn unlink_pr(repo: &Repository, branch: &str) -> Result<()> {
remove_branch_key(repo, branch, PR_CONFIG_KEY)?;
remove_branch_key(repo, branch, PR_TITLE_CONFIG_KEY)?;
remove_branch_key(repo, branch, PR_STATE_CONFIG_KEY)?;
remove_branch_key(repo, branch, DETECTED_PR_CONFIG_KEY)?;
remove_branch_key(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?;
remove_branch_key(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)
}
pub fn persist_detected_pr(repo: &Repository, branch: &str, number: u64) -> Result<()> {
stamp_link_origin(repo, branch);
let previous = read_branch_u64(repo, branch, DETECTED_PR_CONFIG_KEY)?;
write_branch_u64(repo, branch, DETECTED_PR_CONFIG_KEY, number)?;
if previous == Some(number) {
Ok(())
} else {
remove_branch_key(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?;
remove_branch_key(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)
}
}
pub fn clear_persisted_detected_pr(repo: &Repository, branch: &str) -> Result<()> {
remove_branch_key(repo, branch, DETECTED_PR_CONFIG_KEY)?;
remove_branch_key(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY)?;
remove_branch_key(repo, branch, DETECTED_PR_STATE_CONFIG_KEY)
}
pub fn persist_issue_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
stamp_link_origin(repo, branch);
write_branch_string(repo, branch, ISSUE_TITLE_CONFIG_KEY, title)
}
pub fn persist_pr_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
stamp_link_origin(repo, branch);
write_branch_string(repo, branch, PR_TITLE_CONFIG_KEY, title)
}
pub fn persist_detected_pr_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
stamp_link_origin(repo, branch);
write_branch_string(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY, title)
}
pub fn persist_issue_state(repo: &Repository, branch: &str, state: IssueState) -> Result<()> {
stamp_link_origin(repo, branch);
write_branch_string(repo, branch, ISSUE_STATE_CONFIG_KEY, issue_state_config_value(state))
}
pub fn persist_pr_state(repo: &Repository, branch: &str, state: PrState) -> Result<()> {
stamp_link_origin(repo, branch);
write_branch_string(repo, branch, PR_STATE_CONFIG_KEY, pr_state_config_value(state))
}
pub fn persist_detected_pr_state(repo: &Repository, branch: &str, state: PrState) -> Result<()> {
stamp_link_origin(repo, branch);
write_branch_string(repo, branch, DETECTED_PR_STATE_CONFIG_KEY, pr_state_config_value(state))
}
fn config_key(branch: &str, leaf: &str) -> String {
format!("branch.{}.{}", branch, leaf)
}
fn read_branch_u64(repo: &Repository, branch: &str, leaf: &str) -> Result<Option<u64>> {
let cfg = repo.config()?;
let key = config_key(branch, leaf);
match cfg.get_string(&key) {
Ok(s) => s
.trim()
.parse::<u64>()
.map(Some)
.map_err(|_| GwmError::Other(format!("config '{}' is not a valid number: {}", key, s))),
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
Err(e) => Err(GwmError::Git(e)),
}
}
fn read_branch_string(repo: &Repository, branch: &str, leaf: &str) -> Result<Option<String>> {
let cfg = repo.config()?;
let key = config_key(branch, leaf);
match cfg.get_string(&key) {
Ok(s) => Ok(Some(s)),
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
Err(e) => Err(GwmError::Git(e)),
}
}
fn read_branch_issue_state(repo: &Repository, branch: &str) -> Result<Option<IssueState>> {
Ok(
read_branch_string(repo, branch, ISSUE_STATE_CONFIG_KEY)?
.as_deref()
.and_then(parse_issue_state_config_value),
)
}
fn read_branch_pr_state(repo: &Repository, branch: &str, leaf: &str) -> Result<Option<PrState>> {
Ok(
read_branch_string(repo, branch, leaf)?
.as_deref()
.and_then(parse_pr_state_config_value),
)
}
fn parse_issue_state_config_value(value: &str) -> Option<IssueState> {
match value.trim().to_ascii_lowercase().as_str() {
"open" => Some(IssueState::Open),
"closed" => Some(IssueState::Closed),
_ => None,
}
}
fn parse_pr_state_config_value(value: &str) -> Option<PrState> {
match value.trim().to_ascii_lowercase().as_str() {
"open" => Some(PrState::Open),
"draft" => Some(PrState::Draft),
"closed" => Some(PrState::Closed),
"merged" => Some(PrState::Merged),
_ => None,
}
}
fn issue_state_config_value(state: IssueState) -> &'static str {
match state {
IssueState::Open => "open",
IssueState::Closed => "closed",
}
}
fn pr_state_config_value(state: PrState) -> &'static str {
match state {
PrState::Open => "open",
PrState::Draft => "draft",
PrState::Closed => "closed",
PrState::Merged => "merged",
}
}
fn write_branch_u64(repo: &Repository, branch: &str, leaf: &str, value: u64) -> Result<()> {
let mut cfg = repo.config()?;
cfg.set_str(&config_key(branch, leaf), &value.to_string())?;
Ok(())
}
fn write_branch_string(repo: &Repository, branch: &str, leaf: &str, value: &str) -> Result<()> {
let mut cfg = repo.config()?;
cfg.set_str(&config_key(branch, leaf), value)?;
Ok(())
}
pub fn pinnable_branch(branch: Option<&str>) -> Option<&str> {
match branch {
None | Some("HEAD") => None,
other => other,
}
}
pub fn agent_pins(repo: &Repository, branch: &str) -> Result<Vec<String>> {
let cfg = repo.config()?;
let key = config_key(branch, AGENT_PIN_CONFIG_KEY);
let mut out = Vec::new();
let result = match cfg.multivar(&key, None) {
Ok(entries) => {
entries
.for_each(|e| {
if let Ok(v) = e.value() {
out.push(v.to_string());
}
})
.map_err(GwmError::Git)?;
Ok(out)
}
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(out),
Err(e) => Err(GwmError::Git(e)),
};
result
}
pub fn add_agent_pin(repo: &Repository, branch: &str, session_id: &str) -> Result<()> {
if agent_pins(repo, branch)?.iter().any(|p| p == session_id) {
return Ok(());
}
let mut cfg = repo.config()?;
cfg.set_multivar(&config_key(branch, AGENT_PIN_CONFIG_KEY), "^$", session_id)?;
Ok(())
}
pub fn remove_agent_pin(repo: &Repository, branch: &str, session_id: &str) -> Result<bool> {
if !agent_pins(repo, branch)?.iter().any(|p| p == session_id) {
return Ok(false);
}
let mut cfg = repo.config()?;
let escaped: String = session_id
.chars()
.flat_map(|c| {
if c.is_ascii_alphanumeric() {
vec![c]
} else {
vec!['\\', c]
}
})
.collect();
cfg.remove_multivar(&config_key(branch, AGENT_PIN_CONFIG_KEY), &format!("^{escaped}$"))?;
Ok(true)
}
pub fn clear_agent_pins(repo: &Repository, branch: &str) -> Result<()> {
let mut cfg = repo.config()?;
match cfg.remove_multivar(&config_key(branch, AGENT_PIN_CONFIG_KEY), ".*") {
Ok(()) => Ok(()),
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(()),
Err(e) => Err(GwmError::Git(e)),
}
}
fn remove_branch_key(repo: &Repository, branch: &str, leaf: &str) -> Result<()> {
let mut cfg = repo.config()?;
let key = config_key(branch, leaf);
match cfg.remove(&key) {
Ok(_) => Ok(()),
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(()),
Err(e) => Err(GwmError::Git(e)),
}
}
#[derive(Deserialize)]
struct RawIssue {
number: u64,
title: String,
state: String,
url: String,
#[serde(default)]
labels: Vec<RawLabel>,
#[serde(rename = "updatedAt", default)]
updated_at: String,
}
#[derive(Deserialize)]
struct RawLabel {
name: String,
}
#[derive(Deserialize)]
struct RawPr {
number: u64,
title: String,
state: String,
#[serde(rename = "isDraft", default)]
is_draft: bool,
url: String,
#[serde(rename = "updatedAt", default)]
updated_at: String,
#[serde(rename = "statusCheckRollup", default)]
status_check_rollup: Vec<RawCheck>,
}
#[derive(Deserialize)]
struct RawCheck {
#[serde(default)]
status: String,
#[serde(default)]
conclusion: Option<String>,
#[serde(default)]
state: String,
#[serde(default)]
name: String,
#[serde(rename = "detailsUrl", default)]
details_url: Option<String>,
#[serde(default)]
context: String,
#[serde(rename = "targetUrl", default)]
target_url: Option<String>,
#[serde(rename = "workflowName", default)]
workflow_name: Option<String>,
#[serde(rename = "startedAt", default)]
started_at: Option<String>,
#[serde(rename = "completedAt", default)]
completed_at: Option<String>,
}
pub fn parse_issue_json(s: &str) -> Result<IssueStatus> {
let raw: RawIssue = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "issue",
source: e,
})?;
let state = match raw.state.as_str() {
"OPEN" | "open" => IssueState::Open,
"CLOSED" | "closed" => IssueState::Closed,
other => return Err(GwmError::Other(format!("unknown issue state '{}'", other))),
};
Ok(IssueStatus {
number: raw.number,
title: raw.title,
state,
url: raw.url,
labels: raw.labels.into_iter().map(|l| l.name).collect(),
updated_at: raw.updated_at,
})
}
pub fn parse_pr_json(s: &str) -> Result<PrStatus> {
let raw: RawPr = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse { kind: "pr", source: e })?;
let state = match (raw.state.as_str(), raw.is_draft) {
("MERGED" | "merged", _) => PrState::Merged,
("CLOSED" | "closed", _) => PrState::Closed,
("OPEN" | "open", true) => PrState::Draft,
("OPEN" | "open", false) => PrState::Open,
(other, _) => return Err(GwmError::Other(format!("unknown PR state '{}'", other))),
};
let checks_total = raw.status_check_rollup.len() as u32;
let checks_passed = raw
.status_check_rollup
.iter()
.filter(|c| matches!(classify_check(c), CheckOutcome::Passing))
.count() as u32;
let ci = derive_ci_state(&raw.status_check_rollup);
let checks = raw
.status_check_rollup
.iter()
.map(|c| PrCheck {
name: if c.name.is_empty() {
c.context.clone()
} else {
c.name.clone()
},
outcome: classify_check(c),
url: c.details_url.clone().or_else(|| c.target_url.clone()),
workflow_name: c.workflow_name.clone(),
started_at: c.started_at.clone(),
completed_at: c.completed_at.clone(),
})
.collect();
Ok(PrStatus {
number: raw.number,
title: raw.title,
state,
url: raw.url,
updated_at: raw.updated_at,
checks_passed,
checks_total,
ci,
checks,
})
}
fn classify_check(c: &RawCheck) -> CheckOutcome {
if !c.status.is_empty() {
if !c.status.eq_ignore_ascii_case("COMPLETED") {
return CheckOutcome::Running;
}
return match c.conclusion.as_deref() {
Some(s) if is_accepted_conclusion(s) => CheckOutcome::Passing,
None => CheckOutcome::Passing,
Some(_) => CheckOutcome::Failing,
};
}
match c.state.to_ascii_uppercase().as_str() {
"SUCCESS" => CheckOutcome::Passing,
"FAILURE" | "ERROR" => CheckOutcome::Failing,
_ => CheckOutcome::Running,
}
}
fn is_accepted_conclusion(conclusion: &str) -> bool {
matches!(
conclusion.to_ascii_uppercase().as_str(),
"SUCCESS" | "NEUTRAL" | "SKIPPED"
)
}
fn derive_ci_state(checks: &[RawCheck]) -> CiState {
forge::aggregate_ci_state(checks.iter().map(classify_check))
}
const ISSUE_JSON_FIELDS: &str = "number,title,state,url,labels,updatedAt";
const PR_JSON_FIELDS: &str = "number,title,state,isDraft,url,updatedAt,statusCheckRollup";
pub fn fetch_issue(slug: &str, number: u64) -> Result<IssueStatus> {
fetch_issue_with(&gh_program(), slug, number)
}
pub fn fetch_issue_with(program: &OsStr, slug: &str, number: u64) -> Result<IssueStatus> {
parse_issue_json(&run_gh_with(program, issue_view_argv(slug, number))?)
}
fn repo_flag(slug: &str) -> Vec<String> {
if slug.is_empty() {
Vec::new()
} else {
vec!["--repo".into(), slug.into()]
}
}
fn repo_api_path(slug: &str) -> String {
if slug.is_empty() {
"repos/{owner}/{repo}".to_string()
} else {
format!("repos/{slug}")
}
}
pub fn issue_view_argv(slug: &str, number: u64) -> Vec<String> {
let mut argv: Vec<String> = vec!["issue".into(), "view".into(), number.to_string()];
argv.extend(repo_flag(slug));
argv.extend(["--json".into(), ISSUE_JSON_FIELDS.into()]);
argv
}
pub fn gh_program() -> OsString {
std::env::var_os("GWM_GH").unwrap_or_else(|| "gh".into())
}
pub fn create_issue(slug: &str, req: &IssueCreateRequest<'_>) -> Result<CreatedIssue> {
parse_created_issue(&run_gh(issue_create_argv(slug, req))?)
}
pub fn issue_create_argv(slug: &str, req: &IssueCreateRequest<'_>) -> Vec<OsString> {
let mut args: Vec<OsString> = Vec::with_capacity(8 + 2 * req.labels.len());
args.push("issue".into());
args.push("create".into());
args.push("--title".into());
args.push(req.title.into());
args.push("--body-file".into());
args.push(req.body_file.as_os_str().to_owned());
for label in req.labels {
args.push("--label".into());
args.push(label.into());
}
if !slug.is_empty() {
args.push("--repo".into());
args.push(slug.into());
}
args
}
pub fn parse_created_issue(stdout: &str) -> Result<CreatedIssue> {
let stdout = stdout.trim().to_string();
let Some(caps) = ISSUE_URL_RE.captures(&stdout) else {
return Err(GwmError::CommandFailed(format!(
"gh issue create did not print an issue URL containing a number: {}",
stdout
)));
};
let number = caps
.get(1)
.and_then(|m| m.as_str().parse::<u64>().ok())
.ok_or_else(|| GwmError::CommandFailed(format!("failed to parse issue number from gh output: {}", stdout)))?;
Ok(CreatedIssue { number, url: stdout })
}
pub fn create_pr(slug: &str, req: &PrCreateRequest<'_>) -> Result<CreatedPr> {
parse_created_pr(&run_gh(pr_create_argv(slug, req))?)
}
pub fn pr_create_argv(slug: &str, req: &PrCreateRequest<'_>) -> Vec<OsString> {
let mut args: Vec<OsString> =
Vec::with_capacity(10 + if req.draft { 1 } else { 0 } + if req.base.is_some() { 2 } else { 0 });
args.push("pr".into());
args.push("create".into());
args.push("--title".into());
args.push(req.title.into());
args.push("--body-file".into());
args.push(req.body_file.as_os_str().to_owned());
args.push("--head".into());
args.push(req.head.into());
if let Some(base) = req.base {
args.push("--base".into());
args.push(base.into());
}
if req.draft {
args.push("--draft".into());
}
if !slug.is_empty() {
args.push("--repo".into());
args.push(slug.into());
}
args
}
pub fn parse_created_pr(stdout: &str) -> Result<CreatedPr> {
let stdout = stdout.trim().to_string();
let Some(caps) = PR_URL_RE.captures(&stdout) else {
return Err(GwmError::CommandFailed(format!(
"gh pr create did not print a PR URL containing a number: {}",
stdout
)));
};
let number = caps
.get(1)
.and_then(|m| m.as_str().parse::<u64>().ok())
.ok_or_else(|| GwmError::CommandFailed(format!("failed to parse PR number from gh output: {}", stdout)))?;
Ok(CreatedPr { number, url: stdout })
}
pub fn fetch_pr(slug: &str, number: u64) -> Result<PrStatus> {
fetch_pr_with(&gh_program(), slug, number)
}
pub fn fetch_pr_with(program: &OsStr, slug: &str, number: u64) -> Result<PrStatus> {
parse_pr_json(&run_gh_with(program, pr_view_argv(slug, number))?)
}
pub fn pr_view_argv(slug: &str, number: u64) -> Vec<String> {
let mut argv: Vec<String> = vec!["pr".into(), "view".into(), number.to_string()];
argv.extend(repo_flag(slug));
argv.extend(["--json".into(), PR_JSON_FIELDS.into()]);
argv
}
#[derive(Deserialize)]
struct RawPrHead {
number: u64,
#[serde(default)]
author: Option<RawAuthor>,
#[serde(rename = "headRefName", default)]
head_ref_name: String,
#[serde(rename = "baseRefName", default)]
base_ref_name: String,
}
#[derive(Deserialize, Default)]
struct RawAuthor {
#[serde(default)]
login: String,
}
const PR_HEAD_JSON_FIELDS: &str = "number,author,headRefName,baseRefName";
pub fn parse_pr_head_json(s: &str) -> Result<PrHead> {
let raw: RawPrHead = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "pr head",
source: e,
})?;
Ok(PrHead {
number: raw.number,
author: raw.author.unwrap_or_default().login,
head_ref_name: raw.head_ref_name,
base_ref_name: raw.base_ref_name,
})
}
pub fn fetch_pr_head(slug: &str, number: u64) -> Result<PrHead> {
parse_pr_head_json(&run_gh(pr_head_argv(slug, number))?)
}
pub fn pr_head_argv(slug: &str, number: u64) -> Vec<String> {
let mut argv: Vec<String> = vec!["pr".into(), "view".into(), number.to_string()];
argv.extend(repo_flag(slug));
argv.extend(["--json".into(), PR_HEAD_JSON_FIELDS.into()]);
argv
}
pub fn find_pr_for_branch(slug: &str, branch: &str) -> Result<Option<u64>> {
let stdout = run_gh(find_pr_argv(slug, branch))?;
parse_pr_list_number(&stdout)
}
pub fn find_pr_argv(slug: &str, branch: &str) -> Vec<String> {
let mut argv: Vec<String> = vec!["pr".into(), "list".into()];
argv.extend(repo_flag(slug));
argv.extend([
"--head".into(),
branch.into(),
"--state".into(),
"all".into(),
"--json".into(),
"number,isCrossRepository".into(),
"--limit".into(),
"20".into(),
]);
argv
}
pub fn parse_pr_list_number(s: &str) -> Result<Option<u64>> {
#[derive(Deserialize)]
struct PrRef {
number: u64,
#[serde(rename = "isCrossRepository", default)]
is_cross_repository: Option<bool>,
}
let arr: Vec<PrRef> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "pr list",
source: e,
})?;
Ok(
arr
.iter()
.find(|p| !p.is_cross_repository.unwrap_or(false))
.or_else(|| arr.first())
.map(|p| p.number),
)
}
fn run_gh<I, S>(args: I) -> Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_gh_with(&gh_program(), args)
}
fn run_gh_with<I, S>(program: &OsStr, args: I) -> Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
forge::run_cli(program, args)
}
const LABEL_JSON_FIELDS: &str = "name,color,description";
const LABEL_LIST_LIMIT: &str = "1000";
#[derive(Deserialize)]
struct RawLabel2 {
name: String,
color: String,
#[serde(default)]
description: Option<String>,
}
pub fn parse_labels_json(s: &str) -> Result<Vec<RemoteLabel>> {
let raw: Vec<RawLabel2> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "labels",
source: e,
})?;
Ok(
raw
.into_iter()
.map(|r| RemoteLabel {
name: r.name,
description: r.description,
color: r.color.to_ascii_lowercase(),
})
.collect(),
)
}
pub fn label_list_argv(slug: &str) -> Vec<String> {
let mut argv: Vec<String> = vec!["label".into(), "list".into()];
argv.extend(repo_flag(slug));
argv.extend([
"--json".into(),
LABEL_JSON_FIELDS.into(),
"--limit".into(),
LABEL_LIST_LIMIT.into(),
]);
argv
}
pub fn label_create_argv(slug: &str, spec: &LabelSpec) -> Vec<String> {
let mut argv: Vec<String> = vec!["label".into(), "create".into(), spec.name.clone()];
argv.extend(repo_flag(slug));
argv.extend(["--color".into(), spec.color.clone(), "--force".into()]);
if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
argv.push("--description".into());
argv.push(desc.clone());
}
argv
}
pub fn label_delete_argv(slug: &str, name: &str) -> Vec<String> {
let mut argv: Vec<String> = vec!["label".into(), "delete".into(), name.into()];
argv.extend(repo_flag(slug));
argv.push("--yes".into());
argv
}
pub fn fetch_remote_labels(slug: &str) -> Result<Vec<RemoteLabel>> {
let argv = label_list_argv(slug);
let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
let stdout = run_gh(&args)?;
parse_labels_json(&stdout)
}
pub fn push_label(slug: &str, spec: &LabelSpec) -> Result<()> {
let argv = label_create_argv(slug, spec);
let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
run_gh(&args)?;
Ok(())
}
pub fn delete_label(slug: &str, name: &str) -> Result<()> {
validate_remote_label_name(name)?;
let argv = label_delete_argv(slug, name);
let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
run_gh(&args)?;
Ok(())
}
fn validate_remote_label_name(name: &str) -> Result<()> {
crate::labels::validate_label_name(name).map_err(|e| {
let inner = match e {
GwmError::Config(msg) => msg,
other => other.to_string(),
};
GwmError::Config(format!(
"labels (remote): {} — refusing to delete via `gh label delete`",
inner
))
})
}
const MILESTONE_PER_PAGE: &str = "100";
#[derive(Deserialize)]
struct RawMilestone {
number: u64,
title: String,
state: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
due_on: Option<String>,
}
pub fn parse_milestones_json(s: &str) -> Result<Vec<RemoteMilestone>> {
let raw: Vec<RawMilestone> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "milestones",
source: e,
})?;
raw
.into_iter()
.map(|r| {
let state = match r.state.as_str() {
"open" => MilestoneState::Open,
"closed" => MilestoneState::Closed,
other => {
return Err(GwmError::Other(format!(
"milestone '{}' has unknown state '{}': expected 'open' or 'closed'",
r.title, other
)))
}
};
Ok(RemoteMilestone {
number: r.number,
title: r.title,
description: r.description,
due_on: r.due_on,
state,
})
})
.collect()
}
pub fn milestone_list_argv(slug: &str) -> Vec<String> {
vec![
"api".into(),
"--paginate".into(),
format!(
"{}/milestones?state=all&per_page={}",
repo_api_path(slug),
MILESTONE_PER_PAGE
),
]
}
pub fn milestone_create_argv(slug: &str, spec: &MilestoneSpec) -> Vec<String> {
let mut argv = vec![
"api".into(),
"-X".into(),
"POST".into(),
format!("{}/milestones", repo_api_path(slug)),
"-f".into(),
format!("title={}", spec.title),
"-f".into(),
format!("state={}", spec.state.as_str()),
];
if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
argv.push("-f".into());
argv.push(format!("description={}", desc));
}
if let Some(due) = spec.due_on.as_ref().filter(|s| !s.is_empty()) {
argv.push("-f".into());
argv.push(format!("due_on={}", due));
}
argv
}
pub fn milestone_update_argv(slug: &str, number: u64, spec: &MilestoneSpec) -> Vec<String> {
let mut argv = vec![
"api".into(),
"-X".into(),
"PATCH".into(),
format!("{}/milestones/{}", repo_api_path(slug), number),
"-f".into(),
format!("title={}", spec.title),
"-f".into(),
format!("state={}", spec.state.as_str()),
];
if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
argv.push("-f".into());
argv.push(format!("description={}", desc));
}
if let Some(due) = spec.due_on.as_ref().filter(|s| !s.is_empty()) {
argv.push("-f".into());
argv.push(format!("due_on={}", due));
}
argv
}
pub fn milestone_delete_argv(slug: &str, number: u64) -> Vec<String> {
vec![
"api".into(),
"-X".into(),
"DELETE".into(),
format!("{}/milestones/{}", repo_api_path(slug), number),
]
}
pub fn fetch_remote_milestones(slug: &str) -> Result<Vec<RemoteMilestone>> {
let argv = milestone_list_argv(slug);
let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
let stdout = run_gh(&args)?;
parse_milestones_json(&stdout)
}
pub fn create_milestone(slug: &str, spec: &MilestoneSpec) -> Result<()> {
let argv = milestone_create_argv(slug, spec);
let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
run_gh(&args)?;
Ok(())
}
pub fn update_milestone(slug: &str, number: u64, spec: &MilestoneSpec) -> Result<()> {
let argv = milestone_update_argv(slug, number, spec);
let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
run_gh(&args)?;
Ok(())
}
pub fn delete_milestone(slug: &str, number: u64) -> Result<()> {
let argv = milestone_delete_argv(slug, number);
let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
run_gh(&args)?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct GitHubForge {
origin: forge::RemoteRef,
program: OsString,
env: Vec<(String, String)>,
env_remove: Vec<&'static str>,
workdir: Option<std::path::PathBuf>,
}
impl GitHubForge {
pub fn new(origin: forge::RemoteRef, workdir: Option<std::path::PathBuf>) -> Self {
Self {
env: gh_env(&origin),
env_remove: gh_env_remove(&origin, workdir.is_some()),
origin,
program: gh_program(),
workdir,
}
}
fn run<I, S>(&self, args: I) -> Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
forge::run_cli_with(
&self.program,
args,
&forge::CliSpawn {
env: &self.env,
cwd: self.workdir.as_deref(),
env_remove: &self.env_remove,
redact_after: &[],
redact_output: false,
stdin: None,
},
)
}
}
pub fn gh_env_remove(origin: &forge::RemoteRef, has_workdir: bool) -> Vec<&'static str> {
if origin.path.is_empty() && !has_workdir {
return Vec::new();
}
vec!["GH_REPO"]
}
pub fn gh_env(origin: &forge::RemoteRef) -> Vec<(String, String)> {
if origin.trust != forge::OriginTrust::FromUrl || origin.path.is_empty() {
return Vec::new();
}
let Some(host) = gh_pinnable_host(origin) else {
return Vec::new();
};
vec![("GH_HOST".to_string(), host)]
}
fn gh_pinnable_host(origin: &forge::RemoteRef) -> Option<String> {
let (scheme, rest) = origin.web_origin.split_once("://")?;
if !scheme.eq_ignore_ascii_case("https") {
return None;
}
let authority = rest.trim_end_matches('/');
match authority.rsplit_once(':') {
Some((h, "443")) => Some(h.to_string()),
Some(_) => None,
None => Some(authority.to_string()),
}
}
impl Forge for GitHubForge {
fn kind(&self) -> ForgeKind {
ForgeKind::GitHub
}
fn slug(&self) -> &str {
&self.origin.path
}
fn web_origin(&self) -> &str {
&self.origin.web_origin
}
fn workdir(&self) -> Option<&std::path::Path> {
self.workdir.as_deref()
}
fn origin_is_authoritative(&self) -> bool {
self.origin.trust == forge::OriginTrust::FromUrl
}
fn repo_selector(&self) -> &str {
let pinned = !gh_env(&self.origin).is_empty() || self.origin.host.eq_ignore_ascii_case("github.com");
if !pinned && self.workdir.is_some() {
return "";
}
&self.origin.path
}
fn issue_url(&self, number: u64) -> String {
format!("{}/{}/issues/{}", self.origin.web_origin, self.origin.path, number)
}
fn pr_url(&self, number: u64) -> String {
format!("{}/{}/pull/{}", self.origin.web_origin, self.origin.path, number)
}
fn pr_head_refspec(&self, number: u64) -> String {
format!("pull/{number}/head")
}
fn fetch_issue(&self, number: u64) -> Result<IssueStatus> {
parse_issue_json(&self.run(issue_view_argv(self.repo_selector(), number))?)
}
fn fetch_pr(&self, number: u64) -> Result<PrStatus> {
parse_pr_json(&self.run(pr_view_argv(self.repo_selector(), number))?)
}
fn fetch_pr_head(&self, number: u64) -> Result<PrHead> {
parse_pr_head_json(&self.run(pr_head_argv(self.repo_selector(), number))?)
}
fn find_pr_for_branch(&self, branch: &str) -> Result<Option<u64>> {
parse_pr_list_number(&self.run(find_pr_argv(self.repo_selector(), branch))?)
}
fn create_issue(&self, req: &IssueCreateRequest<'_>) -> Result<CreatedIssue> {
parse_created_issue(&self.run(issue_create_argv(self.repo_selector(), req))?)
}
fn create_pr(&self, req: &PrCreateRequest<'_>) -> Result<CreatedPr> {
parse_created_pr(&self.run(pr_create_argv(self.repo_selector(), req))?)
}
fn fetch_remote_labels(&self) -> Result<Vec<RemoteLabel>> {
parse_labels_json(&self.run(label_list_argv(self.repo_selector()))?)
}
fn create_label(&self, spec: &LabelSpec) -> Result<()> {
self.run(label_create_argv(self.repo_selector(), spec))?;
Ok(())
}
fn update_label(&self, spec: &LabelSpec) -> Result<()> {
self.create_label(spec)
}
fn delete_label(&self, name: &str) -> Result<()> {
validate_remote_label_name(name)?;
self.run(label_delete_argv(self.repo_selector(), name))?;
Ok(())
}
fn fetch_remote_milestones(&self) -> Result<Vec<RemoteMilestone>> {
parse_milestones_json(&self.run(milestone_list_argv(self.repo_selector()))?)
}
fn create_milestone(&self, spec: &MilestoneSpec) -> Result<()> {
self.run(milestone_create_argv(self.repo_selector(), spec))?;
Ok(())
}
fn update_milestone(&self, number: u64, spec: &MilestoneSpec) -> Result<()> {
self.run(milestone_update_argv(self.repo_selector(), number, spec))?;
Ok(())
}
fn delete_milestone(&self, number: u64) -> Result<()> {
self.run(milestone_delete_argv(self.repo_selector(), number))?;
Ok(())
}
}