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 #[serde(default)]
26 pub start_date: Option<String>,
27 #[serde(default)]
29 pub due_date: Option<String>,
30 #[serde(default)]
35 pub created_at: Option<String>,
36}
37
38#[derive(Debug, Deserialize)]
40pub struct MergeRequest {
41 pub iid: u64,
42 pub title: String,
43 #[serde(default)]
44 pub description: Option<String>,
45 pub state: String,
46 pub web_url: String,
47 pub source_branch: String,
48 pub target_branch: String,
49}
50
51pub struct Client {
53 http: reqwest::blocking::Client,
54 base_url: String,
55 project_path: String,
56}
57
58fn build_http_client(token: &str) -> Result<reqwest::blocking::Client, Box<dyn std::error::Error>> {
60 let mut headers = HeaderMap::new();
61 headers.insert(
62 HeaderName::from_static("private-token"),
63 HeaderValue::from_str(token)?,
64 );
65 Ok(reqwest::blocking::Client::builder()
66 .user_agent("sandogasa-gitlab/0.6.2")
67 .default_headers(headers)
68 .build()?)
69}
70
71impl Client {
72 pub fn from_project_url(url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
74 let (base_url, project_path) = parse_project_url(url)?;
75 Self::new(&base_url, &project_path, token)
76 }
77
78 pub fn new(
80 base_url: &str,
81 project_path: &str,
82 token: &str,
83 ) -> Result<Self, Box<dyn std::error::Error>> {
84 let http = build_http_client(token)?;
85 Ok(Self {
86 http,
87 base_url: base_url.trim_end_matches('/').to_string(),
88 project_path: project_path.to_string(),
89 })
90 }
91
92 pub fn merge_request(&self, iid: u64) -> Result<MergeRequest, Box<dyn std::error::Error>> {
94 let encoded = self.project_path.replace('/', "%2F");
95 let url = format!(
96 "{}/api/v4/projects/{}/merge_requests/{}",
97 self.base_url, encoded, iid
98 );
99 let resp = self.http.get(&url).send()?;
100 if !resp.status().is_success() {
101 let status = resp.status();
102 let text = resp.text()?;
103 return Err(format!("GitLab GET {url} failed: {status}: {text}").into());
104 }
105 Ok(resp.json()?)
106 }
107
108 pub fn issue(&self, iid: u64) -> Result<Issue, Box<dyn std::error::Error>> {
110 let encoded = self.project_path.replace('/', "%2F");
111 let url = format!(
112 "{}/api/v4/projects/{}/issues/{}",
113 self.base_url, encoded, iid
114 );
115 let resp = self.http.get(&url).send()?;
116 if !resp.status().is_success() {
117 let status = resp.status();
118 let text = resp.text()?;
119 return Err(format!("GitLab GET {url} failed: {status}: {text}").into());
120 }
121 Ok(resp.json()?)
122 }
123
124 pub fn create_issue(
126 &self,
127 title: &str,
128 description: Option<&str>,
129 labels: Option<&str>,
130 ) -> Result<Issue, Box<dyn std::error::Error>> {
131 let mut body = serde_json::json!({"title": title});
132 if let Some(desc) = description {
133 body["description"] = desc.into();
134 }
135 if let Some(labels) = labels {
136 body["labels"] = labels.into();
137 }
138
139 let resp = self.http.post(self.issues_url()).json(&body).send()?;
140 check_response(resp)
141 }
142
143 pub fn list_issues(
145 &self,
146 label: &str,
147 state: Option<&str>,
148 ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
149 let mut query = vec![("labels", label)];
150 if let Some(s) = state {
151 query.push(("state", s));
152 }
153 let resp = self.http.get(self.issues_url()).query(&query).send()?;
154 if !resp.status().is_success() {
155 let status = resp.status();
156 let text = resp.text()?;
157 return Err(format!("GitLab API error {status}: {text}").into());
158 }
159 Ok(resp.json()?)
160 }
161
162 pub fn add_note(&self, iid: u64, body: &str) -> Result<(), Box<dyn std::error::Error>> {
164 let payload = serde_json::json!({ "body": body });
165 let resp = self
166 .http
167 .post(format!("{}/{iid}/notes", self.issues_url()))
168 .json(&payload)
169 .send()?;
170 if !resp.status().is_success() {
171 let status = resp.status();
172 let text = resp.text()?;
173 return Err(format!("GitLab API error {status}: {text}").into());
174 }
175 Ok(())
176 }
177
178 pub fn edit_issue(
180 &self,
181 iid: u64,
182 updates: &IssueUpdate,
183 ) -> Result<Issue, Box<dyn std::error::Error>> {
184 let body = serde_json::to_value(updates)?;
185 let resp = self
186 .http
187 .put(format!("{}/{iid}", self.issues_url()))
188 .json(&body)
189 .send()?;
190 check_response(resp)
191 }
192
193 pub fn get_work_item_status(
198 &self,
199 iid: u64,
200 ) -> Result<Option<String>, Box<dyn std::error::Error>> {
201 let query = format!(
202 r#"{{ project(fullPath: "{}") {{
203 workItems(iids: ["{}"]) {{
204 nodes {{ widgets {{
205 type
206 ... on WorkItemWidgetStatus {{
207 status {{ name }}
208 }}
209 }} }}
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 Ok(parse_work_item_status(&json))
223 }
224
225 pub fn set_work_item_dates(
234 &self,
235 iid: u64,
236 start_date: Option<&str>,
237 due_date: Option<&str>,
238 ) -> Result<(), Box<dyn std::error::Error>> {
239 if start_date.is_none() && due_date.is_none() {
240 return Ok(());
241 }
242 let work_item_id = self.get_work_item_id(iid)?;
243 let mut widget_fields: Vec<String> = Vec::new();
244 if let Some(sd) = start_date {
245 widget_fields.push(format!(r#"startDate: "{sd}""#));
246 }
247 if let Some(dd) = due_date {
248 widget_fields.push(format!(r#"dueDate: "{dd}""#));
249 }
250 let query = format!(
251 r#"mutation {{
252 workItemUpdate(input: {{
253 id: "{work_item_id}"
254 startAndDueDateWidget: {{ {} }}
255 }}) {{
256 errors
257 }}
258 }}"#,
259 widget_fields.join(" "),
260 );
261 let body = serde_json::json!({ "query": query });
262 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
263 if !resp.status().is_success() {
264 let http_status = resp.status();
265 let text = resp.text()?;
266 return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
267 }
268 let json: serde_json::Value = resp.json()?;
269 if let Some(errors) = parse_mutation_errors(&json) {
270 return Err(format!("workItemUpdate errors: {errors:?}").into());
271 }
272 Ok(())
273 }
274
275 pub fn set_work_item_status(
281 &self,
282 iid: u64,
283 status: &str,
284 ) -> Result<(), Box<dyn std::error::Error>> {
285 let work_item_id = self.get_work_item_id(iid)?;
286 let status_id = self.resolve_status_id(status)?;
287 let query = format!(
288 r#"mutation {{
289 workItemUpdate(input: {{
290 id: "{work_item_id}"
291 statusWidget: {{ status: "{status_id}" }}
292 }}) {{
293 errors
294 }}
295 }}"#,
296 );
297 let body = serde_json::json!({ "query": query });
298 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
299 if !resp.status().is_success() {
300 let http_status = resp.status();
301 let text = resp.text()?;
302 return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
303 }
304 let json: serde_json::Value = resp.json()?;
305 if let Some(errors) = parse_mutation_errors(&json) {
306 return Err(format!("workItemUpdate errors: {errors:?}").into());
307 }
308 Ok(())
309 }
310
311 fn get_work_item_id(&self, iid: u64) -> Result<String, Box<dyn std::error::Error>> {
313 let query = format!(
314 r#"{{ project(fullPath: "{}") {{
315 workItems(iids: ["{}"]) {{
316 nodes {{ id }}
317 }}
318 }} }}"#,
319 self.project_path, iid
320 );
321 let body = serde_json::json!({ "query": query });
322 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
323 if !resp.status().is_success() {
324 let status = resp.status();
325 let text = resp.text()?;
326 return Err(format!("GitLab GraphQL error {status}: {text}").into());
327 }
328 let json: serde_json::Value = resp.json()?;
329 parse_work_item_id(&json).ok_or_else(|| "work item not found".into())
330 }
331
332 fn resolve_status_id(&self, name: &str) -> Result<String, Box<dyn std::error::Error>> {
334 let query = format!(
335 r#"{{ project(fullPath: "{}") {{
336 workItemTypes(name: ISSUE) {{
337 nodes {{
338 widgetDefinitions {{
339 type
340 ... on WorkItemWidgetDefinitionStatus {{
341 allowedStatuses {{ id name }}
342 }}
343 }}
344 }}
345 }}
346 }} }}"#,
347 self.project_path
348 );
349 let body = serde_json::json!({ "query": query });
350 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
351 if !resp.status().is_success() {
352 let http_status = resp.status();
353 let text = resp.text()?;
354 return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
355 }
356 let json: serde_json::Value = resp.json()?;
357 parse_status_id(&json, name)
358 .ok_or_else(|| format!("status {name:?} not found in project").into())
359 }
360
361 fn issues_url(&self) -> String {
362 let encoded = self.project_path.replace('/', "%2F");
363 format!("{}/api/v4/projects/{}/issues", self.base_url, encoded)
364 }
365
366 fn graphql_url(&self) -> String {
367 format!("{}/api/graphql", self.base_url)
368 }
369}
370
371fn parse_work_item_status(json: &serde_json::Value) -> Option<String> {
373 json.pointer("/data/project/workItems/nodes/0/widgets")
374 .and_then(|w| w.as_array())
375 .and_then(|widgets| {
376 widgets
377 .iter()
378 .find(|w| w.get("type").and_then(|t| t.as_str()) == Some("STATUS"))
379 })
380 .and_then(|w| w.pointer("/status/name"))
381 .and_then(|n| n.as_str())
382 .map(String::from)
383}
384
385fn parse_work_item_id(json: &serde_json::Value) -> Option<String> {
387 json.pointer("/data/project/workItems/nodes/0/id")
388 .and_then(|v| v.as_str())
389 .map(String::from)
390}
391
392fn parse_mutation_errors(json: &serde_json::Value) -> Option<Vec<String>> {
394 let errors = json.pointer("/data/workItemUpdate/errors")?.as_array()?;
395 if errors.is_empty() {
396 return None;
397 }
398 Some(
399 errors
400 .iter()
401 .filter_map(|e| e.as_str().map(String::from))
402 .collect(),
403 )
404}
405
406fn parse_status_id(json: &serde_json::Value, name: &str) -> Option<String> {
408 let types = json
409 .pointer("/data/project/workItemTypes/nodes")?
410 .as_array()?;
411 for work_item_type in types {
412 let defs = work_item_type.get("widgetDefinitions")?.as_array()?;
413 for def in defs {
414 if def.get("type").and_then(|t| t.as_str()) != Some("STATUS") {
415 continue;
416 }
417 let statuses = def.get("allowedStatuses")?.as_array()?;
418 for status in statuses {
419 if status.get("name").and_then(|n| n.as_str()) == Some(name) {
420 return status.get("id").and_then(|v| v.as_str()).map(String::from);
421 }
422 }
423 }
424 }
425 None
426}
427
428pub struct GroupClient {
430 http: reqwest::blocking::Client,
431 base_url: String,
432 group_path: String,
433}
434
435impl GroupClient {
436 pub fn from_group_url(url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
438 let (base_url, group_path) = parse_project_url(url)?;
439 Self::new(&base_url, &group_path, token)
440 }
441
442 pub fn new(
444 base_url: &str,
445 group_path: &str,
446 token: &str,
447 ) -> Result<Self, Box<dyn std::error::Error>> {
448 let http = build_http_client(token)?;
449 Ok(Self {
450 http,
451 base_url: base_url.trim_end_matches('/').to_string(),
452 group_path: group_path.to_string(),
453 })
454 }
455
456 pub fn list_issues(
459 &self,
460 label: &str,
461 state: Option<&str>,
462 ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
463 let mut all_issues = Vec::new();
464 let mut page = 1u32;
465 loop {
466 let page_str = page.to_string();
467 let mut query = vec![("labels", label), ("per_page", "100"), ("page", &page_str)];
468 if let Some(s) = state {
469 query.push(("state", s));
470 }
471 let resp = self.http.get(self.issues_url()).query(&query).send()?;
472 if !resp.status().is_success() {
473 let status = resp.status();
474 let text = resp.text()?;
475 return Err(format!("GitLab API error {status}: {text}").into());
476 }
477 let next_page = resp
478 .headers()
479 .get("x-next-page")
480 .and_then(|v| v.to_str().ok())
481 .unwrap_or("")
482 .to_string();
483 let issues: Vec<Issue> = resp.json()?;
484 all_issues.extend(issues);
485 if next_page.is_empty() {
486 break;
487 }
488 page = next_page.parse()?;
489 }
490 Ok(all_issues)
491 }
492
493 pub fn get_work_item_status(
495 &self,
496 project_path: &str,
497 iid: u64,
498 ) -> Result<Option<String>, Box<dyn std::error::Error>> {
499 let query = format!(
500 r#"{{ project(fullPath: "{}") {{
501 workItems(iids: ["{}"]) {{
502 nodes {{ widgets {{
503 type
504 ... on WorkItemWidgetStatus {{
505 status {{ name }}
506 }}
507 }} }}
508 }}
509 }} }}"#,
510 project_path, iid
511 );
512 let body = serde_json::json!({ "query": query });
513 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
514 if !resp.status().is_success() {
515 let status = resp.status();
516 let text = resp.text()?;
517 return Err(format!("GitLab GraphQL error {status}: {text}").into());
518 }
519 let json: serde_json::Value = resp.json()?;
520 Ok(parse_work_item_status(&json))
521 }
522
523 fn issues_url(&self) -> String {
524 let encoded = self.group_path.replace('/', "%2F");
525 format!("{}/api/v4/groups/{}/issues", self.base_url, encoded)
526 }
527
528 fn graphql_url(&self) -> String {
529 format!("{}/api/graphql", self.base_url)
530 }
531}
532
533fn project_part_of_issue_url(web_url: &str) -> &str {
539 for sep in ["/-/issues/", "/-/work_items/"] {
540 if let Some(idx) = web_url.find(sep) {
541 return &web_url[..idx];
542 }
543 }
544 web_url
545}
546
547pub fn package_from_issue_url(web_url: &str) -> Option<&str> {
554 let project_part = project_part_of_issue_url(web_url);
555 let name = project_part.rsplit('/').next()?;
556 if name.is_empty() { None } else { Some(name) }
557}
558
559pub fn project_path_from_issue_url(web_url: &str) -> Option<String> {
566 let project_part = project_part_of_issue_url(web_url);
567 let rest = project_part
568 .strip_prefix("https://")
569 .or_else(|| project_part.strip_prefix("http://"))?;
570 let slash = rest.find('/')?;
571 let path = &rest[slash + 1..];
572 if path.is_empty() {
573 None
574 } else {
575 Some(path.to_string())
576 }
577}
578
579#[derive(Debug, Default, serde::Serialize)]
581pub struct IssueUpdate {
582 #[serde(skip_serializing_if = "Option::is_none")]
583 pub title: Option<String>,
584 #[serde(skip_serializing_if = "Option::is_none")]
585 pub description: Option<String>,
586 #[serde(skip_serializing_if = "Option::is_none")]
587 pub add_labels: Option<String>,
588 #[serde(skip_serializing_if = "Option::is_none")]
589 pub remove_labels: Option<String>,
590 #[serde(skip_serializing_if = "Option::is_none")]
591 pub state_event: Option<String>,
592 #[serde(skip_serializing_if = "Option::is_none")]
595 pub start_date: Option<String>,
596 #[serde(skip_serializing_if = "Option::is_none")]
599 pub due_date: Option<String>,
600}
601
602fn check_response(resp: reqwest::blocking::Response) -> Result<Issue, Box<dyn std::error::Error>> {
603 if !resp.status().is_success() {
604 let status = resp.status();
605 let text = resp.text()?;
606 return Err(format!("GitLab API error {status}: {text}").into());
607 }
608 Ok(resp.json()?)
609}
610
611pub fn validate_token(base_url: &str, token: &str) -> Result<bool, Box<dyn std::error::Error>> {
613 let mut headers = HeaderMap::new();
614 headers.insert(
615 HeaderName::from_static("private-token"),
616 HeaderValue::from_str(token)?,
617 );
618 let client = reqwest::blocking::Client::builder()
619 .user_agent("sandogasa-gitlab/0.6.2")
620 .default_headers(headers)
621 .build()?;
622 let url = format!("{}/api/v4/user", base_url.trim_end_matches('/'));
623 let resp = client.get(&url).send()?;
624 Ok(resp.status().is_success())
625}
626
627#[derive(Debug, Deserialize)]
629pub struct GroupProject {
630 pub name: String,
631 pub path: String,
632}
633
634pub fn list_group_projects(
640 group_url: &str,
641) -> Result<Vec<GroupProject>, Box<dyn std::error::Error>> {
642 let (base_url, group_path) = parse_project_url(group_url)?;
643 let encoded = group_path.replace('/', "%2F");
644 let client = reqwest::blocking::Client::builder()
645 .user_agent("sandogasa-gitlab")
646 .build()?;
647 let mut all = Vec::new();
648 let mut page = 1u32;
649 loop {
650 let url = format!(
651 "{}/api/v4/groups/{}/projects?per_page=100&page={}&simple=true&include_subgroups=false",
652 base_url, encoded, page
653 );
654 eprint!("\r fetching page {page}...");
655 let resp = get_with_retry_blocking(&client, &url)?;
656 let next_page = resp
657 .headers()
658 .get("x-next-page")
659 .and_then(|v| v.to_str().ok())
660 .unwrap_or("")
661 .to_string();
662 let projects: Vec<GroupProject> = resp.json()?;
663 all.extend(projects);
664 if next_page.is_empty() {
665 break;
666 }
667 page = next_page.parse()?;
668 }
669 eprintln!("\r fetched {} project(s)", all.len());
670 Ok(all)
671}
672
673fn get_with_retry_blocking(
675 client: &reqwest::blocking::Client,
676 url: &str,
677) -> Result<reqwest::blocking::Response, Box<dyn std::error::Error>> {
678 let mut last_err = None;
679 for attempt in 0..=3u32 {
680 let resp = client.get(url).send()?;
681 let status = resp.status();
682 if status == reqwest::StatusCode::INTERNAL_SERVER_ERROR
683 || status == reqwest::StatusCode::BAD_GATEWAY
684 || status == reqwest::StatusCode::SERVICE_UNAVAILABLE
685 || status == reqwest::StatusCode::GATEWAY_TIMEOUT
686 {
687 let delay = std::time::Duration::from_secs(1 << attempt);
688 eprintln!(
689 " {status}, retrying in {}s ({}/3)",
690 delay.as_secs(),
691 attempt + 1,
692 );
693 std::thread::sleep(delay);
694 last_err = Some(format!("{status} for {url}"));
695 continue;
696 }
697 if !resp.status().is_success() {
698 let text = resp.text()?;
699 return Err(format!("GitLab API error {status}: {text}").into());
700 }
701 return Ok(resp);
702 }
703 Err(last_err.unwrap().into())
704}
705
706pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
711 let url = url.trim_end_matches('/');
712 let rest = url
713 .strip_prefix("https://")
714 .or_else(|| url.strip_prefix("http://"))
715 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
716
717 let slash = rest
718 .find('/')
719 .ok_or_else(|| format!("no project path in URL: {url}"))?;
720
721 let host = &rest[..slash];
722 let path = &rest[slash + 1..];
723
724 if path.is_empty() {
725 return Err(format!("no project path in URL: {url}"));
726 }
727
728 let scheme = if url.starts_with("https://") {
729 "https"
730 } else {
731 "http"
732 };
733 Ok((format!("{scheme}://{host}"), path.to_string()))
734}
735
736pub fn parse_mr_url(url: &str) -> Result<(String, String, u64), String> {
742 let trimmed = url.trim_end_matches('/');
743 let rest = trimmed
744 .strip_prefix("https://")
745 .or_else(|| trimmed.strip_prefix("http://"))
746 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
747 let slash = rest
748 .find('/')
749 .ok_or_else(|| format!("no project path in URL: {url}"))?;
750 let host = &rest[..slash];
751 let path = &rest[slash + 1..];
752
753 let scheme = if trimmed.starts_with("https://") {
754 "https"
755 } else {
756 "http"
757 };
758
759 let (project, iid_str) = path
760 .rsplit_once("/-/merge_requests/")
761 .ok_or_else(|| format!("not a merge request URL: {url}"))?;
762 let iid_str = iid_str.split(['?', '#']).next().unwrap_or(iid_str);
764 let iid: u64 = iid_str
765 .parse()
766 .map_err(|_| format!("invalid merge request IID in URL: {url}"))?;
767
768 if project.is_empty() {
769 return Err(format!("no project path in URL: {url}"));
770 }
771
772 Ok((format!("{scheme}://{host}"), project.to_string(), iid))
773}
774
775pub fn parse_issue_url(url: &str) -> Result<(String, String, u64), String> {
782 let trimmed = url.trim_end_matches('/');
783 let rest = trimmed
784 .strip_prefix("https://")
785 .or_else(|| trimmed.strip_prefix("http://"))
786 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
787 let slash = rest
788 .find('/')
789 .ok_or_else(|| format!("no project path in URL: {url}"))?;
790 let host = &rest[..slash];
791 let path = &rest[slash + 1..];
792
793 let scheme = if trimmed.starts_with("https://") {
794 "https"
795 } else {
796 "http"
797 };
798
799 let (project, iid_str) = path
800 .rsplit_once("/-/issues/")
801 .or_else(|| path.rsplit_once("/-/work_items/"))
802 .ok_or_else(|| format!("not an issue or work-item URL: {url}"))?;
803 let iid_str = iid_str.split(['?', '#']).next().unwrap_or(iid_str);
804 let iid: u64 = iid_str
805 .parse()
806 .map_err(|_| format!("invalid issue IID in URL: {url}"))?;
807
808 if project.is_empty() {
809 return Err(format!("no project path in URL: {url}"));
810 }
811
812 Ok((format!("{scheme}://{host}"), project.to_string(), iid))
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818
819 #[test]
820 fn test_parse_project_url() {
821 let (base, path) =
822 parse_project_url("https://gitlab.com/CentOS/Hyperscale/rpms/perf").unwrap();
823 assert_eq!(base, "https://gitlab.com");
824 assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
825 }
826
827 #[test]
828 fn test_parse_project_url_trailing_slash() {
829 let (base, path) = parse_project_url("https://gitlab.com/group/project/").unwrap();
830 assert_eq!(base, "https://gitlab.com");
831 assert_eq!(path, "group/project");
832 }
833
834 #[test]
835 fn test_parse_project_url_http() {
836 let (base, path) = parse_project_url("http://gitlab.example.com/group/project").unwrap();
837 assert_eq!(base, "http://gitlab.example.com");
838 assert_eq!(path, "group/project");
839 }
840
841 #[test]
842 fn test_parse_project_url_no_scheme() {
843 assert!(parse_project_url("gitlab.com/group/project").is_err());
844 }
845
846 #[test]
847 fn test_parse_project_url_no_path() {
848 assert!(parse_project_url("https://gitlab.com/").is_err());
849 assert!(parse_project_url("https://gitlab.com").is_err());
850 }
851
852 #[test]
853 fn test_issues_url() {
854 let client = Client::new(
855 "https://gitlab.com",
856 "CentOS/Hyperscale/rpms/perf",
857 "fake-token",
858 )
859 .unwrap();
860 assert_eq!(
861 client.issues_url(),
862 "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
863 );
864 }
865
866 #[test]
867 fn test_issue_update_serialization() {
868 let update = IssueUpdate {
869 title: Some("new title".into()),
870 add_labels: Some("bug".into()),
871 ..Default::default()
872 };
873 let json = serde_json::to_value(&update).unwrap();
874 assert_eq!(json["title"], "new title");
875 assert_eq!(json["add_labels"], "bug");
876 assert!(json.get("description").is_none());
877 assert!(json.get("state_event").is_none());
878 }
879
880 #[test]
881 fn test_issue_deserialize() {
882 let json = r#"{
883 "iid": 42,
884 "title": "Test issue",
885 "description": "Some description",
886 "state": "opened",
887 "web_url": "https://gitlab.com/group/project/-/issues/42",
888 "assignees": [
889 {"username": "alice"},
890 {"username": "bob"}
891 ]
892 }"#;
893 let issue: Issue = serde_json::from_str(json).unwrap();
894 assert_eq!(issue.iid, 42);
895 assert_eq!(issue.title, "Test issue");
896 assert_eq!(issue.description.as_deref(), Some("Some description"));
897 assert_eq!(issue.state, "opened");
898 assert_eq!(issue.assignees.len(), 2);
899 assert_eq!(issue.assignees[0].username, "alice");
900 assert_eq!(issue.assignees[1].username, "bob");
901 }
902
903 #[test]
904 fn test_issue_deserialize_no_assignees() {
905 let json =
906 r#"{"iid": 1, "title": "t", "description": null, "state": "opened", "web_url": "u"}"#;
907 let issue: Issue = serde_json::from_str(json).unwrap();
908 assert!(issue.description.is_none());
909 assert!(issue.assignees.is_empty());
910 }
911
912 #[test]
913 fn test_graphql_url() {
914 let client = Client::new(
915 "https://gitlab.com",
916 "CentOS/Hyperscale/rpms/perf",
917 "fake-token",
918 )
919 .unwrap();
920 assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
921 }
922
923 #[test]
924 fn test_parse_work_item_status_found() {
925 let json: serde_json::Value = serde_json::from_str(
926 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"STATUS","status":{"name":"To do"}}]}]}}}}"#,
927 ).unwrap();
928 assert_eq!(parse_work_item_status(&json).as_deref(), Some("To do"));
929 }
930
931 #[test]
932 fn test_parse_work_item_status_in_progress() {
933 let json: serde_json::Value = serde_json::from_str(
934 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":{"name":"In progress"}}]}]}}}}"#,
935 ).unwrap();
936 assert_eq!(
937 parse_work_item_status(&json).as_deref(),
938 Some("In progress")
939 );
940 }
941
942 #[test]
943 fn test_parse_work_item_status_no_status_widget() {
944 let json: serde_json::Value = serde_json::from_str(
945 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"LABELS"}]}]}}}}"#,
946 ).unwrap();
947 assert!(parse_work_item_status(&json).is_none());
948 }
949
950 #[test]
951 fn test_parse_work_item_status_empty_nodes() {
952 let json: serde_json::Value =
953 serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
954 assert!(parse_work_item_status(&json).is_none());
955 }
956
957 #[test]
958 fn test_parse_work_item_status_null_status() {
959 let json: serde_json::Value = serde_json::from_str(
960 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":null}]}]}}}}"#,
961 ).unwrap();
962 assert!(parse_work_item_status(&json).is_none());
963 }
964
965 #[test]
966 fn test_package_from_issue_url() {
967 assert_eq!(
968 package_from_issue_url("https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"),
969 Some("ethtool")
970 );
971 assert_eq!(
972 package_from_issue_url("https://gitlab.com/group/project/-/issues/42"),
973 Some("project")
974 );
975 }
976
977 #[test]
978 fn test_package_from_issue_url_no_issues_path() {
979 assert_eq!(
980 package_from_issue_url("https://gitlab.com/group/project"),
981 Some("project")
982 );
983 }
984
985 #[test]
986 fn test_package_from_issue_url_empty() {
987 assert_eq!(package_from_issue_url(""), None);
988 }
989
990 #[test]
991 fn test_package_from_issue_url_work_items_form() {
992 assert_eq!(
993 package_from_issue_url(
994 "https://gitlab.com/CentOS/proposed_updates/rpms/PackageKit/-/work_items/1"
995 ),
996 Some("PackageKit"),
997 );
998 }
999
1000 #[test]
1001 fn test_project_path_from_issue_url_work_items_form() {
1002 assert_eq!(
1003 project_path_from_issue_url(
1004 "https://gitlab.com/CentOS/proposed_updates/rpms/PackageKit/-/work_items/1"
1005 )
1006 .as_deref(),
1007 Some("CentOS/proposed_updates/rpms/PackageKit"),
1008 );
1009 }
1010
1011 #[test]
1012 fn test_project_path_from_issue_url() {
1013 assert_eq!(
1014 project_path_from_issue_url(
1015 "https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"
1016 )
1017 .as_deref(),
1018 Some("CentOS/Hyperscale/rpms/ethtool")
1019 );
1020 }
1021
1022 #[test]
1023 fn test_project_path_from_issue_url_no_issues() {
1024 assert_eq!(
1025 project_path_from_issue_url("https://gitlab.com/group/project").as_deref(),
1026 Some("group/project")
1027 );
1028 }
1029
1030 #[test]
1031 fn test_project_path_from_issue_url_no_scheme() {
1032 assert!(project_path_from_issue_url("gitlab.com/group/project").is_none());
1033 }
1034
1035 #[test]
1036 fn test_parse_work_item_id_found() {
1037 let json: serde_json::Value = serde_json::from_str(
1038 r#"{"data":{"project":{"workItems":{"nodes":[{"id":"gid://gitlab/WorkItem/42"}]}}}}"#,
1039 )
1040 .unwrap();
1041 assert_eq!(
1042 parse_work_item_id(&json).as_deref(),
1043 Some("gid://gitlab/WorkItem/42")
1044 );
1045 }
1046
1047 #[test]
1048 fn test_parse_work_item_id_empty() {
1049 let json: serde_json::Value =
1050 serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
1051 assert!(parse_work_item_id(&json).is_none());
1052 }
1053
1054 #[test]
1055 fn test_parse_mutation_errors_none() {
1056 let json: serde_json::Value =
1057 serde_json::from_str(r#"{"data":{"workItemUpdate":{"errors":[]}}}"#).unwrap();
1058 assert!(parse_mutation_errors(&json).is_none());
1059 }
1060
1061 #[test]
1062 fn test_parse_mutation_errors_present() {
1063 let json: serde_json::Value = serde_json::from_str(
1064 r#"{"data":{"workItemUpdate":{"errors":["something went wrong"]}}}"#,
1065 )
1066 .unwrap();
1067 let errors = parse_mutation_errors(&json).unwrap();
1068 assert_eq!(errors, vec!["something went wrong"]);
1069 }
1070
1071 #[test]
1072 fn test_parse_status_id_found() {
1073 let json: serde_json::Value = serde_json::from_str(
1074 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"}]}]}]}}}}"#,
1075 ).unwrap();
1076 assert_eq!(
1077 parse_status_id(&json, "In progress").as_deref(),
1078 Some("gid://gitlab/WorkItems::Statuses::Custom::Status/2")
1079 );
1080 }
1081
1082 #[test]
1083 fn test_parse_status_id_not_found() {
1084 let json: serde_json::Value = serde_json::from_str(
1085 r#"{"data":{"project":{"workItemTypes":{"nodes":[{"widgetDefinitions":[{"type":"STATUS","allowedStatuses":[{"id":"gid://id/1","name":"To do"}]}]}]}}}}"#,
1086 ).unwrap();
1087 assert!(parse_status_id(&json, "In progress").is_none());
1088 }
1089
1090 #[test]
1091 fn test_group_client_issues_url() {
1092 let client =
1093 GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
1094 assert_eq!(
1095 client.issues_url(),
1096 "https://gitlab.com/api/v4/groups/CentOS%2FHyperscale%2Frpms/issues"
1097 );
1098 }
1099
1100 #[test]
1101 fn test_group_client_graphql_url() {
1102 let client =
1103 GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
1104 assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
1105 }
1106
1107 #[test]
1108 fn test_add_note_success() {
1109 let mut server = mockito::Server::new();
1110 let mock = server
1111 .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
1112 .match_header("private-token", "tok")
1113 .match_body(mockito::Matcher::Json(serde_json::json!({"body": "hello"})))
1114 .with_status(201)
1115 .with_body("{}")
1116 .create();
1117 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1118 client.add_note(1, "hello").unwrap();
1119 mock.assert();
1120 }
1121
1122 #[test]
1123 fn test_add_note_error() {
1124 let mut server = mockito::Server::new();
1125 let mock = server
1126 .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
1127 .with_status(403)
1128 .with_body("forbidden")
1129 .create();
1130 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1131 let err = client.add_note(1, "x").unwrap_err();
1132 assert!(err.to_string().contains("403"), "{}", err);
1133 mock.assert();
1134 }
1135
1136 #[test]
1137 fn test_edit_issue_success() {
1138 let mut server = mockito::Server::new();
1139 let mock = server
1140 .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
1141 .match_header("private-token", "tok")
1142 .with_status(200)
1143 .with_header("content-type", "application/json")
1144 .with_body(r#"{"iid":5,"title":"t","description":null,"state":"closed","web_url":"https://example.com/-/issues/5"}"#)
1145 .create();
1146 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1147 let updates = IssueUpdate {
1148 state_event: Some("close".into()),
1149 ..Default::default()
1150 };
1151 let issue = client.edit_issue(5, &updates).unwrap();
1152 assert_eq!(issue.state, "closed");
1153 mock.assert();
1154 }
1155
1156 #[test]
1157 fn test_edit_issue_error() {
1158 let mut server = mockito::Server::new();
1159 let mock = server
1160 .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
1161 .with_status(404)
1162 .with_body("not found")
1163 .create();
1164 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1165 let updates = IssueUpdate::default();
1166 let err = client.edit_issue(5, &updates).unwrap_err();
1167 assert!(err.to_string().contains("404"), "{}", err);
1168 mock.assert();
1169 }
1170
1171 #[test]
1172 fn test_create_issue_success() {
1173 let mut server = mockito::Server::new();
1174 let mock = server
1175 .mock("POST", "/api/v4/projects/g%2Fp/issues")
1176 .match_header("private-token", "tok")
1177 .with_status(201)
1178 .with_header("content-type", "application/json")
1179 .with_body(r#"{"iid":10,"title":"new issue","description":"desc","state":"opened","web_url":"https://example.com/-/issues/10"}"#)
1180 .create();
1181 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1182 let issue = client
1183 .create_issue("new issue", Some("desc"), Some("bug"))
1184 .unwrap();
1185 assert_eq!(issue.iid, 10);
1186 assert_eq!(issue.title, "new issue");
1187 mock.assert();
1188 }
1189
1190 #[test]
1191 fn test_list_issues_success() {
1192 let mut server = mockito::Server::new();
1193 let mock = server
1194 .mock("GET", "/api/v4/projects/g%2Fp/issues")
1195 .match_query(mockito::Matcher::AllOf(vec![
1196 mockito::Matcher::UrlEncoded("labels".into(), "relmon".into()),
1197 mockito::Matcher::UrlEncoded("state".into(), "opened".into()),
1198 ]))
1199 .with_status(200)
1200 .with_header("content-type", "application/json")
1201 .with_body(
1202 r#"[{"iid":1,"title":"t","description":null,"state":"opened","web_url":"u"}]"#,
1203 )
1204 .create();
1205 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1206 let issues = client.list_issues("relmon", Some("opened")).unwrap();
1207 assert_eq!(issues.len(), 1);
1208 assert_eq!(issues[0].iid, 1);
1209 mock.assert();
1210 }
1211
1212 #[test]
1213 fn test_list_issues_error() {
1214 let mut server = mockito::Server::new();
1215 let mock = server
1216 .mock("GET", "/api/v4/projects/g%2Fp/issues")
1217 .match_query(mockito::Matcher::Any)
1218 .with_status(500)
1219 .with_body("internal error")
1220 .create();
1221 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1222 let err = client.list_issues("relmon", None).unwrap_err();
1223 assert!(err.to_string().contains("500"), "{}", err);
1224 mock.assert();
1225 }
1226
1227 #[test]
1230 fn parse_mr_url_standard() {
1231 let (base, project, iid) =
1232 parse_mr_url("https://gitlab.com/redhat/centos-stream/rpms/xz/-/merge_requests/42")
1233 .unwrap();
1234 assert_eq!(base, "https://gitlab.com");
1235 assert_eq!(project, "redhat/centos-stream/rpms/xz");
1236 assert_eq!(iid, 42);
1237 }
1238
1239 #[test]
1240 fn parse_mr_url_strips_trailing_slash() {
1241 let (_, _, iid) =
1242 parse_mr_url("https://gitlab.com/redhat/centos-stream/rpms/xz/-/merge_requests/42/")
1243 .unwrap();
1244 assert_eq!(iid, 42);
1245 }
1246
1247 #[test]
1248 fn parse_mr_url_strips_query() {
1249 let (_, _, iid) =
1250 parse_mr_url("https://gitlab.com/a/b/-/merge_requests/7?commit_id=abc").unwrap();
1251 assert_eq!(iid, 7);
1252 }
1253
1254 #[test]
1255 fn parse_mr_url_strips_fragment() {
1256 let (_, _, iid) =
1257 parse_mr_url("https://gitlab.com/a/b/-/merge_requests/7#note_123").unwrap();
1258 assert_eq!(iid, 7);
1259 }
1260
1261 #[test]
1262 fn parse_mr_url_rejects_issue_url() {
1263 assert!(parse_mr_url("https://gitlab.com/a/b/-/issues/1").is_err());
1264 }
1265
1266 #[test]
1267 fn parse_mr_url_rejects_non_numeric_iid() {
1268 assert!(parse_mr_url("https://gitlab.com/a/b/-/merge_requests/abc").is_err());
1269 }
1270
1271 #[test]
1272 fn parse_mr_url_rejects_no_scheme() {
1273 assert!(parse_mr_url("gitlab.com/a/b/-/merge_requests/1").is_err());
1274 }
1275
1276 #[test]
1277 fn parse_issue_url_handles_legacy_form() {
1278 let (base, project, iid) =
1279 parse_issue_url("https://gitlab.com/group/project/-/issues/42").unwrap();
1280 assert_eq!(base, "https://gitlab.com");
1281 assert_eq!(project, "group/project");
1282 assert_eq!(iid, 42);
1283 }
1284
1285 #[test]
1286 fn parse_issue_url_handles_work_items_form() {
1287 let (base, project, iid) =
1288 parse_issue_url("https://gitlab.com/CentOS/proposed_updates/rpms/xz/-/work_items/1")
1289 .unwrap();
1290 assert_eq!(base, "https://gitlab.com");
1291 assert_eq!(project, "CentOS/proposed_updates/rpms/xz");
1292 assert_eq!(iid, 1);
1293 }
1294
1295 #[test]
1296 fn parse_issue_url_strips_query_and_fragment() {
1297 let (_, _, iid) =
1298 parse_issue_url("https://gitlab.com/a/b/-/work_items/7?note=123#xyz").unwrap();
1299 assert_eq!(iid, 7);
1300 }
1301
1302 #[test]
1303 fn parse_issue_url_rejects_mr_url() {
1304 assert!(parse_issue_url("https://gitlab.com/a/b/-/merge_requests/1").is_err());
1305 }
1306
1307 #[test]
1308 fn parse_issue_url_rejects_non_numeric_iid() {
1309 assert!(parse_issue_url("https://gitlab.com/a/b/-/issues/xyz").is_err());
1310 }
1311}