use std::fmt;
use std::process::{Command, Output};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use serde_json::Value;
use crate::issue::{IssueUrl, Repo};
use crate::labels::{Label, Labels};
use crate::process::{self, Control, Interruption};
mod check_read;
pub(crate) use check_read::is_transient as check_read_is_transient;
mod advisories;
mod session_issue;
mod trust;
pub use advisories::{
DraftAdvisory, FindingDraft, FindingProvenance, Package, RecordedFinding, SecurityRecord,
SecurityRecords,
};
#[cfg(test)]
mod execution_tests;
#[derive(Debug)]
enum CommandFailure {
Rejected { command: String, stderr: String },
InvalidJson { command: String },
InvalidResponse(String),
Execution,
}
impl CommandFailure {
fn rejected(command: String, output: &Output) -> Self {
Self::Rejected {
command,
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
}
}
}
impl fmt::Display for CommandFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Rejected { command, stderr } => write!(f, "{command} failed: {stderr}"),
Self::InvalidJson { command } => write!(f, "{command} returned invalid JSON"),
Self::InvalidResponse(message) => f.write_str(message),
Self::Execution => f.write_str("could not run gh"),
}
}
}
impl std::error::Error for CommandFailure {}
pub(crate) fn operational_cause(error: &anyhow::Error) -> Option<&'static str> {
let stderr = match error.downcast_ref::<CommandFailure>() {
Some(CommandFailure::Rejected { stderr, .. }) => stderr,
Some(_) => return Some("GitHub dependency failure"),
None => &error.downcast_ref::<check_read::ReadFailure>()?.stderr,
};
let stderr = stderr.to_ascii_lowercase();
Some(
if stderr.contains("api rate limit exceeded")
|| stderr.contains("api rate limit already exceeded")
|| stderr.contains("secondary rate limit")
{
"GitHub rate-limit exhaustion"
} else {
"GitHub dependency failure"
},
)
}
pub struct GitHub {
interruption: Interruption,
}
impl GitHub {
pub fn new() -> Self {
Self {
interruption: Interruption::Ordinary,
}
}
pub fn completion(&self) -> Self {
Self {
interruption: Interruption::Completion,
}
}
fn issue_view(&self, issue: &IssueUrl, fields: &str) -> Result<Value> {
self.gh_json(&[
"issue",
"view",
&issue.number.to_string(),
"--repo",
&issue.repo_slug(),
"--json",
fields,
])
}
pub fn issue_is_open(&self, issue: &IssueUrl) -> Result<bool> {
state_is_open(&self.issue_view(issue, "state")?)
}
pub fn issue_title(&self, issue: &IssueUrl) -> Result<String> {
let json = self.issue_view(issue, "title")?;
Ok(json["title"]
.as_str()
.context(CommandFailure::InvalidResponse(
"gh output has no title".into(),
))?
.to_string())
}
pub fn issue(&self, issue: &IssueUrl) -> Result<Issue> {
let json = self.issue_view(issue, "title,state,labels,createdAt")?;
let created = json["createdAt"]
.as_str()
.context(CommandFailure::InvalidResponse(
"gh output has no createdAt".into(),
))?;
Ok(Issue {
title: json["title"].as_str().map(String::from),
is_open: state_is_open(&json)?,
labels: labels_of(&json)?,
created: DateTime::parse_from_rfc3339(created)
.with_context(|| {
CommandFailure::InvalidResponse(format!(
"gh output has an unreadable createdAt {created}"
))
})?
.to_utc(),
})
}
pub fn issue_labels(&self, issue: &IssueUrl) -> Result<Labels> {
labels_of(&self.issue_view(issue, "labels")?)
}
pub fn set_labels(&self, issue: &IssueUrl, labels: &Labels) -> Result<()> {
let path = labels_path(issue);
let fields: Vec<String> = labels
.names()
.map(|label| format!("labels[]={label}"))
.collect();
let mut args = vec!["api", "--method", "PUT", &path, "--silent"];
for field in &fields {
args.extend(["-f", field]);
}
self.gh(&args)
}
pub fn remove_label(&self, issue: &IssueUrl, label: &str) -> Result<()> {
self.gh(&[
"api",
"--method",
"DELETE",
&label_path(issue, label),
"--silent",
])
}
pub fn tickets(&self, issue: &IssueUrl) -> Result<Vec<Ticket>> {
let json = self.issue_query(issue, TICKETS_QUERY, None)?;
nodes(
&json["data"]["repository"]["issue"]["subIssues"],
"subIssues",
)?
.iter()
.map(|node| {
let mut blockers = Vec::new();
let mut open_blockers = Vec::new();
for blocker in nodes(&node["blockedBy"], "blockedBy")? {
blockers.push(node_issue(blocker)?);
if node_is_open(blocker)? {
open_blockers.push(node_issue(blocker)?);
}
}
Ok(Ticket {
issue: node_issue(node)?,
is_open: node_is_open(node)?,
labels: named(nodes(&node["labels"], "labels")?),
has_sub_issues: sub_issue_count(node)? > 0,
blockers,
open_blockers,
})
})
.collect()
}
fn issue_query(&self, issue: &IssueUrl, query: &str, after: Option<&str>) -> Result<Value> {
let query = format!("query={query}");
let owner = format!("owner={}", issue.owner);
let repo = format!("repo={}", issue.repo);
let number = format!("number={}", issue.number);
let mut args = vec![
"api", "graphql", "-f", &query, "-f", &owner, "-f", &repo, "-F", &number,
];
let cursor;
if let Some(after) = after {
cursor = format!("after={after}");
args.extend(["-f", &cursor]);
}
self.gh_json(&args)
}
fn paged_nodes(
&self,
issue: &IssueUrl,
query: &str,
connection: &str,
what: &str,
) -> Result<(Value, Vec<Value>)> {
let mut after = None;
let mut all = Vec::new();
loop {
let json = self.issue_query(issue, query, after.as_deref())?;
let page = &json["data"]["repository"]["issue"][connection];
all.extend(nodes(page, connection)?.iter().cloned());
let info = &page["pageInfo"];
if !info["hasNextPage"].as_bool().with_context(|| {
CommandFailure::InvalidResponse(format!("{what} has no hasNextPage"))
})? {
return Ok((json, all));
}
let next = info["endCursor"].as_str().with_context(|| {
CommandFailure::InvalidResponse(format!("{what} has no endCursor"))
})?;
if Some(next) == after.as_deref() {
bail!(CommandFailure::InvalidResponse(format!(
"{what} cursor did not advance"
)));
}
after = Some(next.to_string());
}
}
pub fn close_issue(&self, issue: &IssueUrl, comment: &str) -> Result<()> {
self.gh(&[
"issue",
"close",
&issue.number.to_string(),
"--repo",
&issue.repo_slug(),
"--comment",
comment,
])
}
fn issue_db_id(&self, issue: &IssueUrl) -> Result<u64> {
let json = self.gh_json(&[
"api",
&format!("repos/{}/issues/{}", issue.repo_slug(), issue.number),
"--jq",
".id",
])?;
json.as_u64().context(CommandFailure::InvalidResponse(
"gh api did not return the issue's database id".into(),
))
}
pub fn add_sub_issue(&self, parent: &IssueUrl, child: &IssueUrl) -> Result<()> {
let child_id = self.issue_db_id(child)?;
self.post_by_id(
parent,
&format!("issues/{}/sub_issues", parent.number),
"sub_issue_id",
child_id,
)
}
pub fn add_blocked_by(&self, ticket: &IssueUrl, blocker: &IssueUrl) -> Result<()> {
let blocker_id = self.issue_db_id(blocker)?;
self.post_by_id(
ticket,
&format!("issues/{}/dependencies/blocked_by", ticket.number),
"issue_id",
blocker_id,
)
}
fn post_by_id(&self, issue: &IssueUrl, endpoint: &str, field: &str, id: u64) -> Result<()> {
self.gh(&[
"api",
"--method",
"POST",
&format!("repos/{}/{}", issue.repo_slug(), endpoint),
"-F",
&format!("{field}={id}"),
])
}
pub fn open_issues_labelled(&self, repo: &str, label: Label) -> Result<Vec<ListedIssue>> {
self.issues_labelled(repo, label, "open")
}
pub fn closed_issues_labelled(&self, repo: &str, label: Label) -> Result<Vec<ListedIssue>> {
self.issues_labelled(repo, label, "closed")
}
fn issues_labelled(&self, repo: &str, label: Label, state: &str) -> Result<Vec<ListedIssue>> {
let json = self.gh_json(&[
"issue",
"list",
"--state",
state,
"--repo",
repo,
"--label",
label.name(),
"--json",
"url,title,labels",
"--limit",
"1000",
])?;
json.as_array()
.context(CommandFailure::InvalidResponse(
"gh issue list did not return a list".into(),
))?
.iter()
.map(|listed| {
let field = |name: &str| {
listed[name].as_str().with_context(|| {
CommandFailure::InvalidResponse(format!(
"gh issue list output has no {name}"
))
})
};
Ok(ListedIssue {
issue: IssueUrl::parse(field("url")?).context(
CommandFailure::InvalidResponse(
"gh issue list output has an invalid issue URL".into(),
),
)?,
title: field("title")?.to_string(),
labels: labels_of(listed)?,
})
})
.collect()
}
pub fn issues_created_since(&self, repo: &str, since: DateTime<Utc>) -> Result<Vec<IssueUrl>> {
let mut found = Vec::new();
for state in ["open", "closed"] {
let json = self.gh_json(&[
"issue",
"list",
"--state",
state,
"--repo",
repo,
"--json",
"url,createdAt",
"--limit",
"1000",
])?;
for listed in json.as_array().context(CommandFailure::InvalidResponse(
"gh issue list did not return a list".into(),
))? {
let url = listed["url"]
.as_str()
.context(CommandFailure::InvalidResponse(
"gh issue list output has no url".into(),
))?;
let at = listed["createdAt"]
.as_str()
.context(CommandFailure::InvalidResponse(
"gh issue list output has no createdAt".into(),
))?;
let at = DateTime::parse_from_rfc3339(at)
.with_context(|| {
CommandFailure::InvalidResponse(format!(
"gh issue list output has an unreadable createdAt {at}"
))
})?
.to_utc();
if at.timestamp() >= since.timestamp() {
found.push(
IssueUrl::parse(url).context(CommandFailure::InvalidResponse(
"gh issue list output has an invalid issue URL".into(),
))?,
);
}
}
}
Ok(found)
}
pub fn candidate(&self, issue: &IssueUrl, label: Label) -> Result<Candidate> {
let (json, label_events) =
self.paged_nodes(issue, CANDIDATE_QUERY, "labelled", "label history")?;
let issue = &json["data"]["repository"]["issue"];
let parent = issue["parent"]["url"]
.as_str()
.map(IssueUrl::parse)
.transpose()
.context(CommandFailure::InvalidResponse(
"gh api graphql output has an invalid parent issue URL".into(),
))?;
let mut open_blockers = Vec::new();
for blocker in nodes(&issue["blockedBy"], "blockedBy")? {
if node_is_open(blocker)? {
open_blockers.push(node_number(blocker)?);
}
}
let at = |event: &Value| -> Result<DateTime<Utc>> {
let at = event["createdAt"]
.as_str()
.context(CommandFailure::InvalidResponse(
"gh api graphql output has an event with no createdAt".into(),
))?;
Ok(DateTime::parse_from_rfc3339(at)
.with_context(|| {
CommandFailure::InvalidResponse(format!(
"gh api graphql output has an unreadable createdAt {at}"
))
})?
.to_utc())
};
let mut shaped = Vec::new();
let mut history = ClaimHistory::NeverClaimed;
let mut claim_generation = 0;
for event in &label_events {
let applied = match event["__typename"].as_str() {
Some("LabeledEvent") => true,
Some("UnlabeledEvent") => false,
other => bail!(CommandFailure::InvalidResponse(format!(
"gh api graphql output has an unknown label event {other:?}"
))),
};
let name = event["label"]["name"].as_str();
if applied && name.is_some_and(|name| crate::claim::IN_PROGRESS.is_named(name)) {
claim_generation += 1;
}
history = history.after(name, applied, label);
if applied && name.is_some_and(|name| label.is_named(name)) {
shaped.push(Shaped {
by: Shaping::Labelled,
at: at(event)?,
});
}
}
for event in nodes(&issue["linked"], "linked")? {
let by = match event["__typename"].as_str() {
Some("SubIssueAddedEvent" | "SubIssueRemovedEvent") => Shaping::SubIssues,
Some("BlockedByAddedEvent" | "BlockedByRemovedEvent") => Shaping::Blockers,
other => bail!(CommandFailure::InvalidResponse(format!(
"gh api graphql output has an unknown event {other:?}"
))),
};
shaped.push(Shaped { by, at: at(event)? });
}
Ok(Candidate {
parent,
sub_issue_is_open: nodes(&issue["subIssues"], "subIssues")?
.iter()
.map(node_is_open)
.collect::<Result<_>>()?,
open_blockers,
last_shaped: shaped.into_iter().max_by_key(|shaped| shaped.at),
handed_back: matches!(history, ClaimHistory::HandedBack),
claim_generation,
claimed: matches!(history, ClaimHistory::Claimed),
})
}
pub fn ensure_labels(&self, repo: &str, labels: &[Label]) -> Result<()> {
let known = self.gh_json(&[
"label", "list", "--repo", repo, "--json", "name", "--limit", "1000",
])?;
let known = named(known.as_array().context(CommandFailure::InvalidResponse(
"gh label list did not return a list".into(),
))?);
for label in labels {
if !known.has(*label) {
self.gh(&[
"label",
"create",
label.name(),
"--repo",
repo,
"--description",
label.description(),
])?;
}
}
Ok(())
}
pub fn create_issue(
&self,
issue: &IssueUrl,
title: &str,
body: &str,
labels: &[Label],
) -> Result<IssueUrl> {
self.create_issue_in(&issue.repo(), title, body, labels)
}
pub fn create_issue_in(
&self,
repo: &Repo,
title: &str,
body: &str,
labels: &[Label],
) -> Result<IssueUrl> {
let repo = repo.slug();
self.ensure_labels(&repo, labels)?;
let names: Vec<&str> = labels.iter().map(|label| label.name()).collect();
let url = self.gh_stdout(&[
"issue",
"create",
"--repo",
&repo,
"--title",
title,
"--body",
body,
"--label",
&names.join(","),
])?;
IssueUrl::parse(&url).context(CommandFailure::InvalidResponse(
"gh issue create did not print the issue's URL".into(),
))
}
pub fn profile_email(&self) -> Result<Option<String>> {
let json = self.gh_json(&["api", "user"])?;
Ok(json["email"].as_str().map(str::to_string))
}
fn pr_view(&self, issue: &IssueUrl, pr: &str, fields: &str) -> Result<Value> {
self.gh_json(&[
"pr",
"view",
pr,
"--repo",
&issue.repo_slug(),
"--json",
fields,
])
}
fn optional_pr_view(
&self,
issue: &IssueUrl,
selector: &str,
fields: &str,
) -> Result<Option<Value>> {
match self.pr_view(issue, selector, fields) {
Ok(json) => Ok(Some(json)),
Err(error) if format!("{error:#}").contains("no pull requests found") => Ok(None),
Err(error) => Err(error),
}
}
pub fn pr_snapshot(&self, issue: &IssueUrl, selector: &str) -> Result<Option<PrSnapshot>> {
let Some(json) = self.optional_pr_view(issue, selector, SNAPSHOT_FIELDS)? else {
return Ok(None);
};
let mergeable = match json["mergeable"].as_str() {
Some("MERGEABLE") => Mergeable::Yes,
Some("CONFLICTING") => Mergeable::No,
Some("UNKNOWN") => Mergeable::Unknown,
other => bail!(CommandFailure::InvalidResponse(format!(
"gh output has an unknown mergeable state {other:?}"
))),
};
Ok(Some(PrSnapshot {
pr: PullRequest::from_json(&json)?,
mergeable,
head_commit: json["headRefOid"]
.as_str()
.context(CommandFailure::InvalidResponse(
"gh output has no headRefOid".into(),
))?
.to_string(),
from_fork: json["isCrossRepository"].as_bool().context(
CommandFailure::InvalidResponse("gh output has no isCrossRepository".into()),
)?,
}))
}
pub fn pull_requests_with_head_prefix(
&self,
issue: &IssueUrl,
prefix: &str,
) -> Result<Vec<PullRequest>> {
let json = self.gh_json(&[
"pr",
"list",
"--repo",
&issue.repo_slug(),
"--state",
"all",
"--search",
&format!("head:{prefix}"),
"--limit",
"1000",
"--json",
&format!("{PR_FIELDS},isCrossRepository"),
])?;
json.as_array()
.context(CommandFailure::InvalidResponse(
"gh pr list did not return a list".into(),
))?
.iter()
.filter(|pr| pr["isCrossRepository"] != Value::Bool(true))
.map(PullRequest::from_json)
.collect()
}
pub fn create_draft_pr(
&self,
issue: &IssueUrl,
head: &str,
base: &str,
title: &str,
body: &str,
) -> Result<String> {
self.gh_stdout(&[
"pr",
"create",
"--repo",
&issue.repo_slug(),
"--head",
head,
"--base",
base,
"--title",
title,
"--body",
body,
"--draft",
])
}
pub fn pr_body(&self, issue: &IssueUrl, number: u64) -> Result<String> {
let json = self.pr_view(issue, &number.to_string(), "body")?;
Ok(json["body"]
.as_str()
.context(CommandFailure::InvalidResponse(
"gh output has no body".into(),
))?
.to_string())
}
pub fn set_pr_body(&self, issue: &IssueUrl, number: u64, body: &str) -> Result<()> {
self.gh(&[
"api",
"--method",
"PATCH",
&format!("repos/{}/pulls/{number}", issue.repo_slug()),
"-f",
&format!("body={body}"),
"--silent",
])
}
pub fn mark_ready(&self, issue: &IssueUrl, selector: &str) -> Result<()> {
self.gh(&["pr", "ready", selector, "--repo", &issue.repo_slug()])
}
pub fn convert_to_draft(&self, issue: &IssueUrl, selector: &str) -> Result<()> {
self.gh(&[
"pr",
"ready",
selector,
"--undo",
"--repo",
&issue.repo_slug(),
])
}
pub fn review_blocks_merge(&self, issue: &IssueUrl, number: u64) -> bool {
self.pr_view(issue, &number.to_string(), "reviewDecision")
.ok()
.is_some_and(|json| {
matches!(
json["reviewDecision"].as_str(),
Some("REVIEW_REQUIRED" | "CHANGES_REQUESTED")
)
})
}
pub fn merge(&self, issue: &IssueUrl, number: u64, head: &str) -> Result<()> {
self.gh(&[
"pr",
"merge",
&number.to_string(),
"--repo",
&issue.repo_slug(),
"--merge",
"--match-head-commit",
head,
])
}
pub fn checks_on(&self, issue: &IssueUrl, sha: &str) -> Result<Vec<Check>> {
let commit = format!("repos/{}/commits/{sha}", issue.repo_slug());
let runs =
self.gh_api_items_once(&format!("{commit}/check-runs?per_page=100"), "check_runs")?;
let statuses =
self.gh_api_items_once(&format!("{commit}/status?per_page=100"), "statuses")?;
let url = |value: &Value| {
value
.as_str()
.filter(|url| !url.is_empty())
.map(String::from)
};
let mut checks = Vec::new();
for run in &runs {
let conclusion = match run["status"].as_str() {
Some("completed") => Conclusion::of_check_run(run["conclusion"].as_str()),
_ => Conclusion::Unfinished,
};
checks.push(Check {
name: run["name"]
.as_str()
.context(CommandFailure::InvalidResponse(
"a check run has no name".into(),
))?
.to_string(),
conclusion,
job: ActionsJob::of(run["id"].as_u64(), run["details_url"].as_str()),
url: url(&run["details_url"]).or_else(|| url(&run["html_url"])),
});
}
for status in &statuses {
let conclusion = Conclusion::of_commit_status(status["state"].as_str());
checks.push(Check {
name: status["context"]
.as_str()
.context(CommandFailure::InvalidResponse(
"a commit status has no context".into(),
))?
.to_string(),
conclusion,
url: url(&status["target_url"]),
job: None,
});
}
Ok(checks)
}
pub fn job_run(&self, issue: &IssueUrl, check_run: u64) -> Result<JobRun> {
let path = format!("repos/{}/actions/jobs/{check_run}", issue.repo_slug());
let job = self.gh_json(&["api", &path])?;
let conclusion = |value: &Value| match value["status"].as_str() {
Some("completed") => Conclusion::of_check_run(value["conclusion"].as_str()),
_ => Conclusion::Unfinished,
};
let steps = job["steps"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default();
Ok(JobRun {
conclusion: conclusion(&job),
steps: steps.iter().map(conclusion).collect(),
})
}
pub fn rerun_failed_jobs(&self, issue: &IssueUrl, workflow_run: u64) -> Result<()> {
self.gh(&[
"run",
"rerun",
&workflow_run.to_string(),
"--failed",
"--repo",
&issue.repo_slug(),
])
}
fn gh(&self, args: &[&str]) -> Result<()> {
self.gh_stdout(args).map(drop)
}
fn gh_stdout(&self, args: &[&str]) -> Result<String> {
let output = self.output(args)?;
if !output.status.success() {
bail!(CommandFailure::rejected(
format!("gh {}", args.join(" ")),
&output
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn gh_api_items(&self, path: &str, field: &str) -> Result<Vec<Value>> {
self.retry_read(&format!("gh api {path}"), || {
self.gh_api_items_once(path, field)
})
}
fn gh_api_items_once(&self, path: &str, field: &str) -> Result<Vec<Value>> {
let output = self.output(&["api", "--paginate", "--jq", &format!(".{field}[]"), path])?;
if !output.status.success() {
bail!(check_read::ReadFailure {
command: format!("gh api {path}"),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
private_status: None,
});
}
serde_json::Deserializer::from_slice(&output.stdout)
.into_iter()
.collect::<Result<_, _>>()
.with_context(|| CommandFailure::InvalidJson {
command: format!("gh api {path}"),
})
}
fn gh_json(&self, args: &[&str]) -> Result<Value> {
let command = format!("gh {}", args[..2].join(" "));
self.retry_read(&command, || self.gh_json_once(args, &command))
}
fn retry_read<T>(&self, command: &str, mut read: impl FnMut() -> Result<T>) -> Result<T> {
for attempt in 1..=3 {
let error = match read() {
Ok(value) => return Ok(value),
Err(error) => error,
};
if attempt == 3 || !check_read::is_transient(&error) {
return Err(error);
}
crate::progress::step(format!(
"{command} failed (attempt {attempt}/3): {error:#}; retrying in {attempt}s"
));
self.interruption.pause(Duration::from_secs(attempt))?;
}
unreachable!("the third attempt returns success or the last cause")
}
fn gh_json_once(&self, args: &[&str], command: &str) -> Result<Value> {
let output = self.output(args)?;
if !output.status.success() {
bail!(check_read::ReadFailure {
command: command.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
private_status: None,
});
}
serde_json::from_slice(&output.stdout).with_context(|| CommandFailure::InvalidJson {
command: command.to_string(),
})
}
fn output(&self, args: &[&str]) -> Result<Output> {
self.output_with_input(args, None)
}
fn output_with_input(&self, args: &[&str], input: Option<&[u8]>) -> Result<Output> {
process::output(
Command::new("gh").args(args),
input,
Control {
name: "gh",
interruption: self.interruption,
stop: &|child| process::stop(child, &[libc::SIGTERM]),
},
)
.map_err(|error| {
self.interruption
.check()
.err()
.unwrap_or_else(|| error.context(CommandFailure::Execution))
})
}
}
fn state_is_open(json: &Value) -> Result<bool> {
Ok(json["state"]
.as_str()
.context(CommandFailure::InvalidResponse(
"gh output has no state".into(),
))?
== "OPEN")
}
#[derive(Clone)]
pub struct Issue {
pub title: Option<String>,
pub is_open: bool,
pub labels: Labels,
pub created: DateTime<Utc>,
}
fn labels_of(json: &Value) -> Result<Labels> {
Ok(named(json["labels"].as_array().context(
CommandFailure::InvalidResponse("gh output has no labels".into()),
)?))
}
fn named(labels: &[Value]) -> Labels {
labels
.iter()
.filter_map(|label| label["name"].as_str())
.collect()
}
fn labels_path(issue: &IssueUrl) -> String {
format!("repos/{}/issues/{}/labels", issue.repo_slug(), issue.number)
}
fn label_path(issue: &IssueUrl, label: &str) -> String {
format!("{}/{label}", labels_path(issue))
}
pub fn remove_label_command(issue: &IssueUrl, label: Label) -> String {
format!("gh api --method DELETE {}", label_path(issue, label.name()))
}
pub fn add_label_command(issue: &IssueUrl, label: Label) -> String {
format!(
"gh api --method POST {} -f 'labels[]={label}'",
labels_path(issue)
)
}
#[derive(Clone)]
pub struct Ticket {
pub issue: IssueUrl,
pub is_open: bool,
pub labels: Labels,
pub has_sub_issues: bool,
pub blockers: Vec<IssueUrl>,
pub open_blockers: Vec<IssueUrl>,
}
const TICKETS_QUERY: &str = "\
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
subIssues(first: 100) {
nodes {
url
state
labels(first: 100) { nodes { name } }
subIssues { totalCount }
blockedBy(first: 100) { nodes { url state } }
}
}
}
}
}";
fn nodes<'a>(connection: &'a Value, what: &str) -> Result<&'a Vec<Value>> {
connection["nodes"].as_array().with_context(|| {
CommandFailure::InvalidResponse(format!("gh api graphql output has no {what}"))
})
}
fn node_is_open(node: &Value) -> Result<bool> {
Ok(node["state"]
.as_str()
.context(CommandFailure::InvalidResponse(
"gh api graphql output has an issue with no state".into(),
))?
== "OPEN")
}
fn node_issue(node: &Value) -> Result<IssueUrl> {
IssueUrl::parse(
node["url"]
.as_str()
.context(CommandFailure::InvalidResponse(
"gh api graphql output has an issue with no URL".into(),
))?,
)
.context(CommandFailure::InvalidResponse(
"gh api graphql output has an invalid issue URL".into(),
))
}
fn node_number(node: &Value) -> Result<u64> {
node["number"]
.as_u64()
.context(CommandFailure::InvalidResponse(
"gh api graphql output has an issue with no number".into(),
))
}
fn sub_issue_count(node: &Value) -> Result<u64> {
node["subIssues"]["totalCount"]
.as_u64()
.context(CommandFailure::InvalidResponse(
"gh api graphql output has an issue with no subIssues count".into(),
))
}
#[derive(Debug, Clone)]
pub struct ListedIssue {
pub issue: IssueUrl,
pub title: String,
pub labels: Labels,
}
pub struct Candidate {
pub parent: Option<IssueUrl>,
pub sub_issue_is_open: Vec<bool>,
pub open_blockers: Vec<u64>,
pub last_shaped: Option<Shaped>,
pub handed_back: bool,
pub claim_generation: usize,
pub claimed: bool,
}
impl Candidate {
pub fn has_sub_issues(&self) -> bool {
!self.sub_issue_is_open.is_empty()
}
}
enum ClaimHistory {
NeverClaimed,
Claimed,
Released,
HandedBack,
}
impl ClaimHistory {
fn after(self, name: Option<&str>, applied: bool, ready: Label) -> Self {
if name.is_some_and(|name| crate::claim::IN_PROGRESS.is_named(name)) {
return match (self, applied) {
(_, true) => Self::Claimed,
(Self::Claimed, false) => Self::Released,
(state, false) => state,
};
}
if name.is_some_and(|name| ready.is_named(name)) {
return match (self, applied) {
(Self::Released | Self::HandedBack, true) => Self::HandedBack,
(Self::HandedBack, false) => Self::Released,
(state, _) => state,
};
}
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Shaped {
pub by: Shaping,
pub at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shaping {
Labelled,
SubIssues,
Blockers,
}
const CANDIDATE_QUERY: &str = "\
query($owner: String!, $repo: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
parent { url }
subIssues(first: 100) { nodes { state } }
blockedBy(first: 100) { nodes { number state } }
labelled: timelineItems(first: 100, after: $after, itemTypes: [LABELED_EVENT, UNLABELED_EVENT]) {
pageInfo { hasNextPage endCursor }
nodes {
__typename
... on LabeledEvent { createdAt label { name } }
... on UnlabeledEvent { createdAt label { name } }
}
}
linked: timelineItems(
last: 1
itemTypes: [
SUB_ISSUE_ADDED_EVENT
SUB_ISSUE_REMOVED_EVENT
BLOCKED_BY_ADDED_EVENT
BLOCKED_BY_REMOVED_EVENT
]
) {
nodes {
__typename
... on SubIssueAddedEvent { createdAt }
... on SubIssueRemovedEvent { createdAt }
... on BlockedByAddedEvent { createdAt }
... on BlockedByRemovedEvent { createdAt }
}
}
}
}
}";
const PR_FIELDS: &str = "number,url,state,headRefName,baseRefName,isDraft";
const SNAPSHOT_FIELDS: &str =
"number,url,state,headRefName,baseRefName,isDraft,mergeable,headRefOid,isCrossRepository";
#[derive(Debug, Clone)]
pub struct PrSnapshot {
pub pr: PullRequest,
pub mergeable: Mergeable,
pub head_commit: String,
pub from_fork: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrState {
Open,
Closed,
Merged,
}
impl fmt::Display for PrState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
PrState::Open => "open",
PrState::Closed => "closed",
PrState::Merged => "merged",
})
}
}
#[derive(Debug, Clone)]
pub struct PullRequest {
pub number: u64,
pub url: String,
pub state: PrState,
pub head: String,
pub base: String,
pub is_draft: bool,
}
impl PullRequest {
pub fn is_open(&self) -> bool {
self.state == PrState::Open
}
fn from_json(json: &Value) -> Result<Self> {
let field = |name: &str| -> Result<String> {
json[name].as_str().map(String::from).with_context(|| {
CommandFailure::InvalidResponse(format!("gh output has no {name}"))
})
};
let state = match field("state")?.as_str() {
"OPEN" => PrState::Open,
"CLOSED" => PrState::Closed,
"MERGED" => PrState::Merged,
other => bail!(CommandFailure::InvalidResponse(format!(
"gh output has an unknown PR state {other}"
))),
};
Ok(PullRequest {
number: json["number"]
.as_u64()
.context(CommandFailure::InvalidResponse(
"gh output has no number".into(),
))?,
url: field("url")?,
state,
head: field("headRefName")?,
base: field("baseRefName")?,
is_draft: json["isDraft"]
.as_bool()
.context(CommandFailure::InvalidResponse(
"gh output has no isDraft".into(),
))?,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mergeable {
Yes,
No,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Conclusion {
Unfinished,
Success,
Failure,
Neutral,
Skipped,
Cancelled,
TimedOut,
ActionRequired,
Stale,
StartupFailure,
Pending,
Error,
Unrecognised(String),
}
impl Conclusion {
fn of_check_run(conclusion: Option<&str>) -> Self {
match conclusion {
Some("success") => Self::Success,
Some("failure") => Self::Failure,
Some("neutral") => Self::Neutral,
Some("skipped") => Self::Skipped,
Some("cancelled") => Self::Cancelled,
Some("timed_out") => Self::TimedOut,
Some("action_required") => Self::ActionRequired,
Some("stale") => Self::Stale,
Some("startup_failure") => Self::StartupFailure,
other => Self::Unrecognised(other.unwrap_or_default().to_string()),
}
}
fn of_commit_status(state: Option<&str>) -> Self {
match state {
Some("success") => Self::Success,
Some("failure") => Self::Failure,
Some("pending") => Self::Pending,
Some("error") => Self::Error,
other => Self::Unrecognised(other.unwrap_or_default().to_string()),
}
}
}
#[derive(Clone)]
pub struct Check {
pub name: String,
pub conclusion: Conclusion,
pub url: Option<String>,
pub job: Option<ActionsJob>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActionsJob {
pub check_run: u64,
pub workflow_run: u64,
pub abandoned: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobRun {
pub conclusion: Conclusion,
pub steps: Vec<Conclusion>,
}
impl ActionsJob {
fn of(check_run: Option<u64>, details_url: Option<&str>) -> Option<Self> {
let (_, after) = details_url?.split_once("/actions/runs/")?;
let (workflow_run, _) = after.split_once("/job/")?;
Some(ActionsJob {
check_run: check_run?,
workflow_run: workflow_run.parse().ok()?,
abandoned: false,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_check_run_whose_details_are_a_jobs_page_is_an_actions_job() {
let job = "https://github.com/acme/widgets/actions/runs/900/job/41";
assert_eq!(
ActionsJob::of(Some(41), Some(job)),
Some(ActionsJob {
check_run: 41,
workflow_run: 900,
abandoned: false,
})
);
for not_a_job in [
"https://ci.example/test",
"https://github.com/acme/widgets/actions/runs/900",
"https://github.com/acme/widgets/actions/runs/latest/job/41",
"https://github.com/acme/widgets/runs/41",
] {
assert_eq!(ActionsJob::of(Some(41), Some(not_a_job)), None);
}
assert_eq!(ActionsJob::of(Some(41), None), None);
assert_eq!(ActionsJob::of(None, Some(job)), None);
}
}