use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::store::{Cached, HttpEntry, Store};
use cynic::{MutationBuilder, QueryBuilder};
use http::{HeaderMap, HeaderValue, StatusCode, header};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::api::auth::Token;
use crate::api::browse;
use crate::api::model::{
Capped, ChecksState, NewThread, NodeId, PatchFile, PendingReview, PrDetail, PrRef, RepoId,
ReviewEvent, ReviewThread, ViewedFiles,
};
use crate::api::queries::{self, nodes};
use crate::api::rate_limit::{RateLimits, retry_after};
use crate::api::raw;
const MAX_ATTEMPTS: u32 = 3;
const GITHUB_TIMEOUT: Duration = Duration::from_secs(9);
const MAX_INLINE_WAIT_SECS: u64 = 10;
#[derive(Debug, Clone, thiserror::Error)]
pub enum ApiError {
#[error("GitHub rejected the token. Run `gh auth login` or check GH_TOKEN.")]
Unauthorized,
#[error("rate limited by GitHub; retry in {0}s")]
RateLimited(u64),
#[error("not found: {0}")]
NotFound(String),
#[error("{0}")]
Blocked(String),
#[error("{0}")]
Gone(String),
#[error("git: {0}")]
Git(String),
#[error("GitHub returned {status}: {message}")]
Http { status: u16, message: String },
#[error("GraphQL: {}", .0.join("; "))]
GraphQl(Vec<String>),
#[error("network error: {0}")]
Network(String),
#[error("unexpected response from GitHub: {0}")]
Decode(String),
#[error("could not start HTTP client: {0}")]
Setup(String),
#[error("internal error: {0}")]
Internal(String),
}
impl From<serde_json::Error> for ApiError {
fn from(err: serde_json::Error) -> Self {
ApiError::Decode(err.to_string())
}
}
#[derive(Clone)]
pub struct GitHub {
http: Arc<Http>,
store: Store,
limits: Arc<Mutex<RateLimits>>,
rejected: Arc<(AtomicBool, AtomicBool)>,
left_out: Arc<Mutex<Vec<String>>>,
doubts: Arc<Mutex<Vec<String>>>,
recorder: Option<Arc<crate::api::corpus::Recorder>>,
}
struct Http {
token: Token,
base_uri: String,
client: tokio::sync::OnceCell<reqwest::Client>,
}
impl std::fmt::Debug for GitHub {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitHub")
.field("store", &self.store)
.finish()
}
}
struct Response {
status: StatusCode,
headers: HeaderMap,
body: String,
}
enum Request<'a> {
Get {
path: &'a str,
etag: Option<&'a str>,
},
Post {
path: &'a str,
body: &'a serde_json::Value,
idempotent: bool,
},
}
impl Request<'_> {
fn idempotent(&self) -> bool {
match self {
Request::Get { .. } => true,
Request::Post { idempotent, .. } => *idempotent,
}
}
}
impl GitHub {
pub fn new(token: Token, store: Store) -> Self {
Self::with_base_uri(token, store, None)
}
pub fn with_base_uri(token: Token, store: Store, base_uri: Option<&str>) -> Self {
Self {
http: Arc::new(Http {
token,
base_uri: base_uri
.unwrap_or("https://api.github.com")
.trim_end_matches('/')
.to_owned(),
client: tokio::sync::OnceCell::new(),
}),
store,
limits: Arc::default(),
rejected: Arc::default(),
left_out: Arc::default(),
doubts: Arc::default(),
recorder: None,
}
}
#[must_use]
pub fn recording(mut self, dir: std::path::PathBuf) -> Self {
self.recorder = Some(Arc::new(crate::api::corpus::Recorder(dir)));
self
}
async fn client(&self) -> Result<&reqwest::Client, ApiError> {
self.http
.client
.get_or_try_init(|| async {
let token = self.http.token.expose().to_owned();
tokio::task::spawn_blocking(move || build_client(&token))
.await
.map_err(|e| ApiError::Setup(e.to_string()))?
})
.await
}
pub fn take_left_out(&self) -> Vec<String> {
std::mem::take(
&mut *self
.left_out
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
)
}
pub fn take_doubts(&self) -> Vec<String> {
std::mem::take(
&mut *self
.doubts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
)
}
fn doubt(&self, what: String) {
tracing::warn!(what, "GitHub's answer doesn't add up");
let mut doubts = self
.doubts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !doubts.contains(&what) {
doubts.push(what);
}
}
fn check_page<T: browse::Page>(&self, list: &T, what: impl std::fmt::Display) {
let (items, total) = list.counts();
if items as u64 > total {
self.doubt(format!("{what}: a page of {items}, of {total} in all"));
}
}
async fn more_pages<T, F: Future<Output = Result<browse::Results<T>, ApiError>>>(
&self,
what: impl std::fmt::Display,
first: browse::Results<T>,
max: u32,
mut fetch: impl FnMut(Option<String>) -> F,
) -> Result<Capped<T>, ApiError> {
let mut list = first;
let mut seen = std::collections::HashSet::new();
for _ in 1..max {
let Some(after) = list.next.take() else {
break;
};
if !seen.insert(after.clone()) {
self.doubt(format!("{what}: GitHub gave the cursor {after} twice"));
break;
}
let page = fetch(Some(after)).await?;
list.total = list.total.max(page.total);
list.items.extend(page.items);
list.next = page.next;
}
let fetched = list.items.len() as u64;
if list.next.is_some() && list.total <= fetched {
self.leave_out(vec![format!("{what}: only the first {fetched}")]);
list.total = fetched + 1;
}
Ok(Capped::new(list.items, list.total))
}
fn check_connection<T>(
&self,
page: &browse::wire::Connection<T>,
size: usize,
what: impl std::fmt::Display,
) {
let n = page.nodes.len();
if page.page_info.has_next_page && n < size {
self.doubt(format!(
"{what}: a page of {n} (of {size}) says there are more"
));
}
if n as u64 > page.total_count {
self.doubt(format!(
"{what}: a page of {n}, of {} in all",
page.total_count
));
}
}
fn note_incomplete(&self, incomplete: bool) {
if incomplete {
self.leave_out(vec![
"the search took too long and found only some results".to_owned(),
]);
}
}
fn leave_out(&self, errors: Vec<String>) {
if errors.is_empty() {
return;
}
tracing::warn!(?errors, "partial GraphQL result");
let mut left_out = self
.left_out
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for error in errors {
if !left_out.contains(&error) {
left_out.push(error);
}
}
}
pub fn token_rejected_change(&self) -> Option<bool> {
let now = self.rejected.0.load(Ordering::Relaxed);
let was = self.rejected.1.swap(now, Ordering::Relaxed);
(now != was).then_some(now)
}
pub fn rate_limits(&self) -> RateLimits {
*self
.limits
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
async fn send(&self, request: &Request<'_>) -> Result<Response, ApiError> {
let client = self.client().await?;
let mut attempt = 0;
loop {
attempt += 1;
let last = attempt >= MAX_ATTEMPTS;
let sent = std::time::Instant::now();
let result = match request {
Request::Get { path, etag } => {
let mut get = client.get(format!("{}{path}", self.http.base_uri));
if let Some(etag) = etag
&& let Ok(value) = HeaderValue::from_str(etag)
{
get = get.header(header::IF_NONE_MATCH, value);
}
get.send().await
}
Request::Post { path, body, .. } => {
let url = format!("{}{path}", self.http.base_uri);
client.post(url).json(body).send().await
}
};
let response = match result {
Ok(response) => response,
Err(err) if !last && (request.idempotent() || err.is_connect()) => {
tracing::debug!(%err, attempt, "request failed; retrying");
backoff(attempt).await;
continue;
}
Err(err) => return Err(ApiError::Network(err.to_string())),
};
let status = response.status();
self.rejected
.0
.store(status == StatusCode::UNAUTHORIZED, Ordering::Relaxed);
let headers = response.headers().clone();
self.limits
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.update(&headers);
if let Some(wait) = retry_after(status, &headers, crate::store::now()) {
if last || wait > MAX_INLINE_WAIT_SECS {
return Err(ApiError::RateLimited(wait));
}
tracing::info!(wait, "rate limited; waiting");
tokio::time::sleep(Duration::from_secs(wait.max(1))).await;
continue;
}
let timed_out = sent.elapsed() >= GITHUB_TIMEOUT;
if status.is_server_error() && !last && request.idempotent() && !timed_out {
tracing::debug!(%status, attempt, "server error; retrying");
backoff(attempt).await;
continue;
}
let body = response
.text()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
if let Some(recorder) = &self.recorder {
let (request, sent) = match request {
Request::Get { path, .. } => (format!("GET {path}"), None),
Request::Post { path, body, .. } => (format!("POST {path}"), Some(*body)),
};
let link = headers
.get(header::LINK)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let recorded = crate::api::corpus::Recorded {
request,
body: sent.cloned(),
status: status.as_u16(),
link,
response: body.clone(),
};
recorder.record(&recorded, sent);
}
return Ok(Response {
status,
headers,
body,
});
}
}
async fn graphql<Q: DeserializeOwned + cynic::QueryFragment + Unrooted, V: Serialize>(
&self,
op: cynic::Operation<Q, V>,
) -> Result<Q, ApiError> {
self.find(op, "", Some).await
}
async fn find<Q: DeserializeOwned + cynic::QueryFragment, V: Serialize, T>(
&self,
op: cynic::Operation<Q, V>,
subject: impl std::fmt::Display,
pick: impl FnOnce(Q) -> Option<T>,
) -> Result<T, ApiError> {
let reply = self.ask(&serde_json::to_value(&op)?, true).await?;
reply.picked(subject, pick, |errors| self.leave_out(errors))
}
pub(crate) async fn mutate<Q: DeserializeOwned, V: Serialize>(
&self,
op: cynic::Operation<Q, V>,
) -> Result<Q, ApiError> {
self.ask(&serde_json::to_value(&op)?, false)
.await?
.mutation()
}
pub async fn graphql_json<T: DeserializeOwned>(
&self,
query: &str,
variables: Value,
subject: impl std::fmt::Display,
at: &str,
) -> Result<T, ApiError> {
let body = serde_json::json!({ "query": query, "variables": variables });
let reply = self.ask(&body, true).await?;
reply.at(subject, at, |errors| self.leave_out(errors))
}
async fn ask(&self, body: &Value, idempotent: bool) -> Result<Reply, ApiError> {
#[derive(serde::Deserialize)]
struct Wire {
#[serde(default)]
data: Value,
errors: Option<Vec<GqlError>>,
}
let response = self
.send(&Request::Post {
path: "/graphql",
body,
idempotent,
})
.await?;
check_status(&response, "GitHub's GraphQL API")?;
let wire: Wire = serde_json::from_str(&response.body)?;
let errors = wire.errors.unwrap_or_default();
let missing_root = errors
.iter()
.any(|e| e.is_root() && e.explains_null(&wire.data));
if idempotent
&& missing_root
&& let Some(blocked) = self.blocked(body).await
{
return Err(blocked);
}
if wire.data.is_null() {
return Err(match messages(errors) {
errors if errors.is_empty() => ApiError::Decode("no data".into()),
errors => ApiError::GraphQl(errors),
});
}
let errors = errors
.into_iter()
.filter(|e| !(idempotent && e.explains_null(&wire.data)));
let errors = messages(errors);
Ok(Reply::new(wire.data, errors))
}
async fn blocked(&self, body: &Value) -> Option<ApiError> {
let vars = body.get("variables")?;
let (owner, name) = (vars.get("owner")?.as_str()?, vars.get("name")?.as_str()?);
let path = format!("/repos/{}/{}", encode_path(owner), encode_path(name));
let response = self
.send(&Request::Get {
path: &path,
etag: None,
})
.await;
let response = response.ok()?;
(response.status == StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS)
.then(|| check_status(&response, "").err())
.flatten()
}
pub async fn rest_get(
&self,
path: &str,
what: impl std::fmt::Display,
) -> Result<String, ApiError> {
let cached = {
let (store, key) = (self.store.clone(), path.to_owned());
tokio::task::spawn_blocking(move || store.http_get(&key))
.await
.unwrap_or_else(|err| {
tracing::warn!(%err, "cache read task failed");
None
})
};
let response = self
.send(&Request::Get {
path,
etag: cached.as_ref().map(|c| c.etag.as_str()),
})
.await?;
if response.status == StatusCode::NOT_MODIFIED
&& let Some(cached) = cached
{
return Ok(cached.body);
}
check_status(&response, what)?;
if let Some(etag) = response
.headers
.get(header::ETAG)
.and_then(|v| v.to_str().ok())
{
let entry = HttpEntry {
etag: etag.to_owned(),
body: response.body.clone(),
};
let store = self.store.clone();
let key = path.to_owned();
spawn_store(move || store.http_put(&key, &entry)).await;
}
Ok(response.body)
}
pub(crate) async fn rest_post(&self, path: &str) -> Result<(), ApiError> {
let body = serde_json::json!({});
let response = self
.send(&Request::Post {
path,
body: &body,
idempotent: false,
})
.await?;
let path = path.strip_prefix("/repos/").unwrap_or(path);
check_status(&response, path)
}
async fn rest_json<T: DeserializeOwned>(
&self,
path: &str,
what: impl std::fmt::Display,
) -> Result<T, ApiError> {
Ok(serde_json::from_str(&self.rest_get(path, what).await?)?)
}
async fn rest_cursor_page<T: DeserializeOwned>(
&self,
path: &str,
what: impl std::fmt::Display,
) -> Result<(T, Option<String>), ApiError> {
let response = self.send(&Request::Get { path, etag: None }).await?;
check_status(&response, what)?;
let next = response
.headers
.get(header::LINK)
.and_then(|v| v.to_str().ok())
.and_then(next_after);
Ok((serde_json::from_str(&response.body)?, next))
}
pub async fn viewer_login(&self) -> Result<String, ApiError> {
#[derive(serde::Deserialize)]
struct User {
login: String,
}
let user: User = self.rest_json("/user", "your account").await?;
Ok(user.login)
}
pub fn cached_pull_request(&self, pr: &PrRef) -> Option<Cached<PrDetail>> {
self.cached(&pr_key(pr))
}
pub async fn pull_request(&self, pr: &PrRef) -> Result<PrDetail, ApiError> {
let op = queries::PullRequestQuery::build(number_vars(pr)?);
let mut detail = self
.find(op, pr, |q| PrDetail::from_wire(q.repository?))
.await?;
if detail.state.is_open() {
detail.behind_by = self.behind_by(pr, &detail).await;
}
Ok(self.kept(&pr_key(pr), detail).await)
}
async fn behind_by(&self, pr: &PrRef, detail: &PrDetail) -> Option<u64> {
let op = queries::BehindQuery::build(queries::BehindVariables {
owner: pr.repo.owner.clone(),
name: pr.repo.name.clone(),
base: format!("refs/heads/{}", detail.base_ref),
head: detail.head_oid.clone(),
});
let behind = self.find(op, pr, |q| q.repository?.base?.compare).await;
match behind {
Ok(b) => u64::try_from(b.behind_by).ok(),
Err(err) => {
tracing::debug!(%pr, %err, "couldn't compare the branch with its base");
None
}
}
}
pub async fn viewed_files(&self, pr: &PrRef) -> Result<ViewedFiles, ApiError> {
let page = move |after| async move {
let op = queries::PrFilesQuery::build(page_vars(pr, after)?);
let files = self.find(op, pr, |q| q.repository?.pull_request).await?;
let states = files.files.map(|page| {
let states = nodes(page.nodes).map(|f| (f.path, f.viewer_viewed_state));
browse::Results::uncounted(states.collect(), page.page_info.next())
});
Ok::<_, ApiError>((files.id.into(), states.unwrap_or_default()))
};
let (pull_request_id, first) = page(None).await?;
let states = self.more_pages(
format!("{pr}'s files"),
first,
FILE_PAGES,
|after| async move { Ok(page(after).await?.1) },
);
Ok(ViewedFiles {
pull_request_id,
states: states.await?.items.into_iter().collect(),
})
}
pub async fn set_viewed(
&self,
pull_request_id: &NodeId,
path: &str,
viewed: bool,
) -> Result<(), ApiError> {
let vars = queries::ViewedVariables {
pull_request_id: pull_request_id.gql(),
path: path.to_owned(),
};
if viewed {
self.mutate(queries::MarkFileAsViewed::build(vars)).await?;
} else {
self.mutate(queries::UnmarkFileAsViewed::build(vars))
.await?;
}
Ok(())
}
pub async fn review_threads(&self, pr: &PrRef) -> Result<Vec<ReviewThread>, ApiError> {
let page = move |after| async move {
let op = queries::ThreadsQuery::build(page_vars(pr, after)?);
let page = self
.find(op, pr, |q| q.repository?.pull_request)
.await?
.review_threads;
let threads = nodes(page.nodes).map(ReviewThread::from_wire).collect();
Ok(browse::Results::uncounted(threads, page.page_info.next()))
};
let what = format!("{pr}'s review threads");
let threads = self.more_pages(what, page(None).await?, FILE_PAGES, page);
Ok(threads.await?.items)
}
pub async fn pr_patches(&self, pr: &PrRef) -> Result<Vec<PatchFile>, ApiError> {
let page = move |after: Option<String>| async move {
let page = rest_page(after.as_deref());
let path = format!(
"/repos/{}/{}/pulls/{}/files?per_page=100&page={page}",
pr.repo.owner, pr.repo.name, pr.number
);
let batch: Vec<PatchFile> = self.rest_json(&path, format!("{pr}'s files")).await?;
let next = (batch.len() == 100).then(|| page.saturating_add(1).to_string());
Ok(browse::Results::uncounted(batch, next))
};
let what = format!("{pr}'s patches");
let files = self.more_pages(what, page(None).await?, FILE_PAGES, page);
Ok(files.await?.items)
}
pub async fn pending_review(
&self,
pr: &PrRef,
) -> Result<(NodeId, Option<PendingReview>), ApiError> {
let op = queries::PendingReviewQuery::build(number_vars(pr)?);
let pr_node = self.find(op, pr, |q| q.repository?.pull_request).await?;
let review = nodes(pr_node.reviews.and_then(|r| r.nodes))
.next()
.map(|r| PendingReview {
id: r.id.into(),
commit: r.commit.map(|c| c.oid.0),
comments: u64::try_from(r.comments.total_count).unwrap_or(0),
});
Ok((pr_node.id.into(), review))
}
pub async fn last_review_commit(
&self,
pr: &PrRef,
login: &str,
) -> Result<Option<String>, ApiError> {
let op = queries::LastReviewQuery::build(queries::LastReviewVariables {
owner: pr.repo.owner.clone(),
name: pr.repo.name.clone(),
number: pr_number(pr)?,
login: login.to_owned(),
});
let reviews = self
.find(op, pr, |q| q.repository?.pull_request)
.await?
.reviews
.and_then(|r| r.nodes)
.unwrap_or_default();
Ok(reviews
.into_iter()
.flatten()
.rev()
.find(|r| r.state != crate::api::model::ReviewState::Pending)
.and_then(|r| r.commit)
.map(|c| c.oid.0))
}
pub async fn start_review(
&self,
pull_request_id: &NodeId,
commit: &str,
) -> Result<NodeId, ApiError> {
let op = queries::StartReview::build(queries::StartReviewVariables {
pull_request_id: pull_request_id.gql(),
commit: queries::GitObjectId(commit.to_owned()),
});
self.mutate(op)
.await?
.add_pull_request_review
.and_then(|p| p.pull_request_review)
.map(|r| NodeId::from(r.id))
.ok_or_else(|| ApiError::Decode("no review in response".into()))
}
pub async fn add_review_thread(
&self,
review_id: &NodeId,
thread: &NewThread,
) -> Result<NodeId, ApiError> {
let line = |n: Option<u32>| n.and_then(|n| i32::try_from(n).ok());
let input = queries::AddThreadInput {
pull_request_review_id: Some(review_id.gql()),
path: Some(thread.path.clone()),
body: thread.body.clone(),
line: line(thread.line),
side: thread.line.map(|_| thread.side),
start_line: line(thread.start_line),
start_side: thread.start_line.and(thread.start_side),
subject_type: Some(if thread.line.is_some() {
queries::ThreadSubjectType::Line
} else {
queries::ThreadSubjectType::File
}),
};
self.mutate(queries::AddThread::build(queries::AddThreadVariables {
input,
}))
.await?
.add_pull_request_review_thread
.and_then(|p| p.thread)
.map(|t| NodeId::from(t.id))
.ok_or_else(|| ApiError::Decode("no thread in response".into()))
}
pub async fn submit_review(
&self,
review_id: &NodeId,
event: ReviewEvent,
body: &str,
) -> Result<(), ApiError> {
let event = match event {
ReviewEvent::Comment => queries::ReviewEvent::Comment,
ReviewEvent::Approve => queries::ReviewEvent::Approve,
ReviewEvent::RequestChanges => queries::ReviewEvent::RequestChanges,
};
let op = queries::SubmitReview::build(queries::SubmitReviewVariables {
review_id: review_id.gql(),
event,
body: (!body.trim().is_empty()).then(|| body.to_owned()),
});
self.mutate(op).await.map(drop)
}
pub async fn set_resolved(&self, thread_id: &NodeId, resolved: bool) -> Result<(), ApiError> {
let vars = queries::ThreadIdVariables {
thread_id: thread_id.gql(),
};
if resolved {
self.mutate(queries::Resolve::build(vars)).await.map(drop)
} else {
self.mutate(queries::Unresolve::build(vars)).await.map(drop)
}
}
pub fn cached<T: DeserializeOwned>(&self, key: &str) -> Option<Cached<T>> {
self.store.query_get(key)
}
pub async fn repo(&self, repo: &RepoId) -> Result<browse::RepoOverview, ApiError> {
let op = browse::RepoQuery::build(browse::RepoVariables {
owner: repo.owner.clone(),
name: repo.name.clone(),
expression: "HEAD:".into(),
});
let overview = self
.find(op, repo, |q| q.repository?.into_overview())
.await?;
Ok(self.kept(&browse::keys::repo(repo), overview).await)
}
pub async fn readme(&self, repo: &RepoId) -> Result<Option<browse::Readme>, ApiError> {
use base64::Engine;
#[derive(serde::Deserialize)]
struct Wire {
path: String,
content: String,
}
let wire: Wire = match self
.rest_json(
&format!("/repos/{}/{}/readme", repo.owner, repo.name),
format!("{repo}'s README"),
)
.await
{
Ok(wire) => wire,
Err(ApiError::NotFound(_)) => {
return Ok(self.kept(&browse::keys::readme(repo), None).await);
}
Err(err) => return Err(err),
};
let compact: String = wire.content.split_whitespace().collect();
let bytes = base64::engine::general_purpose::STANDARD
.decode(compact)
.map_err(|e| ApiError::Decode(e.to_string()))?;
let readme = browse::Readme {
path: wire.path,
text: String::from_utf8_lossy(&bytes).into_owned(),
};
Ok(self.kept(&browse::keys::readme(repo), Some(readme)).await)
}
pub async fn tree(
&self,
repo: &RepoId,
rev: &str,
path: &str,
) -> Result<Vec<browse::TreeEntry>, ApiError> {
match self.object(repo, rev, path).await? {
browse::GitObject::Tree(t) => {
let key = browse::keys::tree(repo, rev, path);
Ok(self.kept(&key, browse::entries(t)).await)
}
_ => Err(ApiError::NotFound(format!("{repo}/{path}"))),
}
}
pub async fn blob(
&self,
repo: &RepoId,
rev: &str,
path: &str,
) -> Result<browse::Blob, ApiError> {
match self.object(repo, rev, path).await? {
browse::GitObject::Blob(b) => Ok(browse::Blob {
path: path.to_owned(),
text: b.text.filter(|_| b.is_binary != Some(true)),
size: crate::api::model::count(b.byte_size),
truncated: b.is_truncated,
}),
_ => Err(ApiError::NotFound(format!("{repo}/{path}"))),
}
}
async fn object(
&self,
repo: &RepoId,
rev: &str,
path: &str,
) -> Result<browse::GitObject, ApiError> {
let op = browse::ObjectQuery::build(browse::RepoVariables {
owner: repo.owner.clone(),
name: repo.name.clone(),
expression: format!("{rev}:{path}"),
});
self.find(op, format!("{repo}/{path}"), |q| q.repository?.object)
.await
}
pub async fn search(
&self,
kind: browse::SearchKind,
query: &str,
after: Option<String>,
) -> Result<browse::SearchResults, ApiError> {
self.search_showing(kind, query, after, |_| {}).await
}
pub async fn search_showing(
&self,
kind: browse::SearchKind,
query: &str,
after: Option<String>,
found: impl FnOnce(&browse::SearchResults) + Send,
) -> Result<browse::SearchResults, ApiError> {
use browse::{SearchKind as Kind, SearchType};
let first_page = after.is_none();
let (search_type, is) = match kind {
Kind::Repos => (SearchType::Repository, ""),
Kind::Issues => (SearchType::Issue, " is:issue"),
Kind::Pulls => (SearchType::Issue, " is:pr"),
Kind::Users => (SearchType::User, ""),
Kind::Discussions | Kind::Commits | Kind::Code => {
let results = self.search_other(kind, query, after).await?;
return Ok(self
.kept_if(first_page, &browse::keys::search(kind, query), results)
.await);
}
};
let api_query = if query.contains("is:issue") || query.contains("is:pr") {
query.to_owned()
} else {
format!("{query}{is}")
};
let mut results = self.search_as(kind, search_type, &api_query, after).await?;
if first_page
&& results.counts().0 == 0
&& kind != Kind::Users
&& let Some(refused) = self.refusal(kind, &api_query).await
{
return Err(refused);
}
let prs: Vec<_> = match &results {
browse::SearchResults::Issues(r) => (r.items.iter())
.filter_map(|i| match &i.kind {
browse::IssueKind::Pr { id, .. } => Some(id.clone()),
browse::IssueKind::Issue(_) => None,
})
.collect(),
_ => Vec::new(),
};
if !prs.is_empty() {
found(&results);
let found = self.checks_of(&prs).await;
if let browse::SearchResults::Issues(r) = &mut results {
for item in &mut r.items {
if let browse::IssueKind::Pr { id, checks, .. } = &mut item.kind {
*checks = checks.or_else(|| found.get(id.as_str()).copied());
}
}
}
}
Ok(self
.kept_if(first_page, &browse::keys::search(kind, query), results)
.await)
}
async fn checks_of(&self, ids: &[NodeId]) -> std::collections::HashMap<String, ChecksState> {
let ids = ids.iter().map(NodeId::gql).collect();
let op = queries::RollupsQuery::build(queries::NodesVariables { ids });
match self.graphql(op).await {
Ok(q) => (q.nodes.into_iter().flatten())
.filter_map(|node| match node {
queries::RollupNode::PullRequest(pr) => {
Some((pr.id.into_inner(), crate::api::model::checks(&pr.commits)?))
}
queries::RollupNode::Other => None,
})
.collect(),
Err(err) => {
tracing::warn!(%err, "couldn't read the found pull requests' checks");
std::collections::HashMap::new()
}
}
}
async fn refusal(&self, kind: browse::SearchKind, query: &str) -> Option<ApiError> {
let what = match kind {
browse::SearchKind::Repos => "repositories",
_ => "issues",
};
let path = format!("/search/{what}?q={}&per_page=1", encode_query(query));
match self.rest_get(&path, "the search").await {
Err(err @ ApiError::Http { status: 422, .. }) => Some(err),
Err(err) => {
tracing::debug!(%err, "couldn't ask REST why a search found nothing");
None
}
Ok(_) => None,
}
}
async fn search_other(
&self,
kind: browse::SearchKind,
query: &str,
after: Option<String>,
) -> Result<browse::SearchResults, ApiError> {
use browse::{Results, SearchKind as Kind, SearchResults};
if kind == Kind::Discussions {
let vars = serde_json::json!({ "q": query, "after": after });
let mut search: Value =
(self.graphql_json(raw::DISCUSSION_SEARCH, vars, query, "/search")).await?;
if let Some(serde_json::Value::Array(nodes)) = search.get_mut("nodes") {
nodes.retain(|n| n.get("number").is_some());
}
let page: browse::wire::Connection<browse::wire_discussions::Hit> =
serde_json::from_value(search)?;
return Ok(SearchResults::Discussions(
page.filter_results(browse::wire_discussions::Hit::into_hit),
));
}
if query.trim().is_empty() {
let empty = Results::default();
return Ok(if kind == Kind::Commits {
SearchResults::Commits(empty)
} else {
SearchResults::Code(Results::default())
});
}
let number = rest_page(after.as_deref());
let what = if kind == Kind::Commits {
"commits"
} else {
"code"
};
let path = format!(
"/search/{what}?q={}&per_page={REST_PAGE}&page={number}",
encode_query(query)
);
let next = |total: u64| next_page(number, REST_PAGE, total.min(SEARCH_CAP));
if kind == Kind::Commits {
let page: browse::rest_search::Page<browse::rest_search::Commit> =
self.rest_json(&path, "the search").await?;
self.note_incomplete(page.incomplete_results);
return Ok(SearchResults::Commits(Results {
next: next(page.total_count),
total: page.total_count,
items: page
.items
.into_iter()
.filter_map(browse::rest_search::Commit::into_hit)
.collect(),
}));
}
let page: browse::rest_search::Page<browse::rest_search::Code> =
self.rest_json(&path, "the search").await?;
self.note_incomplete(page.incomplete_results);
Ok(SearchResults::Code(Results {
next: next(page.total_count),
total: page.total_count,
items: page
.items
.into_iter()
.filter_map(browse::rest_search::Code::into_hit)
.collect(),
}))
}
async fn search_as(
&self,
kind: browse::SearchKind,
search_type: browse::SearchType,
query: &str,
after: Option<String>,
) -> Result<browse::SearchResults, ApiError> {
use browse::{BrowseItem, Results, SearchKind as Kind, SearchResults};
let op = browse::BrowseSearch::build(browse::BrowseSearchVariables {
query: query.to_owned(),
kind: search_type,
first: 30,
after,
});
let conn = self.graphql(op).await?.search;
let next = conn.page_info.next();
let items = nodes(conn.nodes);
let count = crate::api::model::count;
Ok(match kind {
Kind::Repos => SearchResults::Repos(Results {
total: count(conn.repository_count),
items: items
.filter_map(|i| match i {
BrowseItem::Repository(r) => r.into_summary(),
_ => None,
})
.collect(),
next,
}),
Kind::Issues | Kind::Pulls => SearchResults::Issues(Results {
total: count(conn.issue_count),
items: items.filter_map(BrowseItem::into_issue).collect(),
next,
}),
Kind::Users | Kind::Discussions | Kind::Commits | Kind::Code => {
SearchResults::Users(Results {
total: count(conn.user_count),
items: items.filter_map(BrowseItem::into_user).collect(),
next,
})
}
})
}
pub async fn issue(
&self,
repo: &RepoId,
number: u64,
) -> Result<Option<browse::IssueDetail>, ApiError> {
let op = browse::IssueQuery::build(queries::NumberVariables {
owner: repo.owner.clone(),
name: repo.name.clone(),
number: i32::try_from(number)
.map_err(|_| ApiError::NotFound(format!("{repo}#{number}")))?,
});
let pick = |q: browse::IssueQuery| match q.repository?.issue_or_pull_request? {
browse::IssueOrPr::Issue(issue) => issue.into_detail().map(Some),
browse::IssueOrPr::PullRequest(_) => Some(None),
browse::IssueOrPr::Other => None,
};
let Some(issue) = self.find(op, format!("{repo}#{number}"), pick).await? else {
return Ok(None);
};
Ok(Some(
self.kept(&browse::keys::issue(repo, number), issue).await,
))
}
pub async fn pr_activity(&self, pr: &PrRef) -> Result<browse::PrActivity, ApiError> {
let op = browse::PrActivityQuery::build(number_vars(pr)?);
let activity = self
.find(op, pr, |q| {
Some(q.repository?.pull_request?.into_activity())
})
.await?;
Ok(self.kept(&browse::keys::pr_activity(pr), activity).await)
}
pub async fn profile(&self, login: &str) -> Result<browse::Profile, ApiError> {
let op = browse::ProfileQuery::build(browse::ProfileVariables {
login: login.to_owned(),
});
let profile = self
.find(op, login, browse::ProfileQuery::into_profile)
.await?;
Ok(self.kept(&browse::keys::profile(login), profile).await)
}
pub async fn file_list(
&self,
repo: &RepoId,
rev: &str,
) -> Result<(Vec<String>, bool), ApiError> {
#[derive(serde::Deserialize)]
struct Entry {
path: String,
#[serde(rename = "type")]
kind: String,
}
#[derive(serde::Deserialize)]
struct Wire {
tree: Vec<Entry>,
#[serde(default)]
truncated: bool,
}
let wire: Wire = self
.rest_json(
&format!(
"/repos/{}/{}/git/trees/{}?recursive=1",
repo.owner,
repo.name,
encode_path(rev)
),
format!("{repo}@{rev}"),
)
.await?;
let files: Vec<String> = wire
.tree
.into_iter()
.filter(|e| e.kind == "blob")
.map(|e| e.path)
.collect();
Ok((files, wire.truncated))
}
pub async fn last_commits(
&self,
repo: &RepoId,
rev: &str,
dir: &str,
names: &[String],
) -> Result<std::collections::HashMap<String, browse::CommitInfo>, ApiError> {
let names = names.get(..browse::LAST_COMMIT_ENTRIES).unwrap_or(names);
let mut out = std::collections::HashMap::new();
for names in names.chunks(100) {
self.last_commits_of(repo, rev, dir, names, &mut out)
.await?;
}
Ok(self
.kept(&browse::keys::last_commits(repo, rev, dir), out)
.await)
}
async fn last_commits_of(
&self,
repo: &RepoId,
rev: &str,
dir: &str,
names: &[String],
out: &mut std::collections::HashMap<String, browse::CommitInfo>,
) -> Result<(), ApiError> {
let paths: Vec<String> = names
.iter()
.map(|name| {
if dir.is_empty() {
name.clone()
} else {
format!("{dir}/{name}")
}
})
.collect();
let query = raw::last_commits(&paths);
let vars = serde_json::json!({ "owner": repo.owner, "name": repo.name, "rev": rev });
let commit: Value =
(self.graphql_json(&query, vars, format!("{repo}@{rev}"), "/repository/object"))
.await?;
for (i, name) in names.iter().enumerate() {
let node = commit.pointer(&format!("/e{i}/nodes/0"));
let Some(node) = node.filter(|n| !n.is_null()) else {
continue;
};
match <browse::wire::Commit as serde::Deserialize>::deserialize(node) {
Ok(commit) => {
out.insert(name.clone(), commit.into_info());
}
Err(err) => tracing::warn!(%err, %name, "a last commit didn't decode"),
}
}
Ok(())
}
pub async fn commit(&self, repo: &RepoId, rev: &str) -> Result<browse::CommitDetail, ApiError> {
let op = browse::CommitQuery::build(browse::RepoVariables {
owner: repo.owner.clone(),
name: repo.name.clone(),
expression: rev.to_owned(),
});
let pick = |q: browse::CommitQuery| match q.repository?.object? {
browse::CommitObject::Commit(c) => Some(c.into_detail()),
browse::CommitObject::Other => None,
};
let commit = self.find(op, format!("{repo}@{rev}"), pick).await?;
Ok(self.kept(&browse::keys::commit(repo, rev), commit).await)
}
pub async fn history(
&self,
repo: &RepoId,
rev: &str,
path: &str,
after: Option<String>,
) -> Result<browse::Results<browse::CommitInfo>, ApiError> {
let first = after.is_none();
let op = browse::HistoryQuery::build(browse::HistoryVariables {
owner: repo.owner.clone(),
name: repo.name.clone(),
expression: rev.to_owned(),
path: (!path.is_empty()).then(|| path.to_owned()),
after,
});
let pick = |q: browse::HistoryQuery| match q.repository?.object? {
browse::HistoryObject::Commit(c) => Some(c.history.into_results()),
browse::HistoryObject::Other => None,
};
let history = self.find(op, format!("{repo}@{rev}"), pick).await?;
Ok(self
.kept_page(first, &browse::keys::history(repo, rev, path), history)
.await)
}
pub async fn pr_checks(&self, pr: &PrRef) -> Result<browse::Checks, ApiError> {
let op = browse::PrChecksQuery::build(number_vars(pr)?);
let pr_checks = self.find(op, pr, |q| q.repository?.pull_request).await?;
let want = pr_checks.head_ref_oid.0;
let head = nodes(pr_checks.commits.nodes)
.next()
.ok_or_else(|| ApiError::NotFound(pr.to_string()))?;
let checks = self.all_checks(&pr.repo, head.commit).await?;
if checks.oid != want {
self.doubt(format!(
"{pr}'s checks are for {}, but its head is {}",
text_short(&checks.oid),
text_short(&want)
));
}
Ok(self.kept(&browse::keys::pr_checks(pr), checks).await)
}
async fn all_checks(
&self,
repo: &RepoId,
commit: browse::ChecksCommit,
) -> Result<browse::Checks, ApiError> {
use cynic::QueryBuilder as _;
let oid = commit.oid.0;
let first = commit
.status_check_rollup
.map(|r| r.contexts.into_results())
.unwrap_or_default();
let what = format!("{}'s checks", text_short(&oid));
let expression = &oid;
let page = move |after| async move {
let op = browse::ContextsQuery::build(browse::ContextsVariables {
owner: repo.owner.clone(),
name: repo.name.clone(),
expression: expression.clone(),
after,
});
let pick = |q: browse::ContextsQuery| match q.repository?.object? {
browse::ContextsTarget::Commit(c) => Some(c.status_check_rollup),
browse::ContextsTarget::Other => None,
};
let rollup = self.find(op, repo, pick).await?;
Ok(rollup
.map(|r| r.contexts.into_results())
.unwrap_or_default())
};
let contexts = self.more_pages(what, first, PAGES, page).await?;
Ok(browse::checks(oid, contexts))
}
pub async fn branch_checks(&self, repo: &RepoId) -> Result<browse::Checks, ApiError> {
let op = browse::BranchChecksQuery::build(browse::BranchesVariables {
owner: repo.owner.clone(),
name: repo.name.clone(),
});
let pick = |q: browse::BranchChecksQuery| match q.repository?.default_branch_ref?.target? {
browse::ChecksTarget::Commit(commit) => Some(commit),
browse::ChecksTarget::Other => None,
};
let commit = self.find(op, repo, pick).await?;
let checks = self.all_checks(repo, commit).await?;
Ok(self.kept(&browse::keys::branch_checks(repo), checks).await)
}
pub async fn users(
&self,
list: &browse::UserList,
after: Option<String>,
) -> Result<browse::Results<browse::UserSummary>, ApiError> {
use browse::UserList as L;
let first = after.is_none();
let (root, field, vars, who) = match list {
L::Stargazers(repo) | L::Watchers(repo) => (
"repository(owner: $owner, name: $name)",
if matches!(list, L::Stargazers(_)) {
"stargazers"
} else {
"watchers"
},
serde_json::json!({ "owner": repo.owner, "name": repo.name, "after": after }),
repo.to_string(),
),
L::Followers(login) | L::Following(login) => (
"user(login: $login)",
if matches!(list, L::Followers(_)) {
"followers"
} else {
"following"
},
serde_json::json!({ "login": login, "after": after }),
login.clone(),
),
L::People(login) => (
"organization(login: $login)",
"membersWithRole",
serde_json::json!({ "login": login, "after": after }),
login.clone(),
),
};
let query = raw::users(root, field);
let wire: browse::wire::Connection<browse::wire::Person> =
self.graphql_json(&query, vars, &who, "/node/list").await?;
let people = field.trim_end_matches("WithRole");
self.check_connection(&wire, 30, format!("{who}'s {people}"));
let results = wire.into_results(browse::wire::Person::into_summary);
Ok(self
.kept_page(first, &browse::keys::users(list), results)
.await)
}
pub async fn owner_repos(
&self,
login: &str,
sort: browse::RepoSort,
after: Option<String>,
) -> Result<browse::Results<browse::RepoSummary>, ApiError> {
let first = after.is_none();
let op = browse::OwnerReposQuery::build(browse::OwnerReposVariables {
login: login.to_owned(),
after,
order: sort.into(),
});
let repos = self
.find(op, login, |q| q.repository_owner)
.await?
.repositories
.into_results();
Ok(self
.kept_page(first, &browse::keys::owner_repos(login, sort), repos)
.await)
}
pub async fn starred(
&self,
login: &str,
after: Option<String>,
) -> Result<browse::Results<browse::RepoSummary>, ApiError> {
let first = after.is_none();
let op = browse::StarredQuery::build(browse::LoginPageVariables {
login: login.to_owned(),
after,
});
let stars = self
.find(op, login, |q| q.user)
.await?
.starred_repositories
.into_results();
Ok(self
.kept_page(first, &browse::keys::starred(login), stars)
.await)
}
pub async fn forks(
&self,
repo: &RepoId,
after: Option<String>,
) -> Result<browse::Results<browse::RepoSummary>, ApiError> {
let first = after.is_none();
let op = browse::ForksQuery::build(list_vars(repo, after));
let forks = self
.find(op, repo, |q| q.repository)
.await?
.forks
.into_results();
Ok(self
.kept_page(first, &browse::keys::forks(repo), forks)
.await)
}
pub async fn releases(
&self,
repo: &RepoId,
after: Option<String>,
) -> Result<browse::Results<browse::Release>, ApiError> {
let first = after.is_none();
let op = browse::ReleasesQuery::build(list_vars(repo, after));
let releases = self
.find(op, repo, |q| q.repository)
.await?
.releases
.into_results();
Ok(self
.kept_page(first, &browse::keys::releases(repo), releases)
.await)
}
pub async fn release(&self, repo: &RepoId, tag: &str) -> Result<browse::Release, ApiError> {
let op = browse::ReleaseQuery::build(browse::ReleaseVariables {
owner: repo.owner.clone(),
name: repo.name.clone(),
tag: tag.to_owned(),
});
let release = self
.find(op, format!("{repo} release {tag}"), |q| {
q.repository?.release
})
.await?
.into_release();
Ok(self.kept(&browse::keys::release(repo, tag), release).await)
}
pub async fn tags(
&self,
repo: &RepoId,
after: Option<String>,
) -> Result<browse::Results<browse::TagInfo>, ApiError> {
let first = after.is_none();
let op = browse::TagsQuery::build(list_vars(repo, after));
let tags = self
.find(op, repo, |q| q.repository?.refs)
.await?
.into_results();
Ok(self.kept_page(first, &browse::keys::tags(repo), tags).await)
}
pub async fn branches(
&self,
repo: &RepoId,
after: Option<String>,
) -> Result<browse::Results<browse::BranchInfo>, ApiError> {
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Wire {
default_branch_ref: Option<browse::wire::Name>,
refs: browse::wire::Connection<browse::wire_branches::Branch>,
}
let first = after.is_none();
let vars = serde_json::json!({ "owner": repo.owner, "name": repo.name, "after": after });
let wire: Wire = self
.graphql_json(raw::BRANCHES, vars, repo, "/repository")
.await?;
self.check_connection(&wire.refs, 30, format!("{repo}'s branches"));
let default = wire.default_branch_ref.map(|r| r.name);
let branches = (wire.refs).into_results(|b| b.into_info(repo, default.as_deref()));
Ok(self
.kept_page(first, &browse::keys::branches(repo), branches)
.await)
}
pub async fn milestones(
&self,
repo: &RepoId,
closed: bool,
after: Option<String>,
) -> Result<browse::MilestoneList, ApiError> {
use browse::wire::{Connection, Count};
#[derive(serde::Deserialize)]
struct Wire {
open: Count,
closed: Count,
milestones: Connection<browse::wire_milestones::Milestone>,
}
let first = after.is_none();
let query = raw::milestones(closed);
let vars = serde_json::json!({ "owner": repo.owner, "name": repo.name, "after": after });
let wire: Wire = self.graphql_json(&query, vars, repo, "/repository").await?;
self.check_connection(&wire.milestones, 25, format!("{repo}'s milestones"));
let list = browse::MilestoneList {
open: wire.open.total_count,
closed: wire.closed.total_count,
results: (wire.milestones).into_results(browse::wire_milestones::Milestone::into_info),
};
Ok(self
.kept_page(first, &browse::keys::milestones(repo, closed), list)
.await)
}
pub async fn milestone(
&self,
repo: &RepoId,
number: u64,
after: Option<String>,
) -> Result<browse::MilestoneDetail, ApiError> {
let first = after.is_none();
let query = raw::milestone();
let vars = serde_json::json!({ "owner": repo.owner, "name": repo.name, "number": number });
let subject = format!("{repo} milestone {number}");
let wire: browse::wire_milestones::Milestone =
(self.graphql_json(&query, vars, subject, "/repository/milestone")).await?;
let info = wire.into_info();
let unsearchable = info.title.contains('"');
let items = if unsearchable {
browse::Results::default()
} else {
let search = format!("repo:{repo} milestone:\"{}\" sort:updated-desc", info.title);
match self
.search_as(
browse::SearchKind::Issues,
browse::SearchType::Issue,
&search,
after,
)
.await?
{
browse::SearchResults::Issues(items) => items,
_ => browse::Results::default(),
}
};
let detail = browse::MilestoneDetail {
info,
items,
unsearchable,
};
Ok(self
.kept_page(first, &browse::keys::milestone(repo, number), detail)
.await)
}
pub async fn deployments(
&self,
repo: &RepoId,
environment: Option<&str>,
after: Option<String>,
) -> Result<browse::DeploymentList, ApiError> {
use browse::wire::{Connection, Counted, Name};
#[derive(serde::Deserialize)]
struct Wire {
deployments: Connection<browse::wire_deployments::Deployment>,
environments: Counted<Name>,
}
let first = after.is_none();
let environments = environment.map(|e| vec![e]);
let vars = serde_json::json!({ "owner": repo.owner, "name": repo.name, "after": after, "envs": environments });
let wire: Wire = self
.graphql_json(raw::DEPLOYMENTS, vars, repo, "/repository")
.await?;
self.check_connection(&wire.deployments, 25, format!("{repo}'s deployments"));
let list = browse::DeploymentList {
environments: wire.environments.into_capped(|n| Some(n.name)),
results: (wire.deployments)
.into_results(browse::wire_deployments::Deployment::into_info),
};
Ok(self
.kept_page(first, &browse::keys::deployments(repo, environment), list)
.await)
}
pub async fn compare(&self, repo: &RepoId, spec: &str) -> Result<browse::Comparison, ApiError> {
let (base, head, direct) = match spec.split_once("...") {
Some((base, head)) => (base, head, false),
None => match spec.split_once("..") {
Some((base, head)) => (base, head, true),
None => ("HEAD", spec, false),
},
};
let path = format!(
"/repos/{}/{}/compare/{}...{}",
repo.owner,
repo.name,
encode_path(base),
encode_path(head)
);
let what = format!("{repo}'s comparison {spec}");
let wire: browse::rest_compare::Compare = self.rest_json(&path, what).await?;
let head = wire
.permalink_url
.rsplit_once("...")
.and_then(|(_, head)| head.rsplit(':').next())
.filter(|head| !head.is_empty())
.map(str::to_owned);
let (listed, total) = (wire.commits.len() as u64, wire.total_commits);
let comparison = wire.into_comparison(direct);
if let Some(head) = head
&& !comparison.to.starts_with(&head)
{
self.doubt(format!(
"comparing {spec}, the newest commit listed is {}, but the head is {head}",
text_short(&comparison.to)
));
}
let expected = total.min(browse::COMPARE_COMMITS);
if listed != expected {
self.doubt(format!(
"comparing {spec}, {listed} commits are listed of {total}, not {expected}"
));
}
Ok(self
.kept(&browse::keys::compare(repo, spec), comparison)
.await)
}
pub async fn blame(
&self,
repo: &RepoId,
rev: &str,
path: &str,
) -> Result<browse::Blame, ApiError> {
let vars =
serde_json::json!({ "owner": repo.owner, "name": repo.name, "rev": rev, "path": path });
let subject = format!("{repo}:{rev}:{path}");
let wire: browse::wire_blame::Blame =
(self.graphql_json(raw::BLAME, vars, subject, "/repository/object/blame")).await?;
let blame = wire.into_blame();
Ok(self
.kept(&browse::keys::blame(repo, rev, path), blame)
.await)
}
pub async fn gist(&self, id: &str) -> Result<browse::Gist, ApiError> {
let wire: browse::rest_gists::Gist = self
.rest_json(&format!("/gists/{}", encode_path(id)), format!("gist {id}"))
.await?;
let gist = wire.into_gist();
Ok(self.kept(&browse::keys::gist(id), gist).await)
}
pub async fn gists(
&self,
login: &str,
after: Option<String>,
) -> Result<browse::Results<browse::GistSummary>, ApiError> {
let first = after.is_none();
let vars = serde_json::json!({ "login": login, "after": after });
let wire: browse::wire::Connection<browse::wire_gists::Gist> =
(self.graphql_json(raw::GISTS, vars, format!("{login}'s gists"), "/user/gists"))
.await?;
self.check_connection(&wire, 30, format!("{login}'s gists"));
let gists = wire.into_results(browse::wire_gists::Gist::into_summary);
Ok(self
.kept_page(first, &browse::keys::gists(login), gists)
.await)
}
pub async fn teams(
&self,
org: &str,
after: Option<String>,
) -> Result<browse::Results<browse::TeamSummary>, ApiError> {
let first = after.is_none();
let vars = serde_json::json!({ "org": org, "after": after });
let wire: browse::wire::Connection<browse::wire_teams::Team> =
(self.graphql_json(&raw::teams(), vars, org, "/organization/teams")).await?;
self.check_connection(&wire, 30, format!("{org}'s teams"));
let teams = wire.into_results(browse::wire_teams::Team::into_summary);
Ok(self
.kept_page(first, &browse::keys::teams(org), teams)
.await)
}
pub async fn team(&self, org: &str, slug: &str) -> Result<browse::TeamDetail, ApiError> {
let vars = serde_json::json!({ "org": org, "slug": slug });
let wire: browse::wire_teams::Detail = (self.graphql_json(
&raw::team(),
vars,
format!("{org}/{slug}"),
"/organization/team",
))
.await?;
let team = wire.into_detail();
Ok(self.kept(&browse::keys::team(org, slug), team).await)
}
pub async fn advisories(
&self,
repo: Option<&RepoId>,
after: Option<String>,
) -> Result<browse::Results<browse::Advisory>, ApiError> {
let first = after.is_none();
let mut path = match repo {
Some(r) => format!(
"/repos/{}/{}/security-advisories?state=published&per_page={REST_PAGE}",
r.owner, r.name
),
None => format!("/advisories?type=reviewed&per_page={REST_PAGE}"),
};
if let Some(after) = &after {
path.push_str(&format!("&after={}", encode_query(after)));
}
let (wire, next): (Vec<browse::rest_advisories::Advisory>, _) =
self.rest_cursor_page(&path, "the advisories").await?;
let items: Vec<browse::Advisory> = wire
.into_iter()
.map(browse::rest_advisories::Advisory::into_advisory)
.collect();
let list = browse::Results {
total: items.len() as u64,
items,
next,
};
Ok(self
.kept_page(first, &browse::keys::advisories(repo), list)
.await)
}
pub async fn advisory(
&self,
repo: Option<&RepoId>,
ghsa: &str,
) -> Result<browse::Advisory, ApiError> {
let id = encode_path(ghsa);
let path = match repo {
Some(r) => format!("/repos/{}/{}/security-advisories/{id}", r.owner, r.name),
None => format!("/advisories/{id}"),
};
let wire: browse::rest_advisories::Advisory = self.rest_json(&path, ghsa).await?;
let advisory = wire.into_advisory();
Ok(self
.kept(&browse::keys::advisory(repo, ghsa), advisory)
.await)
}
pub async fn workflow_run(
&self,
repo: &RepoId,
run: u64,
attempt: Option<u64>,
) -> Result<browse::WorkflowRun, ApiError> {
let base = format!("/repos/{}/{}/actions/runs/{run}", repo.owner, repo.name);
let path = match attempt {
Some(n) => format!("{base}/attempts/{n}"),
None => base,
};
let jobs_path = &format!("{path}/jobs?per_page=100");
let (asked, jobs_asked) = (format!("{repo}'s run {run}"), format!("run {run}'s jobs"));
let (wire, jobs) = tokio::join!(
self.rest_json::<browse::rest_actions::Run>(&path, &asked),
self.rest_json::<browse::rest_actions::Jobs>(jobs_path, &jobs_asked)
);
let results = |page: u64, jobs: browse::rest_actions::Jobs| browse::Results {
next: next_page(page, 100, jobs.total_count).filter(|_| !jobs.jobs.is_empty()),
total: jobs.total_count,
items: jobs.jobs,
};
let page = move |after: Option<String>| async move {
let page = rest_page(after.as_deref());
let jobs_page = format!("{jobs_path}&page={page}");
let jobs = self
.rest_json(&jobs_page, format!("run {run}'s jobs"))
.await?;
Ok(results(page, jobs))
};
let what = format!("run {run}'s jobs");
let jobs = self
.more_pages(what, results(1, jobs?), PAGES, page)
.await?;
let run = wire?.into_run(jobs);
Ok(self
.kept(&browse::keys::run(repo, run.id, attempt), run)
.await)
}
pub async fn job(&self, repo: &RepoId, job: u64) -> Result<browse::Job, ApiError> {
let wire = self.job_wire(repo, job).await?;
let base = format!(
"/repos/{}/{}/actions/runs/{}",
repo.owner, repo.name, wire.run_id
);
let run: browse::rest_actions::Run = self
.rest_json(&base, format!("{repo}'s run {}", wire.run_id))
.await?;
let (attempt, latest) = (wire.run_attempt.unwrap_or(1), run.run_attempt.unwrap_or(1));
let rerun = if latest > attempt {
let path = format!("{base}/attempts/{latest}/jobs?per_page=100");
let jobs: browse::rest_actions::Jobs = self
.rest_json(&path, format!("{repo}'s run {}'s jobs", wire.run_id))
.await?;
(jobs.jobs.into_iter())
.find(|j| j.name == wire.name && j.id != wire.id)
.map(|j| j.id)
} else {
None
};
let job = wire.into_job(run.into_job_run(rerun));
Ok(self.kept(&browse::keys::job(repo, job.id), job).await)
}
async fn job_wire(
&self,
repo: &RepoId,
job: u64,
) -> Result<browse::rest_actions::Job, ApiError> {
let path = format!("/repos/{}/{}/actions/jobs/{job}", repo.owner, repo.name);
self.rest_json(&path, format!("{repo}'s job {job}")).await
}
pub async fn job_log(&self, repo: &RepoId, job: u64) -> Result<browse::JobLog, ApiError> {
if self.job_wire(repo, job).await?.outcome() == browse::CheckOutcome::Pending {
return Ok(browse::JobLog {
running: true,
..browse::JobLog::default()
});
}
let path = format!(
"/repos/{}/{}/actions/jobs/{job}/logs",
repo.owner, repo.name
);
let response = self
.send(&Request::Get {
path: &path,
etag: None,
})
.await?;
if response.status == StatusCode::GONE {
return Err(ApiError::Gone(
"this log has expired (GitHub keeps logs for the repository's retention period, 90 days by default)".into(),
));
}
check_status(&response, format!("{repo}'s job {job}'s log"))?;
let (cut, text) = browse::log::cut(&response.body);
Ok(browse::JobLog {
text: text.to_owned(),
cut,
running: false,
})
}
pub async fn workflow(&self, repo: &RepoId, file: &str) -> Result<browse::Workflow, ApiError> {
let path = format!(
"/repos/{}/{}/actions/workflows/{}",
repo.owner,
repo.name,
encode_path(file)
);
let what = format!("{repo}'s workflow {file}");
let wire: browse::rest_actions::Workflow = self.rest_json(&path, what).await?;
let workflow = browse::Workflow {
name: wire.name,
path: wire.path,
disabled: Some(wire.state)
.filter(|s| s != "active")
.map(|s| s.replace('_', " ")),
};
Ok(self
.kept(&browse::keys::workflow(repo, file), workflow)
.await)
}
pub async fn workflow_runs(
&self,
repo: &RepoId,
file: &str,
after: Option<String>,
) -> Result<browse::Results<browse::RunSummary>, ApiError> {
let page = rest_page(after.as_deref());
let path = format!(
"/repos/{}/{}/actions/workflows/{}/runs?per_page={REST_PAGE}&page={page}",
repo.owner,
repo.name,
encode_path(file)
);
let what = format!("{repo}'s workflow {file}");
let wire: browse::rest_actions::Runs = self.rest_json(&path, what).await?;
let runs = browse::Results {
total: wire.total_count,
items: wire
.workflow_runs
.into_iter()
.map(browse::rest_actions::Run::into_summary)
.collect(),
next: next_page(page, REST_PAGE, wire.total_count),
};
Ok(self
.kept_page(page == 1, &browse::keys::workflow_runs(repo, file), runs)
.await)
}
pub async fn commit_checks(
&self,
repo: &RepoId,
rev: &str,
) -> Result<browse::Checks, ApiError> {
let op = browse::CommitChecksQuery::build(browse::RepoVariables {
owner: repo.owner.clone(),
name: repo.name.clone(),
expression: rev.to_owned(),
});
let pick = |q: browse::CommitChecksQuery| match q.repository?.object? {
browse::ChecksTarget::Commit(commit) => Some(commit),
browse::ChecksTarget::Other => None,
};
let commit = self.find(op, format!("{repo}@{rev}"), pick).await?;
let checks = self.all_checks(repo, commit).await?;
if rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()) && checks.oid != rev {
self.doubt(format!(
"the checks asked for {} are for {}",
text_short(rev),
text_short(&checks.oid)
));
}
Ok(self
.kept(&browse::keys::commit_checks(repo, rev), checks)
.await)
}
async fn discussions_repo(&self, of: &browse::DiscussionsOf) -> Result<RepoId, ApiError> {
let org = match of {
browse::DiscussionsOf::Repo(repo) => return Ok(repo.clone()),
browse::DiscussionsOf::Org(org) => org,
};
let prefix = &format!(
"https://github.com/orgs/{}/discussions/",
org.to_lowercase()
);
let page = move |after| async move {
let vars = serde_json::json!({ "q": format!("org:{org}"), "after": after });
let subject = format!("{org}'s discussions");
let search: browse::wire::Connection<browse::wire_discussions::Found> =
(self.graphql_json(raw::DISCUSSION_URLS, vars, subject, "/search")).await?;
let mut found = search.filter_results(|n| {
(n.url.to_lowercase().starts_with(prefix))
.then(|| RepoId::parse(&n.repository?.name_with_owner))?
});
found.next = found.next.filter(|_| found.items.is_empty());
Ok(found)
};
let what = format!("the search for {org}'s discussions");
let found = self.more_pages(what, page(None).await?, PAGES, page);
(found.await?.items.into_iter().next())
.ok_or_else(|| ApiError::NotFound(format!("{org}'s discussions")))
}
pub async fn discussions(
&self,
of: &browse::DiscussionsOf,
category: Option<&str>,
after: Option<String>,
) -> Result<browse::DiscussionList, ApiError> {
use browse::wire_discussions as w;
let first = after.is_none();
let repo = self.discussions_repo(of).await?;
let vars = serde_json::json!({ "owner": repo.owner, "name": repo.name });
let at = "/repository/discussionCategories";
let categories: browse::wire::Counted<w::Category> =
(self.graphql_json(raw::DISCUSSION_CATEGORIES, vars, &repo, at)).await?;
let categories = categories.into_capped(Some);
let category_id = match category {
Some(slug) => match categories.iter().find(|c| c.slug == slug) {
Some(c) => Some(c.id.clone()),
None if categories.left_out() > 0 => {
return Err(ApiError::NotFound(format!(
"{repo}'s category {slug} (or it's past the first {} of {})",
categories.len(),
categories.total()
)));
}
None => return Err(ApiError::NotFound(format!("{repo}'s category {slug}"))),
},
None => None,
};
let vars = serde_json::json!({ "owner": repo.owner, "name": repo.name, "after": after, "category": category_id });
let page: browse::wire::Connection<w::Summary> =
(self.graphql_json(raw::DISCUSSIONS, vars, &repo, "/repository/discussions")).await?;
self.check_connection(&page, 25, format!("{repo}'s discussions"));
let list = browse::DiscussionList {
categories: categories.map(|c| browse::DiscussionCategory {
name: c.name,
slug: c.slug,
}),
results: page.into_results(w::Summary::into_summary),
};
Ok(self
.kept_page(first, &browse::keys::discussions(of, category), list)
.await)
}
pub async fn discussion(
&self,
of: &browse::DiscussionsOf,
number: u64,
) -> Result<browse::DiscussionDetail, ApiError> {
let repo = self.discussions_repo(of).await?;
let vars = serde_json::json!({ "owner": repo.owner, "name": repo.name, "number": number });
let subject = format!("{repo} discussion {number}");
let wire: browse::wire_discussions::Detail =
(self.graphql_json(raw::DISCUSSION, vars, subject, "/repository/discussion")).await?;
let detail = wire.into_detail(repo);
Ok(self
.kept(&browse::keys::discussion(of, number), detail)
.await)
}
pub async fn refs(&self, repo: &RepoId) -> Result<browse::Refs, ApiError> {
use browse::wire::{Connection, Name};
#[derive(serde::Deserialize)]
struct Wire {
heads: Connection<Name>,
tags: Connection<Name>,
}
let what = &format!("{repo}'s branches");
let vars = |after: &Option<String>| serde_json::json!({ "owner": repo.owner, "name": repo.name, "after": after, "tags": after.is_none() });
let heads = |heads: Connection<Name>| {
self.check_connection(&heads, 100, what);
heads.into_results(|n| n.name)
};
let first: Wire = self
.graphql_json(raw::REFS, vars(&None), repo, "/repository")
.await?;
self.check_connection(&first.tags, 100, format!("{repo}'s tags"));
let tags = first.tags.into_results(|n| n.name);
let tags = Capped::new(tags.items, tags.total);
let more = |after| async move {
let page = self.graphql_json(raw::REFS, vars(&after), repo, "/repository/heads");
Ok(heads(page.await?))
};
let branches = self
.more_pages(what, heads(first.heads), PAGES, more)
.await?;
let refs = browse::Refs { branches, tags };
Ok(self.kept(&browse::keys::refs(repo), refs).await)
}
pub async fn ref_in(&self, repo: &RepoId, spot: &str) -> Result<Option<String>, ApiError> {
let segments: Vec<&str> = spot.split('/').collect();
let names: Vec<String> = (2..=segments.len().min(REF_DEPTH))
.filter_map(|n| segments.get(..n).map(|s| s.join("/")))
.collect();
if names.is_empty() {
return Ok(None);
}
let mut vars = serde_json::Map::new();
vars.insert("owner".into(), Value::from(repo.owner.as_str()));
vars.insert("name".into(), Value::from(repo.name.as_str()));
for (i, name) in names.iter().enumerate() {
vars.insert(format!("r{i}"), Value::from(name.as_str()));
}
let found: std::collections::HashMap<String, Option<browse::wire::Name>> = self
.graphql_json(
&raw::refs_named(names.len()),
vars.into(),
repo,
"/repository",
)
.await?;
Ok(names
.iter()
.enumerate()
.rev()
.find(|(i, _)| found.get(&format!("r{i}")).is_some_and(Option::is_some))
.map(|(_, name)| name.clone()))
}
pub async fn remember<T: Serialize + Send + 'static>(&self, key: &str, value: T) {
let store = self.store.clone();
let key = key.to_owned();
spawn_store(move || store.query_put(&key, &value)).await;
}
async fn kept_page<T: Serialize + Clone + Send + browse::Page + 'static>(
&self,
first: bool,
key: &str,
value: T,
) -> T {
self.check_page(&value, key);
self.kept_if(first, key, value).await
}
async fn kept_if<T: Serialize + Clone + Send + 'static>(
&self,
first: bool,
key: &str,
value: T,
) -> T {
if first {
self.kept(key, value).await
} else {
value
}
}
async fn kept<T: Serialize + Clone + Send + 'static>(&self, key: &str, value: T) -> T {
self.remember(key, value.clone()).await;
value
}
}
trait Unrooted {}
impl Unrooted for queries::RollupsQuery {}
impl Unrooted for browse::BrowseSearch {}
use reply::Reply;
mod reply {
use serde::de::DeserializeOwned;
use serde_json::Value;
use super::ApiError;
pub(super) struct Reply {
data: Value,
errors: Vec<String>,
}
impl Reply {
pub(super) fn new(data: Value, errors: Vec<String>) -> Self {
Self { data, errors }
}
pub(super) fn picked<Q: DeserializeOwned + cynic::QueryFragment, T>(
self,
subject: impl std::fmt::Display,
pick: impl FnOnce(Q) -> Option<T>,
leave_out: impl FnOnce(Vec<String>),
) -> Result<T, ApiError> {
self.found(subject, |d| serde_json::from_value(d).map(pick), leave_out)
}
pub(super) fn at<T: DeserializeOwned>(
self,
subject: impl std::fmt::Display,
at: &str,
leave_out: impl FnOnce(Vec<String>),
) -> Result<T, ApiError> {
if at.len() < 2 || !at.starts_with('/') {
return Err(ApiError::Internal(format!("{at:?} is not below the root")));
}
let read = |mut data: Value| {
let value = data.pointer_mut(at).map(Value::take);
value
.filter(|v| !v.is_null())
.map(T::deserialize)
.transpose()
};
self.found(subject, read, leave_out)
}
fn found<T>(
self,
subject: impl std::fmt::Display,
read: impl FnOnce(Value) -> Result<Option<T>, serde_json::Error>,
leave_out: impl FnOnce(Vec<String>),
) -> Result<T, ApiError> {
match read(self.data) {
Ok(Some(found)) => {
leave_out(self.errors);
Ok(found)
}
_ if !self.errors.is_empty() => Err(ApiError::GraphQl(self.errors)),
Ok(None) => Err(ApiError::NotFound(subject.to_string())),
Err(err) => Err(err.into()),
}
}
pub(super) fn mutation<Q: DeserializeOwned>(self) -> Result<Q, ApiError> {
if !self.errors.is_empty() {
return Err(ApiError::GraphQl(self.errors));
}
Ok(serde_json::from_value(self.data)?)
}
}
}
#[derive(Debug, serde::Deserialize)]
struct GqlError {
message: Option<String>,
#[serde(rename = "type")]
kind: Option<String>,
path: Option<Vec<Value>>,
}
impl GqlError {
fn is_root(&self) -> bool {
self.path.as_ref().is_some_and(|p| p.len() == 1)
}
fn explains_null(&self, data: &Value) -> bool {
self.kind.as_deref() == Some("NOT_FOUND")
&& self
.path
.iter()
.flatten()
.try_fold(data, |at, step| match step {
_ if at.is_null() => Some(at),
Value::String(key) => at.get(key),
Value::Number(i) => at.get(usize::try_from(i.as_u64()?).ok()?),
_ => None,
})
.is_some_and(Value::is_null)
}
}
fn messages(errors: impl IntoIterator<Item = GqlError>) -> Vec<String> {
let said = |e: GqlError| {
e.message
.or(e.kind)
.unwrap_or_else(|| "an unexplained error".into())
};
errors.into_iter().map(said).collect()
}
const REST_PAGE: u64 = 30;
const SEARCH_CAP: u64 = 1000;
const PAGES: u32 = 10;
const REF_DEPTH: usize = 20;
const FILE_PAGES: u32 = 30;
fn rest_page(after: Option<&str>) -> u64 {
after.and_then(|a| a.parse().ok()).unwrap_or(1)
}
fn next_page(page: u64, per_page: u64, total: u64) -> Option<String> {
(page.saturating_mul(per_page) < total).then(|| page.saturating_add(1).to_string())
}
fn text_short(oid: &str) -> &str {
oid.get(..7).unwrap_or(oid)
}
fn next_after(link: &str) -> Option<String> {
let next = link.split(',').find(|l| l.contains("rel=\"next\""))?;
let url = next.split(['<', '>']).nth(1)?;
let query = url.split_once('?')?.1;
let after = query.split('&').find_map(|kv| kv.strip_prefix("after="))?;
Some(
url::form_urlencoded::parse(format!("a={after}").as_bytes())
.next()?
.1
.into_owned(),
)
}
fn encode_query(s: &str) -> String {
escape(s, true)
}
fn encode_path(s: &str) -> String {
escape(s, false)
}
fn escape(s: &str, plus: bool) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(char::from(b));
}
b' ' if plus => out.push('+'),
b => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn build_client(token: &str) -> Result<reqwest::Client, ApiError> {
let _ = rustls::crypto::ring::default_provider().install_default();
let setup = |e: &dyn std::fmt::Display| ApiError::Setup(e.to_string());
let mut auth = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|e| setup(&e))?;
auth.set_sensitive(true);
let headers = HeaderMap::from_iter([
(header::AUTHORIZATION, auth),
(
header::ACCEPT,
HeaderValue::from_static("application/vnd.github+json"),
),
(
http::HeaderName::from_static("x-github-api-version"),
HeaderValue::from_static("2022-11-28"),
),
]);
reqwest::Client::builder()
.user_agent(concat!("ghtui/", env!("CARGO_PKG_VERSION")))
.default_headers(headers)
.connect_timeout(Duration::from_secs(10))
.read_timeout(Duration::from_secs(30))
.build()
.map_err(|e| setup(&e))
}
fn pr_number(pr: &PrRef) -> Result<i32, ApiError> {
i32::try_from(pr.number).map_err(|_| ApiError::NotFound(pr.to_string()))
}
fn list_vars(repo: &RepoId, after: Option<String>) -> browse::ListVariables {
browse::ListVariables {
owner: repo.owner.clone(),
name: repo.name.clone(),
after,
}
}
fn number_vars(pr: &PrRef) -> Result<queries::NumberVariables, ApiError> {
Ok(queries::NumberVariables {
owner: pr.repo.owner.clone(),
name: pr.repo.name.clone(),
number: pr_number(pr)?,
})
}
fn page_vars(pr: &PrRef, after: Option<String>) -> Result<queries::PageVariables, ApiError> {
Ok(queries::PageVariables {
owner: pr.repo.owner.clone(),
name: pr.repo.name.clone(),
number: pr_number(pr)?,
after,
})
}
fn pr_key(pr: &PrRef) -> String {
format!("pr:{pr}")
}
async fn spawn_store(f: impl FnOnce() + Send + 'static) {
if let Err(err) = tokio::task::spawn_blocking(f).await {
tracing::warn!(%err, "cache write task failed");
}
}
async fn backoff(attempt: u32) {
tokio::time::sleep(Duration::from_millis(250 << attempt)).await;
}
fn check_status(response: &Response, what: impl std::fmt::Display) -> Result<(), ApiError> {
#[derive(serde::Deserialize)]
struct Message {
message: String,
block: Option<Block>,
#[serde(default)]
errors: Vec<Detail>,
}
#[derive(serde::Deserialize)]
struct Detail {
message: Option<String>,
}
#[derive(serde::Deserialize)]
struct Block {
reason: Option<String>,
html_url: Option<String>,
}
let status = response.status;
if status.is_success() {
return Ok(());
}
if status == StatusCode::UNAUTHORIZED {
return Err(ApiError::Unauthorized);
}
if status == StatusCode::NOT_FOUND {
return Err(ApiError::NotFound(what.to_string()));
}
let wire = serde_json::from_str::<Message>(&response.body).ok();
if status == StatusCode::UNAVAILABLE_FOR_LEGAL_REASONS {
let block = wire.as_ref().and_then(|m| m.block.as_ref());
return Err(ApiError::Blocked(blocked_text(block.and_then(|b| {
Some((b.reason.as_deref()?, b.html_url.as_deref()))
}))));
}
let message = match wire {
Some(m) => {
let details: Vec<String> = m.errors.into_iter().filter_map(|d| d.message).collect();
if details.is_empty() {
m.message
} else {
details.join("; ")
}
}
None if matches!(status.as_u16(), 502 | 504) => {
"GitHub gave up on it (it stops after 10 seconds)".to_owned()
}
None => response.body.chars().take(200).collect(),
};
Err(ApiError::Http {
status: status.as_u16(),
message,
})
}
fn blocked_text(block: Option<(&str, Option<&str>)>) -> String {
let Some((reason, notice)) = block else {
return "GitHub blocks access to this repository for legal reasons".into();
};
let why = match reason {
"dmca" => "a DMCA takedown notice".to_owned(),
"tos" => "a violation of its Terms of Service".to_owned(),
other => other.replace('_', " "),
};
match notice {
Some(url) => format!("GitHub blocks access to this repository because of {why} ({url})"),
None => format!("GitHub blocks access to this repository because of {why}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rest_lists_page_to_their_total() {
assert_eq!(next_page(1, REST_PAGE, 0), None);
assert_eq!(next_page(1, REST_PAGE, REST_PAGE), None);
assert_eq!(next_page(1, REST_PAGE, REST_PAGE + 1).as_deref(), Some("2"));
assert_eq!(next_page(2, REST_PAGE, 2 * REST_PAGE), None);
assert_eq!(
next_page(2, REST_PAGE, 2 * REST_PAGE + 1).as_deref(),
Some("3")
);
}
#[test]
fn a_not_found_explains_only_the_null_it_points_at() {
let data = serde_json::json!({"a": [{"b": 1}, null], "c": null});
let error = |kind: Option<&str>, path: serde_json::Value| GqlError {
message: None,
kind: kind.map(str::to_owned),
path: serde_json::from_value(path).ok(),
};
let found = Some("NOT_FOUND");
assert!(error(found, serde_json::json!(["a", 1])).explains_null(&data));
assert!(error(found, serde_json::json!(["c"])).explains_null(&data));
assert!(error(found, serde_json::json!(["c", "e", 0])).explains_null(&data));
assert!(!error(found, serde_json::json!(["d"])).explains_null(&data));
assert!(!error(found, serde_json::json!(["a", 0, "b", "e"])).explains_null(&data));
assert!(!error(found, serde_json::json!(["a", 0])).explains_null(&data));
assert!(!error(found, serde_json::json!([])).explains_null(&data));
assert!(!error(None, serde_json::json!(["c"])).explains_null(&data));
assert!(!error(Some("FORBIDDEN"), serde_json::json!(["c"])).explains_null(&data));
}
}