1use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
4use serde::Deserialize;
5
6#[derive(Debug, Deserialize)]
8pub struct Assignee {
9 pub username: String,
10}
11
12#[derive(Debug, Deserialize)]
14pub struct Issue {
15 pub iid: u64,
16 pub title: String,
17 pub description: Option<String>,
18 pub state: String,
19 pub web_url: String,
20 #[serde(default)]
21 pub assignees: Vec<Assignee>,
22}
23
24pub struct Client {
26 http: reqwest::blocking::Client,
27 base_url: String,
28 project_path: String,
29}
30
31impl Client {
32 pub fn from_project_url(
37 url: &str,
38 ) -> Result<Self, Box<dyn std::error::Error>> {
39 let token = std::env::var("GITLAB_TOKEN").ok().or_else(|| {
40 crate::config::load()
41 .ok()
42 .and_then(|c| c.gitlab.map(|g| g.access_token))
43 });
44 let token = token.ok_or(
45 "GitLab token not found; set GITLAB_TOKEN \
46 or run 'hs-relmon config'",
47 )?;
48 let (base_url, project_path) = parse_project_url(url)?;
49 Self::new(&base_url, &project_path, &token)
50 }
51
52 pub fn new(
54 base_url: &str,
55 project_path: &str,
56 token: &str,
57 ) -> Result<Self, Box<dyn std::error::Error>> {
58 let mut headers = HeaderMap::new();
59 headers.insert(
60 HeaderName::from_static("private-token"),
61 HeaderValue::from_str(token)?,
62 );
63 let http = reqwest::blocking::Client::builder()
64 .user_agent("hs-relmon/0.2.1")
65 .default_headers(headers)
66 .build()?;
67 Ok(Self {
68 http,
69 base_url: base_url.trim_end_matches('/').to_string(),
70 project_path: project_path.to_string(),
71 })
72 }
73
74 pub fn create_issue(
76 &self,
77 title: &str,
78 description: Option<&str>,
79 labels: Option<&str>,
80 ) -> Result<Issue, Box<dyn std::error::Error>> {
81 let mut body = serde_json::json!({"title": title});
82 if let Some(desc) = description {
83 body["description"] = desc.into();
84 }
85 if let Some(labels) = labels {
86 body["labels"] = labels.into();
87 }
88
89 let resp = self.http.post(&self.issues_url()).json(&body).send()?;
90 check_response(resp)
91 }
92
93 pub fn list_issues(
95 &self,
96 label: &str,
97 state: Option<&str>,
98 ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
99 let mut query = vec![("labels", label)];
100 if let Some(s) = state {
101 query.push(("state", s));
102 }
103 let resp = self
104 .http
105 .get(&self.issues_url())
106 .query(&query)
107 .send()?;
108 if !resp.status().is_success() {
109 let status = resp.status();
110 let text = resp.text()?;
111 return Err(
112 format!("GitLab API error {status}: {text}").into(),
113 );
114 }
115 Ok(resp.json()?)
116 }
117
118 pub fn edit_issue(
120 &self,
121 iid: u64,
122 updates: &IssueUpdate,
123 ) -> Result<Issue, Box<dyn std::error::Error>> {
124 let body = serde_json::to_value(updates)?;
125 let resp = self
126 .http
127 .put(&format!("{}/{iid}", self.issues_url()))
128 .json(&body)
129 .send()?;
130 check_response(resp)
131 }
132
133 pub fn get_work_item_status(
138 &self,
139 iid: u64,
140 ) -> Result<Option<String>, Box<dyn std::error::Error>>
141 {
142 let query = format!(
143 r#"{{ project(fullPath: "{}") {{
144 workItems(iids: ["{}"]) {{
145 nodes {{ widgets {{
146 type
147 ... on WorkItemWidgetStatus {{
148 status {{ name }}
149 }}
150 }} }}
151 }}
152 }} }}"#,
153 self.project_path, iid
154 );
155 let body = serde_json::json!({ "query": query });
156 let resp = self
157 .http
158 .post(&self.graphql_url())
159 .json(&body)
160 .send()?;
161 if !resp.status().is_success() {
162 let status = resp.status();
163 let text = resp.text()?;
164 return Err(format!(
165 "GitLab GraphQL error {status}: {text}"
166 )
167 .into());
168 }
169 let json: serde_json::Value = resp.json()?;
170 Ok(parse_work_item_status(&json))
171 }
172
173 fn issues_url(&self) -> String {
174 let encoded = self.project_path.replace('/', "%2F");
175 format!(
176 "{}/api/v4/projects/{}/issues",
177 self.base_url, encoded
178 )
179 }
180
181 fn graphql_url(&self) -> String {
182 format!("{}/api/graphql", self.base_url)
183 }
184}
185
186fn parse_work_item_status(
188 json: &serde_json::Value,
189) -> Option<String> {
190 json.pointer("/data/project/workItems/nodes/0/widgets")
191 .and_then(|w| w.as_array())
192 .and_then(|widgets| {
193 widgets.iter().find(|w| {
194 w.get("type").and_then(|t| t.as_str())
195 == Some("STATUS")
196 })
197 })
198 .and_then(|w| w.pointer("/status/name"))
199 .and_then(|n| n.as_str())
200 .map(String::from)
201}
202
203#[derive(Debug, Default, serde::Serialize)]
205pub struct IssueUpdate {
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub title: Option<String>,
208 #[serde(skip_serializing_if = "Option::is_none")]
209 pub description: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub add_labels: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none")]
213 pub remove_labels: Option<String>,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub state_event: Option<String>,
216}
217
218fn check_response(
219 resp: reqwest::blocking::Response,
220) -> Result<Issue, Box<dyn std::error::Error>> {
221 if !resp.status().is_success() {
222 let status = resp.status();
223 let text = resp.text()?;
224 return Err(format!("GitLab API error {status}: {text}").into());
225 }
226 Ok(resp.json()?)
227}
228
229pub fn validate_token(
231 base_url: &str,
232 token: &str,
233) -> Result<bool, Box<dyn std::error::Error>> {
234 let mut headers = HeaderMap::new();
235 headers.insert(
236 HeaderName::from_static("private-token"),
237 HeaderValue::from_str(token)?,
238 );
239 let client = reqwest::blocking::Client::builder()
240 .user_agent("hs-relmon/0.2.1")
241 .default_headers(headers)
242 .build()?;
243 let url = format!(
244 "{}/api/v4/user",
245 base_url.trim_end_matches('/')
246 );
247 let resp = client.get(&url).send()?;
248 Ok(resp.status().is_success())
249}
250
251pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
256 let url = url.trim_end_matches('/');
257 let rest = url
258 .strip_prefix("https://")
259 .or_else(|| url.strip_prefix("http://"))
260 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
261
262 let slash = rest
263 .find('/')
264 .ok_or_else(|| format!("no project path in URL: {url}"))?;
265
266 let host = &rest[..slash];
267 let path = &rest[slash + 1..];
268
269 if path.is_empty() {
270 return Err(format!("no project path in URL: {url}"));
271 }
272
273 let scheme = if url.starts_with("https://") {
274 "https"
275 } else {
276 "http"
277 };
278 Ok((format!("{scheme}://{host}"), path.to_string()))
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn test_parse_project_url() {
287 let (base, path) = parse_project_url(
288 "https://gitlab.com/CentOS/Hyperscale/rpms/perf",
289 )
290 .unwrap();
291 assert_eq!(base, "https://gitlab.com");
292 assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
293 }
294
295 #[test]
296 fn test_parse_project_url_trailing_slash() {
297 let (base, path) =
298 parse_project_url("https://gitlab.com/group/project/")
299 .unwrap();
300 assert_eq!(base, "https://gitlab.com");
301 assert_eq!(path, "group/project");
302 }
303
304 #[test]
305 fn test_parse_project_url_http() {
306 let (base, path) = parse_project_url(
307 "http://gitlab.example.com/group/project",
308 )
309 .unwrap();
310 assert_eq!(base, "http://gitlab.example.com");
311 assert_eq!(path, "group/project");
312 }
313
314 #[test]
315 fn test_parse_project_url_no_scheme() {
316 assert!(
317 parse_project_url("gitlab.com/group/project").is_err()
318 );
319 }
320
321 #[test]
322 fn test_parse_project_url_no_path() {
323 assert!(parse_project_url("https://gitlab.com/").is_err());
324 assert!(parse_project_url("https://gitlab.com").is_err());
325 }
326
327 #[test]
328 fn test_issues_url() {
329 let client = Client::new(
330 "https://gitlab.com",
331 "CentOS/Hyperscale/rpms/perf",
332 "fake-token",
333 )
334 .unwrap();
335 assert_eq!(
336 client.issues_url(),
337 "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
338 );
339 }
340
341 #[test]
342 fn test_issue_update_serialization() {
343 let update = IssueUpdate {
344 title: Some("new title".into()),
345 add_labels: Some("bug".into()),
346 ..Default::default()
347 };
348 let json = serde_json::to_value(&update).unwrap();
349 assert_eq!(json["title"], "new title");
350 assert_eq!(json["add_labels"], "bug");
351 assert!(json.get("description").is_none());
353 assert!(json.get("state_event").is_none());
354 }
355
356 #[test]
357 fn test_issue_deserialize() {
358 let json = r#"{
359 "iid": 42,
360 "title": "Test issue",
361 "description": "Some description",
362 "state": "opened",
363 "web_url": "https://gitlab.com/group/project/-/issues/42",
364 "assignees": [
365 {"username": "alice"},
366 {"username": "bob"}
367 ]
368 }"#;
369 let issue: Issue = serde_json::from_str(json).unwrap();
370 assert_eq!(issue.iid, 42);
371 assert_eq!(issue.title, "Test issue");
372 assert_eq!(issue.description.as_deref(), Some("Some description"));
373 assert_eq!(issue.state, "opened");
374 assert_eq!(issue.assignees.len(), 2);
375 assert_eq!(issue.assignees[0].username, "alice");
376 assert_eq!(issue.assignees[1].username, "bob");
377 }
378
379 #[test]
380 fn test_issue_deserialize_no_assignees() {
381 let json = r#"{
382 "iid": 1,
383 "title": "t",
384 "description": null,
385 "state": "opened",
386 "web_url": "u"
387 }"#;
388 let issue: Issue = serde_json::from_str(json).unwrap();
389 assert!(issue.description.is_none());
390 assert!(issue.assignees.is_empty());
391 }
392
393 #[test]
394 fn test_issue_deserialize_null_description() {
395 let json = r#"{
396 "iid": 1,
397 "title": "t",
398 "description": null,
399 "state": "opened",
400 "web_url": "u"
401 }"#;
402 let issue: Issue = serde_json::from_str(json).unwrap();
403 assert!(issue.description.is_none());
404 }
405
406 #[test]
407 fn test_graphql_url() {
408 let client = Client::new(
409 "https://gitlab.com",
410 "CentOS/Hyperscale/rpms/perf",
411 "fake-token",
412 )
413 .unwrap();
414 assert_eq!(
415 client.graphql_url(),
416 "https://gitlab.com/api/graphql"
417 );
418 }
419
420 #[test]
421 fn test_parse_work_item_status_found() {
422 let json: serde_json::Value = serde_json::from_str(
423 r#"{
424 "data": {
425 "project": {
426 "workItems": {
427 "nodes": [{
428 "widgets": [
429 { "type": "ASSIGNEES" },
430 {
431 "type": "STATUS",
432 "status": {
433 "name": "To do"
434 }
435 }
436 ]
437 }]
438 }
439 }
440 }
441 }"#,
442 )
443 .unwrap();
444 assert_eq!(
445 parse_work_item_status(&json).as_deref(),
446 Some("To do")
447 );
448 }
449
450 #[test]
451 fn test_parse_work_item_status_in_progress() {
452 let json: serde_json::Value = serde_json::from_str(
453 r#"{
454 "data": {
455 "project": {
456 "workItems": {
457 "nodes": [{
458 "widgets": [
459 {
460 "type": "STATUS",
461 "status": {
462 "name": "In progress"
463 }
464 }
465 ]
466 }]
467 }
468 }
469 }
470 }"#,
471 )
472 .unwrap();
473 assert_eq!(
474 parse_work_item_status(&json).as_deref(),
475 Some("In progress")
476 );
477 }
478
479 #[test]
480 fn test_parse_work_item_status_no_status_widget() {
481 let json: serde_json::Value = serde_json::from_str(
482 r#"{
483 "data": {
484 "project": {
485 "workItems": {
486 "nodes": [{
487 "widgets": [
488 { "type": "ASSIGNEES" },
489 { "type": "LABELS" }
490 ]
491 }]
492 }
493 }
494 }
495 }"#,
496 )
497 .unwrap();
498 assert!(parse_work_item_status(&json).is_none());
499 }
500
501 #[test]
502 fn test_parse_work_item_status_empty_nodes() {
503 let json: serde_json::Value = serde_json::from_str(
504 r#"{
505 "data": {
506 "project": {
507 "workItems": {
508 "nodes": []
509 }
510 }
511 }
512 }"#,
513 )
514 .unwrap();
515 assert!(parse_work_item_status(&json).is_none());
516 }
517
518 #[test]
519 fn test_parse_work_item_status_null_status() {
520 let json: serde_json::Value = serde_json::from_str(
521 r#"{
522 "data": {
523 "project": {
524 "workItems": {
525 "nodes": [{
526 "widgets": [
527 {
528 "type": "STATUS",
529 "status": null
530 }
531 ]
532 }]
533 }
534 }
535 }
536 }"#,
537 )
538 .unwrap();
539 assert!(parse_work_item_status(&json).is_none());
540 }
541}