gonzalo_ticket_gitlab/
source.rs1use 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
19pub 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 pub fn new(project: impl Into<String>, token: impl Into<String>) -> Result<Self> {
31 Self::with_base(DEFAULT_BASE, project, token)
32 }
33
34 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 #[must_use]
56 pub fn with_mapping(mut self, mapping: StateMapping) -> Self {
57 self.mapping = Some(mapping);
58 self
59 }
60
61 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 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 let terminal = matches!(target, StateCategory::Done | StateCategory::Canceled);
149 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
197fn 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}