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
31pub fn load_token() -> Result<String, Box<dyn std::error::Error>> {
33 let token = std::env::var("GITLAB_TOKEN").ok().or_else(|| {
34 crate::config::load()
35 .ok()
36 .and_then(|c| c.gitlab.map(|g| g.access_token))
37 });
38 token.ok_or_else(|| {
39 "GitLab token not found; set GITLAB_TOKEN \
40 or run 'hs-relmon config'"
41 .into()
42 })
43}
44
45fn build_http_client(
47 token: &str,
48) -> Result<reqwest::blocking::Client, Box<dyn std::error::Error>> {
49 let mut headers = HeaderMap::new();
50 headers.insert(
51 HeaderName::from_static("private-token"),
52 HeaderValue::from_str(token)?,
53 );
54 Ok(reqwest::blocking::Client::builder()
55 .user_agent("hs-relmon/0.2.1")
56 .default_headers(headers)
57 .build()?)
58}
59
60impl Client {
61 pub fn from_project_url(
66 url: &str,
67 ) -> Result<Self, Box<dyn std::error::Error>> {
68 let token = load_token()?;
69 let (base_url, project_path) = parse_project_url(url)?;
70 Self::new(&base_url, &project_path, &token)
71 }
72
73 pub fn new(
75 base_url: &str,
76 project_path: &str,
77 token: &str,
78 ) -> Result<Self, Box<dyn std::error::Error>> {
79 let http = build_http_client(token)?;
80 Ok(Self {
81 http,
82 base_url: base_url.trim_end_matches('/').to_string(),
83 project_path: project_path.to_string(),
84 })
85 }
86
87 pub fn create_issue(
89 &self,
90 title: &str,
91 description: Option<&str>,
92 labels: Option<&str>,
93 ) -> Result<Issue, Box<dyn std::error::Error>> {
94 let mut body = serde_json::json!({"title": title});
95 if let Some(desc) = description {
96 body["description"] = desc.into();
97 }
98 if let Some(labels) = labels {
99 body["labels"] = labels.into();
100 }
101
102 let resp = self.http.post(&self.issues_url()).json(&body).send()?;
103 check_response(resp)
104 }
105
106 pub fn list_issues(
108 &self,
109 label: &str,
110 state: Option<&str>,
111 ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
112 let mut query = vec![("labels", label)];
113 if let Some(s) = state {
114 query.push(("state", s));
115 }
116 let resp = self
117 .http
118 .get(&self.issues_url())
119 .query(&query)
120 .send()?;
121 if !resp.status().is_success() {
122 let status = resp.status();
123 let text = resp.text()?;
124 return Err(
125 format!("GitLab API error {status}: {text}").into(),
126 );
127 }
128 Ok(resp.json()?)
129 }
130
131 pub fn edit_issue(
133 &self,
134 iid: u64,
135 updates: &IssueUpdate,
136 ) -> Result<Issue, Box<dyn std::error::Error>> {
137 let body = serde_json::to_value(updates)?;
138 let resp = self
139 .http
140 .put(&format!("{}/{iid}", self.issues_url()))
141 .json(&body)
142 .send()?;
143 check_response(resp)
144 }
145
146 pub fn get_work_item_status(
151 &self,
152 iid: u64,
153 ) -> Result<Option<String>, Box<dyn std::error::Error>>
154 {
155 let query = format!(
156 r#"{{ project(fullPath: "{}") {{
157 workItems(iids: ["{}"]) {{
158 nodes {{ widgets {{
159 type
160 ... on WorkItemWidgetStatus {{
161 status {{ name }}
162 }}
163 }} }}
164 }}
165 }} }}"#,
166 self.project_path, iid
167 );
168 let body = serde_json::json!({ "query": query });
169 let resp = self
170 .http
171 .post(&self.graphql_url())
172 .json(&body)
173 .send()?;
174 if !resp.status().is_success() {
175 let status = resp.status();
176 let text = resp.text()?;
177 return Err(format!(
178 "GitLab GraphQL error {status}: {text}"
179 )
180 .into());
181 }
182 let json: serde_json::Value = resp.json()?;
183 Ok(parse_work_item_status(&json))
184 }
185
186 fn issues_url(&self) -> String {
187 let encoded = self.project_path.replace('/', "%2F");
188 format!(
189 "{}/api/v4/projects/{}/issues",
190 self.base_url, encoded
191 )
192 }
193
194 fn graphql_url(&self) -> String {
195 format!("{}/api/graphql", self.base_url)
196 }
197}
198
199fn parse_work_item_status(
201 json: &serde_json::Value,
202) -> Option<String> {
203 json.pointer("/data/project/workItems/nodes/0/widgets")
204 .and_then(|w| w.as_array())
205 .and_then(|widgets| {
206 widgets.iter().find(|w| {
207 w.get("type").and_then(|t| t.as_str())
208 == Some("STATUS")
209 })
210 })
211 .and_then(|w| w.pointer("/status/name"))
212 .and_then(|n| n.as_str())
213 .map(String::from)
214}
215
216pub struct GroupClient {
218 http: reqwest::blocking::Client,
219 base_url: String,
220 group_path: String,
221}
222
223impl GroupClient {
224 pub fn from_group_url(
226 url: &str,
227 ) -> Result<Self, Box<dyn std::error::Error>> {
228 let token = load_token()?;
229 let (base_url, group_path) = parse_project_url(url)?;
230 Self::new(&base_url, &group_path, &token)
231 }
232
233 pub fn new(
235 base_url: &str,
236 group_path: &str,
237 token: &str,
238 ) -> Result<Self, Box<dyn std::error::Error>> {
239 let http = build_http_client(token)?;
240 Ok(Self {
241 http,
242 base_url: base_url.trim_end_matches('/').to_string(),
243 group_path: group_path.to_string(),
244 })
245 }
246
247 pub fn list_issues(
250 &self,
251 label: &str,
252 state: Option<&str>,
253 ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
254 let mut all_issues = Vec::new();
255 let mut page = 1u32;
256 loop {
257 let page_str = page.to_string();
258 let mut query = vec![
259 ("labels", label),
260 ("per_page", "100"),
261 ("page", &page_str),
262 ];
263 if let Some(s) = state {
264 query.push(("state", s));
265 }
266 let resp = self
267 .http
268 .get(&self.issues_url())
269 .query(&query)
270 .send()?;
271 if !resp.status().is_success() {
272 let status = resp.status();
273 let text = resp.text()?;
274 return Err(format!(
275 "GitLab API error {status}: {text}"
276 )
277 .into());
278 }
279 let next_page = resp
280 .headers()
281 .get("x-next-page")
282 .and_then(|v| v.to_str().ok())
283 .unwrap_or("")
284 .to_string();
285 let issues: Vec<Issue> = resp.json()?;
286 all_issues.extend(issues);
287 if next_page.is_empty() {
288 break;
289 }
290 page = next_page.parse()?;
291 }
292 Ok(all_issues)
293 }
294
295 pub fn get_work_item_status(
297 &self,
298 project_path: &str,
299 iid: u64,
300 ) -> Result<Option<String>, Box<dyn std::error::Error>>
301 {
302 let query = format!(
303 r#"{{ project(fullPath: "{}") {{
304 workItems(iids: ["{}"]) {{
305 nodes {{ widgets {{
306 type
307 ... on WorkItemWidgetStatus {{
308 status {{ name }}
309 }}
310 }} }}
311 }}
312 }} }}"#,
313 project_path, iid
314 );
315 let body = serde_json::json!({ "query": query });
316 let resp = self
317 .http
318 .post(&self.graphql_url())
319 .json(&body)
320 .send()?;
321 if !resp.status().is_success() {
322 let status = resp.status();
323 let text = resp.text()?;
324 return Err(format!(
325 "GitLab GraphQL error {status}: {text}"
326 )
327 .into());
328 }
329 let json: serde_json::Value = resp.json()?;
330 Ok(parse_work_item_status(&json))
331 }
332
333 fn issues_url(&self) -> String {
334 let encoded = self.group_path.replace('/', "%2F");
335 format!(
336 "{}/api/v4/groups/{}/issues",
337 self.base_url, encoded
338 )
339 }
340
341 fn graphql_url(&self) -> String {
342 format!("{}/api/graphql", self.base_url)
343 }
344}
345
346pub fn package_from_issue_url(web_url: &str) -> Option<&str> {
351 let project_part = web_url.split("/-/issues/").next()?;
352 let name = project_part.rsplit('/').next()?;
353 if name.is_empty() {
354 None
355 } else {
356 Some(name)
357 }
358}
359
360pub fn project_path_from_issue_url(
365 web_url: &str,
366) -> Option<String> {
367 let project_part = web_url.split("/-/issues/").next()?;
368 let rest = project_part
369 .strip_prefix("https://")
370 .or_else(|| project_part.strip_prefix("http://"))?;
371 let slash = rest.find('/')?;
372 let path = &rest[slash + 1..];
373 if path.is_empty() {
374 None
375 } else {
376 Some(path.to_string())
377 }
378}
379
380#[derive(Debug, Default, serde::Serialize)]
382pub struct IssueUpdate {
383 #[serde(skip_serializing_if = "Option::is_none")]
384 pub title: Option<String>,
385 #[serde(skip_serializing_if = "Option::is_none")]
386 pub description: Option<String>,
387 #[serde(skip_serializing_if = "Option::is_none")]
388 pub add_labels: Option<String>,
389 #[serde(skip_serializing_if = "Option::is_none")]
390 pub remove_labels: Option<String>,
391 #[serde(skip_serializing_if = "Option::is_none")]
392 pub state_event: Option<String>,
393}
394
395fn check_response(
396 resp: reqwest::blocking::Response,
397) -> Result<Issue, Box<dyn std::error::Error>> {
398 if !resp.status().is_success() {
399 let status = resp.status();
400 let text = resp.text()?;
401 return Err(format!("GitLab API error {status}: {text}").into());
402 }
403 Ok(resp.json()?)
404}
405
406pub fn validate_token(
408 base_url: &str,
409 token: &str,
410) -> Result<bool, Box<dyn std::error::Error>> {
411 let mut headers = HeaderMap::new();
412 headers.insert(
413 HeaderName::from_static("private-token"),
414 HeaderValue::from_str(token)?,
415 );
416 let client = reqwest::blocking::Client::builder()
417 .user_agent("hs-relmon/0.2.1")
418 .default_headers(headers)
419 .build()?;
420 let url = format!(
421 "{}/api/v4/user",
422 base_url.trim_end_matches('/')
423 );
424 let resp = client.get(&url).send()?;
425 Ok(resp.status().is_success())
426}
427
428pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
433 let url = url.trim_end_matches('/');
434 let rest = url
435 .strip_prefix("https://")
436 .or_else(|| url.strip_prefix("http://"))
437 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
438
439 let slash = rest
440 .find('/')
441 .ok_or_else(|| format!("no project path in URL: {url}"))?;
442
443 let host = &rest[..slash];
444 let path = &rest[slash + 1..];
445
446 if path.is_empty() {
447 return Err(format!("no project path in URL: {url}"));
448 }
449
450 let scheme = if url.starts_with("https://") {
451 "https"
452 } else {
453 "http"
454 };
455 Ok((format!("{scheme}://{host}"), path.to_string()))
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn test_parse_project_url() {
464 let (base, path) = parse_project_url(
465 "https://gitlab.com/CentOS/Hyperscale/rpms/perf",
466 )
467 .unwrap();
468 assert_eq!(base, "https://gitlab.com");
469 assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
470 }
471
472 #[test]
473 fn test_parse_project_url_trailing_slash() {
474 let (base, path) =
475 parse_project_url("https://gitlab.com/group/project/")
476 .unwrap();
477 assert_eq!(base, "https://gitlab.com");
478 assert_eq!(path, "group/project");
479 }
480
481 #[test]
482 fn test_parse_project_url_http() {
483 let (base, path) = parse_project_url(
484 "http://gitlab.example.com/group/project",
485 )
486 .unwrap();
487 assert_eq!(base, "http://gitlab.example.com");
488 assert_eq!(path, "group/project");
489 }
490
491 #[test]
492 fn test_parse_project_url_no_scheme() {
493 assert!(
494 parse_project_url("gitlab.com/group/project").is_err()
495 );
496 }
497
498 #[test]
499 fn test_parse_project_url_no_path() {
500 assert!(parse_project_url("https://gitlab.com/").is_err());
501 assert!(parse_project_url("https://gitlab.com").is_err());
502 }
503
504 #[test]
505 fn test_issues_url() {
506 let client = Client::new(
507 "https://gitlab.com",
508 "CentOS/Hyperscale/rpms/perf",
509 "fake-token",
510 )
511 .unwrap();
512 assert_eq!(
513 client.issues_url(),
514 "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
515 );
516 }
517
518 #[test]
519 fn test_issue_update_serialization() {
520 let update = IssueUpdate {
521 title: Some("new title".into()),
522 add_labels: Some("bug".into()),
523 ..Default::default()
524 };
525 let json = serde_json::to_value(&update).unwrap();
526 assert_eq!(json["title"], "new title");
527 assert_eq!(json["add_labels"], "bug");
528 assert!(json.get("description").is_none());
530 assert!(json.get("state_event").is_none());
531 }
532
533 #[test]
534 fn test_issue_deserialize() {
535 let json = r#"{
536 "iid": 42,
537 "title": "Test issue",
538 "description": "Some description",
539 "state": "opened",
540 "web_url": "https://gitlab.com/group/project/-/issues/42",
541 "assignees": [
542 {"username": "alice"},
543 {"username": "bob"}
544 ]
545 }"#;
546 let issue: Issue = serde_json::from_str(json).unwrap();
547 assert_eq!(issue.iid, 42);
548 assert_eq!(issue.title, "Test issue");
549 assert_eq!(issue.description.as_deref(), Some("Some description"));
550 assert_eq!(issue.state, "opened");
551 assert_eq!(issue.assignees.len(), 2);
552 assert_eq!(issue.assignees[0].username, "alice");
553 assert_eq!(issue.assignees[1].username, "bob");
554 }
555
556 #[test]
557 fn test_issue_deserialize_no_assignees() {
558 let json = r#"{
559 "iid": 1,
560 "title": "t",
561 "description": null,
562 "state": "opened",
563 "web_url": "u"
564 }"#;
565 let issue: Issue = serde_json::from_str(json).unwrap();
566 assert!(issue.description.is_none());
567 assert!(issue.assignees.is_empty());
568 }
569
570 #[test]
571 fn test_issue_deserialize_null_description() {
572 let json = r#"{
573 "iid": 1,
574 "title": "t",
575 "description": null,
576 "state": "opened",
577 "web_url": "u"
578 }"#;
579 let issue: Issue = serde_json::from_str(json).unwrap();
580 assert!(issue.description.is_none());
581 }
582
583 #[test]
584 fn test_graphql_url() {
585 let client = Client::new(
586 "https://gitlab.com",
587 "CentOS/Hyperscale/rpms/perf",
588 "fake-token",
589 )
590 .unwrap();
591 assert_eq!(
592 client.graphql_url(),
593 "https://gitlab.com/api/graphql"
594 );
595 }
596
597 #[test]
598 fn test_parse_work_item_status_found() {
599 let json: serde_json::Value = serde_json::from_str(
600 r#"{
601 "data": {
602 "project": {
603 "workItems": {
604 "nodes": [{
605 "widgets": [
606 { "type": "ASSIGNEES" },
607 {
608 "type": "STATUS",
609 "status": {
610 "name": "To do"
611 }
612 }
613 ]
614 }]
615 }
616 }
617 }
618 }"#,
619 )
620 .unwrap();
621 assert_eq!(
622 parse_work_item_status(&json).as_deref(),
623 Some("To do")
624 );
625 }
626
627 #[test]
628 fn test_parse_work_item_status_in_progress() {
629 let json: serde_json::Value = serde_json::from_str(
630 r#"{
631 "data": {
632 "project": {
633 "workItems": {
634 "nodes": [{
635 "widgets": [
636 {
637 "type": "STATUS",
638 "status": {
639 "name": "In progress"
640 }
641 }
642 ]
643 }]
644 }
645 }
646 }
647 }"#,
648 )
649 .unwrap();
650 assert_eq!(
651 parse_work_item_status(&json).as_deref(),
652 Some("In progress")
653 );
654 }
655
656 #[test]
657 fn test_parse_work_item_status_no_status_widget() {
658 let json: serde_json::Value = serde_json::from_str(
659 r#"{
660 "data": {
661 "project": {
662 "workItems": {
663 "nodes": [{
664 "widgets": [
665 { "type": "ASSIGNEES" },
666 { "type": "LABELS" }
667 ]
668 }]
669 }
670 }
671 }
672 }"#,
673 )
674 .unwrap();
675 assert!(parse_work_item_status(&json).is_none());
676 }
677
678 #[test]
679 fn test_parse_work_item_status_empty_nodes() {
680 let json: serde_json::Value = serde_json::from_str(
681 r#"{
682 "data": {
683 "project": {
684 "workItems": {
685 "nodes": []
686 }
687 }
688 }
689 }"#,
690 )
691 .unwrap();
692 assert!(parse_work_item_status(&json).is_none());
693 }
694
695 #[test]
696 fn test_parse_work_item_status_null_status() {
697 let json: serde_json::Value = serde_json::from_str(
698 r#"{
699 "data": {
700 "project": {
701 "workItems": {
702 "nodes": [{
703 "widgets": [
704 {
705 "type": "STATUS",
706 "status": null
707 }
708 ]
709 }]
710 }
711 }
712 }
713 }"#,
714 )
715 .unwrap();
716 assert!(parse_work_item_status(&json).is_none());
717 }
718
719 #[test]
720 fn test_package_from_issue_url() {
721 assert_eq!(
722 package_from_issue_url(
723 "https://gitlab.com/CentOS/Hyperscale/\
724 rpms/ethtool/-/issues/1"
725 ),
726 Some("ethtool")
727 );
728 assert_eq!(
729 package_from_issue_url(
730 "https://gitlab.com/group/project/-/issues/42"
731 ),
732 Some("project")
733 );
734 }
735
736 #[test]
737 fn test_package_from_issue_url_no_issues_path() {
738 assert_eq!(
739 package_from_issue_url(
740 "https://gitlab.com/group/project"
741 ),
742 Some("project")
743 );
744 }
745
746 #[test]
747 fn test_package_from_issue_url_empty() {
748 assert_eq!(package_from_issue_url(""), None);
749 }
750
751 #[test]
752 fn test_project_path_from_issue_url() {
753 assert_eq!(
754 project_path_from_issue_url(
755 "https://gitlab.com/CentOS/Hyperscale/\
756 rpms/ethtool/-/issues/1"
757 )
758 .as_deref(),
759 Some("CentOS/Hyperscale/rpms/ethtool")
760 );
761 }
762
763 #[test]
764 fn test_project_path_from_issue_url_no_issues() {
765 assert_eq!(
766 project_path_from_issue_url(
767 "https://gitlab.com/group/project"
768 )
769 .as_deref(),
770 Some("group/project")
771 );
772 }
773
774 #[test]
775 fn test_project_path_from_issue_url_no_scheme() {
776 assert!(project_path_from_issue_url(
777 "gitlab.com/group/project"
778 )
779 .is_none());
780 }
781
782 #[test]
783 fn test_group_client_issues_url() {
784 let client = GroupClient::new(
785 "https://gitlab.com",
786 "CentOS/Hyperscale/rpms",
787 "fake-token",
788 )
789 .unwrap();
790 assert_eq!(
791 client.issues_url(),
792 "https://gitlab.com/api/v4/groups/\
793 CentOS%2FHyperscale%2Frpms/issues"
794 );
795 }
796
797 #[test]
798 fn test_group_client_graphql_url() {
799 let client = GroupClient::new(
800 "https://gitlab.com",
801 "CentOS/Hyperscale/rpms",
802 "fake-token",
803 )
804 .unwrap();
805 assert_eq!(
806 client.graphql_url(),
807 "https://gitlab.com/api/graphql"
808 );
809 }
810}