//! The mobile REST API, as the Android client speaks it.
//!
//! Repos are addressed by `owner/name` rather than id throughout,
//! because that's what the forge's own URLs use — a link pasted in from
//! the web maps onto these paths without a lookup.
//!
//! Every call carries `Authorization: Bearer <PAT>`. Each response is
//! kept in both its raw and its parsed form: `--json` prints exactly
//! what the server said, while the human renderers work off the structs.
pub mod models;
use anyhow::{Context, Result, anyhow, bail};
use reqwest::{Method, StatusCode};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::ops::Deref;
use std::time::Duration;
use url::Url;
use crate::config::Credentials;
use models::*;
/// A repository, as `owner/name`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoRef {
pub owner: String,
pub name: String,
}
impl RepoRef {
pub fn parse(raw: &str) -> Result<Self> {
let raw = raw.trim().trim_end_matches(".git");
let (owner, name) = raw
.split_once('/')
.ok_or_else(|| anyhow!("expected a repository as owner/name, got {raw:?}"))?;
if owner.is_empty() || name.is_empty() || name.contains('/') {
bail!("expected a repository as owner/name, got {raw:?}");
}
Ok(Self {
owner: owner.to_string(),
name: name.to_string(),
})
}
pub fn slug(&self) -> String {
format!("{}/{}", self.owner, self.name)
}
}
impl std::fmt::Display for RepoRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.slug())
}
}
/// A response in both forms: what the server sent, and what it means.
pub struct Res<T> {
pub raw: Value,
pub data: T,
}
impl<T> Deref for Res<T> {
type Target = T;
fn deref(&self) -> &T {
&self.data
}
}
pub struct Client {
http: reqwest::Client,
base: Url,
}
impl Client {
pub fn new(creds: &Credentials) -> Result<Self> {
let base = Url::parse(&creds.url).with_context(|| format!("{} is not a URL", creds.url))?;
let mut auth = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", creds.token))
.context("the API token has characters that can't go in a header")?;
auth.set_sensitive(true);
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(reqwest::header::AUTHORIZATION, auth);
headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
);
let http = reqwest::Client::builder()
.user_agent(concat!("gitgud-cli/", env!("CARGO_PKG_VERSION")))
.default_headers(headers)
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(60))
.build()
.context("building the HTTP client")?;
Ok(Self { http, base })
}
/// `<base>/api/mobile/v1/<segments…>`, percent-encoded per segment so
/// a package named `c++` or a wiki slug with a space survives.
fn endpoint(&self, segments: &[&str], query: &[(&str, String)]) -> Result<Url> {
let mut url = self.base.clone();
{
let mut path = url
.path_segments_mut()
.map_err(|_| anyhow!("{} cannot have a path", self.base))?;
path.pop_if_empty();
path.extend(["api", "mobile", "v1"]);
path.extend(segments);
}
if !query.is_empty() {
let mut pairs = url.query_pairs_mut();
for (k, v) in query {
pairs.append_pair(k, v);
}
}
Ok(url)
}
async fn send<T: DeserializeOwned>(
&self,
method: Method,
segments: &[&str],
query: &[(&str, String)],
body: Option<Value>,
) -> Result<Res<T>> {
let url = self.endpoint(segments, query)?;
let mut req = self.http.request(method, url.clone());
if let Some(body) = body {
req = req.json(&body);
}
let res = req
.send()
.await
.with_context(|| format!("couldn't reach {}", redact(&url)))?;
let status = res.status();
let text = res.text().await.context("reading the response body")?;
if !status.is_success() {
return Err(ApiFailure::new(status, &text).into());
}
let raw: Value = serde_json::from_str(&text)
.with_context(|| format!("{} returned something that isn't JSON", redact(&url)))?;
let data = serde_json::from_value(raw.clone()).with_context(|| {
format!(
"{} returned JSON this client doesn't understand",
redact(&url)
)
})?;
Ok(Res { raw, data })
}
async fn get<T: DeserializeOwned>(
&self,
segments: &[&str],
query: &[(&str, String)],
) -> Result<Res<T>> {
self.send(Method::GET, segments, query, None).await
}
async fn post<T: DeserializeOwned>(
&self,
segments: &[&str],
body: Option<Value>,
) -> Result<Res<T>> {
self.send(Method::POST, segments, &[], body).await
}
async fn patch<T: DeserializeOwned>(&self, segments: &[&str], body: Value) -> Result<Res<T>> {
self.send(Method::PATCH, segments, &[], Some(body)).await
}
async fn delete<T: DeserializeOwned>(&self, segments: &[&str]) -> Result<Res<T>> {
self.send(Method::DELETE, segments, &[], None).await
}
// ── identity ─────────────────────────────────────────────────────
pub async fn me(&self) -> Result<Res<MeResponse>> {
self.get(&["me"], &[]).await
}
// ── repositories ─────────────────────────────────────────────────
pub async fn repos(&self) -> Result<Res<ReposResponse>> {
self.get(&["repos"], &[]).await
}
pub async fn repo(&self, repo: &RepoRef) -> Result<Res<RepoDetailResponse>> {
self.get(&["repos", &repo.owner, &repo.name], &[]).await
}
// ── CI ───────────────────────────────────────────────────────────
pub async fn runs(&self, repo: &RepoRef, limit: Option<u32>) -> Result<Res<RunsResponse>> {
self.get(
&["repos", &repo.owner, &repo.name, "runs"],
&limit_query(limit),
)
.await
}
pub async fn run(&self, repo: &RepoRef, number: i64) -> Result<Res<RunDetailResponse>> {
self.get(
&[
"repos",
&repo.owner,
&repo.name,
"runs",
&number.to_string(),
],
&[],
)
.await
}
/// Logs are addressed by the job's database id, which comes from
/// [`Client::run`] — the workflow-level `job_id` string isn't unique
/// across reruns.
pub async fn logs(
&self,
repo: &RepoRef,
job_id: i64,
limit: Option<u32>,
) -> Result<Res<LogsResponse>> {
self.get(
&[
"repos",
&repo.owner,
&repo.name,
"jobs",
&job_id.to_string(),
"logs",
],
&limit_query(limit),
)
.await
}
pub async fn rerun(&self, repo: &RepoRef, number: i64) -> Result<Res<RunResponse>> {
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"runs",
&number.to_string(),
"rerun",
],
None,
)
.await
}
pub async fn rerun_job(
&self,
repo: &RepoRef,
number: i64,
job: &str,
) -> Result<Res<RunResponse>> {
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"runs",
&number.to_string(),
"jobs",
job,
"rerun",
],
None,
)
.await
}
pub async fn cancel_run(&self, repo: &RepoRef, number: i64) -> Result<Res<RunResponse>> {
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"runs",
&number.to_string(),
"cancel",
],
None,
)
.await
}
/// The default branch's `on: workflow_dispatch` workflows, with the
/// inputs each declares.
pub async fn workflows(&self, repo: &RepoRef) -> Result<Res<WorkflowsResponse>> {
self.get(&["repos", &repo.owner, &repo.name, "workflows"], &[])
.await
}
/// `workflow_dispatch`. Inputs go as strings — `"true"`/`"false"` for
/// a boolean — as GitHub's API takes them; the server checks each
/// against its declared type and answers 422 with per-input reasons.
pub async fn dispatch(
&self,
repo: &RepoRef,
workflow: &str,
git_ref: Option<&str>,
inputs: &BTreeMap<String, String>,
) -> Result<Res<RunResponse>> {
let mut body = json!({ "workflow": workflow, "inputs": inputs });
if let Some(git_ref) = git_ref {
body["ref"] = json!(git_ref);
}
self.post(
&["repos", &repo.owner, &repo.name, "workflows", "dispatch"],
Some(body),
)
.await
}
// ── issues ───────────────────────────────────────────────────────
pub async fn issues(
&self,
repo: &RepoRef,
state: Option<&str>,
search: Option<&str>,
limit: Option<u32>,
) -> Result<Res<IssuesResponse>> {
self.get(
&["repos", &repo.owner, &repo.name, "issues"],
&list_query(state, search, limit),
)
.await
}
pub async fn issue(&self, repo: &RepoRef, number: i64) -> Result<Res<IssueDetailResponse>> {
self.get(
&[
"repos",
&repo.owner,
&repo.name,
"issues",
&number.to_string(),
],
&[],
)
.await
}
pub async fn comment_on_issue(
&self,
repo: &RepoRef,
number: i64,
body: &str,
) -> Result<Res<Value>> {
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"issues",
&number.to_string(),
"comments",
],
Some(json!({ "body": body })),
)
.await
}
pub async fn set_issue_state(
&self,
repo: &RepoRef,
number: i64,
state: &str,
) -> Result<Res<Value>> {
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"issues",
&number.to_string(),
"state",
],
Some(json!({ "state": state })),
)
.await
}
// ── pull requests ────────────────────────────────────────────────
pub async fn pulls(
&self,
repo: &RepoRef,
state: Option<&str>,
search: Option<&str>,
limit: Option<u32>,
) -> Result<Res<PullsResponse>> {
self.get(
&["repos", &repo.owner, &repo.name, "pulls"],
&list_query(state, search, limit),
)
.await
}
pub async fn pull(&self, repo: &RepoRef, number: i64) -> Result<Res<PullDetailResponse>> {
self.get(
&[
"repos",
&repo.owner,
&repo.name,
"pulls",
&number.to_string(),
],
&[],
)
.await
}
/// Open a pull request. `base` defaults to the default branch on the
/// server; `head_repo` (`owner/name`) takes the head from a fork.
pub async fn create_pull(
&self,
repo: &RepoRef,
new: &NewPull<'_>,
) -> Result<Res<PullDetailResponse>> {
let mut body = json!({ "head": new.head, "title": new.title });
if let Some(base) = new.base {
body["base"] = json!(base);
}
if let Some(head_repo) = new.head_repo {
body["head_repo"] = json!(head_repo);
}
if let Some(text) = new.body {
body["body"] = json!(text);
}
self.post(&["repos", &repo.owner, &repo.name, "pulls"], Some(body))
.await
}
/// Change the title and/or the description; whichever is `None` is
/// left alone.
pub async fn edit_pull(
&self,
repo: &RepoRef,
number: i64,
title: Option<&str>,
body: Option<&str>,
) -> Result<Res<PullDetailResponse>> {
let mut change = json!({});
if let Some(title) = title {
change["title"] = json!(title);
}
if let Some(body) = body {
change["body"] = json!(body);
}
self.patch(
&[
"repos",
&repo.owner,
&repo.name,
"pulls",
&number.to_string(),
],
change,
)
.await
}
/// `"closed"` or `"open"`.
pub async fn set_pull_state(
&self,
repo: &RepoRef,
number: i64,
state: &str,
) -> Result<Res<PullDetailResponse>> {
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"pulls",
&number.to_string(),
"state",
],
Some(json!({ "state": state })),
)
.await
}
pub async fn add_pull_label(
&self,
repo: &RepoRef,
number: i64,
label: &str,
) -> Result<Res<PullDetailResponse>> {
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"pulls",
&number.to_string(),
"labels",
],
Some(json!({ "label": label })),
)
.await
}
/// The label goes in the path, percent-encoded like every segment, so
/// one named `area/ui` survives.
pub async fn remove_pull_label(
&self,
repo: &RepoRef,
number: i64,
label: &str,
) -> Result<Res<PullDetailResponse>> {
self.delete(&[
"repos",
&repo.owner,
&repo.name,
"pulls",
&number.to_string(),
"labels",
label,
])
.await
}
pub async fn comment_on_pull(
&self,
repo: &RepoRef,
number: i64,
body: &str,
) -> Result<Res<Value>> {
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"pulls",
&number.to_string(),
"comments",
],
Some(json!({ "body": body })),
)
.await
}
pub async fn review(
&self,
repo: &RepoRef,
number: i64,
state: &str,
body: Option<&str>,
) -> Result<Res<Value>> {
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"pulls",
&number.to_string(),
"reviews",
],
Some(json!({ "state": state, "body": body })),
)
.await
}
pub async fn merge(
&self,
repo: &RepoRef,
number: i64,
message: Option<&str>,
override_rules: bool,
) -> Result<Res<Value>> {
let mut body = json!({ "message": message });
// Only sent when asked for: the server refuses the flag outright
// for non-admins rather than downgrading to a normal merge.
if override_rules {
body["override"] = json!(true);
}
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"pulls",
&number.to_string(),
"merge",
],
Some(body),
)
.await
}
/// Let a fork's runs waiting on the pull request's current head go
/// ahead. Answers with the runs it approved.
pub async fn approve_pull_runs(
&self,
repo: &RepoRef,
number: i64,
) -> Result<Res<RunsResponse>> {
self.pull_runs(repo, number, "approve").await
}
/// Cancel a fork's runs waiting for approval on the pull request, on
/// any head. Answers with the runs it cancelled.
pub async fn reject_pull_runs(&self, repo: &RepoRef, number: i64) -> Result<Res<RunsResponse>> {
self.pull_runs(repo, number, "reject").await
}
async fn pull_runs(
&self,
repo: &RepoRef,
number: i64,
verb: &str,
) -> Result<Res<RunsResponse>> {
self.post(
&[
"repos",
&repo.owner,
&repo.name,
"pulls",
&number.to_string(),
"runs",
verb,
],
None,
)
.await
}
// ── labels ───────────────────────────────────────────────────────
/// An organization's audit log. `query` holds the filters and paging
/// (`category`, `actor`, `repo`, `before`, `limit`).
pub async fn org_audit(
&self,
org: &str,
query: &[(&str, String)],
) -> Result<Res<AuditResponse>> {
self.get(&["orgs", org, "audit"], query).await
}
/// A repository's audit log; `query` as for `org_audit`, without `repo`.
pub async fn repo_audit(
&self,
repo: &RepoRef,
query: &[(&str, String)],
) -> Result<Res<AuditResponse>> {
self.get(&["repos", &repo.owner, &repo.name, "audit"], query)
.await
}
pub async fn labels(&self, repo: &RepoRef) -> Result<Res<LabelsResponse>> {
self.get(&["repos", &repo.owner, &repo.name, "labels"], &[])
.await
}
pub async fn create_label(
&self,
repo: &RepoRef,
name: &str,
color: Option<&str>,
description: Option<&str>,
) -> Result<Res<LabelResponse>> {
let mut body = json!({ "label": name });
if let Some(color) = color {
body["color"] = json!(color);
}
if let Some(description) = description {
body["description"] = json!(description);
}
self.post(&["repos", &repo.owner, &repo.name, "labels"], Some(body))
.await
}
// ── wiki ─────────────────────────────────────────────────────────
pub async fn wiki(&self, repo: &RepoRef) -> Result<Res<WikiIndexResponse>> {
self.get(&["repos", &repo.owner, &repo.name, "wiki"], &[])
.await
}
pub async fn wiki_page(&self, repo: &RepoRef, slug: &str) -> Result<Res<WikiPageResponse>> {
self.get(&["repos", &repo.owner, &repo.name, "wiki", slug], &[])
.await
}
// ── packages ─────────────────────────────────────────────────────
pub async fn packages(&self, repo: &RepoRef) -> Result<Res<PackagesResponse>> {
self.get(&["repos", &repo.owner, &repo.name, "packages"], &[])
.await
}
pub async fn package(
&self,
repo: &RepoRef,
format: &str,
package: &str,
) -> Result<Res<PackageDetail>> {
self.get(
&[
"repos",
&repo.owner,
&repo.name,
"packages",
format,
package,
],
&[],
)
.await
}
pub async fn package_usage(&self, repo: &RepoRef, format: &str) -> Result<Res<PackageUsage>> {
self.get(
&["repos", &repo.owner, &repo.name, "package-usage", format],
&[],
)
.await
}
}
/// What `gg pull create` sends.
pub struct NewPull<'a> {
pub head: &'a str,
pub base: Option<&'a str>,
pub head_repo: Option<&'a str>,
pub title: &'a str,
pub body: Option<&'a str>,
}
fn limit_query(limit: Option<u32>) -> Vec<(&'static str, String)> {
limit
.map(|n| vec![("limit", n.to_string())])
.unwrap_or_default()
}
fn list_query(
state: Option<&str>,
search: Option<&str>,
limit: Option<u32>,
) -> Vec<(&'static str, String)> {
let mut q = limit_query(limit);
if let Some(state) = state {
q.push(("state", state.to_string()));
}
if let Some(search) = search.filter(|s| !s.is_empty()) {
q.push(("search", search.to_string()));
}
q
}
/// A request the server answered with an error status.
///
/// Kept typed rather than flattened into a string, so a caller can tell a
/// 404 (a matrix placeholder replaced by its legs, say) from anything else
/// with `downcast_ref`, and so a dispatch's per-input reasons survive.
#[derive(Debug, Clone)]
pub struct ApiFailure {
pub status: StatusCode,
pub message: Option<String>,
/// Input name → reason, when a dispatch's inputs don't fit.
pub errors: BTreeMap<String, String>,
}
impl ApiFailure {
fn new(status: StatusCode, body: &str) -> Self {
let parsed = serde_json::from_str::<ApiError>(body).ok();
Self {
status,
message: parsed
.as_ref()
.and_then(|e| e.message.clone())
.filter(|m| !m.trim().is_empty()),
errors: parsed.map(|e| e.errors).unwrap_or_default(),
}
}
/// The failure behind `error`, if it was one.
pub fn of(error: &anyhow::Error) -> Option<&ApiFailure> {
error.downcast_ref::<ApiFailure>()
}
}
impl std::fmt::Display for ApiFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// The message already strings the per-input reasons into one
// sentence; one line per input reads better, so it replaces it.
if self.errors.is_empty() {
return f.write_str(&describe(self.status, self.message.as_deref()));
}
write!(
f,
"the server refused these inputs ({}):",
self.status.as_u16()
)?;
for (name, reason) in &self.errors {
write!(f, "\n {name}: {reason}")?;
}
Ok(())
}
}
impl std::error::Error for ApiFailure {}
/// A message worth showing a user.
///
/// The API puts the actual reason in a JSON `message` on every 4xx —
/// "token is missing the 'ci:write' scope" is the difference between
/// re-minting a token with the right scopes and filing a bug about a
/// command that does nothing. So unwrap the body before falling back to
/// the bare status line.
fn describe(status: StatusCode, message: Option<&str>) -> String {
match message {
Some(message) => format!("{} ({})", message, status.as_u16()),
None if status == StatusCode::UNAUTHORIZED => {
"unauthorized — the token was rejected; try `gg login` again (401)".to_string()
}
None => format!("HTTP {status}"),
}
}
/// Query strings never carry the token, but a URL can end up in an error
/// message, so drop the query before printing one either way.
fn redact(url: &Url) -> String {
let mut url = url.clone();
url.set_query(None);
url.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_owner_name() {
let r = RepoRef::parse("acme/widgets").unwrap();
assert_eq!(r.owner, "acme");
assert_eq!(r.name, "widgets");
assert_eq!(r.slug(), "acme/widgets");
}
#[test]
fn strips_dot_git() {
assert_eq!(RepoRef::parse("acme/widgets.git").unwrap().name, "widgets");
}
#[test]
fn failures_keep_the_message_and_per_input_errors() {
let failure = ApiFailure::new(
StatusCode::UNPROCESSABLE_ENTITY,
r#"{"message":"input level must be one of: debug, info","errors":{"level":"must be one of: debug, info"}}"#,
);
assert_eq!(failure.status.as_u16(), 422);
assert_eq!(
failure.to_string(),
"the server refused these inputs (422):\n level: must be one of: debug, info"
);
let bare = ApiFailure::new(StatusCode::NOT_FOUND, "not json");
assert_eq!(bare.to_string(), "HTTP 404 Not Found");
let wrapped = anyhow::Error::new(bare).context("reading logs");
assert_eq!(
ApiFailure::of(&wrapped).map(|f| f.status),
Some(StatusCode::NOT_FOUND)
);
}
#[test]
fn label_segments_are_percent_encoded() {
let client = Client::new(&Credentials {
url: "https://forge.example.com/".into(),
token: "ggp_test".into(),
})
.unwrap();
let url = client
.endpoint(
&[
"repos",
"acme",
"widgets",
"pulls",
"3",
"labels",
"area/ui x",
],
&[],
)
.unwrap();
assert_eq!(
url.as_str(),
"https://forge.example.com/api/mobile/v1/repos/acme/widgets/pulls/3/labels/area%2Fui%20x"
);
}
#[test]
fn rejects_incomplete_refs() {
for bad in ["widgets", "acme/", "/widgets", "a/b/c"] {
assert!(RepoRef::parse(bad).is_err(), "{bad} should not parse");
}
}
}
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
26.3 KiB · text
History
d859987