20.7 KiB · text History d1406da
//! 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
    }

    // ── 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
    }

    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
    }

    // ── 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 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 rejects_incomplete_refs() {
        for bad in ["widgets", "acme/", "/widgets", "a/b/c"] {
            assert!(RepoRef::parse(bad).is_err(), "{bad} should not parse");
        }
    }
}