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 provider_error,
16};
17
18const DEFAULT_BASE: &str = "https://gitlab.com";
19
20pub 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 pub fn new(project: impl Into<String>, token: impl Into<String>) -> Result<Self> {
32 Self::with_base(DEFAULT_BASE, project, token)
33 }
34
35 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 #[must_use]
57 pub fn with_mapping(mut self, mapping: StateMapping) -> Self {
58 self.mapping = Some(mapping);
59 self
60 }
61
62 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 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 let terminal = matches!(target, StateCategory::Done | StateCategory::Canceled);
150 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
198fn 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
211trait 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 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}