Skip to main content

gonzalo_ticket_gitlab/
source.rs

1//! A read-only [`TicketSource`] backed by the GitLab REST v4 API.
2//!
3//! Authenticates with a personal/project access token (`PRIVATE-TOKEN` header).
4//! Phase 1 imports a single project's issues via `fetch_changed` (page-numbered,
5//! advanced by the `x-next-page` header) and `get`. An optional
6//! [`StateMapping`] with a `ScopedLabel` signal drives the category from
7//! `workflow::`-style labels (see [`crate::mapping`]). HTTP-level behavior is
8//! exercised by the conformance suite's fixtures (#20).
9
10use crate::mapping::{GlIssue, issue_to_ticket};
11use async_trait::async_trait;
12use gonzalo_domain::{StateCategory, Ticket};
13use gonzalo_ticket::{
14    Capabilities, Cursor, Page, Result, SourceError, StateMapping, StateSignal, TicketSource,
15    provider_error,
16};
17
18const DEFAULT_BASE: &str = "https://gitlab.com";
19
20/// Imports issues from a single GitLab project.
21pub struct GitLabSource {
22    client: reqwest::Client,
23    base: reqwest::Url,
24    project: String,
25    token: String,
26    mapping: Option<StateMapping>,
27}
28
29impl GitLabSource {
30    /// Import from `project` (full path, e.g. `group/sub/proj`) on gitlab.com.
31    pub fn new(project: impl Into<String>, token: impl Into<String>) -> Result<Self> {
32        Self::with_base(DEFAULT_BASE, project, token)
33    }
34
35    /// As [`GitLabSource::new`] but against a self-managed instance `base`.
36    pub fn with_base(
37        base: &str,
38        project: impl Into<String>,
39        token: impl Into<String>,
40    ) -> Result<Self> {
41        let client = reqwest::Client::builder()
42            .user_agent("gonzalo-ticket-gitlab")
43            .build()
44            .map_err(be)?;
45        Ok(Self {
46            client,
47            base: reqwest::Url::parse(base).map_err(be)?,
48            project: project.into(),
49            token: token.into(),
50            mapping: None,
51        })
52    }
53
54    /// Apply a per-connection [`StateMapping`] (e.g. a `ScopedLabel` policy for
55    /// `workflow::` labels).
56    #[must_use]
57    pub fn with_mapping(mut self, mapping: StateMapping) -> Self {
58        self.mapping = Some(mapping);
59        self
60    }
61
62    /// `.../api/v4/projects/<url-encoded project>/issues[/<trailing>...]`. Each
63    /// `trailing` element is a distinct, individually-encoded segment.
64    fn issues_url(&self, trailing: &[&str]) -> Result<reqwest::Url> {
65        let mut url = self.base.clone();
66        {
67            let mut seg = url
68                .path_segments_mut()
69                .map_err(|_| SourceError::Backend("base URL cannot be a base".into()))?;
70            // Pushing the full path as one segment URL-encodes the slashes,
71            // which is how GitLab addresses a project (`group%2Fproj`).
72            seg.extend(["api", "v4", "projects"]);
73            seg.push(&self.project);
74            seg.push("issues");
75            seg.extend(trailing);
76        }
77        Ok(url)
78    }
79
80    fn auth(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
81        rb.header("PRIVATE-TOKEN", &self.token)
82    }
83}
84
85#[async_trait]
86impl TicketSource for GitLabSource {
87    fn capabilities(&self) -> Capabilities {
88        Capabilities {
89            push: true,
90            comments: true,
91            ..Capabilities::default()
92        }
93    }
94
95    async fn fetch_changed(&self, cursor: &Cursor) -> Result<Page> {
96        let mut url = self.issues_url(&[])?;
97        {
98            let mut q = url.query_pairs_mut();
99            q.append_pair("scope", "all");
100            q.append_pair("per_page", "100");
101            q.append_pair("order_by", "updated_at");
102            q.append_pair("sort", "asc");
103            q.append_pair("page", cursor.0.as_deref().unwrap_or("1"));
104        }
105        let resp = self
106            .auth(self.client.get(url))
107            .send()
108            .await
109            .map_err(be)?
110            .check_status()
111            .await?;
112        let next_page = resp
113            .headers()
114            .get("x-next-page")
115            .and_then(|v| v.to_str().ok())
116            .filter(|s| !s.is_empty())
117            .map(str::to_string);
118        let issues: Vec<GlIssue> = resp.json().await.map_err(be)?;
119        let tickets = issues
120            .iter()
121            .map(|i| issue_to_ticket(i, &self.project, self.mapping.as_ref()))
122            .collect();
123        Ok(Page {
124            tickets,
125            next: Cursor(next_page),
126        })
127    }
128
129    async fn get(&self, uid: &str) -> Result<Ticket> {
130        let url = self.issues_url(&[&issue_iid(uid)?.to_string()])?;
131        let resp = self
132            .auth(self.client.get(url))
133            .send()
134            .await
135            .map_err(be)?
136            .check_status()
137            .await?;
138        let issue: GlIssue = resp.json().await.map_err(be)?;
139        Ok(issue_to_ticket(
140            &issue,
141            &self.project,
142            self.mapping.as_ref(),
143        ))
144    }
145
146    async fn set_state(&self, uid: &str, target: StateCategory) -> Result<()> {
147        // GitLab issue state is binary; map terminal categories to `close`,
148        // everything else to `reopen`.
149        let terminal = matches!(target, StateCategory::Done | StateCategory::Canceled);
150        // When a scoped-label mapping drives the read path, the category comes
151        // from the `workflow::` label. Only a terminal move is expressible via
152        // the intrinsic `close` event; a non-terminal move would toggle the
153        // intrinsic state without touching the label, so the read path would
154        // still report the label's category. Reject it rather than reporting a
155        // false success (#143). (Scoped-label workflow write-back is out of
156        // scope here.)
157        if !terminal
158            && matches!(
159                self.mapping.as_ref().map(|m| &m.signal),
160                Some(StateSignal::ScopedLabel { .. })
161            )
162        {
163            return Err(SourceError::Unsupported(
164                "non-terminal set_state under a scoped-label mapping (workflow label write-back not implemented)",
165            ));
166        }
167        let event = if terminal { "close" } else { "reopen" };
168        let url = self.issues_url(&[&issue_iid(uid)?.to_string()])?;
169        self.auth(
170            self.client
171                .put(url)
172                .json(&serde_json::json!({ "state_event": event })),
173        )
174        .send()
175        .await
176        .map_err(be)?
177        .check_status()
178        .await?;
179        Ok(())
180    }
181
182    async fn comment(&self, uid: &str, body: &str) -> Result<()> {
183        let url = self.issues_url(&[&issue_iid(uid)?.to_string(), "notes"])?;
184        self.auth(
185            self.client
186                .post(url)
187                .json(&serde_json::json!({ "body": body })),
188        )
189        .send()
190        .await
191        .map_err(be)?
192        .check_status()
193        .await?;
194        Ok(())
195    }
196}
197
198/// Parse the issue `iid` from a `uid` (`group/proj#N` or a bare `N`).
199fn issue_iid(uid: &str) -> Result<u64> {
200    uid.rsplit_once('#')
201        .map(|(_, n)| n)
202        .unwrap_or(uid)
203        .parse::<u64>()
204        .map_err(|_| SourceError::Backend(format!("cannot parse iid from uid {uid}")))
205}
206
207fn be<E: std::fmt::Display>(e: E) -> SourceError {
208    SourceError::Backend(e.to_string())
209}
210
211/// Fail with the provider's own message instead of reqwest's generic one.
212///
213/// reqwest's `error_for_status()` discards the response body, which is exactly
214/// where a ticket provider puts the reason — expired token, missing scope,
215/// malformed query, rate-limit window. Swapping it for `check_status()` keeps
216/// the reason (#240). The message format is shared via
217/// [`provider_error`](gonzalo_ticket::provider_error) so the connectors cannot
218/// drift apart.
219trait CheckStatus: Sized {
220    async fn check_status(self) -> Result<Self>;
221}
222
223impl CheckStatus for reqwest::Response {
224    async fn check_status(self) -> Result<Self> {
225        let status = self.status();
226        if status.is_success() {
227            return Ok(self);
228        }
229        // Read the body only on the failure path; the caller decodes success.
230        let body = self.text().await.unwrap_or_default();
231        Err(provider_error(status.as_u16(), &body))
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn url_encodes_project_path() {
241        let src = GitLabSource::new("group/sub/proj", "tok").unwrap();
242        assert_eq!(
243            src.issues_url(&[]).unwrap().as_str(),
244            "https://gitlab.com/api/v4/projects/group%2Fsub%2Fproj/issues"
245        );
246        assert_eq!(
247            src.issues_url(&["7", "notes"]).unwrap().as_str(),
248            "https://gitlab.com/api/v4/projects/group%2Fsub%2Fproj/issues/7/notes"
249        );
250    }
251
252    #[test]
253    fn write_capabilities_enabled() {
254        let caps = GitLabSource::new("g/p", "t").unwrap().capabilities();
255        assert!(caps.push);
256        assert!(caps.comments);
257    }
258}