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
10pub struct Client {
14 http: reqwest::blocking::Client,
15 token: String,
16}
17
18impl Client {
19 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 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 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 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 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 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 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 let payload = serde_json::json!({ "labels": [label.id.to_string()] });
207 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 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}