use crate::config::Config;
use crate::error::{GwmError, Result};
use crate::labels::{LabelSpec, RemoteLabel};
use crate::milestones::{MilestoneSpec, RemoteMilestone};
use git2::Repository;
use serde::{Deserialize, Serialize};
use std::ffi::{OsStr, OsString};
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
#[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, 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, Copy, PartialEq, Eq)]
pub enum CheckOutcome {
Passing,
Running,
Failing,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrCheck {
pub name: String,
pub outcome: CheckOutcome,
pub url: Option<String>,
pub workflow_name: Option<String>,
pub started_at: Option<String>,
pub completed_at: Option<String>,
}
#[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,
pub checks: Vec<PrCheck>,
}
#[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(Debug, Clone)]
pub struct IssueCreateRequest<'a> {
pub title: &'a str,
pub body_file: &'a Path,
pub labels: &'a [String],
}
#[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 Path,
pub head: &'a str,
pub base: Option<&'a str>,
pub draft: bool,
}
#[derive(Debug, Clone)]
pub struct CreatedPr {
pub number: u64,
pub url: String,
}
pub fn aggregate_ci_state(outcomes: impl IntoIterator<Item = CheckOutcome>) -> CiState {
let mut any_inconclusive = false;
let mut any = false;
for outcome in outcomes {
any = true;
match outcome {
CheckOutcome::Failing => return CiState::Failing,
CheckOutcome::Running | CheckOutcome::Unknown => any_inconclusive = true,
CheckOutcome::Passing => {}
}
}
if !any {
CiState::None
} else if any_inconclusive {
CiState::Running
} else {
CiState::Passing
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ForgeKind {
GitHub,
GitLab,
}
impl ForgeKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHub => "github",
Self::GitLab => "gitlab",
}
}
pub fn cli_name(&self) -> &'static str {
match self {
Self::GitHub => "gh",
Self::GitLab => "glab",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginTrust {
FromUrl,
Guessed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteRef {
pub host: String,
pub path: String,
pub web_origin: String,
pub trust: OriginTrust,
}
impl RemoteRef {
pub fn authority(&self) -> &str {
self
.web_origin
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(&self.web_origin)
.trim_end_matches('/')
}
}
pub fn parse_remote_url(url: &str) -> Result<RemoteRef> {
let url = url.trim();
let scheme = url.split_once("://").map(|(s, _)| s.to_ascii_lowercase());
let (host_part, path_part) = split_host_and_path(url)
.ok_or_else(|| GwmError::Other(format!("origin '{}' is not a recognised git remote URL", url)))?;
let authority = host_part.rsplit('@').next().unwrap_or(host_part);
let port_sep = match authority.rfind(']') {
Some(close) => authority[close..].find(':').map(|i| close + i),
None => authority.rfind(':'),
};
let (host, port) = match port_sep.map(|i| (&authority[..i], &authority[i + 1..])) {
Some((h, p)) if !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) => (h, Some(p)),
_ => (authority, None),
};
if host.is_empty() {
return Err(GwmError::Other(format!("origin '{}' has no host", url)));
}
let lower_host = host.to_ascii_lowercase();
let (host, known_alias) = match lower_host.as_str() {
"ssh.github.com" => ("github.com", true),
"altssh.gitlab.com" => ("gitlab.com", true),
other => (other, false),
};
let (web_origin, trust) = match scheme.as_deref() {
Some("http") => (
format!("http://{}{}", host, port.map(|p| format!(":{p}")).unwrap_or_default()),
OriginTrust::FromUrl,
),
Some("https") => (
format!("https://{}{}", host, port.map(|p| format!(":{p}")).unwrap_or_default()),
OriginTrust::FromUrl,
),
_ if known_alias => (format!("https://{host}"), OriginTrust::FromUrl),
_ => (format!("https://{host}"), OriginTrust::Guessed),
};
let path = trim_git_suffix(path_part.trim_start_matches('/'));
if path.is_empty() {
return Err(GwmError::Other(format!("origin '{}' has no repository path", url)));
}
Ok(RemoteRef {
host: host.to_string(),
path: path.to_string(),
web_origin,
trust,
})
}
fn split_host_and_path(url: &str) -> Option<(&str, &str)> {
if let Some((_scheme, rest)) = url.split_once("://") {
return rest.split_once('/');
}
if !url.contains('@') {
let mut chars = url.chars();
if let (Some(first), Some(':')) = (chars.next(), chars.next()) {
if first.is_ascii_alphabetic() {
return None;
}
}
}
let user_len = url.find('@').map(|i| i + 1).unwrap_or(0);
let hostpath = &url[user_len..];
let sep = if hostpath.starts_with('[') {
hostpath
.find(']')
.and_then(|close| hostpath[close..].find(':').map(|i| close + i))?
} else {
hostpath.find(':')?
};
let host_end = user_len + sep;
Some((&url[..host_end], &url[host_end + 1..]))
}
fn trim_git_suffix(s: &str) -> &str {
let trimmed = s.trim_end_matches('/');
trimmed.strip_suffix(".git").unwrap_or(trimmed).trim_end_matches('/')
}
pub fn detect_kind(host: &str) -> ForgeKind {
let lower = host.to_ascii_lowercase();
if lower.starts_with("gitlab.") || lower.contains(".gitlab.") {
return ForgeKind::GitLab;
}
known_kind(host).unwrap_or(ForgeKind::GitHub)
}
pub fn known_kind(host: &str) -> Option<ForgeKind> {
let host = host.to_ascii_lowercase();
match host.as_str() {
"gitlab.com" | "www.gitlab.com" => Some(ForgeKind::GitLab),
"github.com" | "www.github.com" | "ghe.com" => Some(ForgeKind::GitHub),
h if h.ends_with(".ghe.com") => Some(ForgeKind::GitHub),
_ => None,
}
}
pub trait Forge: Send + Sync + std::fmt::Debug {
fn kind(&self) -> ForgeKind;
fn slug(&self) -> &str;
fn web_origin(&self) -> &str;
fn origin_is_authoritative(&self) -> bool;
fn repo_selector(&self) -> &str;
fn workdir(&self) -> Option<&std::path::Path>;
fn pr_noun(&self) -> &'static str {
match self.kind() {
ForgeKind::GitHub => "PR",
ForgeKind::GitLab => "MR",
}
}
fn issue_url(&self, number: u64) -> String;
fn pr_url(&self, number: u64) -> String;
fn issue_url_confirmed(&self, number: u64) -> String {
if self.origin_is_authoritative() {
return self.issue_url(number);
}
self
.fetch_issue(number)
.ok()
.map(|s| s.url)
.filter(|u| !u.is_empty())
.unwrap_or_else(|| self.issue_url(number))
}
fn pr_url_confirmed(&self, number: u64) -> String {
if self.origin_is_authoritative() {
return self.pr_url(number);
}
self
.fetch_pr(number)
.ok()
.map(|s| s.url)
.filter(|u| !u.is_empty())
.unwrap_or_else(|| self.pr_url(number))
}
fn pr_head_refspec(&self, number: u64) -> String;
fn fetch_issue(&self, number: u64) -> Result<IssueStatus>;
fn fetch_pr(&self, number: u64) -> Result<PrStatus>;
fn fetch_pr_head(&self, number: u64) -> Result<PrHead>;
fn find_pr_for_branch(&self, branch: &str) -> Result<Option<u64>>;
fn create_issue(&self, req: &IssueCreateRequest<'_>) -> Result<CreatedIssue>;
fn create_pr(&self, req: &PrCreateRequest<'_>) -> Result<CreatedPr>;
fn fetch_remote_labels(&self) -> Result<Vec<RemoteLabel>>;
fn create_label(&self, spec: &LabelSpec) -> Result<()>;
fn update_label(&self, spec: &LabelSpec) -> Result<()>;
fn delete_label(&self, name: &str) -> Result<()>;
fn fetch_remote_milestones(&self) -> Result<Vec<RemoteMilestone>>;
fn validate_milestone(&self, _spec: &MilestoneSpec) -> Result<()> {
Ok(())
}
fn create_milestone(&self, spec: &MilestoneSpec) -> Result<()>;
fn update_milestone(&self, number: u64, spec: &MilestoneSpec) -> Result<()>;
fn delete_milestone(&self, number: u64) -> Result<()>;
}
pub fn for_kind(kind: ForgeKind, origin: RemoteRef) -> Arc<dyn Forge> {
for_kind_in(kind, origin, None)
}
pub fn for_kind_in(kind: ForgeKind, origin: RemoteRef, workdir: Option<std::path::PathBuf>) -> Arc<dyn Forge> {
match kind {
ForgeKind::GitHub => Arc::new(crate::github::GitHubForge::new(origin, workdir)),
ForgeKind::GitLab => Arc::new(crate::gitlab::GitLabForge::new(origin, workdir)),
}
}
pub fn origin_ref(repo: &Repository) -> Result<RemoteRef> {
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_remote_url(&url)
}
pub fn repo_slug(repo: &Repository) -> Result<String> {
Ok(origin_ref(repo)?.path)
}
pub fn resolve_or_default(repo: &Repository, config: &Config) -> Result<Arc<dyn Forge>> {
if repo.find_remote("origin").is_ok() {
return resolve(repo, config);
}
Ok({
let kind = config.forge.unwrap_or(ForgeKind::GitHub);
let (host, web_origin) = match kind {
ForgeKind::GitHub => ("github.com", "https://github.com"),
ForgeKind::GitLab => ("gitlab.com", "https://gitlab.com"),
};
for_kind(
kind,
RemoteRef {
host: host.into(),
path: String::new(),
web_origin: web_origin.into(),
trust: OriginTrust::Guessed,
},
)
})
}
pub fn reconcile_links(repo: &Repository, config: &Config) {
if resolve(repo, config).is_ok() {
return;
}
if let Some(kind) = config.forge {
crate::github::reconcile_link_forge(repo, kind);
}
}
fn authorised_kind(repo: &Repository, config: &Config, parsed: &RemoteRef) -> Result<ForgeKind> {
if let Some(known) = known_kind(&parsed.host) {
return Ok(config.forge.unwrap_or(known));
}
if let Some(kind) = Config::global_forge_host(&parsed.host) {
return Ok(kind);
}
let Some(kind) = config.forge else {
return Err(GwmError::Other(format!(
"origin host '{}' is not one gwm recognises, so it will not guess a forge and send an \
authenticated call there. Authorise it in ~/.config/gwm/config.toml:\n\n \
[forge_hosts]\n \"{}\" = \"gitlab\" # or \"github\"\n\nOr name the backend in the \
repo's own .gwm.toml and approve the repo with `gwm trust add`.",
parsed.host, parsed.host
)));
};
let workdir = repo.workdir().unwrap_or_else(|| repo.path());
let origin_key = crate::trust::origin_key_for_repo(repo, workdir);
if crate::trust::config_is_trusted(workdir, &origin_key, crate::trust::resolve_mode(false, false))? {
return Ok(kind);
}
Err(GwmError::Other(format!(
"this repo's .gwm.toml points gwm at '{}', a host gwm does not recognise, and the repo is \
not in the trust ledger. That file ships with the repo, so approving it is the same \
decision as approving its bootstrap commands. Run `gwm trust add` here if you trust it, \
or authorise the host yourself in ~/.config/gwm/config.toml:\n\n [forge_hosts]\n \
\"{}\" = \"{}\"",
parsed.host,
parsed.host,
kind.as_str()
)))
}
pub fn resolve(repo: &Repository, config: &Config) -> Result<Arc<dyn Forge>> {
let parsed = origin_ref(repo)?;
let kind = authorised_kind(repo, config, &parsed)?;
crate::github::reconcile_link_forge(repo, kind);
let cwd = repo
.workdir()
.map(|p| p.to_path_buf())
.or_else(|| Some(repo.path().to_path_buf()));
Ok(for_kind_in(kind, parsed, cwd))
}
pub fn cli_command_line(program: &OsStr, args: &[OsString]) -> String {
cli_command_line_redacted(program, args, &[])
}
pub fn cli_command_line_redacted(program: &OsStr, args: &[OsString], redact_after: &[&str]) -> String {
let mut line = program_name(program);
let mut redact_next = false;
for arg in args {
let text = arg.to_string_lossy();
line.push(' ');
if redact_next {
line.push_str(&format!("<redacted:{} chars>", text.chars().count()));
} else {
line.push_str(&text);
}
redact_next = redact_after.contains(&text.as_ref());
}
line
}
fn program_name(program: &OsStr) -> String {
Path::new(program)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| program.to_string_lossy().into_owned())
}
pub fn run_cli<I, S>(program: &OsStr, args: I) -> Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_cli_with(program, args, &CliSpawn::default())
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CliSpawn<'a> {
pub env: &'a [(String, String)],
pub cwd: Option<&'a std::path::Path>,
pub env_remove: &'a [&'a str],
pub redact_after: &'a [&'a str],
pub stdin: Option<&'a [u8]>,
pub redact_output: bool,
}
pub fn run_cli_with<I, S>(program: &OsStr, args: I, spawn: &CliSpawn<'_>) -> 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 name = program_name(program);
let cmdline = cli_command_line_redacted(program, &collected, spawn.redact_after);
let mut cmd = Command::new(program);
cmd.args(&collected);
for k in spawn.env_remove {
cmd.env_remove(k);
}
for (k, v) in spawn.env {
cmd.env(k, v);
}
if let Some(cwd) = spawn.cwd {
cmd.current_dir(cwd);
}
let output = match spawn.stdin {
Some(payload) => crate::command_log::run_logged_with_stdin(&mut cmd, cmdline, payload, spawn.redact_output),
None if spawn.redact_output => crate::command_log::run_logged_redacted(&mut cmd, cmdline),
None => crate::command_log::run_logged(&mut cmd, cmdline),
}
.map_err(|e| {
GwmError::CommandFailed(format!(
"{name}: failed to spawn ({e}). Is `{name}` installed and on PATH?"
))
})?;
if !output.status.success() {
return Err(GwmError::CommandFailed(format!(
"{name} exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub(crate) fn read_body_file(path: &Path) -> Result<String> {
std::fs::read_to_string(path)
.map_err(|e| GwmError::Other(format!("could not read rendered body file {}: {}", path.display(), e)))
}