1use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
6use serde::Deserialize;
7
8#[derive(Debug, Deserialize)]
10pub struct Assignee {
11 pub username: String,
12}
13
14#[derive(Debug, Deserialize)]
16pub struct Issue {
17 pub iid: u64,
18 pub title: String,
19 pub description: Option<String>,
20 pub state: String,
21 pub web_url: String,
22 #[serde(default)]
23 pub assignees: Vec<Assignee>,
24}
25
26pub struct Client {
28 http: reqwest::blocking::Client,
29 base_url: String,
30 project_path: String,
31}
32
33fn build_http_client(token: &str) -> Result<reqwest::blocking::Client, Box<dyn std::error::Error>> {
35 let mut headers = HeaderMap::new();
36 headers.insert(
37 HeaderName::from_static("private-token"),
38 HeaderValue::from_str(token)?,
39 );
40 Ok(reqwest::blocking::Client::builder()
41 .user_agent("sandogasa-gitlab/0.6.2")
42 .default_headers(headers)
43 .build()?)
44}
45
46impl Client {
47 pub fn from_project_url(url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
49 let (base_url, project_path) = parse_project_url(url)?;
50 Self::new(&base_url, &project_path, token)
51 }
52
53 pub fn new(
55 base_url: &str,
56 project_path: &str,
57 token: &str,
58 ) -> Result<Self, Box<dyn std::error::Error>> {
59 let http = build_http_client(token)?;
60 Ok(Self {
61 http,
62 base_url: base_url.trim_end_matches('/').to_string(),
63 project_path: project_path.to_string(),
64 })
65 }
66
67 pub fn create_issue(
69 &self,
70 title: &str,
71 description: Option<&str>,
72 labels: Option<&str>,
73 ) -> Result<Issue, Box<dyn std::error::Error>> {
74 let mut body = serde_json::json!({"title": title});
75 if let Some(desc) = description {
76 body["description"] = desc.into();
77 }
78 if let Some(labels) = labels {
79 body["labels"] = labels.into();
80 }
81
82 let resp = self.http.post(self.issues_url()).json(&body).send()?;
83 check_response(resp)
84 }
85
86 pub fn list_issues(
88 &self,
89 label: &str,
90 state: Option<&str>,
91 ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
92 let mut query = vec![("labels", label)];
93 if let Some(s) = state {
94 query.push(("state", s));
95 }
96 let resp = self.http.get(self.issues_url()).query(&query).send()?;
97 if !resp.status().is_success() {
98 let status = resp.status();
99 let text = resp.text()?;
100 return Err(format!("GitLab API error {status}: {text}").into());
101 }
102 Ok(resp.json()?)
103 }
104
105 pub fn add_note(&self, iid: u64, body: &str) -> Result<(), Box<dyn std::error::Error>> {
107 let payload = serde_json::json!({ "body": body });
108 let resp = self
109 .http
110 .post(format!("{}/{iid}/notes", self.issues_url()))
111 .json(&payload)
112 .send()?;
113 if !resp.status().is_success() {
114 let status = resp.status();
115 let text = resp.text()?;
116 return Err(format!("GitLab API error {status}: {text}").into());
117 }
118 Ok(())
119 }
120
121 pub fn edit_issue(
123 &self,
124 iid: u64,
125 updates: &IssueUpdate,
126 ) -> Result<Issue, Box<dyn std::error::Error>> {
127 let body = serde_json::to_value(updates)?;
128 let resp = self
129 .http
130 .put(format!("{}/{iid}", self.issues_url()))
131 .json(&body)
132 .send()?;
133 check_response(resp)
134 }
135
136 pub fn get_work_item_status(
141 &self,
142 iid: u64,
143 ) -> Result<Option<String>, Box<dyn std::error::Error>> {
144 let query = format!(
145 r#"{{ project(fullPath: "{}") {{
146 workItems(iids: ["{}"]) {{
147 nodes {{ widgets {{
148 type
149 ... on WorkItemWidgetStatus {{
150 status {{ name }}
151 }}
152 }} }}
153 }}
154 }} }}"#,
155 self.project_path, iid
156 );
157 let body = serde_json::json!({ "query": query });
158 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
159 if !resp.status().is_success() {
160 let status = resp.status();
161 let text = resp.text()?;
162 return Err(format!("GitLab GraphQL error {status}: {text}").into());
163 }
164 let json: serde_json::Value = resp.json()?;
165 Ok(parse_work_item_status(&json))
166 }
167
168 pub fn set_work_item_status(
174 &self,
175 iid: u64,
176 status: &str,
177 ) -> Result<(), Box<dyn std::error::Error>> {
178 let work_item_id = self.get_work_item_id(iid)?;
179 let status_id = self.resolve_status_id(status)?;
180 let query = format!(
181 r#"mutation {{
182 workItemUpdate(input: {{
183 id: "{work_item_id}"
184 statusWidget: {{ status: "{status_id}" }}
185 }}) {{
186 errors
187 }}
188 }}"#,
189 );
190 let body = serde_json::json!({ "query": query });
191 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
192 if !resp.status().is_success() {
193 let http_status = resp.status();
194 let text = resp.text()?;
195 return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
196 }
197 let json: serde_json::Value = resp.json()?;
198 if let Some(errors) = parse_mutation_errors(&json) {
199 return Err(format!("workItemUpdate errors: {errors:?}").into());
200 }
201 Ok(())
202 }
203
204 fn get_work_item_id(&self, iid: u64) -> Result<String, Box<dyn std::error::Error>> {
206 let query = format!(
207 r#"{{ project(fullPath: "{}") {{
208 workItems(iids: ["{}"]) {{
209 nodes {{ id }}
210 }}
211 }} }}"#,
212 self.project_path, iid
213 );
214 let body = serde_json::json!({ "query": query });
215 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
216 if !resp.status().is_success() {
217 let status = resp.status();
218 let text = resp.text()?;
219 return Err(format!("GitLab GraphQL error {status}: {text}").into());
220 }
221 let json: serde_json::Value = resp.json()?;
222 parse_work_item_id(&json).ok_or_else(|| "work item not found".into())
223 }
224
225 fn resolve_status_id(&self, name: &str) -> Result<String, Box<dyn std::error::Error>> {
227 let query = format!(
228 r#"{{ project(fullPath: "{}") {{
229 workItemTypes(name: ISSUE) {{
230 nodes {{
231 widgetDefinitions {{
232 type
233 ... on WorkItemWidgetDefinitionStatus {{
234 allowedStatuses {{ id name }}
235 }}
236 }}
237 }}
238 }}
239 }} }}"#,
240 self.project_path
241 );
242 let body = serde_json::json!({ "query": query });
243 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
244 if !resp.status().is_success() {
245 let http_status = resp.status();
246 let text = resp.text()?;
247 return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
248 }
249 let json: serde_json::Value = resp.json()?;
250 parse_status_id(&json, name)
251 .ok_or_else(|| format!("status {name:?} not found in project").into())
252 }
253
254 fn issues_url(&self) -> String {
255 let encoded = self.project_path.replace('/', "%2F");
256 format!("{}/api/v4/projects/{}/issues", self.base_url, encoded)
257 }
258
259 fn graphql_url(&self) -> String {
260 format!("{}/api/graphql", self.base_url)
261 }
262}
263
264fn parse_work_item_status(json: &serde_json::Value) -> Option<String> {
266 json.pointer("/data/project/workItems/nodes/0/widgets")
267 .and_then(|w| w.as_array())
268 .and_then(|widgets| {
269 widgets
270 .iter()
271 .find(|w| w.get("type").and_then(|t| t.as_str()) == Some("STATUS"))
272 })
273 .and_then(|w| w.pointer("/status/name"))
274 .and_then(|n| n.as_str())
275 .map(String::from)
276}
277
278fn parse_work_item_id(json: &serde_json::Value) -> Option<String> {
280 json.pointer("/data/project/workItems/nodes/0/id")
281 .and_then(|v| v.as_str())
282 .map(String::from)
283}
284
285fn parse_mutation_errors(json: &serde_json::Value) -> Option<Vec<String>> {
287 let errors = json.pointer("/data/workItemUpdate/errors")?.as_array()?;
288 if errors.is_empty() {
289 return None;
290 }
291 Some(
292 errors
293 .iter()
294 .filter_map(|e| e.as_str().map(String::from))
295 .collect(),
296 )
297}
298
299fn parse_status_id(json: &serde_json::Value, name: &str) -> Option<String> {
301 let types = json
302 .pointer("/data/project/workItemTypes/nodes")?
303 .as_array()?;
304 for work_item_type in types {
305 let defs = work_item_type.get("widgetDefinitions")?.as_array()?;
306 for def in defs {
307 if def.get("type").and_then(|t| t.as_str()) != Some("STATUS") {
308 continue;
309 }
310 let statuses = def.get("allowedStatuses")?.as_array()?;
311 for status in statuses {
312 if status.get("name").and_then(|n| n.as_str()) == Some(name) {
313 return status.get("id").and_then(|v| v.as_str()).map(String::from);
314 }
315 }
316 }
317 }
318 None
319}
320
321pub struct GroupClient {
323 http: reqwest::blocking::Client,
324 base_url: String,
325 group_path: String,
326}
327
328impl GroupClient {
329 pub fn from_group_url(url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
331 let (base_url, group_path) = parse_project_url(url)?;
332 Self::new(&base_url, &group_path, token)
333 }
334
335 pub fn new(
337 base_url: &str,
338 group_path: &str,
339 token: &str,
340 ) -> Result<Self, Box<dyn std::error::Error>> {
341 let http = build_http_client(token)?;
342 Ok(Self {
343 http,
344 base_url: base_url.trim_end_matches('/').to_string(),
345 group_path: group_path.to_string(),
346 })
347 }
348
349 pub fn list_issues(
352 &self,
353 label: &str,
354 state: Option<&str>,
355 ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
356 let mut all_issues = Vec::new();
357 let mut page = 1u32;
358 loop {
359 let page_str = page.to_string();
360 let mut query = vec![("labels", label), ("per_page", "100"), ("page", &page_str)];
361 if let Some(s) = state {
362 query.push(("state", s));
363 }
364 let resp = self.http.get(self.issues_url()).query(&query).send()?;
365 if !resp.status().is_success() {
366 let status = resp.status();
367 let text = resp.text()?;
368 return Err(format!("GitLab API error {status}: {text}").into());
369 }
370 let next_page = resp
371 .headers()
372 .get("x-next-page")
373 .and_then(|v| v.to_str().ok())
374 .unwrap_or("")
375 .to_string();
376 let issues: Vec<Issue> = resp.json()?;
377 all_issues.extend(issues);
378 if next_page.is_empty() {
379 break;
380 }
381 page = next_page.parse()?;
382 }
383 Ok(all_issues)
384 }
385
386 pub fn get_work_item_status(
388 &self,
389 project_path: &str,
390 iid: u64,
391 ) -> Result<Option<String>, Box<dyn std::error::Error>> {
392 let query = format!(
393 r#"{{ project(fullPath: "{}") {{
394 workItems(iids: ["{}"]) {{
395 nodes {{ widgets {{
396 type
397 ... on WorkItemWidgetStatus {{
398 status {{ name }}
399 }}
400 }} }}
401 }}
402 }} }}"#,
403 project_path, iid
404 );
405 let body = serde_json::json!({ "query": query });
406 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
407 if !resp.status().is_success() {
408 let status = resp.status();
409 let text = resp.text()?;
410 return Err(format!("GitLab GraphQL error {status}: {text}").into());
411 }
412 let json: serde_json::Value = resp.json()?;
413 Ok(parse_work_item_status(&json))
414 }
415
416 fn issues_url(&self) -> String {
417 let encoded = self.group_path.replace('/', "%2F");
418 format!("{}/api/v4/groups/{}/issues", self.base_url, encoded)
419 }
420
421 fn graphql_url(&self) -> String {
422 format!("{}/api/graphql", self.base_url)
423 }
424}
425
426pub fn package_from_issue_url(web_url: &str) -> Option<&str> {
431 let project_part = web_url.split("/-/issues/").next()?;
432 let name = project_part.rsplit('/').next()?;
433 if name.is_empty() { None } else { Some(name) }
434}
435
436pub fn project_path_from_issue_url(web_url: &str) -> Option<String> {
441 let project_part = web_url.split("/-/issues/").next()?;
442 let rest = project_part
443 .strip_prefix("https://")
444 .or_else(|| project_part.strip_prefix("http://"))?;
445 let slash = rest.find('/')?;
446 let path = &rest[slash + 1..];
447 if path.is_empty() {
448 None
449 } else {
450 Some(path.to_string())
451 }
452}
453
454#[derive(Debug, Default, serde::Serialize)]
456pub struct IssueUpdate {
457 #[serde(skip_serializing_if = "Option::is_none")]
458 pub title: Option<String>,
459 #[serde(skip_serializing_if = "Option::is_none")]
460 pub description: Option<String>,
461 #[serde(skip_serializing_if = "Option::is_none")]
462 pub add_labels: Option<String>,
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub remove_labels: Option<String>,
465 #[serde(skip_serializing_if = "Option::is_none")]
466 pub state_event: Option<String>,
467}
468
469fn check_response(resp: reqwest::blocking::Response) -> Result<Issue, Box<dyn std::error::Error>> {
470 if !resp.status().is_success() {
471 let status = resp.status();
472 let text = resp.text()?;
473 return Err(format!("GitLab API error {status}: {text}").into());
474 }
475 Ok(resp.json()?)
476}
477
478pub fn validate_token(base_url: &str, token: &str) -> Result<bool, Box<dyn std::error::Error>> {
480 let mut headers = HeaderMap::new();
481 headers.insert(
482 HeaderName::from_static("private-token"),
483 HeaderValue::from_str(token)?,
484 );
485 let client = reqwest::blocking::Client::builder()
486 .user_agent("sandogasa-gitlab/0.6.2")
487 .default_headers(headers)
488 .build()?;
489 let url = format!("{}/api/v4/user", base_url.trim_end_matches('/'));
490 let resp = client.get(&url).send()?;
491 Ok(resp.status().is_success())
492}
493
494pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
499 let url = url.trim_end_matches('/');
500 let rest = url
501 .strip_prefix("https://")
502 .or_else(|| url.strip_prefix("http://"))
503 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
504
505 let slash = rest
506 .find('/')
507 .ok_or_else(|| format!("no project path in URL: {url}"))?;
508
509 let host = &rest[..slash];
510 let path = &rest[slash + 1..];
511
512 if path.is_empty() {
513 return Err(format!("no project path in URL: {url}"));
514 }
515
516 let scheme = if url.starts_with("https://") {
517 "https"
518 } else {
519 "http"
520 };
521 Ok((format!("{scheme}://{host}"), path.to_string()))
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn test_parse_project_url() {
530 let (base, path) =
531 parse_project_url("https://gitlab.com/CentOS/Hyperscale/rpms/perf").unwrap();
532 assert_eq!(base, "https://gitlab.com");
533 assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
534 }
535
536 #[test]
537 fn test_parse_project_url_trailing_slash() {
538 let (base, path) = parse_project_url("https://gitlab.com/group/project/").unwrap();
539 assert_eq!(base, "https://gitlab.com");
540 assert_eq!(path, "group/project");
541 }
542
543 #[test]
544 fn test_parse_project_url_http() {
545 let (base, path) = parse_project_url("http://gitlab.example.com/group/project").unwrap();
546 assert_eq!(base, "http://gitlab.example.com");
547 assert_eq!(path, "group/project");
548 }
549
550 #[test]
551 fn test_parse_project_url_no_scheme() {
552 assert!(parse_project_url("gitlab.com/group/project").is_err());
553 }
554
555 #[test]
556 fn test_parse_project_url_no_path() {
557 assert!(parse_project_url("https://gitlab.com/").is_err());
558 assert!(parse_project_url("https://gitlab.com").is_err());
559 }
560
561 #[test]
562 fn test_issues_url() {
563 let client = Client::new(
564 "https://gitlab.com",
565 "CentOS/Hyperscale/rpms/perf",
566 "fake-token",
567 )
568 .unwrap();
569 assert_eq!(
570 client.issues_url(),
571 "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
572 );
573 }
574
575 #[test]
576 fn test_issue_update_serialization() {
577 let update = IssueUpdate {
578 title: Some("new title".into()),
579 add_labels: Some("bug".into()),
580 ..Default::default()
581 };
582 let json = serde_json::to_value(&update).unwrap();
583 assert_eq!(json["title"], "new title");
584 assert_eq!(json["add_labels"], "bug");
585 assert!(json.get("description").is_none());
586 assert!(json.get("state_event").is_none());
587 }
588
589 #[test]
590 fn test_issue_deserialize() {
591 let json = r#"{
592 "iid": 42,
593 "title": "Test issue",
594 "description": "Some description",
595 "state": "opened",
596 "web_url": "https://gitlab.com/group/project/-/issues/42",
597 "assignees": [
598 {"username": "alice"},
599 {"username": "bob"}
600 ]
601 }"#;
602 let issue: Issue = serde_json::from_str(json).unwrap();
603 assert_eq!(issue.iid, 42);
604 assert_eq!(issue.title, "Test issue");
605 assert_eq!(issue.description.as_deref(), Some("Some description"));
606 assert_eq!(issue.state, "opened");
607 assert_eq!(issue.assignees.len(), 2);
608 assert_eq!(issue.assignees[0].username, "alice");
609 assert_eq!(issue.assignees[1].username, "bob");
610 }
611
612 #[test]
613 fn test_issue_deserialize_no_assignees() {
614 let json =
615 r#"{"iid": 1, "title": "t", "description": null, "state": "opened", "web_url": "u"}"#;
616 let issue: Issue = serde_json::from_str(json).unwrap();
617 assert!(issue.description.is_none());
618 assert!(issue.assignees.is_empty());
619 }
620
621 #[test]
622 fn test_graphql_url() {
623 let client = Client::new(
624 "https://gitlab.com",
625 "CentOS/Hyperscale/rpms/perf",
626 "fake-token",
627 )
628 .unwrap();
629 assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
630 }
631
632 #[test]
633 fn test_parse_work_item_status_found() {
634 let json: serde_json::Value = serde_json::from_str(
635 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"STATUS","status":{"name":"To do"}}]}]}}}}"#,
636 ).unwrap();
637 assert_eq!(parse_work_item_status(&json).as_deref(), Some("To do"));
638 }
639
640 #[test]
641 fn test_parse_work_item_status_in_progress() {
642 let json: serde_json::Value = serde_json::from_str(
643 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":{"name":"In progress"}}]}]}}}}"#,
644 ).unwrap();
645 assert_eq!(
646 parse_work_item_status(&json).as_deref(),
647 Some("In progress")
648 );
649 }
650
651 #[test]
652 fn test_parse_work_item_status_no_status_widget() {
653 let json: serde_json::Value = serde_json::from_str(
654 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"LABELS"}]}]}}}}"#,
655 ).unwrap();
656 assert!(parse_work_item_status(&json).is_none());
657 }
658
659 #[test]
660 fn test_parse_work_item_status_empty_nodes() {
661 let json: serde_json::Value =
662 serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
663 assert!(parse_work_item_status(&json).is_none());
664 }
665
666 #[test]
667 fn test_parse_work_item_status_null_status() {
668 let json: serde_json::Value = serde_json::from_str(
669 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":null}]}]}}}}"#,
670 ).unwrap();
671 assert!(parse_work_item_status(&json).is_none());
672 }
673
674 #[test]
675 fn test_package_from_issue_url() {
676 assert_eq!(
677 package_from_issue_url("https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"),
678 Some("ethtool")
679 );
680 assert_eq!(
681 package_from_issue_url("https://gitlab.com/group/project/-/issues/42"),
682 Some("project")
683 );
684 }
685
686 #[test]
687 fn test_package_from_issue_url_no_issues_path() {
688 assert_eq!(
689 package_from_issue_url("https://gitlab.com/group/project"),
690 Some("project")
691 );
692 }
693
694 #[test]
695 fn test_package_from_issue_url_empty() {
696 assert_eq!(package_from_issue_url(""), None);
697 }
698
699 #[test]
700 fn test_project_path_from_issue_url() {
701 assert_eq!(
702 project_path_from_issue_url(
703 "https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"
704 )
705 .as_deref(),
706 Some("CentOS/Hyperscale/rpms/ethtool")
707 );
708 }
709
710 #[test]
711 fn test_project_path_from_issue_url_no_issues() {
712 assert_eq!(
713 project_path_from_issue_url("https://gitlab.com/group/project").as_deref(),
714 Some("group/project")
715 );
716 }
717
718 #[test]
719 fn test_project_path_from_issue_url_no_scheme() {
720 assert!(project_path_from_issue_url("gitlab.com/group/project").is_none());
721 }
722
723 #[test]
724 fn test_parse_work_item_id_found() {
725 let json: serde_json::Value = serde_json::from_str(
726 r#"{"data":{"project":{"workItems":{"nodes":[{"id":"gid://gitlab/WorkItem/42"}]}}}}"#,
727 )
728 .unwrap();
729 assert_eq!(
730 parse_work_item_id(&json).as_deref(),
731 Some("gid://gitlab/WorkItem/42")
732 );
733 }
734
735 #[test]
736 fn test_parse_work_item_id_empty() {
737 let json: serde_json::Value =
738 serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
739 assert!(parse_work_item_id(&json).is_none());
740 }
741
742 #[test]
743 fn test_parse_mutation_errors_none() {
744 let json: serde_json::Value =
745 serde_json::from_str(r#"{"data":{"workItemUpdate":{"errors":[]}}}"#).unwrap();
746 assert!(parse_mutation_errors(&json).is_none());
747 }
748
749 #[test]
750 fn test_parse_mutation_errors_present() {
751 let json: serde_json::Value = serde_json::from_str(
752 r#"{"data":{"workItemUpdate":{"errors":["something went wrong"]}}}"#,
753 )
754 .unwrap();
755 let errors = parse_mutation_errors(&json).unwrap();
756 assert_eq!(errors, vec!["something went wrong"]);
757 }
758
759 #[test]
760 fn test_parse_status_id_found() {
761 let json: serde_json::Value = serde_json::from_str(
762 r#"{"data":{"project":{"workItemTypes":{"nodes":[{"widgetDefinitions":[{"type":"ASSIGNEES"},{"type":"STATUS","allowedStatuses":[{"id":"gid://gitlab/WorkItems::Statuses::Custom::Status/1","name":"To do"},{"id":"gid://gitlab/WorkItems::Statuses::Custom::Status/2","name":"In progress"}]}]}]}}}}"#,
763 ).unwrap();
764 assert_eq!(
765 parse_status_id(&json, "In progress").as_deref(),
766 Some("gid://gitlab/WorkItems::Statuses::Custom::Status/2")
767 );
768 }
769
770 #[test]
771 fn test_parse_status_id_not_found() {
772 let json: serde_json::Value = serde_json::from_str(
773 r#"{"data":{"project":{"workItemTypes":{"nodes":[{"widgetDefinitions":[{"type":"STATUS","allowedStatuses":[{"id":"gid://id/1","name":"To do"}]}]}]}}}}"#,
774 ).unwrap();
775 assert!(parse_status_id(&json, "In progress").is_none());
776 }
777
778 #[test]
779 fn test_group_client_issues_url() {
780 let client =
781 GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
782 assert_eq!(
783 client.issues_url(),
784 "https://gitlab.com/api/v4/groups/CentOS%2FHyperscale%2Frpms/issues"
785 );
786 }
787
788 #[test]
789 fn test_group_client_graphql_url() {
790 let client =
791 GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
792 assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
793 }
794
795 #[test]
796 fn test_add_note_success() {
797 let mut server = mockito::Server::new();
798 let mock = server
799 .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
800 .match_header("private-token", "tok")
801 .match_body(mockito::Matcher::Json(serde_json::json!({"body": "hello"})))
802 .with_status(201)
803 .with_body("{}")
804 .create();
805 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
806 client.add_note(1, "hello").unwrap();
807 mock.assert();
808 }
809
810 #[test]
811 fn test_add_note_error() {
812 let mut server = mockito::Server::new();
813 let mock = server
814 .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
815 .with_status(403)
816 .with_body("forbidden")
817 .create();
818 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
819 let err = client.add_note(1, "x").unwrap_err();
820 assert!(err.to_string().contains("403"), "{}", err);
821 mock.assert();
822 }
823
824 #[test]
825 fn test_edit_issue_success() {
826 let mut server = mockito::Server::new();
827 let mock = server
828 .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
829 .match_header("private-token", "tok")
830 .with_status(200)
831 .with_header("content-type", "application/json")
832 .with_body(r#"{"iid":5,"title":"t","description":null,"state":"closed","web_url":"https://example.com/-/issues/5"}"#)
833 .create();
834 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
835 let updates = IssueUpdate {
836 state_event: Some("close".into()),
837 ..Default::default()
838 };
839 let issue = client.edit_issue(5, &updates).unwrap();
840 assert_eq!(issue.state, "closed");
841 mock.assert();
842 }
843
844 #[test]
845 fn test_edit_issue_error() {
846 let mut server = mockito::Server::new();
847 let mock = server
848 .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
849 .with_status(404)
850 .with_body("not found")
851 .create();
852 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
853 let updates = IssueUpdate::default();
854 let err = client.edit_issue(5, &updates).unwrap_err();
855 assert!(err.to_string().contains("404"), "{}", err);
856 mock.assert();
857 }
858
859 #[test]
860 fn test_create_issue_success() {
861 let mut server = mockito::Server::new();
862 let mock = server
863 .mock("POST", "/api/v4/projects/g%2Fp/issues")
864 .match_header("private-token", "tok")
865 .with_status(201)
866 .with_header("content-type", "application/json")
867 .with_body(r#"{"iid":10,"title":"new issue","description":"desc","state":"opened","web_url":"https://example.com/-/issues/10"}"#)
868 .create();
869 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
870 let issue = client
871 .create_issue("new issue", Some("desc"), Some("bug"))
872 .unwrap();
873 assert_eq!(issue.iid, 10);
874 assert_eq!(issue.title, "new issue");
875 mock.assert();
876 }
877
878 #[test]
879 fn test_list_issues_success() {
880 let mut server = mockito::Server::new();
881 let mock = server
882 .mock("GET", "/api/v4/projects/g%2Fp/issues")
883 .match_query(mockito::Matcher::AllOf(vec![
884 mockito::Matcher::UrlEncoded("labels".into(), "relmon".into()),
885 mockito::Matcher::UrlEncoded("state".into(), "opened".into()),
886 ]))
887 .with_status(200)
888 .with_header("content-type", "application/json")
889 .with_body(
890 r#"[{"iid":1,"title":"t","description":null,"state":"opened","web_url":"u"}]"#,
891 )
892 .create();
893 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
894 let issues = client.list_issues("relmon", Some("opened")).unwrap();
895 assert_eq!(issues.len(), 1);
896 assert_eq!(issues[0].iid, 1);
897 mock.assert();
898 }
899
900 #[test]
901 fn test_list_issues_error() {
902 let mut server = mockito::Server::new();
903 let mock = server
904 .mock("GET", "/api/v4/projects/g%2Fp/issues")
905 .match_query(mockito::Matcher::Any)
906 .with_status(500)
907 .with_body("internal error")
908 .create();
909 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
910 let err = client.list_issues("relmon", None).unwrap_err();
911 assert!(err.to_string().contains("500"), "{}", err);
912 mock.assert();
913 }
914}