torii_lib/platforms/gitlab/
issue.rs1use crate::error::{Result, ToriiError};
4use crate::platforms::issue::*;
5use reqwest::blocking::Client;
6
7pub struct GitLabIssueClient {
8 token: String,
9 base_url: String,
10}
11
12impl GitLabIssueClient {
13 pub fn new() -> Result<Self> {
14 let token = crate::auth::resolve_token("gitlab", ".")
15 .value
16 .ok_or_else(|| ToriiError::Auth {
17 provider: "gitlab".into(),
18 message: "GitLab token not found. Run: torii auth set gitlab YOUR_TOKEN"
19 .to_string(),
20 })?;
21 let base_url =
22 std::env::var("GITLAB_URL").unwrap_or_else(|_| "https://gitlab.com/api/v4".to_string());
23 Ok(Self { token, base_url })
24 }
25
26 fn client(&self) -> Client {
27 crate::http::make_client()
28 }
29
30 fn project_path(owner: &str, repo: &str) -> String {
31 crate::url::encode(&format!("{}/{}", owner, repo))
32 }
33}
34
35impl IssueClient for GitLabIssueClient {
36 fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<Issue>> {
37 let gl_state = match state {
38 "open" => "opened",
39 "closed" => "closed",
40 other => other,
41 };
42 let url = format!(
43 "{}/projects/{}/issues?state={}&per_page=50",
44 self.base_url,
45 Self::project_path(owner, repo),
46 gl_state
47 );
48 let req = self
49 .client()
50 .get(&url)
51 .header("Authorization", format!("Bearer {}", self.token));
52 let json = crate::http::send_json(req, &format!("GitLab (url: {})", url))?;
53 crate::http::extract_array(&json, &url)?
54 .iter()
55 .map(parse_gitlab_issue)
56 .collect()
57 }
58
59 fn create(&self, owner: &str, repo: &str, opts: CreateIssueOptions) -> Result<Issue> {
60 let url = format!(
61 "{}/projects/{}/issues",
62 self.base_url,
63 Self::project_path(owner, repo)
64 );
65 let body = serde_json::json!({
66 "title": opts.title,
67 "description": opts.body.unwrap_or_default(),
68 });
69 let req = self
70 .client()
71 .post(&url)
72 .header("Authorization", format!("Bearer {}", self.token))
73 .json(&body);
74 let json = crate::http::send_json(req, "GitLab create issue")?;
75 parse_gitlab_issue(&json)
76 }
77
78 fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
79 let url = format!(
80 "{}/projects/{}/issues/{}",
81 self.base_url,
82 Self::project_path(owner, repo),
83 number
84 );
85 let body = serde_json::json!({ "state_event": "close" });
86 let req = self
87 .client()
88 .put(&url)
89 .header("Authorization", format!("Bearer {}", self.token))
90 .json(&body);
91 crate::http::send_empty(req, "GitLab close issue")
92 }
93
94 fn comment(&self, owner: &str, repo: &str, number: u64, body: &str) -> Result<()> {
95 let url = format!(
96 "{}/projects/{}/issues/{}/notes",
97 self.base_url,
98 Self::project_path(owner, repo),
99 number
100 );
101 let payload = serde_json::json!({ "body": body });
102 let req = self
103 .client()
104 .post(&url)
105 .header("Authorization", format!("Bearer {}", self.token))
106 .json(&payload);
107 crate::http::send_empty(req, "GitLab comment issue")
108 }
109}
110
111fn parse_gitlab_issue(json: &serde_json::Value) -> Result<Issue> {
112 Ok(Issue {
113 number: json["iid"].as_u64().unwrap_or(0),
114 title: json["title"].as_str().unwrap_or("").to_string(),
115 body: json["description"].as_str().map(|s| s.to_string()),
116 state: json["state"].as_str().unwrap_or("").to_string(),
117 author: json["author"]["username"]
118 .as_str()
119 .unwrap_or("")
120 .to_string(),
121 url: json["web_url"].as_str().unwrap_or("").to_string(),
122 labels: json["labels"]
123 .as_array()
124 .map(|a| {
125 a.iter()
126 .filter_map(|l| l.as_str().map(|s| s.to_string()))
127 .collect()
128 })
129 .unwrap_or_default(),
130 assignees: json["assignees"]
131 .as_array()
132 .map(|a| {
133 a.iter()
134 .filter_map(|u| u["username"].as_str().map(|s| s.to_string()))
135 .collect()
136 })
137 .unwrap_or_default(),
138 created_at: json["created_at"].as_str().unwrap_or("").to_string(),
139 comments: json["user_notes_count"].as_u64().unwrap_or(0),
140 })
141}
142
143#[cfg(test)]
149mod tests {
150 use super::*;
151 use httpmock::prelude::*;
152
153 #[test]
156 fn parse_gitlab_issue_full() {
157 let json = serde_json::json!({
158 "iid": 12u64,
159 "title": "Crash on startup",
160 "description": "Steps to reproduce…",
161 "state": "opened",
162 "author": { "username": "paski" },
163 "web_url": "https://gitlab.com/acme/widget/-/issues/12",
164 "labels": ["bug", "p1"],
165 "assignees": [{ "username": "alice" }, { "username": "bob" }],
166 "created_at": "2026-06-02T08:30:00Z",
167 "user_notes_count": 3u64
168 });
169 let issue = parse_gitlab_issue(&json).unwrap();
170 assert_eq!(issue.number, 12);
171 assert_eq!(issue.title, "Crash on startup");
172 assert_eq!(issue.body.as_deref(), Some("Steps to reproduce…"));
173 assert_eq!(issue.state, "opened");
174 assert_eq!(issue.author, "paski");
175 assert_eq!(issue.url, "https://gitlab.com/acme/widget/-/issues/12");
176 assert_eq!(issue.labels, vec!["bug", "p1"]);
177 assert_eq!(issue.assignees, vec!["alice", "bob"]);
178 assert_eq!(issue.created_at, "2026-06-02T08:30:00Z");
179 assert_eq!(issue.comments, 3);
180 }
181
182 #[test]
183 fn parse_gitlab_issue_missing_optionals_defaults() {
184 let json = serde_json::json!({ "iid": 3u64, "title": "bare", "state": "closed" });
185 let issue = parse_gitlab_issue(&json).unwrap();
186 assert_eq!(issue.number, 3);
187 assert_eq!(issue.body, None);
188 assert_eq!(issue.author, "");
189 assert!(issue.labels.is_empty());
190 assert!(issue.assignees.is_empty());
191 assert_eq!(issue.comments, 0);
192 }
193
194 fn client(server: &MockServer) -> GitLabIssueClient {
197 GitLabIssueClient {
198 token: "test-token".into(),
199 base_url: server.base_url(),
200 }
201 }
202
203 #[test]
204 fn list_translates_open_state_and_parses_issues() {
205 let server = MockServer::start();
206 let m = server.mock(|when, then| {
207 when.method(GET)
208 .path("/projects/acme%2Fwidget/issues")
209 .query_param("state", "opened")
210 .header("Authorization", "Bearer test-token");
211 then.status(200).json_body(serde_json::json!([{
212 "iid": 9u64, "title": "Issue nine", "state": "opened",
213 "author": { "username": "paski" }, "web_url": "https://x",
214 "labels": ["bug"], "assignees": [], "created_at": "",
215 "user_notes_count": 1u64
216 }]));
217 });
218 let issues = client(&server).list("acme", "widget", "open").unwrap();
219 m.assert();
220 assert_eq!(issues.len(), 1);
221 assert_eq!(issues[0].number, 9);
222 assert_eq!(issues[0].labels, vec!["bug"]);
223 }
224
225 #[test]
226 fn comment_posts_note_with_bearer_auth() {
227 let server = MockServer::start();
228 let m = server.mock(|when, then| {
229 when.method(POST)
230 .path("/projects/acme%2Fwidget/issues/5/notes")
231 .header("Authorization", "Bearer test-token")
232 .json_body(serde_json::json!({ "body": "lgtm" }));
233 then.status(201);
234 });
235 client(&server)
236 .comment("acme", "widget", 5, "lgtm")
237 .unwrap();
238 m.assert();
239 }
240
241 #[test]
242 fn create_non_2xx_maps_to_platform_api_error() {
243 let server = MockServer::start();
244 server.mock(|when, then| {
245 when.method(POST).path("/projects/acme%2Fwidget/issues");
246 then.status(500)
247 .json_body(serde_json::json!({ "message": "boom" }));
248 });
249 let opts = CreateIssueOptions {
250 title: "x".into(),
251 body: None,
252 };
253 let err = client(&server).create("acme", "widget", opts).unwrap_err();
254 assert!(
255 matches!(err, ToriiError::PlatformApi { .. }),
256 "expected PlatformApi, got: {err:?}"
257 );
258 }
259}