use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::boundary::Visibility;
use crate::error::{invalid, Error, Result};
use crate::event::ArtifactId;
use crate::publish::DraftReason;
use crate::review::{CommentId, PostedReply, ReadMarker, ReviewRead};
use crate::rules::MergePolicy;
use crate::{gh, git, review, stream};
const CHECKS_PENDING: i32 = 8;
const NO_REQUIRED_CHECKS: &str = "no required checks reported on the ";
fn usable(answer: &gh::Answer) -> bool {
matches!(answer.code, Some(0) | Some(CHECKS_PENDING))
}
const NO_CHECKS_YET: &str = "no checks reported on the ";
fn no_required_checks(answer: &gh::Answer) -> bool {
answer.code == Some(1)
&& answer.stdout.trim().is_empty()
&& answer.stderr.trim_start().starts_with(NO_REQUIRED_CHECKS)
}
fn no_checks_yet(answer: &gh::Answer) -> bool {
answer.code == Some(1)
&& answer.stdout.trim().is_empty()
&& answer.stderr.trim_start().starts_with(NO_CHECKS_YET)
}
const NOT_PROTECTED: &str = "Branch not protected";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Consult {
Either,
StatusChecks,
Actions,
}
fn consult() -> Result<Consult> {
let Some(raw) = std::env::var_os(gh::CHECK_SOURCE_ENV) else {
return Ok(Consult::Either);
};
match raw.to_string_lossy().trim().to_ascii_lowercase().as_str() {
"" | "auto" => Ok(Consult::Either),
"status-checks" => Ok(Consult::StatusChecks),
"actions" => Ok(Consult::Actions),
other => Err(invalid(format!(
"{} names {other:?}, which is not a check source this build can read: it must be \
\"auto\", \"status-checks\", or \"actions\"",
gh::CHECK_SOURCE_ENV
))),
}
}
pub(crate) fn check_source_names_a_source() -> Result<()> {
consult().map(|_| ())
}
const PAGE: u32 = 100;
const CHECK_RUN_REFUSAL: &str = "GraphQL: Resource not accessible by personal access token";
fn check_rollup_refused_for_pat(error: &Error) -> bool {
let Error::Invalid { reason } = error else {
return false;
};
reason.contains(CHECK_RUN_REFUSAL) && reason.contains("statusCheckRollup")
}
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<ChangeChecks>;
fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId>;
fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome>;
fn merged_at(&self, _cr: &ChangeRequest) -> Result<Option<Sha>> {
Err(Error::NotImplemented {
operation: "RemoteHost::merged_at",
})
}
fn merge_time(&self, _cr: &ChangeRequest) -> Result<Option<String>> {
Err(Error::NotImplemented {
operation: "RemoteHost::merge_time",
})
}
fn mergeability(&self, _cr: &ChangeRequest) -> Result<Mergeability> {
Err(Error::NotImplemented {
operation: "RemoteHost::mergeability",
})
}
fn ready_for_review(&self, _cr: &ChangeRequest) -> Result<()> {
Err(Error::NotImplemented {
operation: "RemoteHost::ready_for_review",
})
}
fn is_draft(&self, _cr: &ChangeRequest) -> Result<bool> {
Err(Error::NotImplemented {
operation: "RemoteHost::is_draft",
})
}
fn required_checks_on(&self, _base: &str) -> Result<RequiredChecks> {
Err(Error::NotImplemented {
operation: "RemoteHost::required_checks_on",
})
}
fn describe_change(
&self,
_cr: &ChangeRequest,
_title: Option<&str>,
_body: &str,
) -> Result<()> {
Err(Error::NotImplemented {
operation: "RemoteHost::describe_change",
})
}
fn visibility(&self) -> Result<Visibility> {
Err(Error::NotImplemented {
operation: "RemoteHost::visibility",
})
}
fn change_description(&self, _cr: &ChangeRequest) -> Result<Description> {
Err(Error::NotImplemented {
operation: "RemoteHost::change_description",
})
}
fn review_comments(
&self,
_change: &ChangeId,
_since: Option<&ReadMarker>,
) -> Result<ReviewRead> {
Err(Error::NotImplemented {
operation: "RemoteHost::review_comments",
})
}
fn reply_to_comment(
&self,
_change: &ChangeId,
_comment: &CommentId,
_body: &str,
_key: &str,
) -> Result<PostedReply> {
Err(Error::NotImplemented {
operation: "RemoteHost::reply_to_comment",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Mergeability {
Mergeable,
Conflicting {
reported: String,
},
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Description {
pub title: String,
pub body: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProtectionSource {
Rulesets,
BranchProtection,
}
impl ProtectionSource {
pub fn every() -> impl Iterator<Item = ProtectionSource> {
[
ProtectionSource::Rulesets,
ProtectionSource::BranchProtection,
]
.into_iter()
}
pub fn describe(self) -> &'static str {
match self {
ProtectionSource::Rulesets => "the repository's rulesets",
ProtectionSource::BranchProtection => "classic branch protection",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RequiredChecks {
pub checks: BTreeSet<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub unconsulted: BTreeMap<ProtectionSource, String>,
}
impl RequiredChecks {
pub fn complete(&self) -> bool {
self.unconsulted.is_empty()
}
}
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft: Option<DraftReason>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub draft_awaiting_checks: bool,
}
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<Sha>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_at: Option<String>,
}
impl Check {
pub fn settled(&self) -> bool {
self.status.eq_ignore_ascii_case("completed")
}
pub fn state(&self) -> CheckState {
if !self.settled() {
return CheckState::Pending;
}
match self
.conclusion
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("success" | "neutral") => CheckState::Passed,
Some("skipped") => CheckState::Skipped,
Some("cancelled" | "stale") => CheckState::NoVerdict,
_ => CheckState::Failed,
}
}
pub fn green(&self) -> bool {
self.state() == CheckState::Passed
}
pub fn red(&self) -> bool {
self.state() == CheckState::Failed
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CheckState {
Passed,
Failed,
Skipped,
Pending,
NoVerdict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CheckSource {
StatusChecks,
Actions,
BranchRules,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangeChecks {
pub checks: Vec<Check>,
pub sources: BTreeSet<CheckSource>,
}
impl ChangeChecks {
pub fn complete(&self) -> bool {
self.sources.contains(&CheckSource::StatusChecks)
}
}
#[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 required_checks(&self, cr: &ChangeRequest) -> Result<BTreeSet<String>> {
addressable(&cr.id.0, "change request id")?;
let answer = gh::attempt(&[
"pr",
"checks",
&cr.id.0,
"--repo",
&self.repo,
"--required",
"--json",
"name",
])?;
if !usable(&answer) {
if no_required_checks(&answer) {
return Ok(BTreeSet::new());
}
if no_checks_yet(&answer) {
return Err(unregistered(&cr.url, answer.stderr.trim()));
}
return Err(unsaid(&cr.url, &answer.detail()));
}
let value = gh::json(&answer.stdout)?;
let entries = value
.as_array()
.ok_or_else(|| unsaid(&cr.url, &value.to_string()))?;
let mut names = BTreeSet::new();
for entry in entries {
let name = entry
.get("name")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| unsaid(&cr.url, &entry.to_string()))?;
names.insert(name.to_owned());
}
Ok(names)
}
fn job_log(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
addressable(&cr.id.0, "change request id")?;
let answer = gh::attempt(&[
"pr",
"checks",
&cr.id.0,
"--repo",
&self.repo,
"--json",
"name,link",
])?;
if !usable(&answer) {
return Err(invalid(format!(
"gh pr checks would not say where check {name:?} on {} ran: {}",
cr.url,
answer.detail()
)));
}
let value = gh::json(&answer.stdout)?;
let entries = value.as_array().ok_or_else(|| {
invalid(format!(
"gh pr checks returned a non-list of checks on {}, so where check {name:?} ran \
cannot be read from it: {value}",
cr.url
))
})?;
let link = entries
.iter()
.find(|entry| entry.get("name").and_then(|value| value.as_str()) == Some(name))
.and_then(|entry| entry.get("link"))
.and_then(|value| value.as_str())
.ok_or_else(|| {
invalid(format!(
"the host reports no job for check {name:?} on {}",
cr.url
))
})?;
let job = link
.rsplit_once("/job/")
.map(|(_, id)| id)
.filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()))
.ok_or_else(|| {
invalid(format!(
"the host says check {name:?} ran at {link:?}, which names no job this build \
can ask for a log"
))
})?;
gh::invoke_content(&["run", "view", "--repo", &self.repo, "--log", "--job", job])
}
fn log_of(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
match consult()? {
Consult::StatusChecks => self.job_log(cr, name),
Consult::Actions => self.actions_log(cr, name),
Consult::Either => self.job_log(cr, name).or_else(|reported| {
if !check_rollup_refused_for_pat(&reported) {
return Err(reported);
}
self.actions_log(cr, name).map_err(|actions| {
invalid(format!(
"neither of GitHub's check sources would produce the log of check \
{name:?} on {}: gh pr checks answered {reported}, and the Actions API \
answered {actions}",
cr.url
))
})
}),
}
}
fn rollup_checks(&self, cr: &ChangeRequest) -> Result<Vec<Check>> {
let value = self.view(&cr.id.0, "headRefOid,statusCheckRollup")?;
let head = reported_sha(&value, "headRefOid")?;
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}")))?;
if rollup.is_empty() {
return Ok(Vec::new());
}
let required = self.required_checks(cr)?;
rollup
.iter()
.map(|entry| check(entry, cr, &required, head.as_ref()))
.collect()
}
fn actions_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
let jobs = self.actions_jobs(cr)?;
if jobs.is_empty() {
return Ok(ChangeChecks {
checks: Vec::new(),
sources: [CheckSource::Actions].into_iter().collect(),
});
}
let required = self.ruled_checks(cr)?;
Ok(ChangeChecks {
checks: jobs
.into_iter()
.map(|job| Check {
required: required.contains(&job.name),
name: job.name,
status: job.status,
conclusion: job.conclusion,
head: job.head,
url: job.url,
started_at: job.started_at,
completed_at: job.completed_at,
})
.collect(),
sources: [CheckSource::Actions, CheckSource::BranchRules]
.into_iter()
.collect(),
})
}
fn actions_jobs(&self, cr: &ChangeRequest) -> Result<Vec<Job>> {
let current = head_sha(&self.view(&cr.id.0, "headRefOid")?)?;
let sha = commit(¤t)?;
let runs = self.api(&format!(
"repos/{}/actions/runs?head_sha={sha}&per_page={PAGE}",
self.repo
))?;
let mut jobs = Vec::new();
for run in listed(&runs, "workflow_runs", &format!("the commit {sha}"))? {
let id = run
.get("id")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
invalid(format!(
"the Actions API reported a workflow run on {} with no id: {run}",
cr.url
))
})?;
let head = reported_sha(run, "head_sha")?;
let listing = self.api(&format!(
"repos/{}/actions/runs/{id}/jobs?per_page={PAGE}",
self.repo
))?;
for entry in listed(&listing, "jobs", &format!("workflow run {id}"))? {
jobs.push(job(entry, cr, head.as_ref())?);
}
}
Ok(jobs)
}
fn ruled_checks(&self, cr: &ChangeRequest) -> Result<BTreeSet<String>> {
self.required_on(&cr.base, cr.url.as_ref())
}
fn required_on(&self, base: &str, about: &str) -> Result<BTreeSet<String>> {
addressable_branch(base, "the base branch")?;
let value = self.api(&format!(
"repos/{}/rules/branches/{}",
self.repo,
path_segment(base)
))?;
let rules = value.as_array().ok_or_else(|| {
invalid(format!(
"the rules on {about}'s base branch {base:?} came back as something that is \
not a list of them, so which of its checks block the merge cannot be read \
from it: {value}"
))
})?;
let mut names = BTreeSet::new();
for rule in rules {
if rule.get("type").and_then(|value| value.as_str()) != Some("required_status_checks") {
continue;
}
let required = rule
.get("parameters")
.and_then(|parameters| parameters.get("required_status_checks"))
.and_then(|value| value.as_array())
.ok_or_else(|| unsaid_about(about, &rule.to_string()))?;
for entry in required {
let name = entry
.get("context")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| unsaid_about(about, &entry.to_string()))?;
names.insert(name.to_owned());
}
}
Ok(names)
}
fn classic_protection(
&self,
base: &str,
) -> Result<std::result::Result<BTreeSet<String>, String>> {
addressable_branch(base, "the base branch")?;
let path = format!(
"repos/{}/branches/{}/protection/required_status_checks",
self.repo,
path_segment(base)
);
let answer = gh::attempt(&["api", &path])?;
if answer.code != Some(0) {
if answer.stderr.contains(NOT_PROTECTED) {
return Ok(Ok(BTreeSet::new()));
}
return Ok(Err(answer.detail()));
}
let value = gh::json(&answer.stdout)?;
let contexts = value
.get("contexts")
.and_then(|value| value.as_array())
.ok_or_else(|| {
invalid(format!(
"the classic branch protection on {}'s {base:?} came back without its \
contexts, so which checks it requires cannot be read from it: {value}",
self.repo
))
})?;
let mut names = BTreeSet::new();
for context in contexts {
let name = context
.as_str()
.filter(|value| !value.is_empty())
.ok_or_else(|| unsaid_about(&self.repo, &context.to_string()))?;
names.insert(name.to_owned());
}
Ok(Ok(names))
}
fn actions_log(&self, cr: &ChangeRequest, name: &str) -> Result<String> {
let job = self
.actions_jobs(cr)?
.into_iter()
.find(|job| job.name == name)
.ok_or_else(|| {
invalid(format!(
"GitHub Actions reports no job named {name:?} on the head commit of {}",
cr.url
))
})?;
gh::invoke_content(&[
"api",
&format!("repos/{}/actions/jobs/{}/logs", self.repo, job.id),
])
}
fn api(&self, path: &str) -> Result<serde_json::Value> {
gh::json(&gh::invoke(&["api", path])?)
}
fn view(&self, id: &str, fields: &str) -> Result<serde_json::Value> {
addressable(id, "change request id")?;
let raw = gh::invoke(&["pr", "view", id, "--repo", &self.repo, "--json", fields])?;
gh::json(&raw)
}
}
fn addressable(value: &str, what: &str) -> Result<()> {
if value.is_empty() || value.starts_with('-') {
return Err(invalid(format!(
"{what} {value:?} cannot address anything on the host: it must be non-empty and must \
not begin with '-'"
)));
}
Ok(())
}
fn matchable(name: &str, cr: &ChangeRequest) -> Result<()> {
if name.is_empty() {
return Err(invalid(format!(
"onevcs will not ask for the log of a check with no name on {}: every check the host \
reports is named, so an empty name matches none of them. This build refused the \
request; the host was not asked.",
cr.url
)));
}
Ok(())
}
fn path_segment(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn commit(sha: &Sha) -> Result<&str> {
if !is_commit_hash(&sha.0) {
return Err(invalid(format!(
"{:?} is not a commit hash, so the checks reported against it cannot be asked for",
sha.0
)));
}
Ok(&sha.0)
}
fn is_commit_hash(sha: &str) -> bool {
!sha.is_empty() && sha.chars().all(|c| c.is_ascii_hexdigit())
}
struct Job {
id: u64,
name: String,
status: String,
conclusion: Option<String>,
head: Option<Sha>,
url: Option<Url>,
started_at: Option<String>,
completed_at: Option<String>,
}
fn job(entry: &serde_json::Value, cr: &ChangeRequest, head: Option<&Sha>) -> Result<Job> {
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!(
"the Actions API returned a job on {} with no {name}: {entry}",
cr.url
))
})
};
Ok(Job {
id: entry
.get("id")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
invalid(format!(
"the Actions API returned a job on {} with no id, so its log cannot be asked \
for: {entry}",
cr.url
))
})?,
name: field("name")?.to_owned(),
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),
head: head.cloned(),
url: reported_url(entry, "html_url"),
started_at: reported_time(entry, "started_at"),
completed_at: reported_time(entry, "completed_at"),
})
}
fn reported_sha(value: &serde_json::Value, field: &str) -> Result<Option<Sha>> {
let Some(reported) = value
.get(field)
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
if !is_commit_hash(reported) {
return Err(invalid(format!(
"gh reported {reported:?} as the {field} of a check, and that is not a commit hash, \
so which commit the check is attached to cannot be decided"
)));
}
Ok(Some(Sha(reported.to_owned())))
}
fn reported_url(entry: &serde_json::Value, field: &str) -> Option<Url> {
entry
.get(field)
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.and_then(|value| Url::parse(value).ok())
}
fn listed<'a>(
value: &'a serde_json::Value,
field: &str,
what: &str,
) -> Result<&'a Vec<serde_json::Value>> {
let entries = value
.get(field)
.and_then(|value| value.as_array())
.ok_or_else(|| {
invalid(format!(
"the Actions API answered about {what} with something that lists no {field}: \
{value}"
))
})?;
let total = value
.get("total_count")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
invalid(format!(
"the Actions API did not say how many {field} there are on {what}: {value}"
))
})?;
if total > entries.len() as u64 {
return Err(invalid(format!(
"the Actions API reports {total} {field} on {what} and returned {}, so this build has \
not been shown all of them",
entries.len()
)));
}
Ok(entries)
}
fn unreadable(repo: &str, cr: &ChangeRequest, rollup: &Error, actions: &Error) -> Error {
invalid(format!(
"neither of GitHub's check sources could be read for {}, so what its checks say is \
unknown rather than empty. Its check rollup answered: {rollup}. Its Actions API \
answered: {actions}. Grant this credential `Actions: Read` on {repo} to read the \
repository's workflow checks — a fine-grained personal access token cannot read the \
rollup at all, whatever it is scoped to, because GitHub offers no Checks permission for \
one.",
cr.url
))
}
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")?;
if let Some(reason) = &req.draft {
reason.checked()?;
}
let body = req.body.unwrap_or_default();
let mut args = vec![
"pr", "create", "--repo", &self.repo, "--head", &req.head, "--base", &req.base,
"--title", &req.title, "--body", &body,
];
if req.draft.is_some() || req.draft_awaiting_checks {
args.push("--draft");
}
let raw = gh::invoke(&args)?;
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, "headRefOid")?)?,
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<ChangeChecks> {
let rollup = |checks| ChangeChecks {
checks,
sources: [CheckSource::StatusChecks].into_iter().collect(),
};
match consult()? {
Consult::StatusChecks => self.rollup_checks(cr).map(rollup),
Consult::Actions => self.actions_checks(cr),
Consult::Either => match self.rollup_checks(cr) {
Ok(checks) => Ok(rollup(checks)),
Err(refused) if check_rollup_refused_for_pat(&refused) => self
.actions_checks(cr)
.map_err(|actions| unreadable(&self.repo, cr, &refused, &actions)),
Err(refused) => Err(refused),
},
}
}
fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
matchable(&check.name, cr)?;
let log = self.log_of(cr, &check.name).map_err(|error| {
invalid(format!(
"the host could not produce a log for check {:?} on {}: {error}",
check.name, cr.url
))
})?;
Ok(ArtifactId(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, MERGE_FIELDS)?;
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, MERGE_FIELDS)?;
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 ready_for_review(&self, cr: &ChangeRequest) -> Result<()> {
addressable(&cr.id.0, "change request id")?;
gh::invoke(&["pr", "ready", &cr.id.0, "--repo", &self.repo]).map(|_| ())
}
fn describe_change(&self, cr: &ChangeRequest, title: Option<&str>, body: &str) -> Result<()> {
addressable(&cr.id.0, "change request id")?;
let scratch = tempfile::tempdir().map_err(|failure| {
invalid(format!(
"could not create a scratch directory to hand the body of {} to gh: {failure}",
cr.url
))
})?;
let file = scratch.path().join("body.md");
std::fs::write(&file, body).map_err(crate::error::at(
"write the change request's body to",
&file,
))?;
let file = file.to_string_lossy().into_owned();
let mut args = vec![
"pr",
"edit",
&cr.id.0,
"--repo",
&self.repo,
"--body-file",
&file,
];
if let Some(title) = title {
args.extend(["--title", title]);
}
gh::invoke(&args).map(|_| ())
}
fn change_description(&self, cr: &ChangeRequest) -> Result<Description> {
addressable(&cr.id.0, "change request id")?;
let view = self.view(&cr.id.0, "title,body")?;
let read = |name: &str| {
view.get(name)
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.ok_or_else(|| {
invalid(format!(
"gh pr view answered about {} without its {name}, so what the change \
request says cannot be read",
cr.url
))
})
};
Ok(Description {
title: read("title")?,
body: read("body")?,
})
}
fn visibility(&self) -> Result<Visibility> {
let answer = gh::json(&gh::invoke(&["api", &format!("repos/{}", self.repo)])?)?;
match answer.get("visibility").and_then(serde_json::Value::as_str) {
Some("public") => return Ok(Visibility::Public),
Some("private" | "internal") => return Ok(Visibility::Private),
_ => {}
}
match answer.get("private").and_then(serde_json::Value::as_bool) {
Some(true) => Ok(Visibility::Private),
Some(false) => Ok(Visibility::Public),
None => Err(invalid(format!(
"gh api answered about {} without its visibility",
self.repo
))),
}
}
fn is_draft(&self, cr: &ChangeRequest) -> Result<bool> {
addressable(&cr.id.0, "change request id")?;
let view = self.view(&cr.id.0, "isDraft")?;
view.get("isDraft")
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| {
invalid(format!(
"gh pr view answered about {} without saying whether it is a draft, so \
whether the host is holding it cannot be read",
cr.url
))
})
}
fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
addressable(&cr.id.0, "change request id")?;
merged_sha(&self.view(&cr.id.0, MERGE_FIELDS)?, cr)
}
fn merge_time(&self, cr: &ChangeRequest) -> Result<Option<String>> {
addressable(&cr.id.0, "change request id")?;
Ok(reported_time(
&self.view(&cr.id.0, MERGE_TIME_FIELDS)?,
"mergedAt",
))
}
fn mergeability(&self, cr: &ChangeRequest) -> Result<Mergeability> {
addressable(&cr.id.0, "change request id")?;
let view = self.view(&cr.id.0, MERGEABILITY_FIELDS)?;
let mergeable = view
.get("mergeable")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
invalid(format!(
"gh pr view answered about {} without saying whether it can merge: {view}",
cr.url
))
})?;
let status = view
.get("mergeStateStatus")
.and_then(serde_json::Value::as_str)
.unwrap_or("unreported");
match mergeable.to_ascii_uppercase().as_str() {
"MERGEABLE" => Ok(Mergeability::Mergeable),
"CONFLICTING" => Ok(Mergeability::Conflicting {
reported: format!("mergeable: {mergeable}, mergeStateStatus: {status}"),
}),
"UNKNOWN" => Ok(Mergeability::Unknown),
_ => Err(invalid(format!(
"gh pr view reports {} as mergeable {mergeable:?}, which is none of \
MERGEABLE, CONFLICTING or UNKNOWN, so whether it can merge cannot be read",
cr.url
))),
}
}
fn review_comments(&self, change: &ChangeId, since: Option<&ReadMarker>) -> Result<ReviewRead> {
review::github::read(&self.repo, change, since)
}
fn reply_to_comment(
&self,
change: &ChangeId,
comment: &CommentId,
body: &str,
_key: &str,
) -> Result<PostedReply> {
review::github::reply(&self.repo, change, comment, body)
}
fn required_checks_on(&self, base: &str) -> Result<RequiredChecks> {
let checks = self.required_on(base, &self.repo)?;
let mut answer = RequiredChecks {
checks,
unconsulted: BTreeMap::new(),
};
match self.classic_protection(base)? {
Ok(contexts) => answer.checks.extend(contexts),
Err(refusal) => {
answer
.unconsulted
.insert(ProtectionSource::BranchProtection, refusal);
}
}
Ok(answer)
}
}
fn unsaid(url: &Url, detail: &str) -> Error {
invalid(format!(
"gh pr checks --required answered about {url} with {detail}, so a check it reported does \
not say whether it blocks the merge"
))
}
fn unsaid_about(about: &str, detail: &str) -> Error {
invalid(format!(
"the repository's rulesets answered about {about} with {detail}, so a check they \
reported does not say whether it blocks the merge"
))
}
fn unregistered(url: &Url, said: &str) -> Error {
invalid(format!(
"the host reports no check at all yet on the head of {url} ({said}), which is not the \
same as a repository that declares none required — it is what a head pushed moments ago \
looks like — so whether anything blocks the merge cannot be read from it"
))
}
fn check(
entry: &serde_json::Value,
cr: &ChangeRequest,
required: &BTreeSet<String>,
head: Option<&Sha>,
) -> 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();
let blocks = required.contains(&name);
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: blocks,
head: head.cloned(),
url: reported_url(entry, "detailsUrl"),
started_at: reported_time(entry, "startedAt"),
completed_at: reported_time(entry, "completedAt"),
})
}
fn reported_time(entry: &serde_json::Value, field: &str) -> Option<String> {
entry
.get(field)
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty() && !value.starts_with("0001-01-01"))
.map(str::to_owned)
}
const MERGE_FIELDS: &str = "state,mergeCommit";
const MERGE_TIME_FIELDS: &str = "mergedAt";
const MERGEABILITY_FIELDS: &str = "mergeable,mergeStateStatus";
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}")))
}