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
494#[derive(Debug, Deserialize)]
496pub struct GroupProject {
497 pub name: String,
498 pub path: String,
499}
500
501pub fn list_group_projects(
507 group_url: &str,
508) -> Result<Vec<GroupProject>, Box<dyn std::error::Error>> {
509 let (base_url, group_path) = parse_project_url(group_url)?;
510 let encoded = group_path.replace('/', "%2F");
511 let client = reqwest::blocking::Client::builder()
512 .user_agent("sandogasa-gitlab")
513 .build()?;
514 let mut all = Vec::new();
515 let mut page = 1u32;
516 loop {
517 let url = format!(
518 "{}/api/v4/groups/{}/projects?per_page=100&page={}&simple=true&include_subgroups=false",
519 base_url, encoded, page
520 );
521 eprint!("\r fetching page {page}...");
522 let resp = get_with_retry_blocking(&client, &url)?;
523 let next_page = resp
524 .headers()
525 .get("x-next-page")
526 .and_then(|v| v.to_str().ok())
527 .unwrap_or("")
528 .to_string();
529 let projects: Vec<GroupProject> = resp.json()?;
530 all.extend(projects);
531 if next_page.is_empty() {
532 break;
533 }
534 page = next_page.parse()?;
535 }
536 eprintln!("\r fetched {} project(s)", all.len());
537 Ok(all)
538}
539
540fn get_with_retry_blocking(
542 client: &reqwest::blocking::Client,
543 url: &str,
544) -> Result<reqwest::blocking::Response, Box<dyn std::error::Error>> {
545 let mut last_err = None;
546 for attempt in 0..=3u32 {
547 let resp = client.get(url).send()?;
548 let status = resp.status();
549 if status == reqwest::StatusCode::INTERNAL_SERVER_ERROR
550 || status == reqwest::StatusCode::BAD_GATEWAY
551 || status == reqwest::StatusCode::SERVICE_UNAVAILABLE
552 || status == reqwest::StatusCode::GATEWAY_TIMEOUT
553 {
554 let delay = std::time::Duration::from_secs(1 << attempt);
555 eprintln!(
556 " {status}, retrying in {}s ({}/3)",
557 delay.as_secs(),
558 attempt + 1,
559 );
560 std::thread::sleep(delay);
561 last_err = Some(format!("{status} for {url}"));
562 continue;
563 }
564 if !resp.status().is_success() {
565 let text = resp.text()?;
566 return Err(format!("GitLab API error {status}: {text}").into());
567 }
568 return Ok(resp);
569 }
570 Err(last_err.unwrap().into())
571}
572
573pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
578 let url = url.trim_end_matches('/');
579 let rest = url
580 .strip_prefix("https://")
581 .or_else(|| url.strip_prefix("http://"))
582 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
583
584 let slash = rest
585 .find('/')
586 .ok_or_else(|| format!("no project path in URL: {url}"))?;
587
588 let host = &rest[..slash];
589 let path = &rest[slash + 1..];
590
591 if path.is_empty() {
592 return Err(format!("no project path in URL: {url}"));
593 }
594
595 let scheme = if url.starts_with("https://") {
596 "https"
597 } else {
598 "http"
599 };
600 Ok((format!("{scheme}://{host}"), path.to_string()))
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 #[test]
608 fn test_parse_project_url() {
609 let (base, path) =
610 parse_project_url("https://gitlab.com/CentOS/Hyperscale/rpms/perf").unwrap();
611 assert_eq!(base, "https://gitlab.com");
612 assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
613 }
614
615 #[test]
616 fn test_parse_project_url_trailing_slash() {
617 let (base, path) = parse_project_url("https://gitlab.com/group/project/").unwrap();
618 assert_eq!(base, "https://gitlab.com");
619 assert_eq!(path, "group/project");
620 }
621
622 #[test]
623 fn test_parse_project_url_http() {
624 let (base, path) = parse_project_url("http://gitlab.example.com/group/project").unwrap();
625 assert_eq!(base, "http://gitlab.example.com");
626 assert_eq!(path, "group/project");
627 }
628
629 #[test]
630 fn test_parse_project_url_no_scheme() {
631 assert!(parse_project_url("gitlab.com/group/project").is_err());
632 }
633
634 #[test]
635 fn test_parse_project_url_no_path() {
636 assert!(parse_project_url("https://gitlab.com/").is_err());
637 assert!(parse_project_url("https://gitlab.com").is_err());
638 }
639
640 #[test]
641 fn test_issues_url() {
642 let client = Client::new(
643 "https://gitlab.com",
644 "CentOS/Hyperscale/rpms/perf",
645 "fake-token",
646 )
647 .unwrap();
648 assert_eq!(
649 client.issues_url(),
650 "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
651 );
652 }
653
654 #[test]
655 fn test_issue_update_serialization() {
656 let update = IssueUpdate {
657 title: Some("new title".into()),
658 add_labels: Some("bug".into()),
659 ..Default::default()
660 };
661 let json = serde_json::to_value(&update).unwrap();
662 assert_eq!(json["title"], "new title");
663 assert_eq!(json["add_labels"], "bug");
664 assert!(json.get("description").is_none());
665 assert!(json.get("state_event").is_none());
666 }
667
668 #[test]
669 fn test_issue_deserialize() {
670 let json = r#"{
671 "iid": 42,
672 "title": "Test issue",
673 "description": "Some description",
674 "state": "opened",
675 "web_url": "https://gitlab.com/group/project/-/issues/42",
676 "assignees": [
677 {"username": "alice"},
678 {"username": "bob"}
679 ]
680 }"#;
681 let issue: Issue = serde_json::from_str(json).unwrap();
682 assert_eq!(issue.iid, 42);
683 assert_eq!(issue.title, "Test issue");
684 assert_eq!(issue.description.as_deref(), Some("Some description"));
685 assert_eq!(issue.state, "opened");
686 assert_eq!(issue.assignees.len(), 2);
687 assert_eq!(issue.assignees[0].username, "alice");
688 assert_eq!(issue.assignees[1].username, "bob");
689 }
690
691 #[test]
692 fn test_issue_deserialize_no_assignees() {
693 let json =
694 r#"{"iid": 1, "title": "t", "description": null, "state": "opened", "web_url": "u"}"#;
695 let issue: Issue = serde_json::from_str(json).unwrap();
696 assert!(issue.description.is_none());
697 assert!(issue.assignees.is_empty());
698 }
699
700 #[test]
701 fn test_graphql_url() {
702 let client = Client::new(
703 "https://gitlab.com",
704 "CentOS/Hyperscale/rpms/perf",
705 "fake-token",
706 )
707 .unwrap();
708 assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
709 }
710
711 #[test]
712 fn test_parse_work_item_status_found() {
713 let json: serde_json::Value = serde_json::from_str(
714 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"STATUS","status":{"name":"To do"}}]}]}}}}"#,
715 ).unwrap();
716 assert_eq!(parse_work_item_status(&json).as_deref(), Some("To do"));
717 }
718
719 #[test]
720 fn test_parse_work_item_status_in_progress() {
721 let json: serde_json::Value = serde_json::from_str(
722 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":{"name":"In progress"}}]}]}}}}"#,
723 ).unwrap();
724 assert_eq!(
725 parse_work_item_status(&json).as_deref(),
726 Some("In progress")
727 );
728 }
729
730 #[test]
731 fn test_parse_work_item_status_no_status_widget() {
732 let json: serde_json::Value = serde_json::from_str(
733 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"LABELS"}]}]}}}}"#,
734 ).unwrap();
735 assert!(parse_work_item_status(&json).is_none());
736 }
737
738 #[test]
739 fn test_parse_work_item_status_empty_nodes() {
740 let json: serde_json::Value =
741 serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
742 assert!(parse_work_item_status(&json).is_none());
743 }
744
745 #[test]
746 fn test_parse_work_item_status_null_status() {
747 let json: serde_json::Value = serde_json::from_str(
748 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":null}]}]}}}}"#,
749 ).unwrap();
750 assert!(parse_work_item_status(&json).is_none());
751 }
752
753 #[test]
754 fn test_package_from_issue_url() {
755 assert_eq!(
756 package_from_issue_url("https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"),
757 Some("ethtool")
758 );
759 assert_eq!(
760 package_from_issue_url("https://gitlab.com/group/project/-/issues/42"),
761 Some("project")
762 );
763 }
764
765 #[test]
766 fn test_package_from_issue_url_no_issues_path() {
767 assert_eq!(
768 package_from_issue_url("https://gitlab.com/group/project"),
769 Some("project")
770 );
771 }
772
773 #[test]
774 fn test_package_from_issue_url_empty() {
775 assert_eq!(package_from_issue_url(""), None);
776 }
777
778 #[test]
779 fn test_project_path_from_issue_url() {
780 assert_eq!(
781 project_path_from_issue_url(
782 "https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"
783 )
784 .as_deref(),
785 Some("CentOS/Hyperscale/rpms/ethtool")
786 );
787 }
788
789 #[test]
790 fn test_project_path_from_issue_url_no_issues() {
791 assert_eq!(
792 project_path_from_issue_url("https://gitlab.com/group/project").as_deref(),
793 Some("group/project")
794 );
795 }
796
797 #[test]
798 fn test_project_path_from_issue_url_no_scheme() {
799 assert!(project_path_from_issue_url("gitlab.com/group/project").is_none());
800 }
801
802 #[test]
803 fn test_parse_work_item_id_found() {
804 let json: serde_json::Value = serde_json::from_str(
805 r#"{"data":{"project":{"workItems":{"nodes":[{"id":"gid://gitlab/WorkItem/42"}]}}}}"#,
806 )
807 .unwrap();
808 assert_eq!(
809 parse_work_item_id(&json).as_deref(),
810 Some("gid://gitlab/WorkItem/42")
811 );
812 }
813
814 #[test]
815 fn test_parse_work_item_id_empty() {
816 let json: serde_json::Value =
817 serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
818 assert!(parse_work_item_id(&json).is_none());
819 }
820
821 #[test]
822 fn test_parse_mutation_errors_none() {
823 let json: serde_json::Value =
824 serde_json::from_str(r#"{"data":{"workItemUpdate":{"errors":[]}}}"#).unwrap();
825 assert!(parse_mutation_errors(&json).is_none());
826 }
827
828 #[test]
829 fn test_parse_mutation_errors_present() {
830 let json: serde_json::Value = serde_json::from_str(
831 r#"{"data":{"workItemUpdate":{"errors":["something went wrong"]}}}"#,
832 )
833 .unwrap();
834 let errors = parse_mutation_errors(&json).unwrap();
835 assert_eq!(errors, vec!["something went wrong"]);
836 }
837
838 #[test]
839 fn test_parse_status_id_found() {
840 let json: serde_json::Value = serde_json::from_str(
841 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"}]}]}]}}}}"#,
842 ).unwrap();
843 assert_eq!(
844 parse_status_id(&json, "In progress").as_deref(),
845 Some("gid://gitlab/WorkItems::Statuses::Custom::Status/2")
846 );
847 }
848
849 #[test]
850 fn test_parse_status_id_not_found() {
851 let json: serde_json::Value = serde_json::from_str(
852 r#"{"data":{"project":{"workItemTypes":{"nodes":[{"widgetDefinitions":[{"type":"STATUS","allowedStatuses":[{"id":"gid://id/1","name":"To do"}]}]}]}}}}"#,
853 ).unwrap();
854 assert!(parse_status_id(&json, "In progress").is_none());
855 }
856
857 #[test]
858 fn test_group_client_issues_url() {
859 let client =
860 GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
861 assert_eq!(
862 client.issues_url(),
863 "https://gitlab.com/api/v4/groups/CentOS%2FHyperscale%2Frpms/issues"
864 );
865 }
866
867 #[test]
868 fn test_group_client_graphql_url() {
869 let client =
870 GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
871 assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
872 }
873
874 #[test]
875 fn test_add_note_success() {
876 let mut server = mockito::Server::new();
877 let mock = server
878 .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
879 .match_header("private-token", "tok")
880 .match_body(mockito::Matcher::Json(serde_json::json!({"body": "hello"})))
881 .with_status(201)
882 .with_body("{}")
883 .create();
884 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
885 client.add_note(1, "hello").unwrap();
886 mock.assert();
887 }
888
889 #[test]
890 fn test_add_note_error() {
891 let mut server = mockito::Server::new();
892 let mock = server
893 .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
894 .with_status(403)
895 .with_body("forbidden")
896 .create();
897 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
898 let err = client.add_note(1, "x").unwrap_err();
899 assert!(err.to_string().contains("403"), "{}", err);
900 mock.assert();
901 }
902
903 #[test]
904 fn test_edit_issue_success() {
905 let mut server = mockito::Server::new();
906 let mock = server
907 .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
908 .match_header("private-token", "tok")
909 .with_status(200)
910 .with_header("content-type", "application/json")
911 .with_body(r#"{"iid":5,"title":"t","description":null,"state":"closed","web_url":"https://example.com/-/issues/5"}"#)
912 .create();
913 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
914 let updates = IssueUpdate {
915 state_event: Some("close".into()),
916 ..Default::default()
917 };
918 let issue = client.edit_issue(5, &updates).unwrap();
919 assert_eq!(issue.state, "closed");
920 mock.assert();
921 }
922
923 #[test]
924 fn test_edit_issue_error() {
925 let mut server = mockito::Server::new();
926 let mock = server
927 .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
928 .with_status(404)
929 .with_body("not found")
930 .create();
931 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
932 let updates = IssueUpdate::default();
933 let err = client.edit_issue(5, &updates).unwrap_err();
934 assert!(err.to_string().contains("404"), "{}", err);
935 mock.assert();
936 }
937
938 #[test]
939 fn test_create_issue_success() {
940 let mut server = mockito::Server::new();
941 let mock = server
942 .mock("POST", "/api/v4/projects/g%2Fp/issues")
943 .match_header("private-token", "tok")
944 .with_status(201)
945 .with_header("content-type", "application/json")
946 .with_body(r#"{"iid":10,"title":"new issue","description":"desc","state":"opened","web_url":"https://example.com/-/issues/10"}"#)
947 .create();
948 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
949 let issue = client
950 .create_issue("new issue", Some("desc"), Some("bug"))
951 .unwrap();
952 assert_eq!(issue.iid, 10);
953 assert_eq!(issue.title, "new issue");
954 mock.assert();
955 }
956
957 #[test]
958 fn test_list_issues_success() {
959 let mut server = mockito::Server::new();
960 let mock = server
961 .mock("GET", "/api/v4/projects/g%2Fp/issues")
962 .match_query(mockito::Matcher::AllOf(vec![
963 mockito::Matcher::UrlEncoded("labels".into(), "relmon".into()),
964 mockito::Matcher::UrlEncoded("state".into(), "opened".into()),
965 ]))
966 .with_status(200)
967 .with_header("content-type", "application/json")
968 .with_body(
969 r#"[{"iid":1,"title":"t","description":null,"state":"opened","web_url":"u"}]"#,
970 )
971 .create();
972 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
973 let issues = client.list_issues("relmon", Some("opened")).unwrap();
974 assert_eq!(issues.len(), 1);
975 assert_eq!(issues[0].iid, 1);
976 mock.assert();
977 }
978
979 #[test]
980 fn test_list_issues_error() {
981 let mut server = mockito::Server::new();
982 let mock = server
983 .mock("GET", "/api/v4/projects/g%2Fp/issues")
984 .match_query(mockito::Matcher::Any)
985 .with_status(500)
986 .with_body("internal error")
987 .create();
988 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
989 let err = client.list_issues("relmon", None).unwrap_err();
990 assert!(err.to_string().contains("500"), "{}", err);
991 mock.assert();
992 }
993}