use crate::error::{GwmError, Result};
use crate::labels::{LabelSpec, RemoteLabel};
use crate::milestones::{MilestoneSpec, MilestoneState, RemoteMilestone};
use crate::naming::parse_branch;
use git2::Repository;
use serde::Deserialize;
use std::ffi::{OsStr, OsString};
use std::path::Path;
use std::process::Command;
use std::sync::LazyLock;
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 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";
#[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) -> String {
match (self.issue, self.pr) {
(None, None) => "no link".into(),
(Some(i), None) => format!("issue #{i}"),
(None, Some(p)) => format!("PR #{p}"),
(Some(i), Some(p)) => format!("issue #{i} · PR #{p}"),
}
}
}
pub fn read_link(repo: &Repository, branch: &str) -> Result<BranchLink> {
let explicit_issue = read_branch_u64(repo, branch, ISSUE_CONFIG_KEY)?;
let explicit_pr = read_branch_u64(repo, branch, PR_CONFIG_KEY)?;
let (issue, issue_source) = match explicit_issue {
Some(n) => (Some(n), LinkSource::Explicit),
None => match parse_branch(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 => 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(_) => read_branch_string(repo, branch, ISSUE_TITLE_CONFIG_KEY)?,
None => None,
};
let issue_state = match issue {
Some(_) => read_branch_issue_state(repo, branch)?,
None => 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, slug: &str) -> Result<BranchLink> {
let mut link = read_link(repo, branch)?;
if link.pr_source != LinkSource::Explicit {
if let Ok(detected) = find_pr_for_branch(slug, 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)
}
pub fn link_issue(repo: &Repository, branch: &str, number: u64) -> Result<()> {
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<()> {
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<()> {
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<()> {
write_branch_string(repo, branch, ISSUE_TITLE_CONFIG_KEY, title)
}
pub fn persist_pr_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
write_branch_string(repo, branch, PR_TITLE_CONFIG_KEY, title)
}
pub fn persist_detected_pr_title(repo: &Repository, branch: &str, title: &str) -> Result<()> {
write_branch_string(repo, branch, DETECTED_PR_TITLE_CONFIG_KEY, title)
}
pub fn persist_issue_state(repo: &Repository, branch: &str, state: IssueState) -> Result<()> {
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<()> {
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<()> {
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(())
}
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)),
}
}
pub fn repo_slug(repo: &Repository) -> Result<String> {
let remote = repo
.find_remote("origin")
.map_err(|_| GwmError::Other("no 'origin' remote configured".into()))?;
let url = remote
.url()
.ok()
.ok_or_else(|| GwmError::Other("origin remote has no URL (non-utf8?)".into()))?
.to_string();
parse_github_slug(&url)
}
fn parse_github_slug(url: &str) -> Result<String> {
if let Some(rest) = url.strip_prefix("git@github.com:") {
return Ok(trim_git_suffix(rest).to_string());
}
for prefix in ["https://github.com/", "http://github.com/"] {
if let Some(rest) = url.strip_prefix(prefix) {
return Ok(trim_git_suffix(rest).to_string());
}
}
Err(GwmError::Other(format!(
"origin '{}' is not a github URL (expected git@github.com:… or https://github.com/…)",
url
)))
}
fn trim_git_suffix(s: &str) -> &str {
let trimmed = s.trim_end_matches('/');
trimmed.strip_suffix(".git").unwrap_or(trimmed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueState {
Open,
Closed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueStatus {
pub number: u64,
pub title: String,
pub state: IssueState,
pub url: String,
pub labels: Vec<String>,
pub updated_at: String,
}
#[derive(Debug, Clone)]
pub struct IssueCreateRequest<'a> {
pub title: &'a str,
pub body_file: &'a std::path::Path,
pub labels: &'a [String],
pub repo: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub struct CreatedIssue {
pub number: u64,
pub url: String,
}
#[derive(Debug, Clone)]
pub struct PrCreateRequest<'a> {
pub title: &'a str,
pub body_file: &'a std::path::Path,
pub head: &'a str,
pub base: Option<&'a str>,
pub draft: bool,
pub repo: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub struct CreatedPr {
pub number: u64,
pub url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrState {
Open,
Draft,
Closed,
Merged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CiState {
None,
Passing,
Running,
Failing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrStatus {
pub number: u64,
pub title: String,
pub state: PrState,
pub url: String,
pub updated_at: String,
pub checks_passed: u32,
pub checks_total: u32,
pub ci: CiState,
}
#[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,
}
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);
Ok(PrStatus {
number: raw.number,
title: raw.title,
state,
url: raw.url,
updated_at: raw.updated_at,
checks_passed,
checks_total,
ci,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CheckOutcome {
Passing,
Running,
Failing,
}
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 {
if checks.is_empty() {
return CiState::None;
}
let mut any_running = false;
for c in checks {
match classify_check(c) {
CheckOutcome::Failing => return CiState::Failing,
CheckOutcome::Running => any_running = true,
CheckOutcome::Passing => {}
}
}
if any_running {
CiState::Running
} else {
CiState::Passing
}
}
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> {
let stdout = run_gh_with(
program,
[
"issue",
"view",
&number.to_string(),
"--repo",
slug,
"--json",
ISSUE_JSON_FIELDS,
],
)?;
parse_issue_json(&stdout)
}
pub fn gh_program() -> OsString {
std::env::var_os("GWM_GH").unwrap_or_else(|| "gh".into())
}
pub fn create_issue(req: &IssueCreateRequest<'_>) -> Result<CreatedIssue> {
let mut args: Vec<OsString> = Vec::with_capacity(6 + 2 * req.labels.len() + if req.repo.is_some() { 2 } else { 0 });
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 let Some(repo) = req.repo {
args.push("--repo".into());
args.push(repo.into());
}
let stdout = run_gh(&args)?;
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(req: &PrCreateRequest<'_>) -> Result<CreatedPr> {
let mut args: Vec<OsString> = Vec::with_capacity(
8 + if req.draft { 1 } else { 0 } + if req.base.is_some() { 2 } else { 0 } + if req.repo.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 let Some(repo) = req.repo {
args.push("--repo".into());
args.push(repo.into());
}
let stdout = run_gh(&args)?;
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> {
let stdout = run_gh_with(
program,
[
"pr",
"view",
&number.to_string(),
"--repo",
slug,
"--json",
PR_JSON_FIELDS,
],
)?;
parse_pr_json(&stdout)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrHead {
pub number: u64,
pub author: String,
pub head_ref_name: String,
pub base_ref_name: String,
}
#[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> {
let stdout = run_gh([
"pr",
"view",
&number.to_string(),
"--repo",
slug,
"--json",
PR_HEAD_JSON_FIELDS,
])?;
parse_pr_head_json(&stdout)
}
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> {
vec![
"pr".into(),
"list".into(),
"--repo".into(),
slug.into(),
"--head".into(),
branch.into(),
"--state".into(),
"all".into(),
"--json".into(),
"number".into(),
"--limit".into(),
"1".into(),
]
}
pub fn parse_pr_list_number(s: &str) -> Result<Option<u64>> {
#[derive(Deserialize)]
struct PrRef {
number: u64,
}
let arr: Vec<PrRef> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "pr list",
source: e,
})?;
Ok(arr.into_iter().next().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)
}
pub fn gh_command_line(program: &OsStr, args: &[OsString]) -> String {
let name = Path::new(program)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| program.to_string_lossy().into_owned());
let mut line = name;
for arg in args {
line.push(' ');
line.push_str(&arg.to_string_lossy());
}
line
}
fn run_gh_with<I, S>(program: &OsStr, args: I) -> Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let collected: Vec<OsString> = args.into_iter().map(|a| a.as_ref().to_os_string()).collect();
let cmdline = gh_command_line(program, &collected);
let mut cmd = Command::new(program);
cmd.args(&collected);
let output = crate::command_log::run_logged(&mut cmd, cmdline)
.map_err(|e| GwmError::CommandFailed(format!("gh: failed to spawn ({}). Is `gh` installed and on PATH?", e)))?;
if !output.status.success() {
return Err(GwmError::CommandFailed(format!(
"gh exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn issue_url(slug: &str, number: u64) -> String {
format!("https://github.com/{}/issues/{}", slug, number)
}
pub fn pr_url(slug: &str, number: u64) -> String {
format!("https://github.com/{}/pull/{}", slug, number)
}
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> {
vec![
"label".into(),
"list".into(),
"--repo".into(),
slug.into(),
"--json".into(),
LABEL_JSON_FIELDS.into(),
"--limit".into(),
LABEL_LIST_LIMIT.into(),
]
}
pub fn label_create_argv(slug: &str, spec: &LabelSpec) -> Vec<String> {
let mut argv = vec![
"label".into(),
"create".into(),
spec.name.clone(),
"--repo".into(),
slug.into(),
"--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> {
vec![
"label".into(),
"delete".into(),
name.into(),
"--repo".into(),
slug.into(),
"--yes".into(),
]
}
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<()> {
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
))
})?;
let argv = label_delete_argv(slug, name);
let args: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
run_gh(&args)?;
Ok(())
}
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!("repos/{}/milestones?state=all&per_page={}", 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!("repos/{}/milestones", 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!("repos/{}/milestones/{}", 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!("repos/{}/milestones/{}", 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(())
}