use std::collections::{BTreeMap, BTreeSet};
use serde_json::Value;
use url::Url;
use super::{
ChangeStanding, ChangeStandings, ChangeState, ReviewStandings, REVIEW_BATCH, STANDINGS_BATCH,
};
use crate::error::{invalid, Result};
use crate::gh;
use crate::host::{reported_time, reported_url, ChangeId, Check, Sha};
use crate::publish::{checks_verdict, Declared};
use crate::review::{github as review_github, ReadCost, ReadMarker, ReviewRead};
mod pattern;
use pattern::Pattern;
pub(crate) const PULL_FIELDS: &str = "url number state isDraft body baseRefName headRefName \
headRefOid mergedAt mergeCommit { oid } baseRef { name }";
pub(crate) const DECLARATION_FIELDS: &str = "defaultBranchRef { name } rulesets(first: 25, \
includeParents: true) { pageInfo { hasNextPage } nodes { target enforcement conditions { \
refName { include exclude } } rules(first: 25) { pageInfo { hasNextPage } nodes { type \
parameters { ... on RequiredStatusChecksParameters { requiredStatusChecks { context } } } } } } } \
branchProtectionRules(first: 100) { pageInfo { hasNextPage } nodes { databaseId pattern \
requiredStatusCheckContexts } }";
pub(crate) const REVIEW_FIELDS: &str = "reviewThreads(first: 50) { pageInfo { hasNextPage \
endCursor } nodes { id isResolved isOutdated path line comments(first: 50) { pageInfo { \
hasNextPage endCursor } nodes { ...ThreadComment } } } } comments(first: 100) { pageInfo { \
hasNextPage endCursor } nodes { id databaseId author { login } body url createdAt updatedAt \
lastEditedAt } } reviews(first: 50) { pageInfo { hasNextPage endCursor } nodes { id databaseId \
author { login } state body url createdAt updatedAt lastEditedAt } }";
const THREAD_COMMENT: &str = "fragment ThreadComment on PullRequestReviewComment { id \
databaseId author { login } body url createdAt updatedAt lastEditedAt replyTo { id databaseId } }";
fn rollup_fields(number: u64) -> String {
format!(
"headRef {{ target {{ ... on Commit {{ statusCheckRollup {{ contexts(first: 100) {{ \
nodes {{ __typename ... on CheckRun {{ name status conclusion startedAt completedAt \
detailsUrl isRequired(pullRequestNumber: {number}) }} ... on StatusContext {{ context \
state targetUrl createdAt isRequired(pullRequestNumber: {number}) }} }} }} }} }} }} }}"
)
}
pub(crate) fn query(numbers: &[u64], reviews: bool, declarations: bool) -> String {
let mut text = String::from(
"query($owner: String!, $name: String!) { rateLimit { cost } repository(owner: $owner, \
name: $name) {",
);
if declarations {
text.push(' ');
text.push_str(DECLARATION_FIELDS);
}
for (at, number) in numbers.iter().enumerate() {
text.push_str(&format!(
" c{at}: pullRequest(number: {number}) {{ {PULL_FIELDS} {}{} }}",
rollup_fields(*number),
if reviews {
format!(" {REVIEW_FIELDS}")
} else {
String::new()
}
));
}
text.push_str(" } }");
if reviews {
text.push(' ');
text.push_str(THREAD_COMMENT);
}
text
}
pub(crate) fn read(repo: &str, root: &str, changes: &[ChangeId]) -> Result<ChangeStandings> {
let numbers = changes
.iter()
.map(|change| review_github::digits(&change.0))
.collect::<Result<Vec<u64>>>()?;
let mut standings = Vec::with_capacity(numbers.len());
let mut cost = ReadCost::default();
let mut required = Required::default();
for (index, batch) in numbers.chunks(STANDINGS_BATCH).enumerate() {
let (data, points) = ask(repo, batch, false, index == 0)?;
cost.graphql_points = cost.graphql_points.saturating_add(points);
if index == 0 {
required.declared(&data)?;
}
for (at, number) in batch.iter().enumerate() {
standings.push(standing(
pull_of(&data, at, *number, repo)?,
root,
*number,
&mut required,
)?);
}
}
Ok(ChangeStandings { standings, cost })
}
pub(crate) fn read_with_reviews(
repo: &str,
root: &str,
changes: &[(ChangeId, Option<ReadMarker>)],
) -> Result<ReviewStandings> {
let numbers = changes
.iter()
.map(|(change, _)| review_github::digits(&change.0))
.collect::<Result<Vec<u64>>>()?;
let mut standings = Vec::with_capacity(numbers.len());
let mut reads: Vec<ReviewRead> = Vec::with_capacity(numbers.len());
let mut cost = ReadCost::default();
let mut required = Required::default();
for (index, (batch, targets)) in numbers
.chunks(REVIEW_BATCH)
.zip(changes.chunks(REVIEW_BATCH))
.enumerate()
{
let (data, points) = ask(repo, batch, true, index == 0)?;
cost.graphql_points = cost.graphql_points.saturating_add(points);
if index == 0 {
required.declared(&data)?;
}
for (at, (number, (change, since))) in batch.iter().zip(targets).enumerate() {
let pull = pull_of(&data, at, *number, repo)?;
standings.push(standing(pull, root, *number, &mut required)?);
let about = format!("pull request {number} of {repo}");
let read = match review_github::first_page(pull)? {
Some(pages) => review_github::answer(&pages, since.as_ref(), &about)?,
None => {
let whole = review_github::read(repo, change, since.as_ref())?;
cost.graphql_points = cost
.graphql_points
.saturating_add(whole.cost.graphql_points);
whole
}
};
reads.push(read);
}
}
Ok(ReviewStandings {
standings,
reads,
cost,
})
}
pub(crate) fn retarget(
repo: &str,
change: &ChangeId,
base: &str,
body: Option<&str>,
) -> Result<ReadCost> {
let number = review_github::digits(&change.0)?;
let path = format!("repos/{repo}/pulls/{number}");
let base_field = format!("base={base}");
let body_field = body.map(|body| format!("body={body}"));
let mut args = vec!["api", &path, "--method", "PATCH", "-f", &base_field];
if let Some(field) = &body_field {
args.extend(["-f", field]);
}
let answer = gh::json(&gh::invoke(&args)?)?;
let reported = answer.pointer("/base/ref").and_then(Value::as_str);
if reported != Some(base) {
return Err(invalid(format!(
"GitHub accepted moving pull request {number} of {repo} onto {base:?} and answered \
its base as {reported:?}"
)));
}
Ok(ReadCost {
graphql_points: 0,
rest_requests: 1,
})
}
pub(crate) fn reopen(repo: &str, change: &ChangeId) -> Result<ReadCost> {
let number = review_github::digits(&change.0)?;
let path = format!("repos/{repo}/pulls/{number}");
let answer = gh::json(&gh::invoke(&[
"api",
&path,
"--method",
"PATCH",
"-f",
"state=open",
])?)?;
let state = answer.get("state").and_then(Value::as_str);
if state != Some("open") {
return Err(invalid(format!(
"GitHub accepted reopening pull request {number} of {repo} and answered its state \
as {state:?}"
)));
}
Ok(ReadCost {
graphql_points: 0,
rest_requests: 1,
})
}
fn ask(repo: &str, numbers: &[u64], reviews: bool, declarations: bool) -> Result<(Value, u32)> {
let (owner, name) = repo
.split_once('/')
.ok_or_else(|| invalid(format!("{repo:?} does not name one repository")))?;
let page = gh::json(&gh::invoke(&[
"api",
"graphql",
"-f",
&format!("query={}", query(numbers, reviews, declarations)),
"-f",
&format!("owner={owner}"),
"-f",
&format!("name={name}"),
])?)?;
let data = page.get("data").cloned().ok_or_else(|| {
invalid(format!(
"GitHub answered the standings read with no data: {page}"
))
})?;
let cost = data
.pointer("/rateLimit/cost")
.and_then(Value::as_u64)
.ok_or_else(|| {
invalid("GitHub answered the standings read without what it charged for it")
})?;
Ok((data, u32::try_from(cost).unwrap_or(u32::MAX)))
}
fn pull_of<'a>(data: &'a Value, at: usize, number: u64, repo: &str) -> Result<&'a Value> {
data.pointer(&format!("/repository/c{at}"))
.filter(|pull| !pull.is_null())
.ok_or_else(|| invalid(format!("GitHub holds no pull request {number} in {repo}")))
}
fn standing(
pull: &Value,
root: &str,
number: u64,
required: &mut Required,
) -> Result<ChangeStanding> {
let text = |name: &str| -> Result<String> {
pull.get(name)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| {
invalid(format!(
"GitHub answered about pull request {number} without its {name}"
))
})
};
let url = text("url")?;
let url = Url::parse(&url).map_err(|failure| {
invalid(format!(
"GitHub answered pull request {number}'s URL as {url:?}, which is not one: {failure}"
))
})?;
let base = text("baseRefName")?;
let head = text("headRefOid")?;
if head.is_empty() {
return Err(invalid(format!(
"GitHub answered about pull request {number} without the commit its head is at"
)));
}
let draft = pull
.get("isDraft")
.and_then(Value::as_bool)
.ok_or_else(|| {
invalid(format!(
"GitHub answered about pull request {number} without saying whether it is a draft"
))
})?;
let base_ref = pull.get("baseRef").filter(|found| !found.is_null());
let state = match text("state")?.to_ascii_uppercase().as_str() {
"OPEN" => ChangeState::Open,
"MERGED" => ChangeState::Merged {
commit: pull
.pointer("/mergeCommit/oid")
.and_then(Value::as_str)
.filter(|oid| !oid.is_empty())
.map(|oid| Sha(oid.to_owned()))
.ok_or_else(|| {
invalid(format!(
"GitHub reports pull request {number} merged without naming the commit \
it merged as"
))
})?,
landing: base == root,
merged_at: reported_time(pull, "mergedAt"),
into: base.clone(),
},
"CLOSED" => ChangeState::Closed {
base_deleted: base_ref.is_none(),
},
other => {
return Err(invalid(format!(
"GitHub reports pull request {number} as {other:?}, which is none of OPEN, \
MERGED or CLOSED"
)))
}
};
let declared = match base_ref {
Some(_) => required.on(&base),
None => Declared::Unknown(format!(
"{base:?} is gone from the origin, so what a merge into it requires cannot be read"
)),
};
let head = Sha(head);
let checks = rollup(pull, number, &head)?;
Ok(ChangeStanding {
checks: checks_verdict(&checks, &declared, true),
body: text("body")?,
head_branch: text("headRefName")?,
url,
base,
head,
draft,
state,
})
}
#[derive(Default)]
struct Required {
declarations: Option<std::result::Result<Declarations, String>>,
bases: BTreeMap<String, Declared>,
}
struct Declarations {
default_branch: Option<String>,
rulesets: Vec<Ruleset>,
classic: Vec<Classic>,
}
struct Classic {
pattern: String,
contexts: Vec<String>,
created: i64,
}
struct Ruleset {
include: Vec<String>,
exclude: Vec<String>,
checks: Vec<String>,
}
impl Required {
fn declared(&mut self, data: &Value) -> Result<()> {
self.declarations = Some(Declarations::read(data)?);
Ok(())
}
fn on(&mut self, base: &str) -> Declared {
if let Some(known) = self.bases.get(base) {
return known.clone();
}
let derived = match &self.declarations {
Some(Ok(declarations)) => declarations.on(base),
Some(Err(why)) => Declared::Unknown(why.clone()),
None => Declared::Unknown(
"the repository's declarations were not read on this call".to_owned(),
),
};
self.bases.insert(base.to_owned(), derived.clone());
derived
}
}
impl Declarations {
fn read(data: &Value) -> Result<std::result::Result<Self, String>> {
let repository = data
.get("repository")
.ok_or_else(|| invalid("GitHub answered the standings read without its repository"))?;
let more = |connection: &Value| {
connection
.pointer("/pageInfo/hasNextPage")
.and_then(Value::as_bool)
.unwrap_or(true)
};
let rulesets = repository.get("rulesets").ok_or_else(|| {
invalid("GitHub answered the standings read without the repository's rulesets")
})?;
let classic = repository.get("branchProtectionRules").ok_or_else(|| {
invalid("GitHub answered the standings read without its branch protection rules")
})?;
if more(rulesets) || more(classic) {
return Ok(Err(
"the repository holds more rulesets or protection rules than one page of the \
read, so what a merge into a base requires cannot be read whole"
.to_owned(),
));
}
let mut read = Declarations {
default_branch: repository
.pointer("/defaultBranchRef/name")
.and_then(Value::as_str)
.map(str::to_owned),
rulesets: Vec::new(),
classic: Vec::new(),
};
for ruleset in nodes(rulesets, "rulesets")? {
let word = |name: &str| ruleset.get(name).and_then(Value::as_str);
if word("target") != Some("BRANCH") || word("enforcement") != Some("ACTIVE") {
continue;
}
let rules = ruleset
.get("rules")
.ok_or_else(|| invalid("GitHub answered a ruleset without its rules"))?;
if more(rules) {
return Ok(Err(
"a ruleset holds more rules than one page of the read, so what a merge \
into a base requires cannot be read whole"
.to_owned(),
));
}
let mut checks = Vec::new();
for rule in nodes(rules, "rules")? {
if rule.get("type").and_then(Value::as_str) != Some("REQUIRED_STATUS_CHECKS") {
continue;
}
let required = rule
.pointer("/parameters/requiredStatusChecks")
.and_then(Value::as_array)
.ok_or_else(|| {
invalid("a ruleset requires status checks and does not say which")
})?;
for check in required {
let context =
check
.get("context")
.and_then(Value::as_str)
.ok_or_else(|| {
invalid(format!(
"a ruleset requires a status check GitHub did not name: {check}"
))
})?;
checks.push(context.to_owned());
}
}
let patterns = |key: &str| {
texts(
ruleset.pointer(&format!("/conditions/refName/{key}")),
&format!("a ruleset's {key} ref patterns"),
)
};
read.rulesets.push(Ruleset {
include: patterns("include")?,
exclude: patterns("exclude")?,
checks,
});
}
for rule in nodes(classic, "branch protection rules")? {
let pattern = rule
.get("pattern")
.and_then(Value::as_str)
.ok_or_else(|| invalid("GitHub answered a protection rule without its pattern"))?;
let contexts = texts(
rule.get("requiredStatusCheckContexts"),
&format!("the contexts the protection rule for {pattern:?} requires"),
)?;
let created = rule
.get("databaseId")
.and_then(Value::as_i64)
.ok_or_else(|| {
invalid(format!(
"GitHub answered the protection rule for {pattern:?} without its \
databaseId, so which of several rules applies cannot be told"
))
})?;
read.classic.push(Classic {
pattern: pattern.to_owned(),
contexts,
created,
});
}
Ok(Ok(read))
}
fn classic_on(&self, base: &str) -> std::result::Result<Option<&Classic>, String> {
let mut rules: Vec<(&Classic, std::result::Result<Pattern, String>)> = self
.classic
.iter()
.map(|rule| (rule, Pattern::parse(&rule.pattern)))
.collect();
rules.sort_by_key(|(rule, _)| rule.created);
let exact = rules.iter().find(|(rule, pattern)| {
pattern
.as_ref()
.is_ok_and(|pattern| pattern.exact() && rule.pattern == base)
});
if let Some((rule, _)) = exact {
return Ok(Some(rule));
}
for (rule, pattern) in &rules {
match pattern {
Ok(pattern) if !pattern.exact() && pattern.matches(base) => return Ok(Some(rule)),
Ok(_) => {}
Err(why) => return Err(why.clone()),
}
}
Ok(None)
}
fn on(&self, base: &str) -> Declared {
let reference = format!("refs/heads/{base}");
let applies = |pattern: &str| match pattern {
"~ALL" => Ok(true),
"~DEFAULT_BRANCH" => Ok(self.default_branch.as_deref() == Some(base)),
spelled => Pattern::parse(spelled).map(|pattern| pattern.matches(&reference)),
};
let any = |patterns: &[String]| -> std::result::Result<bool, String> {
let mut unevaluable = None;
for pattern in patterns {
match applies(pattern) {
Ok(true) => return Ok(true),
Ok(false) => {}
Err(why) => unevaluable = unevaluable.or(Some(why)),
}
}
unevaluable.map_or(Ok(false), Err)
};
let mut names = BTreeSet::new();
for ruleset in &self.rulesets {
if ruleset.checks.is_empty() {
continue;
}
match (any(&ruleset.include), any(&ruleset.exclude)) {
(Ok(false), _) | (_, Ok(true)) => {}
(Ok(true), Ok(false)) => names.extend(ruleset.checks.iter().cloned()),
(Err(why), _) | (_, Err(why)) => return Declared::Unknown(why),
}
}
match self.classic_on(base) {
Ok(Some(classic)) => names.extend(classic.contexts.iter().cloned()),
Ok(None) => {}
Err(why) => return Declared::Unknown(why),
}
if names.is_empty() {
Declared::Nothing
} else {
Declared::Names(names)
}
}
}
fn texts(listed: Option<&Value>, what: &str) -> Result<Vec<String>> {
let entries = match listed {
None | Some(Value::Null) => return Ok(Vec::new()),
Some(Value::Array(entries)) => entries,
Some(other) => {
return Err(invalid(format!(
"GitHub answered {what} as {other}, which is not a list"
)))
}
};
entries
.iter()
.map(|entry| {
entry.as_str().map(str::to_owned).ok_or_else(|| {
invalid(format!(
"GitHub answered {what} with {entry}, which is not a name"
))
})
})
.collect()
}
fn nodes<'a>(connection: &'a Value, what: &str) -> Result<&'a Vec<Value>> {
connection
.get("nodes")
.and_then(Value::as_array)
.ok_or_else(|| {
invalid(format!(
"GitHub answered the repository's {what} without its nodes"
))
})
}
fn rollup(pull: &Value, number: u64, head: &Sha) -> Result<Vec<Check>> {
let Some(contexts) = pull
.pointer("/headRef/target/statusCheckRollup/contexts/nodes")
.and_then(Value::as_array)
else {
return Ok(Vec::new());
};
contexts
.iter()
.map(|context| {
let field = |name: &str| context.get(name).and_then(Value::as_str);
let unsaid = |name: &str| {
invalid(format!(
"GitHub reported a check on pull request {number} without its {name}: \
{context}"
))
};
let required = context
.get("isRequired")
.and_then(Value::as_bool)
.ok_or_else(|| unsaid("isRequired"))?;
let (name, status, conclusion) = match field("__typename") {
Some("StatusContext") => {
let name = field("context").ok_or_else(|| unsaid("context"))?;
let (status, conclusion) = match field("state")
.ok_or_else(|| unsaid("state"))?
.to_ascii_uppercase()
.as_str()
{
"SUCCESS" => ("completed", Some("success")),
"FAILURE" | "ERROR" => ("completed", Some("failure")),
_ => ("pending", None),
};
(name, status.to_owned(), conclusion.map(str::to_owned))
}
_ => (
field("name").ok_or_else(|| unsaid("name"))?,
field("status")
.ok_or_else(|| unsaid("status"))?
.to_ascii_lowercase(),
field("conclusion")
.filter(|conclusion| !conclusion.is_empty())
.map(str::to_ascii_lowercase),
),
};
Ok(Check {
name: name.to_owned(),
status,
conclusion,
required,
head: Some(head.clone()),
url: reported_url(context, "detailsUrl")
.or_else(|| reported_url(context, "targetUrl")),
started_at: reported_time(context, "startedAt"),
completed_at: reported_time(context, "completedAt"),
})
})
.collect()
}