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