use serde::{Deserialize, Serialize};
use url::Url;
use crate::error::{invalid, Error, Result};
use crate::event::ArtifactId;
use crate::rules::MergePolicy;
use crate::{gh, git, stream};
pub trait RemoteHost {
fn authenticated_user(&self) -> Result<String>;
fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest>;
fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>>;
fn change_checks(&self, cr: &ChangeRequest) -> Result<Vec<Check>>;
fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId>;
fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome>;
}
pub trait Hosting {
fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>>;
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct GitHubHosting;
impl Hosting for GitHubHosting {
fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
Ok(Box::new(GitHub::new(slug)?))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangeSpec {
pub head: String,
pub base: String,
pub title: String,
pub body: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangeRequest {
pub id: ChangeId,
pub url: Url,
pub head_sha: Sha,
pub base: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ChangeId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Sha(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Check {
pub name: String,
pub status: String,
pub conclusion: Option<String>,
pub required: bool,
}
impl Check {
pub fn settled(&self) -> bool {
self.status.eq_ignore_ascii_case("completed")
}
pub fn green(&self) -> bool {
self.settled()
&& self.conclusion.as_deref().is_some_and(|value| {
matches!(
value.to_ascii_lowercase().as_str(),
"success" | "skipped" | "neutral"
)
})
}
pub fn red(&self) -> bool {
self.settled() && !self.green()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MergeOutcome {
Merged(Sha),
Queued,
Open,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHub {
repo: String,
}
impl GitHub {
pub fn new(repo: impl Into<String>) -> Result<Self> {
let repo = repo.into();
let mut parts = repo.split('/');
let named = matches!(
(parts.next(), parts.next(), parts.next()),
(Some(owner), Some(name), None)
if !owner.is_empty()
&& !name.is_empty()
&& !repo.starts_with('-')
&& !repo.contains(char::is_whitespace)
);
if !named {
return Err(invalid(format!(
"{repo:?} does not name one repository as owner/name"
)));
}
Ok(Self { repo })
}
fn view(&self, id: &str) -> Result<serde_json::Value> {
addressable(id, "change request id")?;
let raw = gh::invoke(&[
"pr",
"view",
id,
"--repo",
&self.repo,
"--json",
"number,state,mergeStateStatus,headRefOid,mergeCommit,statusCheckRollup",
])?;
gh::json(&raw)
}
}
fn addressable(value: &str, what: &str) -> Result<()> {
if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
return Err(invalid(format!(
"{what} {value:?} cannot address anything on the host: it must be non-empty, must \
not begin with '-', and must carry no whitespace"
)));
}
Ok(())
}
fn addressable_branch(value: &str, what: &str) -> Result<()> {
if !git::is_valid_branch_name(value) {
return Err(invalid(format!(
"{what} {value:?} is a name git would not accept"
)));
}
Ok(())
}
impl RemoteHost for GitHub {
fn authenticated_user(&self) -> Result<String> {
let login = gh::invoke(&["api", "user", "--jq", ".login"])?
.trim()
.to_owned();
if login.is_empty() {
return Err(Error::Invalid {
reason: "gh reported no authenticated user".to_owned(),
});
}
Ok(login)
}
fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
addressable_branch(&req.head, "the head branch")?;
addressable_branch(&req.base, "the base branch")?;
let body = req.body.unwrap_or_default();
let raw = gh::invoke(&[
"pr", "create", "--repo", &self.repo, "--head", &req.head, "--base", &req.base,
"--title", &req.title, "--body", &body,
])?;
let url = raw
.lines()
.map(str::trim)
.rfind(|line| line.starts_with("http"))
.ok_or_else(|| invalid(format!("gh pr create printed no URL: {raw:?}")))?;
let parsed = Url::parse(url)
.map_err(|e| invalid(format!("gh pr create printed {url:?}, not a URL: {e}")))?;
let id = parsed
.path_segments()
.and_then(|mut segments| segments.next_back())
.filter(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
.ok_or_else(|| {
invalid(format!(
"gh pr create printed {url:?}, which names no change"
))
})?
.to_owned();
Ok(ChangeRequest {
head_sha: head_sha(&self.view(&id)?)?,
id: ChangeId(id),
url: parsed,
base: req.base,
})
}
fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
addressable_branch(head, "the head branch")?;
addressable_branch(base, "the base branch")?;
let raw = gh::invoke(&[
"pr",
"list",
"--repo",
&self.repo,
"--head",
head,
"--base",
base,
"--state",
"open",
"--json",
"number,url,state,headRefOid",
])?;
let value = gh::json(&raw)?;
let items = value
.as_array()
.ok_or_else(|| invalid(format!("gh pr list returned {raw:?}, not a list")))?;
let mut changes = Vec::new();
for item in items {
let url = item.get("url").and_then(|v| v.as_str()).unwrap_or_default();
let parsed = Url::parse(url)
.map_err(|e| invalid(format!("gh pr list returned {url:?}, not a URL: {e}")))?;
let number = item
.get("number")
.and_then(|value| value.as_u64())
.ok_or_else(|| invalid(format!("gh pr list returned no number: {raw:?}")))?
.to_string();
changes.push(ChangeRequest {
id: ChangeId(number),
url: parsed,
head_sha: head_sha(item)?,
base: base.to_owned(),
});
}
Ok(changes)
}
fn change_checks(&self, cr: &ChangeRequest) -> Result<Vec<Check>> {
let value = self.view(&cr.id.0)?;
let reported = value.get("statusCheckRollup").ok_or_else(|| {
invalid(format!(
"gh pr view reported no checks at all on {}",
cr.url
))
})?;
if reported.is_null() {
return Ok(Vec::new());
}
let rollup = reported
.as_array()
.ok_or_else(|| invalid(format!("gh pr view returned a non-list rollup: {reported}")))?;
rollup.iter().map(|entry| check(entry, cr)).collect()
}
fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
let log = addressable(&check.name, "check name")
.and_then(|()| {
gh::invoke(&[
"run",
"view",
"--repo",
&self.repo,
"--log",
"--job",
&check.name,
])
})
.unwrap_or_else(|error| {
format!(
"the host could not produce a log for check {:?} on {}: {error}\n",
check.name, cr.url
)
});
Ok(stream::store_artifact("log", &log)?.id)
}
fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
match policy {
MergePolicy::LocalDirect | MergePolicy::ChangeOpen => Ok(MergeOutcome::Open),
MergePolicy::ChangeAuto => {
addressable(&cr.id.0, "change request id")?;
gh::invoke(&[
"pr", "merge", &cr.id.0, "--repo", &self.repo, "--squash", "--auto",
])?;
let view = self.view(&cr.id.0)?;
Ok(match merged_sha(&view, cr)? {
Some(sha) => MergeOutcome::Merged(sha),
None => MergeOutcome::Queued,
})
}
MergePolicy::ChangeDirect => {
addressable(&cr.id.0, "change request id")?;
gh::invoke(&["pr", "merge", &cr.id.0, "--repo", &self.repo, "--squash"])?;
let view = self.view(&cr.id.0)?;
match merged_sha(&view, cr)? {
Some(sha) => Ok(MergeOutcome::Merged(sha)),
None => Err(Error::GateFailed {
reason: format!(
"the host accepted the merge of {} but reports it unmerged",
cr.url
),
}),
}
}
}
}
}
fn check(entry: &serde_json::Value, cr: &ChangeRequest) -> Result<Check> {
let field = |name: &str| -> Result<&str> {
entry
.get(name)
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
invalid(format!(
"gh pr view returned a check on {} with no {name}: {entry}",
cr.url
))
})
};
let name = field("name").or_else(|_| field("context"))?.to_owned();
Ok(Check {
name,
status: field("status")?.to_ascii_lowercase(),
conclusion: entry
.get("conclusion")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase),
required: entry
.get("isRequired")
.and_then(|value| value.as_bool())
.ok_or_else(|| {
invalid(format!(
"gh pr view returned a check on {} that does not say whether it blocks \
the merge: {entry}",
cr.url
))
})?,
})
}
fn merged_sha(view: &serde_json::Value, cr: &ChangeRequest) -> Result<Option<Sha>> {
let state = view
.get("state")
.and_then(|value| value.as_str())
.ok_or_else(|| invalid(format!("gh pr view returned no state for {}", cr.url)))?;
if !state.eq_ignore_ascii_case("merged") {
return Ok(None);
}
view.get("mergeCommit")
.and_then(|commit| commit.get("oid"))
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(|sha| Some(Sha(sha.to_owned())))
.ok_or_else(|| {
invalid(format!(
"gh pr view reports {} merged without naming the commit it merged as",
cr.url
))
})
}
fn head_sha(view: &serde_json::Value) -> Result<Sha> {
view.get("headRefOid")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(|sha| Sha(sha.to_owned()))
.ok_or_else(|| invalid(format!("gh returned a change request with no head: {view}")))
}