use anyhow::{anyhow, Context, Result};
use crate::auth;
use super::models::{Comment, CreatedIssue, Issue, RepoLabel};
use super::url::IssueRef;
const API_BASE: &str = "https://atomgit.com/api/v5";
/// Thin blocking HTTP client for the AtomGit REST API, authenticated with
/// the OAuth token stored by `crate::auth`. Blocking is fine here — the
/// fixissue flow runs before the agent loop starts.
pub struct Client {
http: reqwest::blocking::Client,
token: String,
}
impl Client {
/// Build a client using the currently-stored OAuth token. Refreshes
/// the token if expired. Errors with a user-friendly message if the
/// user hasn't logged in.
pub fn from_stored_auth() -> Result<Self> {
if !auth::is_logged_in() {
return Err(anyhow!("not logged in — run `atomcode login` first"));
}
let token = auth::get_valid_token()
.context("failed to load OAuth token (try `atomcode login` again)")?;
// Default reqwest UA (`reqwest/<ver>`) is rejected by AtomGit's gate
// — every request here must carry `atomcode/<ver>`. Builder() with
// `.user_agent(...)` is the blocking-client equivalent of what
// `provider/mod.rs::build_http_client` does.
let http = reqwest::blocking::Client::builder()
.user_agent(crate::ATOMCODE_USER_AGENT)
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new());
Ok(Self { http, token })
}
/// GET /api/v5/repos/{owner}/{repo}/issues/{number}
pub fn get_issue(&self, r: &IssueRef) -> Result<Issue> {
let url = format!(
"{}/repos/{}/{}/issues/{}",
API_BASE, r.owner, r.repo, r.number
);
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.header("Accept", "application/json")
.send()
.with_context(|| format!("GET {} failed", url))?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(anyhow!(
"issue not found: {}/{}/issues/{}",
r.owner,
r.repo,
r.number
));
}
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(anyhow!(
"authentication failed ({}) — run `atomcode login` again",
status.as_u16()
));
}
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(anyhow!(
"AtomGit API returned {} for issue #{}: {}",
status,
r.number,
body
));
}
resp.json::<Issue>().context("failed to parse issue JSON")
}
/// POST /api/v5/repos/{owner}/{repo}/issues — create a new issue in
/// the target repo. Used by the `/issue` wizard in the TUI; returns
/// the server's response so callers can surface the new issue's
/// number + `html_url` to the user.
pub fn create_issue(
&self,
owner: &str,
repo: &str,
title: &str,
body: &str,
) -> Result<CreatedIssue> {
let url = format!("{}/repos/{}/{}/issues", API_BASE, owner, repo);
let payload = serde_json::json!({ "title": title, "body": body });
let resp = self
.http
.post(&url)
.query(&[("access_token", self.token.as_str())])
.header("Accept", "application/json")
.json(&payload)
.send()
.with_context(|| format!("POST {} failed", url))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(anyhow!(
"AtomGit returned {} creating issue in {}/{}: {}",
status,
owner,
repo,
body
));
}
resp.json::<CreatedIssue>()
.context("failed to parse created-issue JSON")
}
/// POST /api/v5/repos/{owner}/{repo}/issues/{number}/comments —
/// append a comment to the issue. Used after a successful fixissue
/// run to leave the agent's repair summary on the issue.
pub fn post_issue_comment(&self, r: &IssueRef, body: &str) -> Result<()> {
let url = format!(
"{}/repos/{}/{}/issues/{}/comments",
API_BASE, r.owner, r.repo, r.number
);
let payload = serde_json::json!({ "body": body });
let resp = self
.http
.post(&url)
.bearer_auth(&self.token)
.header("Accept", "application/json")
.json(&payload)
.send()
.with_context(|| format!("POST {} failed", url))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(anyhow!(
"AtomGit returned {} posting comment on issue #{}: {}",
status,
r.number,
body
));
}
Ok(())
}
/// GET /api/v5/repos/{owner}/{repo}/labels — list the repo's
/// defined labels. Used by `add_issue_label` to look up the numeric
/// ID that the issue-labels POST endpoint requires (AtomGit rejects
/// label-by-name; names alone return 400 "Request body parsing error").
pub fn list_labels(&self, owner: &str, repo: &str) -> Result<Vec<RepoLabel>> {
let url = format!("{}/repos/{}/{}/labels", API_BASE, owner, repo);
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.header("Accept", "application/json")
.send()
.with_context(|| format!("GET {} failed", url))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(anyhow!(
"AtomGit returned {} listing labels for {}/{}: {}",
status,
owner,
repo,
body
));
}
resp.json::<Vec<RepoLabel>>()
.context("failed to parse labels list")
}
/// POST /api/v5/repos/{owner}/{repo}/issues/{number}/labels —
/// attach a label to the issue. AtomGit's endpoint expects the
/// label's numeric **ID**, not its name, so this first calls
/// `list_labels` to resolve the name. If the label doesn't exist
/// in the repo we return a clear error instead of auto-creating —
/// label taxonomy is a repo-setting decision.
pub fn add_issue_label(&self, r: &IssueRef, label_name: &str) -> Result<()> {
let labels = self.list_labels(&r.owner, &r.repo)?;
let label = labels
.iter()
.find(|l| l.name.eq_ignore_ascii_case(label_name))
.ok_or_else(|| {
anyhow!(
"label '{}' not found in repo {}/{} — create it first at \
https://atomgit.com/{}/{}/labels (Repo Settings → Labels)",
label_name,
r.owner,
r.repo,
r.owner,
r.repo
)
})?;
let url = format!(
"{}/repos/{}/{}/issues/{}/labels",
API_BASE, r.owner, r.repo, r.number
);
// AtomGit stringifies numeric IDs in JSON (e.g. the `number` field on
// issues comes back as `"140"` not `140`). Mirror that in the request
// body — sending a numeric ID here returns 400 "Request body parsing
// error". Let `.json()` handle the Content-Type; adding it manually
// on top of .json() has caused the server to reject bodies in the past.
let payload = serde_json::json!({ "labels": [label.id.to_string()] });
// This specific endpoint requires the token in a `?access_token=`
// query parameter rather than the `Authorization: Bearer` header.
// Passing it as a Bearer token makes AtomGit respond with
// {"error_code":400,"error_code_name":"BAD_REQUEST",
// "error_message":"Request body parsing error, please check if
// the header content-type:application/json matches"}
// — the message is misleading (the body is fine), but switching
// to the query-parameter form makes the same request succeed.
// The other endpoints on the same API still accept Bearer auth,
// so we keep `bearer_auth` elsewhere and only special-case this
// one route.
let resp = self
.http
.post(&url)
.query(&[("access_token", self.token.as_str())])
.header("Accept", "application/json")
.json(&payload)
.send()
.with_context(|| format!("POST {} failed", url))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(anyhow!(
"AtomGit returned {} adding label '{}' (id={}) to issue #{}: {}",
status,
label.name,
label.id,
r.number,
body
));
}
Ok(())
}
/// GET /api/v5/repos/{owner}/{repo}/issues/{number}/comments.
/// Swallowed on error: comments are best-effort context, not required
/// for the fix-issue flow to proceed.
pub fn get_issue_comments(&self, r: &IssueRef) -> Vec<Comment> {
let url = format!(
"{}/repos/{}/{}/issues/{}/comments",
API_BASE, r.owner, r.repo, r.number
);
let Ok(resp) = self
.http
.get(&url)
.bearer_auth(&self.token)
.header("Accept", "application/json")
.send()
else {
return Vec::new();
};
if !resp.status().is_success() {
return Vec::new();
}
resp.json::<Vec<Comment>>().unwrap_or_default()
}
}