Skip to main content

torii_lib/platforms/github/
issue.rs

1//! GitHub — issue client.
2
3use 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        // filter out PRs (GitHub issues API returns PRs too)
49        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// ── GitLab ────────────────────────────────────────────────────────────────────
133
134#[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}