//! 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::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() {
bail!("{}", describe(status, &text));
}
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
}
// ── 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
}
// ── 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
}
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
}
// ── 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
}
}
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 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, body: &str) -> String {
let message = serde_json::from_str::<ApiError>(body)
.ok()
.and_then(|e| e.message)
.filter(|m| !m.trim().is_empty());
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 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.
16.2 KiB · text
History
1294137