Skip to main content

torii_lib/platforms/gitlab/
issue.rs

1//! GitLab — issue client.
2
3use 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// ── Gitea / Codeberg / Forgejo ────────────────────────────────────────────────
144//
145// Gitea's issues API mirrors GitHub's at `/api/v1/...` — same field
146// names, same `number` identifier (per-repo), same auth header.
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use httpmock::prelude::*;
152
153    // ── parser ───────────────────────────────────────────────────────────
154
155    #[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    // ── client (httpmock) ────────────────────────────────────────────────
195
196    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}