Skip to main content

atomcode_core/atomgit/
client.rs

1use anyhow::{anyhow, Context, Result};
2
3use crate::auth;
4
5use super::models::{Comment, CreatedIssue, Issue, RepoLabel};
6use super::url::IssueRef;
7
8const API_BASE: &str = "https://atomgit.com/api/v5";
9
10/// Thin blocking HTTP client for the AtomGit REST API, authenticated with
11/// the OAuth token stored by `crate::auth`. Blocking is fine here — the
12/// fixissue flow runs before the agent loop starts.
13pub struct Client {
14    http: reqwest::blocking::Client,
15    token: String,
16}
17
18impl Client {
19    /// Build a client using the currently-stored OAuth token. Refreshes
20    /// the token if expired. Errors with a user-friendly message if the
21    /// user hasn't logged in.
22    pub fn from_stored_auth() -> Result<Self> {
23        if !auth::is_logged_in() {
24            return Err(anyhow!("not logged in — run `atomcode login` first"));
25        }
26        let token = auth::get_valid_token()
27            .context("failed to load OAuth token (try `atomcode login` again)")?;
28        // Default reqwest UA (`reqwest/<ver>`) is rejected by AtomGit's gate
29        // — every request here must carry `atomcode/<ver>`. Builder() with
30        // `.user_agent(...)` is the blocking-client equivalent of what
31        // `provider/mod.rs::build_http_client` does.
32        let http = reqwest::blocking::Client::builder()
33            .user_agent(crate::ATOMCODE_USER_AGENT)
34            .build()
35            .unwrap_or_else(|_| reqwest::blocking::Client::new());
36        Ok(Self { http, token })
37    }
38
39    /// GET /api/v5/repos/{owner}/{repo}/issues/{number}
40    pub fn get_issue(&self, r: &IssueRef) -> Result<Issue> {
41        let url = format!(
42            "{}/repos/{}/{}/issues/{}",
43            API_BASE, r.owner, r.repo, r.number
44        );
45        let resp = self
46            .http
47            .get(&url)
48            .bearer_auth(&self.token)
49            .header("Accept", "application/json")
50            .send()
51            .with_context(|| format!("GET {} failed", url))?;
52
53        let status = resp.status();
54        if status == reqwest::StatusCode::NOT_FOUND {
55            return Err(anyhow!(
56                "issue not found: {}/{}/issues/{}",
57                r.owner,
58                r.repo,
59                r.number
60            ));
61        }
62        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
63            return Err(anyhow!(
64                "authentication failed ({}) — run `atomcode login` again",
65                status.as_u16()
66            ));
67        }
68        if !status.is_success() {
69            let body = resp.text().unwrap_or_default();
70            return Err(anyhow!(
71                "AtomGit API returned {} for issue #{}: {}",
72                status,
73                r.number,
74                body
75            ));
76        }
77        resp.json::<Issue>().context("failed to parse issue JSON")
78    }
79
80    /// POST /api/v5/repos/{owner}/{repo}/issues — create a new issue in
81    /// the target repo. Used by the `/issue` wizard in the TUI; returns
82    /// the server's response so callers can surface the new issue's
83    /// number + `html_url` to the user.
84    pub fn create_issue(
85        &self,
86        owner: &str,
87        repo: &str,
88        title: &str,
89        body: &str,
90    ) -> Result<CreatedIssue> {
91        let url = format!("{}/repos/{}/{}/issues", API_BASE, owner, repo);
92        let payload = serde_json::json!({ "title": title, "body": body });
93        let resp = self
94            .http
95            .post(&url)
96            .query(&[("access_token", self.token.as_str())])
97            .header("Accept", "application/json")
98            .json(&payload)
99            .send()
100            .with_context(|| format!("POST {} failed", url))?;
101        let status = resp.status();
102        if !status.is_success() {
103            let body = resp.text().unwrap_or_default();
104            return Err(anyhow!(
105                "AtomGit returned {} creating issue in {}/{}: {}",
106                status,
107                owner,
108                repo,
109                body
110            ));
111        }
112        resp.json::<CreatedIssue>()
113            .context("failed to parse created-issue JSON")
114    }
115
116    /// POST /api/v5/repos/{owner}/{repo}/issues/{number}/comments —
117    /// append a comment to the issue. Used after a successful fixissue
118    /// run to leave the agent's repair summary on the issue.
119    pub fn post_issue_comment(&self, r: &IssueRef, body: &str) -> Result<()> {
120        let url = format!(
121            "{}/repos/{}/{}/issues/{}/comments",
122            API_BASE, r.owner, r.repo, r.number
123        );
124        let payload = serde_json::json!({ "body": body });
125        let resp = self
126            .http
127            .post(&url)
128            .bearer_auth(&self.token)
129            .header("Accept", "application/json")
130            .json(&payload)
131            .send()
132            .with_context(|| format!("POST {} failed", url))?;
133        let status = resp.status();
134        if !status.is_success() {
135            let body = resp.text().unwrap_or_default();
136            return Err(anyhow!(
137                "AtomGit returned {} posting comment on issue #{}: {}",
138                status,
139                r.number,
140                body
141            ));
142        }
143        Ok(())
144    }
145
146    /// GET /api/v5/repos/{owner}/{repo}/labels — list the repo's
147    /// defined labels. Used by `add_issue_label` to look up the numeric
148    /// ID that the issue-labels POST endpoint requires (AtomGit rejects
149    /// label-by-name; names alone return 400 "Request body parsing error").
150    pub fn list_labels(&self, owner: &str, repo: &str) -> Result<Vec<RepoLabel>> {
151        let url = format!("{}/repos/{}/{}/labels", API_BASE, owner, repo);
152        let resp = self
153            .http
154            .get(&url)
155            .bearer_auth(&self.token)
156            .header("Accept", "application/json")
157            .send()
158            .with_context(|| format!("GET {} failed", url))?;
159        let status = resp.status();
160        if !status.is_success() {
161            let body = resp.text().unwrap_or_default();
162            return Err(anyhow!(
163                "AtomGit returned {} listing labels for {}/{}: {}",
164                status,
165                owner,
166                repo,
167                body
168            ));
169        }
170        resp.json::<Vec<RepoLabel>>()
171            .context("failed to parse labels list")
172    }
173
174    /// POST /api/v5/repos/{owner}/{repo}/issues/{number}/labels —
175    /// attach a label to the issue. AtomGit's endpoint expects the
176    /// label's numeric **ID**, not its name, so this first calls
177    /// `list_labels` to resolve the name. If the label doesn't exist
178    /// in the repo we return a clear error instead of auto-creating —
179    /// label taxonomy is a repo-setting decision.
180    pub fn add_issue_label(&self, r: &IssueRef, label_name: &str) -> Result<()> {
181        let labels = self.list_labels(&r.owner, &r.repo)?;
182        let label = labels
183            .iter()
184            .find(|l| l.name.eq_ignore_ascii_case(label_name))
185            .ok_or_else(|| {
186                anyhow!(
187                    "label '{}' not found in repo {}/{} — create it first at \
188                     https://atomgit.com/{}/{}/labels (Repo Settings → Labels)",
189                    label_name,
190                    r.owner,
191                    r.repo,
192                    r.owner,
193                    r.repo
194                )
195            })?;
196
197        let url = format!(
198            "{}/repos/{}/{}/issues/{}/labels",
199            API_BASE, r.owner, r.repo, r.number
200        );
201        // AtomGit stringifies numeric IDs in JSON (e.g. the `number` field on
202        // issues comes back as `"140"` not `140`). Mirror that in the request
203        // body — sending a numeric ID here returns 400 "Request body parsing
204        // error". Let `.json()` handle the Content-Type; adding it manually
205        // on top of .json() has caused the server to reject bodies in the past.
206        let payload = serde_json::json!({ "labels": [label.id.to_string()] });
207        // This specific endpoint requires the token in a `?access_token=`
208        // query parameter rather than the `Authorization: Bearer` header.
209        // Passing it as a Bearer token makes AtomGit respond with
210        //   {"error_code":400,"error_code_name":"BAD_REQUEST",
211        //    "error_message":"Request body parsing error, please check if
212        //    the header content-type:application/json matches"}
213        // — the message is misleading (the body is fine), but switching
214        // to the query-parameter form makes the same request succeed.
215        // The other endpoints on the same API still accept Bearer auth,
216        // so we keep `bearer_auth` elsewhere and only special-case this
217        // one route.
218        let resp = self
219            .http
220            .post(&url)
221            .query(&[("access_token", self.token.as_str())])
222            .header("Accept", "application/json")
223            .json(&payload)
224            .send()
225            .with_context(|| format!("POST {} failed", url))?;
226        let status = resp.status();
227        if !status.is_success() {
228            let body = resp.text().unwrap_or_default();
229            return Err(anyhow!(
230                "AtomGit returned {} adding label '{}' (id={}) to issue #{}: {}",
231                status,
232                label.name,
233                label.id,
234                r.number,
235                body
236            ));
237        }
238        Ok(())
239    }
240
241    /// GET /api/v5/repos/{owner}/{repo}/issues/{number}/comments.
242    /// Swallowed on error: comments are best-effort context, not required
243    /// for the fix-issue flow to proceed.
244    pub fn get_issue_comments(&self, r: &IssueRef) -> Vec<Comment> {
245        let url = format!(
246            "{}/repos/{}/{}/issues/{}/comments",
247            API_BASE, r.owner, r.repo, r.number
248        );
249        let Ok(resp) = self
250            .http
251            .get(&url)
252            .bearer_auth(&self.token)
253            .header("Accept", "application/json")
254            .send()
255        else {
256            return Vec::new();
257        };
258        if !resp.status().is_success() {
259            return Vec::new();
260        }
261        resp.json::<Vec<Comment>>().unwrap_or_default()
262    }
263}