use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use crate::gh;
pub const HOST_QUERY_SCHEMA_VERSION: u32 = 1;
const PER_PAGE: u32 = 100;
const TOKEN_VARIABLES: [&str; 2] = ["GITHUB_TOKEN", "GH_TOKEN"];
const API_ORIGIN: &str = "https://api.github.com/";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostQueryError {
pub reason: String,
}
impl fmt::Display for HostQueryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.reason)
}
}
impl std::error::Error for HostQueryError {}
pub type HostQueryResult<T> = std::result::Result<T, HostQueryError>;
fn failed(reason: impl Into<String>) -> HostQueryError {
let reason: String = reason.into();
HostQueryError {
reason: reason.split_whitespace().collect::<Vec<_>>().join(" "),
}
}
macro_rules! string_newtype {
($name:ident, $schema:expr) => {
impl Serialize for $name {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
impl JsonSchema for $name {
fn schema_name() -> Cow<'static, str> {
stringify!($name).into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
$schema
}
}
};
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RepositorySlug(String);
impl RepositorySlug {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for RepositorySlug {
type Err = String;
fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
let usable = |part: &str| {
!part.is_empty()
&& part != "."
&& part != ".."
&& part
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
};
match text.split_once('/') {
Some((owner, name)) if usable(owner) && usable(name) => Ok(Self(text.to_owned())),
_ => Err(format!(
"{text:?} is not a repository: expected OWNER/NAME, each of letters, digits, \
'-', '_' or '.'"
)),
}
}
}
string_newtype!(RepositorySlug, schemars::json_schema!({"type": "string"}));
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CommitSha(String);
impl CommitSha {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for CommitSha {
type Err = String;
fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
if text.len() == 40 && text.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(Self(text.to_ascii_lowercase()))
} else {
Err(format!(
"{text:?} is not a commit: expected 40 hexadecimal characters"
))
}
}
}
string_newtype!(
CommitSha,
schemars::json_schema!({"type": "string", "pattern": "^[0-9a-f]{40}$"})
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PositiveId(u64);
impl PositiveId {
pub fn get(self) -> u64 {
self.0
}
}
impl FromStr for PositiveId {
type Err = String;
fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
match text.parse::<u64>() {
Ok(value) if value > 0 && text.chars().all(|c| c.is_ascii_digit()) => Ok(Self(value)),
_ => Err(format!("{text:?} is not a positive integer")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Timestamp {
written: String,
instant: OffsetDateTime,
}
impl Timestamp {
pub fn as_str(&self) -> &str {
&self.written
}
pub fn instant(&self) -> OffsetDateTime {
self.instant
}
}
impl FromStr for Timestamp {
type Err = String;
fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
OffsetDateTime::parse(text, &Rfc3339)
.map(|instant| Self {
written: text.to_owned(),
instant,
})
.map_err(|e| format!("{text:?} is not an RFC 3339 timestamp: {e}"))
}
}
string_newtype!(
Timestamp,
schemars::json_schema!({"type": "string", "format": "date-time"})
);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReleaseChangesRequest {
pub repository: RepositorySlug,
pub tag: String,
pub previous_tag: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeChecksRequest {
pub repository: RepositorySlug,
pub change: PositiveId,
pub head: CommitSha,
pub merged_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommitSelector {
Commit(CommitSha),
Tag(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitRunsRequest {
pub repository: RepositorySlug,
pub commit: CommitSelector,
pub events: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunJobsRequest {
pub repository: RepositorySlug,
pub run: PositiveId,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct QueryCost {
pub graphql_points: u32,
pub rest_requests: u32,
pub requests: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Run {
pub id: u64,
pub workflow_id: u64,
pub workflow_name: String,
pub event: String,
pub head_sha: CommitSha,
pub head_branch: Option<String>,
pub run_attempt: u64,
pub status: String,
pub conclusion: Option<String>,
pub created_at: Timestamp,
pub run_started_at: Option<Timestamp>,
pub updated_at: Timestamp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum RunnerOs {
Linux,
Windows,
#[serde(rename = "macOS")]
MacOs,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Job {
pub id: u64,
pub run_id: u64,
pub run_attempt: u64,
pub name: String,
pub workflow_name: String,
pub status: String,
pub conclusion: Option<String>,
pub runner_name: Option<String>,
pub runner_os: Option<RunnerOs>,
pub labels: Vec<String>,
pub created_at: Timestamp,
pub started_at: Option<Timestamp>,
pub completed_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Change {
pub number: u64,
pub title: String,
pub state: String,
pub base_ref: String,
pub head_sha: CommitSha,
pub created_at: Timestamp,
pub merged_at: Option<Timestamp>,
pub merge_commit_sha: Option<CommitSha>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ReleaseCommit {
pub sha: CommitSha,
pub change: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ReleaseChanges {
pub schema_version: u32,
pub repository: RepositorySlug,
pub tag: String,
pub commit: CommitSha,
pub previous_tag: String,
pub previous_commit: CommitSha,
pub commits: Vec<ReleaseCommit>,
pub changes: Vec<Change>,
pub cost: QueryCost,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ChangeLabel {
pub number: u64,
pub head_sha: CommitSha,
pub merged_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CheckedRun {
#[serde(flatten)]
pub run: Run,
pub jobs: Vec<Job>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ChangeChecks {
pub schema_version: u32,
pub repository: RepositorySlug,
pub change: ChangeLabel,
pub runs: Vec<CheckedRun>,
pub cost: QueryCost,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CommitRuns {
pub schema_version: u32,
pub repository: RepositorySlug,
pub commit: CommitSha,
pub events: Vec<String>,
pub runs: Vec<Run>,
pub cost: QueryCost,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct RunJobs {
pub schema_version: u32,
pub repository: RepositorySlug,
pub run_id: u64,
pub jobs: Vec<Job>,
pub cost: QueryCost,
}
pub fn host_schema() -> Value {
serde_json::json!({
"schema_version": HOST_QUERY_SCHEMA_VERSION,
"release-changes": schemars::schema_for!(ReleaseChanges),
"change-checks": schemars::schema_for!(ChangeChecks),
"commit-runs": schemars::schema_for!(CommitRuns),
"run-jobs": schemars::schema_for!(RunJobs),
})
}
pub fn release_changes(request: &ReleaseChangesRequest) -> HostQueryResult<ReleaseChanges> {
let mut reader = Reader::new(&request.repository)?;
let repo = request.repository.as_str();
let previous_tag = match &request.previous_tag {
Some(tag) => tag.clone(),
None => previous_release(&mut reader, &request.tag)?,
};
let range = format!("{}...{}", encode(&previous_tag), encode(&request.tag));
let what = format!("the comparison {previous_tag}...{}", request.tag);
let mut shas: Vec<CommitSha> = Vec::new();
let mut previous: Option<(CommitSha, Timestamp)> = None;
reader.walk(
format!("repos/{repo}/compare/{range}?per_page={PER_PAGE}"),
&what,
|body, path| {
if previous.is_none() {
let base = field(body, "base_commit", path)?;
let committer = field(field(base, "commit", path)?, "committer", path)?;
previous = Some((sha(base, "sha", path)?, timestamp(committer, "date", path)?));
}
for commit in array(body, "commits", path)? {
shas.push(sha(commit, "sha", path)?);
}
Ok(Paging::More)
},
)?;
let (previous_commit, previous_time) =
previous.ok_or_else(|| failed(format!("{what} was never read")))?;
let commit = shas.last().cloned().ok_or_else(|| {
failed(format!(
"{what} lists no commit, so {} names no commit after {previous_tag}",
request.tag
))
})?;
let mut wanted: BTreeSet<&CommitSha> = shas.iter().collect();
let mut merged: BTreeMap<CommitSha, Change> = BTreeMap::new();
reader.walk(
format!("repos/{repo}/pulls?state=closed&sort=updated&direction=desc&per_page={PER_PAGE}"),
"the closed pull requests",
|body, path| {
let pulls = top_array(body, path)?;
for pull in pulls {
let change = change(pull, path)?;
let Some(commit) = change.merge_commit_sha.clone() else {
continue;
};
if change.merged_at.is_some() && wanted.remove(&commit) {
merged.insert(commit, change);
}
}
let passed = match pulls.last() {
Some(last) => timestamp(last, "updated_at", path)?.instant < previous_time.instant,
None => true,
};
Ok(if wanted.is_empty() || passed {
Paging::Done
} else {
Paging::More
})
},
)?;
let mut commits = Vec::with_capacity(shas.len());
let mut changes: Vec<Change> = Vec::new();
for sha in &shas {
let found = merged.get(sha);
if let Some(found) = found {
if !changes.iter().any(|c| c.number == found.number) {
changes.push(found.clone());
}
}
commits.push(ReleaseCommit {
sha: sha.clone(),
change: found.map(|c| c.number),
});
}
Ok(ReleaseChanges {
schema_version: HOST_QUERY_SCHEMA_VERSION,
repository: request.repository.clone(),
tag: request.tag.clone(),
commit,
previous_tag,
previous_commit,
commits,
changes,
cost: reader.cost(),
})
}
fn previous_release(reader: &mut Reader, tag: &str) -> HostQueryResult<String> {
let Some(version) = Version::of(tag) else {
return Err(failed(format!(
"{tag:?} is not of the form <prefix>MAJOR.MINOR.PATCH, so no previous release \
can be derived; name one with --previous-tag"
)));
};
let listing = format!("repos/{}/releases", reader.repository);
let mut found = false;
let mut previous = None;
reader.walk(
format!("{listing}?per_page={PER_PAGE}"),
"the releases",
|body, path| {
for release in top_array(body, path)? {
let name = string(release, "tag_name", path)?;
if !found {
found = name == tag;
continue;
}
let draft = field(release, "draft", path)?
.as_bool()
.ok_or_else(|| missing("draft boolean", path))?;
let published = nullable_timestamp(release, "published_at", path)?.is_some();
let lower = Version::of(&name).is_some_and(|other| {
other.prefix == version.prefix && other.number < version.number
});
if published && !draft && lower {
previous = Some(name);
return Ok(Paging::Done);
}
}
Ok(Paging::More)
},
)?;
previous.ok_or_else(|| {
failed(if found {
format!(
"no published release before {tag} of its form was found in {listing}; name \
the previous tag with --previous-tag"
)
} else {
format!("the release tag {tag} was not found in {listing}")
})
})
}
struct Version {
prefix: String,
number: (u64, u64, u64),
}
impl Version {
fn of(tag: &str) -> Option<Self> {
let numbers = |rest: &str| -> Option<(u64, u64, u64)> {
let mut parts = rest.split('.');
let mut next = || {
parts
.next()
.filter(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
.and_then(|p| p.parse().ok())
};
let number = (next()?, next()?, next()?);
parts.next().is_none().then_some(number)
};
tag.char_indices().find_map(|(start, _)| {
numbers(&tag[start..]).map(|number| Self {
prefix: tag[..start].to_owned(),
number,
})
})
}
}
pub fn change_checks(request: &ChangeChecksRequest) -> HostQueryResult<ChangeChecks> {
let mut reader = Reader::new(&request.repository)?;
let mut latest: BTreeMap<u64, Run> = BTreeMap::new();
for run in runs_at(&mut reader, &request.head, Some("pull_request"))? {
if run.event != "pull_request" {
continue;
}
let created = run.created_at.instant;
if request
.merged_at
.as_ref()
.is_some_and(|merged| created > merged.instant)
{
continue;
}
let newer = latest
.get(&run.workflow_id)
.is_none_or(|kept| (created, run.id) > (kept.created_at.instant, kept.id));
if newer {
latest.insert(run.workflow_id, run);
}
}
let mut selected: Vec<Run> = latest.into_values().collect();
selected.sort_by(|a, b| {
(a.workflow_name.as_str(), a.workflow_id).cmp(&(b.workflow_name.as_str(), b.workflow_id))
});
let mut runs = Vec::with_capacity(selected.len());
for run in selected {
let jobs = latest_jobs(&mut reader, run.id)?;
runs.push(CheckedRun { run, jobs });
}
Ok(ChangeChecks {
schema_version: HOST_QUERY_SCHEMA_VERSION,
repository: request.repository.clone(),
change: ChangeLabel {
number: request.change.get(),
head_sha: request.head.clone(),
merged_at: request.merged_at.clone(),
},
runs,
cost: reader.cost(),
})
}
pub fn commit_runs(request: &CommitRunsRequest) -> HostQueryResult<CommitRuns> {
let mut reader = Reader::new(&request.repository)?;
let commit = match &request.commit {
CommitSelector::Commit(sha) => sha.clone(),
CommitSelector::Tag(tag) => tagged_commit(&mut reader, tag)?,
};
let mut runs: Vec<Run> = runs_at(&mut reader, &commit, None)?
.into_iter()
.filter(|run| request.events.is_empty() || request.events.contains(&run.event))
.collect();
runs.sort_by_key(|run| (run.created_at.instant, run.id));
Ok(CommitRuns {
schema_version: HOST_QUERY_SCHEMA_VERSION,
repository: request.repository.clone(),
commit,
events: request.events.clone(),
runs,
cost: reader.cost(),
})
}
pub fn run_jobs(request: &RunJobsRequest) -> HostQueryResult<RunJobs> {
let mut reader = Reader::new(&request.repository)?;
let jobs = latest_jobs(&mut reader, request.run.get())?;
Ok(RunJobs {
schema_version: HOST_QUERY_SCHEMA_VERSION,
repository: request.repository.clone(),
run_id: request.run.get(),
jobs,
cost: reader.cost(),
})
}
fn tagged_commit(reader: &mut Reader, tag: &str) -> HostQueryResult<CommitSha> {
let repo = reader.repository.clone();
let mut at = format!("repos/{repo}/git/ref/tags/{}", encode(tag));
let mut object = reader.get(&at, &format!("the tag {tag}"))?;
loop {
let target = field(&object.body, "object", &at)?;
let kind = string(target, "type", &at)?;
let sha = sha(target, "sha", &at)?;
match kind.as_str() {
"commit" => return Ok(sha),
"tag" => {
at = format!("repos/{repo}/git/tags/{}", sha.as_str());
object = reader.get(&at, &format!("the annotated tag {tag}"))?;
}
other => {
return Err(failed(format!(
"the tag {tag} names a {other}, not a commit ({at})"
)))
}
}
}
}
fn runs_at(reader: &mut Reader, sha: &CommitSha, event: Option<&str>) -> HostQueryResult<Vec<Run>> {
let repo = reader.repository.clone();
let sha = sha.as_str();
let narrowed = event.map_or_else(String::new, |event| format!("&event={event}"));
let mut runs = Vec::new();
reader.walk(
format!("repos/{repo}/actions/runs?per_page={PER_PAGE}&head_sha={sha}{narrowed}"),
&format!("the workflow runs at {sha}"),
|body, path| {
for entry in array(body, "workflow_runs", path)? {
runs.push(run(entry, path)?);
}
Ok(Paging::More)
},
)?;
Ok(runs)
}
fn latest_jobs(reader: &mut Reader, run: u64) -> HostQueryResult<Vec<Job>> {
let repo = reader.repository.clone();
let mut jobs = Vec::new();
reader.walk(
format!("repos/{repo}/actions/runs/{run}/jobs?per_page={PER_PAGE}&filter=latest"),
&format!("the workflow run {run}"),
|body, path| {
for entry in array(body, "jobs", path)? {
jobs.push(job(entry, path)?);
}
Ok(Paging::More)
},
)?;
Ok(jobs)
}
fn missing(name: &str, path: &str) -> HostQueryError {
failed(format!("the response to {path} has no {name}"))
}
fn field<'a>(value: &'a Value, name: &str, path: &str) -> HostQueryResult<&'a Value> {
value.get(name).ok_or_else(|| missing(name, path))
}
fn string(value: &Value, name: &str, path: &str) -> HostQueryResult<String> {
field(value, name, path)?
.as_str()
.map(str::to_owned)
.ok_or_else(|| missing(&format!("{name} string"), path))
}
fn nullable(value: &Value, name: &str, path: &str) -> HostQueryResult<Option<String>> {
match field(value, name, path)? {
Value::Null => Ok(None),
Value::String(text) => Ok(Some(text.clone())),
_ => Err(missing(&format!("{name} string"), path)),
}
}
fn parsed<T: FromStr>(value: &Value, name: &str, what: &str, path: &str) -> HostQueryResult<T> {
string(value, name, path)?
.parse()
.map_err(|_| missing(&format!("{name} {what}"), path))
}
fn nullable_parsed<T: FromStr>(
value: &Value,
name: &str,
what: &str,
path: &str,
) -> HostQueryResult<Option<T>> {
nullable(value, name, path)?
.map(|text| {
text.parse()
.map_err(|_| missing(&format!("{name} {what}"), path))
})
.transpose()
}
fn sha(value: &Value, name: &str, path: &str) -> HostQueryResult<CommitSha> {
parsed(value, name, "commit", path)
}
fn timestamp(value: &Value, name: &str, path: &str) -> HostQueryResult<Timestamp> {
parsed(value, name, "timestamp", path)
}
fn nullable_timestamp(value: &Value, name: &str, path: &str) -> HostQueryResult<Option<Timestamp>> {
nullable_parsed(value, name, "timestamp", path)
}
fn integer(value: &Value, name: &str, path: &str) -> HostQueryResult<u64> {
field(value, name, path)?
.as_u64()
.ok_or_else(|| missing(&format!("{name} integer"), path))
}
fn array<'a>(value: &'a Value, name: &str, path: &str) -> HostQueryResult<&'a Vec<Value>> {
field(value, name, path)?
.as_array()
.ok_or_else(|| missing(&format!("{name} list"), path))
}
fn top_array<'a>(value: &'a Value, path: &str) -> HostQueryResult<&'a Vec<Value>> {
value
.as_array()
.ok_or_else(|| failed(format!("the response to {path} is not a list")))
}
fn run(value: &Value, path: &str) -> HostQueryResult<Run> {
Ok(Run {
id: integer(value, "id", path)?,
workflow_id: integer(value, "workflow_id", path)?,
workflow_name: string(value, "name", path)?,
event: string(value, "event", path)?,
head_sha: sha(value, "head_sha", path)?,
head_branch: nullable(value, "head_branch", path)?,
run_attempt: integer(value, "run_attempt", path)?,
status: string(value, "status", path)?,
conclusion: nullable(value, "conclusion", path)?,
created_at: timestamp(value, "created_at", path)?,
run_started_at: nullable_timestamp(value, "run_started_at", path)?,
updated_at: timestamp(value, "updated_at", path)?,
})
}
fn job(value: &Value, path: &str) -> HostQueryResult<Job> {
let labels = array(value, "labels", path)?
.iter()
.map(|label| {
label
.as_str()
.map(str::to_owned)
.ok_or_else(|| missing("labels string", path))
})
.collect::<HostQueryResult<Vec<_>>>()?;
Ok(Job {
id: integer(value, "id", path)?,
run_id: integer(value, "run_id", path)?,
run_attempt: integer(value, "run_attempt", path)?,
name: string(value, "name", path)?,
workflow_name: string(value, "workflow_name", path)?,
status: string(value, "status", path)?,
conclusion: nullable(value, "conclusion", path)?,
runner_name: nullable(value, "runner_name", path)?.filter(|name| !name.is_empty()),
runner_os: runner_os(&labels),
labels,
created_at: timestamp(value, "created_at", path)?,
started_at: nullable_timestamp(value, "started_at", path)?,
completed_at: nullable_timestamp(value, "completed_at", path)?,
})
}
fn runner_os(labels: &[String]) -> Option<RunnerOs> {
labels.iter().find_map(|label| {
let label = label.to_ascii_lowercase();
if label.starts_with("ubuntu") || label.starts_with("linux") {
Some(RunnerOs::Linux)
} else if label.starts_with("windows") {
Some(RunnerOs::Windows)
} else if label.starts_with("macos") {
Some(RunnerOs::MacOs)
} else {
None
}
})
}
fn change(value: &Value, path: &str) -> HostQueryResult<Change> {
Ok(Change {
number: integer(value, "number", path)?,
title: string(value, "title", path)?,
state: string(value, "state", path)?,
base_ref: string(field(value, "base", path)?, "ref", path)?,
head_sha: sha(field(value, "head", path)?, "sha", path)?,
created_at: timestamp(value, "created_at", path)?,
merged_at: nullable_timestamp(value, "merged_at", path)?,
merge_commit_sha: nullable_parsed(value, "merge_commit_sha", "commit", path)?,
})
}
fn encode(text: &str) -> String {
let mut encoded = String::with_capacity(text.len());
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/') {
encoded.push(char::from(byte));
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
struct Page {
body: Value,
next: Option<String>,
}
enum Paging {
More,
Done,
}
struct Reader {
repository: String,
token: String,
requests: Vec<String>,
}
impl Reader {
fn new(repository: &RepositorySlug) -> HostQueryResult<Self> {
let token = TOKEN_VARIABLES
.iter()
.find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
.ok_or_else(|| {
failed(
"no GitHub token: set GITHUB_TOKEN or GH_TOKEN to a token with actions: \
read, contents: read and pull-requests: read",
)
})?;
Ok(Self {
repository: repository.as_str().to_owned(),
token,
requests: Vec::new(),
})
}
fn cost(&self) -> QueryCost {
QueryCost {
graphql_points: 0,
rest_requests: u32::try_from(self.requests.len()).unwrap_or(u32::MAX),
requests: self.requests.clone(),
}
}
fn walk(
&mut self,
first: String,
what: &str,
mut visit: impl FnMut(&Value, &str) -> HostQueryResult<Paging>,
) -> HostQueryResult<()> {
let mut next = Some(first);
while let Some(request) = next {
let page = self.get(&request, what)?;
next = match visit(&page.body, &path_of(&request)?)? {
Paging::More => page.next,
Paging::Done => None,
};
}
Ok(())
}
fn get(&mut self, request: &str, what: &str) -> HostQueryResult<Page> {
let path = path_of(request)?;
self.requests.push(path.clone());
let answer = gh::attempt_with_env(
&["api", "--include", "--method", "GET", request],
&[("GH_TOKEN", self.token.as_str())],
)
.map_err(|e| failed(format!("could not run gh for {path}: {e}")))?;
let Some((status, headers, body)) = split_response(&answer.stdout) else {
let detail = answer.detail();
return Err(failed(format!(
"gh failed for {path}: {}",
if detail.is_empty() {
"it wrote nothing"
} else {
&detail
}
)));
};
if status == 404 {
return Err(failed(format!(
"not found: {what} (GitHub answered 404 for {path})"
)));
}
if !(200..300).contains(&status) {
let message = serde_json::from_str::<Value>(body)
.ok()
.and_then(|v| v.get("message").and_then(Value::as_str).map(str::to_owned))
.unwrap_or_else(|| answer.detail());
return Err(failed(format!(
"GitHub answered {status} for {path}: {message}"
)));
}
if answer.code != Some(0) {
return Err(failed(format!("gh failed for {path}: {}", answer.detail())));
}
let body: Value = serde_json::from_str(body)
.map_err(|e| failed(format!("the response to {path} is not JSON: {e}")))?;
let next = match headers.get("link") {
Some(link) => next_link(link)
.map_err(|reason| failed(format!("the Link header of {path} {reason}: {link}")))?
.map(str::to_owned),
None => None,
};
Ok(Page { body, next })
}
}
fn path_of(request: &str) -> HostQueryResult<String> {
if !request.contains("://") {
return Ok(request.to_owned());
}
request
.strip_prefix(API_ORIGIN)
.map(str::to_owned)
.ok_or_else(|| {
failed(format!(
"GitHub named a next page outside {API_ORIGIN}, which is not followed: {request}"
))
})
}
fn split_response(stdout: &str) -> Option<(u16, BTreeMap<String, String>, &str)> {
let (head, body) = stdout
.split_once("\r\n\r\n")
.or_else(|| stdout.split_once("\n\n"))
.or_else(|| stdout.split_once("\n\r\n"))?;
let mut lines = head.lines().map(|line| line.trim_end_matches('\r'));
let status = lines.next()?;
if !status.starts_with("HTTP/") {
return None;
}
let status: u16 = status.split_whitespace().nth(1)?.parse().ok()?;
let headers = lines
.filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_owned()))
.collect();
Some((status, headers, body))
}
fn next_link(link: &str) -> std::result::Result<Option<&str>, String> {
let mut next = None;
for entry in link.split(',') {
let (target, params) = entry
.trim()
.strip_prefix('<')
.and_then(|rest| rest.split_once('>'))
.ok_or("is malformed")?;
let rel = params
.split(';')
.map(str::trim)
.filter(|param| !param.is_empty())
.map(|param| param.strip_prefix("rel=").ok_or("is malformed"))
.collect::<std::result::Result<Vec<_>, _>>()?;
if rel.contains(&"\"next\"") {
if !target.starts_with(API_ORIGIN) {
return Err(format!("names a next page outside {API_ORIGIN}"));
}
next = Some(target);
}
}
Ok(next)
}