torii_lib/platforms/github/
issue.rs1use crate::error::{Result, ToriiError};
4use crate::platforms::issue::*;
5use reqwest::blocking::Client;
6
7pub struct GitHubIssueClient {
8 token: String,
9 base_url: String,
10}
11
12impl GitHubIssueClient {
13 pub fn new() -> Result<Self> {
14 let token = crate::auth::resolve_token("github", ".")
15 .value
16 .ok_or_else(|| ToriiError::Auth {
17 provider: "github".into(),
18 message: "GitHub token not found. Run: torii auth set github YOUR_TOKEN"
19 .to_string(),
20 })?;
21 Ok(Self {
22 token,
23 base_url: "https://api.github.com".to_string(),
24 })
25 }
26
27 fn client(&self) -> Client {
28 crate::http::make_client()
29 }
30
31 fn auth(&self) -> String {
32 format!("token {}", self.token)
33 }
34}
35
36impl IssueClient for GitHubIssueClient {
37 fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<Issue>> {
38 let url = format!(
39 "{}/repos/{}/{}/issues?state={}&per_page=50",
40 self.base_url, owner, repo, state
41 );
42 let req = self
43 .client()
44 .get(&url)
45 .header("Authorization", self.auth())
46 .header("Accept", "application/vnd.github.v3+json");
47 let json = crate::http::send_json(req, &format!("GitHub (url: {})", url))?;
48 Ok(crate::http::extract_array(&json, &url)?
50 .iter()
51 .filter(|v| v["pull_request"].is_null())
52 .filter_map(|v| parse_github_issue(v).ok())
53 .collect())
54 }
55
56 fn create(&self, owner: &str, repo: &str, opts: CreateIssueOptions) -> Result<Issue> {
57 let url = format!("{}/repos/{}/{}/issues", self.base_url, owner, repo);
58 let body = serde_json::json!({
59 "title": opts.title,
60 "body": opts.body.unwrap_or_default(),
61 });
62 let req = self
63 .client()
64 .post(&url)
65 .header("Authorization", self.auth())
66 .header("Accept", "application/vnd.github.v3+json")
67 .json(&body);
68 let json = crate::http::send_json(req, "GitHub create issue")?;
69 parse_github_issue(&json)
70 }
71
72 fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
73 let url = format!(
74 "{}/repos/{}/{}/issues/{}",
75 self.base_url, owner, repo, number
76 );
77 let body = serde_json::json!({ "state": "closed" });
78 let req = self
79 .client()
80 .patch(&url)
81 .header("Authorization", self.auth())
82 .header("Accept", "application/vnd.github.v3+json")
83 .json(&body);
84 crate::http::send_empty(req, "GitHub close issue")
85 }
86
87 fn comment(&self, owner: &str, repo: &str, number: u64, body: &str) -> Result<()> {
88 let url = format!(
89 "{}/repos/{}/{}/issues/{}/comments",
90 self.base_url, owner, repo, number
91 );
92 let payload = serde_json::json!({ "body": body });
93 let req = self
94 .client()
95 .post(&url)
96 .header("Authorization", self.auth())
97 .header("Accept", "application/vnd.github.v3+json")
98 .json(&payload);
99 crate::http::send_empty(req, "GitHub comment issue")
100 }
101}
102
103fn parse_github_issue(json: &serde_json::Value) -> Result<Issue> {
104 Ok(Issue {
105 number: json["number"].as_u64().unwrap_or(0),
106 title: json["title"].as_str().unwrap_or("").to_string(),
107 body: json["body"].as_str().map(|s| s.to_string()),
108 state: json["state"].as_str().unwrap_or("").to_string(),
109 author: json["user"]["login"].as_str().unwrap_or("").to_string(),
110 url: json["html_url"].as_str().unwrap_or("").to_string(),
111 labels: json["labels"]
112 .as_array()
113 .map(|a| {
114 a.iter()
115 .filter_map(|l| l["name"].as_str().map(|s| s.to_string()))
116 .collect()
117 })
118 .unwrap_or_default(),
119 assignees: json["assignees"]
120 .as_array()
121 .map(|a| {
122 a.iter()
123 .filter_map(|u| u["login"].as_str().map(|s| s.to_string()))
124 .collect()
125 })
126 .unwrap_or_default(),
127 created_at: json["created_at"].as_str().unwrap_or("").to_string(),
128 comments: json["comments"].as_u64().unwrap_or(0),
129 })
130}
131
132#[cfg(test)]
135mod tests {
136 use super::*;
137 use httpmock::prelude::*;
138
139 fn client_for(server: &MockServer) -> GitHubIssueClient {
140 GitHubIssueClient {
141 token: "test-token".into(),
142 base_url: server.base_url(),
143 }
144 }
145
146 #[test]
147 fn parse_github_issue_maps_all_fields() {
148 let json = serde_json::json!({
149 "number": 12u64,
150 "title": "Bug report",
151 "body": "It breaks",
152 "state": "open",
153 "user": { "login": "bob" },
154 "html_url": "https://github.com/o/r/issues/12",
155 "labels": [{ "name": "bug" }, { "name": "p1" }],
156 "assignees": [{ "login": "alice" }],
157 "created_at": "2026-02-03T00:00:00Z",
158 "comments": 4u64,
159 });
160 let issue = parse_github_issue(&json).unwrap();
161 assert_eq!(issue.number, 12);
162 assert_eq!(issue.title, "Bug report");
163 assert_eq!(issue.body.as_deref(), Some("It breaks"));
164 assert_eq!(issue.state, "open");
165 assert_eq!(issue.author, "bob");
166 assert_eq!(issue.url, "https://github.com/o/r/issues/12");
167 assert_eq!(issue.labels, vec!["bug".to_string(), "p1".to_string()]);
168 assert_eq!(issue.assignees, vec!["alice".to_string()]);
169 assert_eq!(issue.created_at, "2026-02-03T00:00:00Z");
170 assert_eq!(issue.comments, 4);
171 }
172
173 #[test]
174 fn parse_github_issue_defaults_when_fields_missing() {
175 let issue = parse_github_issue(&serde_json::json!({})).unwrap();
176 assert_eq!(issue.number, 0);
177 assert_eq!(issue.title, "");
178 assert_eq!(issue.body, None);
179 assert!(issue.labels.is_empty());
180 assert!(issue.assignees.is_empty());
181 assert_eq!(issue.comments, 0);
182 }
183
184 #[test]
185 fn list_filters_out_pull_requests() {
186 let server = MockServer::start();
187 let m = server.mock(|when, then| {
188 when.method(GET)
189 .path("/repos/octo/demo/issues")
190 .query_param("state", "open")
191 .query_param("per_page", "50")
192 .header("Authorization", "token test-token");
193 then.status(200).json_body(serde_json::json!([
194 {
195 "number": 1, "title": "Real issue", "state": "open",
196 "user": { "login": "alice" }, "html_url": "https://x/1",
197 "created_at": "", "comments": 0,
198 },
199 {
200 "number": 2, "title": "Actually a PR", "state": "open",
201 "user": { "login": "bob" }, "html_url": "https://x/2",
202 "created_at": "", "comments": 0,
203 "pull_request": { "url": "https://api/pulls/2" },
204 },
205 ]));
206 });
207 let issues = client_for(&server).list("octo", "demo", "open").unwrap();
208 m.assert();
209 assert_eq!(issues.len(), 1);
210 assert_eq!(issues[0].number, 1);
211 assert_eq!(issues[0].title, "Real issue");
212 }
213
214 #[test]
215 fn close_patches_issue_with_auth() {
216 let server = MockServer::start();
217 let m = server.mock(|when, then| {
218 when.method(PATCH)
219 .path("/repos/octo/demo/issues/3")
220 .header("Authorization", "token test-token")
221 .json_body(serde_json::json!({ "state": "closed" }));
222 then.status(200);
223 });
224 client_for(&server).close("octo", "demo", 3).unwrap();
225 m.assert();
226 }
227
228 #[test]
229 fn create_maps_500_to_platform_api_error() {
230 let server = MockServer::start();
231 server.mock(|when, then| {
232 when.method(POST).path("/repos/octo/demo/issues");
233 then.status(500)
234 .json_body(serde_json::json!({ "message": "boom" }));
235 });
236 let opts = CreateIssueOptions {
237 title: "t".into(),
238 body: None,
239 };
240 let err = client_for(&server)
241 .create("octo", "demo", opts)
242 .unwrap_err();
243 assert!(
244 matches!(err, ToriiError::PlatformApi { status: 500, .. }),
245 "expected PlatformApi 500, got: {err:?}"
246 );
247 }
248}