1use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
6use serde::{Deserialize, Serialize};
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 sandogasa_cli::install_crypto_provider();
66 Ok(reqwest::blocking::Client::builder()
67 .user_agent("sandogasa-gitlab/0.6.2")
68 .default_headers(headers)
69 .build()?)
70}
71
72impl Client {
73 pub fn from_project_url(url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
75 let (base_url, project_path) = parse_project_url(url)?;
76 Self::new(&base_url, &project_path, token)
77 }
78
79 pub fn new(
81 base_url: &str,
82 project_path: &str,
83 token: &str,
84 ) -> Result<Self, Box<dyn std::error::Error>> {
85 sandogasa_cli::ensure_secure_url(base_url)?;
86 let http = build_http_client(token)?;
87 Ok(Self {
88 http,
89 base_url: base_url.trim_end_matches('/').to_string(),
90 project_path: project_path.to_string(),
91 })
92 }
93
94 pub fn merge_request(&self, iid: u64) -> Result<MergeRequest, Box<dyn std::error::Error>> {
96 let encoded = self.project_path.replace('/', "%2F");
97 let url = format!(
98 "{}/api/v4/projects/{}/merge_requests/{}",
99 self.base_url, encoded, iid
100 );
101 let resp = self.http.get(&url).send()?;
102 if !resp.status().is_success() {
103 let status = resp.status();
104 let text = resp.text()?;
105 return Err(format!("GitLab GET {url} failed: {status}: {text}").into());
106 }
107 Ok(resp.json()?)
108 }
109
110 pub fn issue(&self, iid: u64) -> Result<Issue, Box<dyn std::error::Error>> {
112 let encoded = self.project_path.replace('/', "%2F");
113 let url = format!(
114 "{}/api/v4/projects/{}/issues/{}",
115 self.base_url, encoded, iid
116 );
117 let resp = self.http.get(&url).send()?;
118 if !resp.status().is_success() {
119 let status = resp.status();
120 let text = resp.text()?;
121 return Err(format!("GitLab GET {url} failed: {status}: {text}").into());
122 }
123 Ok(resp.json()?)
124 }
125
126 pub fn create_issue(
128 &self,
129 title: &str,
130 description: Option<&str>,
131 labels: Option<&str>,
132 ) -> Result<Issue, Box<dyn std::error::Error>> {
133 let mut body = serde_json::json!({"title": title});
134 if let Some(desc) = description {
135 body["description"] = desc.into();
136 }
137 if let Some(labels) = labels {
138 body["labels"] = labels.into();
139 }
140
141 let resp = self.http.post(self.issues_url()).json(&body).send()?;
142 check_response(resp)
143 }
144
145 pub fn list_issues(
147 &self,
148 label: &str,
149 state: Option<&str>,
150 ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
151 let mut query = vec![("labels", label)];
152 if let Some(s) = state {
153 query.push(("state", s));
154 }
155 let resp = self.http.get(self.issues_url()).query(&query).send()?;
156 if !resp.status().is_success() {
157 let status = resp.status();
158 let text = resp.text()?;
159 return Err(format!("GitLab API error {status}: {text}").into());
160 }
161 Ok(resp.json()?)
162 }
163
164 pub fn add_note(&self, iid: u64, body: &str) -> Result<(), Box<dyn std::error::Error>> {
166 let payload = serde_json::json!({ "body": body });
167 let resp = self
168 .http
169 .post(format!("{}/{iid}/notes", self.issues_url()))
170 .json(&payload)
171 .send()?;
172 if !resp.status().is_success() {
173 let status = resp.status();
174 let text = resp.text()?;
175 return Err(format!("GitLab API error {status}: {text}").into());
176 }
177 Ok(())
178 }
179
180 pub fn edit_issue(
182 &self,
183 iid: u64,
184 updates: &IssueUpdate,
185 ) -> Result<Issue, Box<dyn std::error::Error>> {
186 let body = serde_json::to_value(updates)?;
187 let resp = self
188 .http
189 .put(format!("{}/{iid}", self.issues_url()))
190 .json(&body)
191 .send()?;
192 check_response(resp)
193 }
194
195 pub fn get_work_item_status(
200 &self,
201 iid: u64,
202 ) -> Result<Option<String>, Box<dyn std::error::Error>> {
203 let query = format!(
204 r#"{{ project(fullPath: "{}") {{
205 workItems(iids: ["{}"]) {{
206 nodes {{ widgets {{
207 type
208 ... on WorkItemWidgetStatus {{
209 status {{ name }}
210 }}
211 }} }}
212 }}
213 }} }}"#,
214 self.project_path, iid
215 );
216 let body = serde_json::json!({ "query": query });
217 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
218 if !resp.status().is_success() {
219 let status = resp.status();
220 let text = resp.text()?;
221 return Err(format!("GitLab GraphQL error {status}: {text}").into());
222 }
223 let json: serde_json::Value = resp.json()?;
224 Ok(parse_work_item_status(&json))
225 }
226
227 pub fn set_work_item_dates(
236 &self,
237 iid: u64,
238 start_date: Option<&str>,
239 due_date: Option<&str>,
240 ) -> Result<(), Box<dyn std::error::Error>> {
241 if start_date.is_none() && due_date.is_none() {
242 return Ok(());
243 }
244 let work_item_id = self.get_work_item_id(iid)?;
245 let mut widget_fields: Vec<String> = Vec::new();
246 if let Some(sd) = start_date {
247 widget_fields.push(format!(r#"startDate: "{sd}""#));
248 }
249 if let Some(dd) = due_date {
250 widget_fields.push(format!(r#"dueDate: "{dd}""#));
251 }
252 let query = format!(
253 r#"mutation {{
254 workItemUpdate(input: {{
255 id: "{work_item_id}"
256 startAndDueDateWidget: {{ {} }}
257 }}) {{
258 errors
259 }}
260 }}"#,
261 widget_fields.join(" "),
262 );
263 let body = serde_json::json!({ "query": query });
264 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
265 if !resp.status().is_success() {
266 let http_status = resp.status();
267 let text = resp.text()?;
268 return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
269 }
270 let json: serde_json::Value = resp.json()?;
271 if let Some(errors) = parse_mutation_errors(&json) {
272 return Err(format!("workItemUpdate errors: {errors:?}").into());
273 }
274 Ok(())
275 }
276
277 pub fn set_work_item_status(
283 &self,
284 iid: u64,
285 status: &str,
286 ) -> Result<(), Box<dyn std::error::Error>> {
287 let work_item_id = self.get_work_item_id(iid)?;
288 let status_id = self.resolve_status_id(status)?;
289 let query = format!(
290 r#"mutation {{
291 workItemUpdate(input: {{
292 id: "{work_item_id}"
293 statusWidget: {{ status: "{status_id}" }}
294 }}) {{
295 errors
296 }}
297 }}"#,
298 );
299 let body = serde_json::json!({ "query": query });
300 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
301 if !resp.status().is_success() {
302 let http_status = resp.status();
303 let text = resp.text()?;
304 return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
305 }
306 let json: serde_json::Value = resp.json()?;
307 if let Some(errors) = parse_mutation_errors(&json) {
308 return Err(format!("workItemUpdate errors: {errors:?}").into());
309 }
310 Ok(())
311 }
312
313 fn get_work_item_id(&self, iid: u64) -> Result<String, Box<dyn std::error::Error>> {
315 let query = format!(
316 r#"{{ project(fullPath: "{}") {{
317 workItems(iids: ["{}"]) {{
318 nodes {{ id }}
319 }}
320 }} }}"#,
321 self.project_path, iid
322 );
323 let body = serde_json::json!({ "query": query });
324 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
325 if !resp.status().is_success() {
326 let status = resp.status();
327 let text = resp.text()?;
328 return Err(format!("GitLab GraphQL error {status}: {text}").into());
329 }
330 let json: serde_json::Value = resp.json()?;
331 parse_work_item_id(&json).ok_or_else(|| "work item not found".into())
332 }
333
334 fn resolve_status_id(&self, name: &str) -> Result<String, Box<dyn std::error::Error>> {
336 let query = format!(
337 r#"{{ project(fullPath: "{}") {{
338 workItemTypes(name: ISSUE) {{
339 nodes {{
340 widgetDefinitions {{
341 type
342 ... on WorkItemWidgetDefinitionStatus {{
343 allowedStatuses {{ id name }}
344 }}
345 }}
346 }}
347 }}
348 }} }}"#,
349 self.project_path
350 );
351 let body = serde_json::json!({ "query": query });
352 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
353 if !resp.status().is_success() {
354 let http_status = resp.status();
355 let text = resp.text()?;
356 return Err(format!("GitLab GraphQL error {http_status}: {text}").into());
357 }
358 let json: serde_json::Value = resp.json()?;
359 parse_status_id(&json, name)
360 .ok_or_else(|| format!("status {name:?} not found in project").into())
361 }
362
363 fn issues_url(&self) -> String {
364 let encoded = self.project_path.replace('/', "%2F");
365 format!("{}/api/v4/projects/{}/issues", self.base_url, encoded)
366 }
367
368 fn graphql_url(&self) -> String {
369 format!("{}/api/graphql", self.base_url)
370 }
371}
372
373fn parse_work_item_status(json: &serde_json::Value) -> Option<String> {
375 json.pointer("/data/project/workItems/nodes/0/widgets")
376 .and_then(|w| w.as_array())
377 .and_then(|widgets| {
378 widgets
379 .iter()
380 .find(|w| w.get("type").and_then(|t| t.as_str()) == Some("STATUS"))
381 })
382 .and_then(|w| w.pointer("/status/name"))
383 .and_then(|n| n.as_str())
384 .map(String::from)
385}
386
387fn parse_work_item_id(json: &serde_json::Value) -> Option<String> {
389 json.pointer("/data/project/workItems/nodes/0/id")
390 .and_then(|v| v.as_str())
391 .map(String::from)
392}
393
394fn parse_mutation_errors(json: &serde_json::Value) -> Option<Vec<String>> {
396 let errors = json.pointer("/data/workItemUpdate/errors")?.as_array()?;
397 if errors.is_empty() {
398 return None;
399 }
400 Some(
401 errors
402 .iter()
403 .filter_map(|e| e.as_str().map(String::from))
404 .collect(),
405 )
406}
407
408fn parse_status_id(json: &serde_json::Value, name: &str) -> Option<String> {
410 let types = json
411 .pointer("/data/project/workItemTypes/nodes")?
412 .as_array()?;
413 for work_item_type in types {
414 let defs = work_item_type.get("widgetDefinitions")?.as_array()?;
415 for def in defs {
416 if def.get("type").and_then(|t| t.as_str()) != Some("STATUS") {
417 continue;
418 }
419 let statuses = def.get("allowedStatuses")?.as_array()?;
420 for status in statuses {
421 if status.get("name").and_then(|n| n.as_str()) == Some(name) {
422 return status.get("id").and_then(|v| v.as_str()).map(String::from);
423 }
424 }
425 }
426 }
427 None
428}
429
430pub struct GroupClient {
432 http: reqwest::blocking::Client,
433 base_url: String,
434 group_path: String,
435}
436
437impl GroupClient {
438 pub fn from_group_url(url: &str, token: &str) -> Result<Self, Box<dyn std::error::Error>> {
440 let (base_url, group_path) = parse_project_url(url)?;
441 Self::new(&base_url, &group_path, token)
442 }
443
444 pub fn new(
446 base_url: &str,
447 group_path: &str,
448 token: &str,
449 ) -> Result<Self, Box<dyn std::error::Error>> {
450 sandogasa_cli::ensure_secure_url(base_url)?;
451 let http = build_http_client(token)?;
452 Ok(Self {
453 http,
454 base_url: base_url.trim_end_matches('/').to_string(),
455 group_path: group_path.to_string(),
456 })
457 }
458
459 pub fn list_issues(
462 &self,
463 label: &str,
464 state: Option<&str>,
465 ) -> Result<Vec<Issue>, Box<dyn std::error::Error>> {
466 let mut all_issues = Vec::new();
467 let mut page = 1u32;
468 loop {
469 let page_str = page.to_string();
470 let mut query = vec![("labels", label), ("per_page", "100"), ("page", &page_str)];
471 if let Some(s) = state {
472 query.push(("state", s));
473 }
474 let resp = self.http.get(self.issues_url()).query(&query).send()?;
475 if !resp.status().is_success() {
476 let status = resp.status();
477 let text = resp.text()?;
478 return Err(format!("GitLab API error {status}: {text}").into());
479 }
480 let next_page = resp
481 .headers()
482 .get("x-next-page")
483 .and_then(|v| v.to_str().ok())
484 .unwrap_or("")
485 .to_string();
486 let issues: Vec<Issue> = resp.json()?;
487 all_issues.extend(issues);
488 if next_page.is_empty() {
489 break;
490 }
491 page = next_page.parse()?;
492 }
493 Ok(all_issues)
494 }
495
496 pub fn get_work_item_status(
498 &self,
499 project_path: &str,
500 iid: u64,
501 ) -> Result<Option<String>, Box<dyn std::error::Error>> {
502 let query = format!(
503 r#"{{ project(fullPath: "{}") {{
504 workItems(iids: ["{}"]) {{
505 nodes {{ widgets {{
506 type
507 ... on WorkItemWidgetStatus {{
508 status {{ name }}
509 }}
510 }} }}
511 }}
512 }} }}"#,
513 project_path, iid
514 );
515 let body = serde_json::json!({ "query": query });
516 let resp = self.http.post(self.graphql_url()).json(&body).send()?;
517 if !resp.status().is_success() {
518 let status = resp.status();
519 let text = resp.text()?;
520 return Err(format!("GitLab GraphQL error {status}: {text}").into());
521 }
522 let json: serde_json::Value = resp.json()?;
523 Ok(parse_work_item_status(&json))
524 }
525
526 fn issues_url(&self) -> String {
527 let encoded = self.group_path.replace('/', "%2F");
528 format!("{}/api/v4/groups/{}/issues", self.base_url, encoded)
529 }
530
531 fn graphql_url(&self) -> String {
532 format!("{}/api/graphql", self.base_url)
533 }
534}
535
536fn project_part_of_issue_url(web_url: &str) -> &str {
542 for sep in ["/-/issues/", "/-/work_items/"] {
543 if let Some(idx) = web_url.find(sep) {
544 return &web_url[..idx];
545 }
546 }
547 web_url
548}
549
550pub fn package_from_issue_url(web_url: &str) -> Option<&str> {
557 let project_part = project_part_of_issue_url(web_url);
558 let name = project_part.rsplit('/').next()?;
559 if name.is_empty() { None } else { Some(name) }
560}
561
562pub fn project_path_from_issue_url(web_url: &str) -> Option<String> {
569 let project_part = project_part_of_issue_url(web_url);
570 let rest = project_part
571 .strip_prefix("https://")
572 .or_else(|| project_part.strip_prefix("http://"))?;
573 let slash = rest.find('/')?;
574 let path = &rest[slash + 1..];
575 if path.is_empty() {
576 None
577 } else {
578 Some(path.to_string())
579 }
580}
581
582#[derive(Debug, Default, serde::Serialize)]
584pub struct IssueUpdate {
585 #[serde(skip_serializing_if = "Option::is_none")]
586 pub title: Option<String>,
587 #[serde(skip_serializing_if = "Option::is_none")]
588 pub description: Option<String>,
589 #[serde(skip_serializing_if = "Option::is_none")]
590 pub add_labels: Option<String>,
591 #[serde(skip_serializing_if = "Option::is_none")]
592 pub remove_labels: Option<String>,
593 #[serde(skip_serializing_if = "Option::is_none")]
594 pub state_event: Option<String>,
595 #[serde(skip_serializing_if = "Option::is_none")]
598 pub start_date: Option<String>,
599 #[serde(skip_serializing_if = "Option::is_none")]
602 pub due_date: Option<String>,
603}
604
605fn check_response(resp: reqwest::blocking::Response) -> Result<Issue, Box<dyn std::error::Error>> {
606 if !resp.status().is_success() {
607 let status = resp.status();
608 let text = resp.text()?;
609 return Err(format!("GitLab API error {status}: {text}").into());
610 }
611 Ok(resp.json()?)
612}
613
614pub fn validate_token(base_url: &str, token: &str) -> Result<bool, Box<dyn std::error::Error>> {
616 sandogasa_cli::ensure_secure_url(base_url)?;
617 let mut headers = HeaderMap::new();
618 headers.insert(
619 HeaderName::from_static("private-token"),
620 HeaderValue::from_str(token)?,
621 );
622 sandogasa_cli::install_crypto_provider();
623 let client = reqwest::blocking::Client::builder()
624 .user_agent("sandogasa-gitlab/0.6.2")
625 .default_headers(headers)
626 .build()?;
627 let url = format!("{}/api/v4/user", base_url.trim_end_matches('/'));
628 let resp = client.get(&url).send()?;
629 Ok(resp.status().is_success())
630}
631
632#[derive(Debug, Deserialize)]
634pub struct GroupProject {
635 pub name: String,
636 pub path: String,
637}
638
639pub fn list_group_projects(
645 group_url: &str,
646) -> Result<Vec<GroupProject>, Box<dyn std::error::Error>> {
647 list_group_projects_query(group_url, "")
648}
649
650pub fn list_archived_project_names(
655 group_url: &str,
656) -> Result<std::collections::HashSet<String>, Box<dyn std::error::Error>> {
657 Ok(list_group_projects_query(group_url, "&archived=true")?
658 .into_iter()
659 .map(|p| p.name)
660 .collect())
661}
662
663fn list_group_projects_query(
666 group_url: &str,
667 extra_query: &str,
668) -> Result<Vec<GroupProject>, Box<dyn std::error::Error>> {
669 let (base_url, group_path) = parse_project_url(group_url)?;
670 let encoded = group_path.replace('/', "%2F");
671 sandogasa_cli::install_crypto_provider();
672 let client = reqwest::blocking::Client::builder()
673 .user_agent("sandogasa-gitlab")
674 .build()?;
675 let mut all = Vec::new();
676 let mut page = 1u32;
677 loop {
678 let url = format!(
679 "{}/api/v4/groups/{}/projects?per_page=100&page={}&simple=true&include_subgroups=false{}",
680 base_url, encoded, page, extra_query
681 );
682 eprint!("\r fetching page {page}...");
683 let resp = get_with_retry_blocking(&client, &url)?;
684 let next_page = resp
685 .headers()
686 .get("x-next-page")
687 .and_then(|v| v.to_str().ok())
688 .unwrap_or("")
689 .to_string();
690 let projects: Vec<GroupProject> = resp.json()?;
691 all.extend(projects);
692 if next_page.is_empty() {
693 break;
694 }
695 page = next_page.parse()?;
696 }
697 eprintln!("\r fetched {} project(s)", all.len());
698 Ok(all)
699}
700
701fn get_with_retry_blocking(
703 client: &reqwest::blocking::Client,
704 url: &str,
705) -> Result<reqwest::blocking::Response, Box<dyn std::error::Error>> {
706 let mut last_err = None;
707 for attempt in 0..=3u32 {
708 let resp = client.get(url).send()?;
709 let status = resp.status();
710 if status == reqwest::StatusCode::INTERNAL_SERVER_ERROR
711 || status == reqwest::StatusCode::BAD_GATEWAY
712 || status == reqwest::StatusCode::SERVICE_UNAVAILABLE
713 || status == reqwest::StatusCode::GATEWAY_TIMEOUT
714 {
715 let delay = std::time::Duration::from_secs(1 << attempt);
716 eprintln!(
717 " {status}, retrying in {}s ({}/3)",
718 delay.as_secs(),
719 attempt + 1,
720 );
721 std::thread::sleep(delay);
722 last_err = Some(format!("{status} for {url}"));
723 continue;
724 }
725 if !resp.status().is_success() {
726 let text = resp.text()?;
727 return Err(format!("GitLab API error {status}: {text}").into());
728 }
729 return Ok(resp);
730 }
731 Err(last_err.unwrap().into())
732}
733
734pub fn parse_project_url(url: &str) -> Result<(String, String), String> {
739 let url = url.trim_end_matches('/');
740 let rest = url
741 .strip_prefix("https://")
742 .or_else(|| url.strip_prefix("http://"))
743 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
744
745 let slash = rest
746 .find('/')
747 .ok_or_else(|| format!("no project path in URL: {url}"))?;
748
749 let host = &rest[..slash];
750 let path = &rest[slash + 1..];
751
752 if path.is_empty() {
753 return Err(format!("no project path in URL: {url}"));
754 }
755
756 let scheme = if url.starts_with("https://") {
757 "https"
758 } else {
759 "http"
760 };
761 Ok((format!("{scheme}://{host}"), path.to_string()))
762}
763
764pub fn parse_mr_url(url: &str) -> Result<(String, String, u64), String> {
770 let trimmed = url.trim_end_matches('/');
771 let rest = trimmed
772 .strip_prefix("https://")
773 .or_else(|| trimmed.strip_prefix("http://"))
774 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
775 let slash = rest
776 .find('/')
777 .ok_or_else(|| format!("no project path in URL: {url}"))?;
778 let host = &rest[..slash];
779 let path = &rest[slash + 1..];
780
781 let scheme = if trimmed.starts_with("https://") {
782 "https"
783 } else {
784 "http"
785 };
786
787 let (project, iid_str) = path
788 .rsplit_once("/-/merge_requests/")
789 .ok_or_else(|| format!("not a merge request URL: {url}"))?;
790 let iid_str = iid_str.split(['?', '#']).next().unwrap_or(iid_str);
792 let iid: u64 = iid_str
793 .parse()
794 .map_err(|_| format!("invalid merge request IID in URL: {url}"))?;
795
796 if project.is_empty() {
797 return Err(format!("no project path in URL: {url}"));
798 }
799
800 Ok((format!("{scheme}://{host}"), project.to_string(), iid))
801}
802
803pub fn parse_issue_url(url: &str) -> Result<(String, String, u64), String> {
810 let trimmed = url.trim_end_matches('/');
811 let rest = trimmed
812 .strip_prefix("https://")
813 .or_else(|| trimmed.strip_prefix("http://"))
814 .ok_or_else(|| format!("invalid GitLab URL: {url}"))?;
815 let slash = rest
816 .find('/')
817 .ok_or_else(|| format!("no project path in URL: {url}"))?;
818 let host = &rest[..slash];
819 let path = &rest[slash + 1..];
820
821 let scheme = if trimmed.starts_with("https://") {
822 "https"
823 } else {
824 "http"
825 };
826
827 let (project, iid_str) = path
828 .rsplit_once("/-/issues/")
829 .or_else(|| path.rsplit_once("/-/work_items/"))
830 .ok_or_else(|| format!("not an issue or work-item URL: {url}"))?;
831 let iid_str = iid_str.split(['?', '#']).next().unwrap_or(iid_str);
832 let iid: u64 = iid_str
833 .parse()
834 .map_err(|_| format!("invalid issue IID in URL: {url}"))?;
835
836 if project.is_empty() {
837 return Err(format!("no project path in URL: {url}"));
838 }
839
840 Ok((format!("{scheme}://{host}"), project.to_string(), iid))
841}
842
843#[derive(Debug, Clone, Deserialize, Serialize)]
845pub struct User {
846 pub id: u64,
847 pub username: String,
848}
849
850pub fn user_by_username(
854 base_url: &str,
855 token: &str,
856 username: &str,
857) -> Result<Option<User>, Box<dyn std::error::Error>> {
858 let http = build_http_client(token)?;
859 let url = format!("{}/api/v4/users", base_url.trim_end_matches('/'));
860 let resp = http.get(&url).query(&[("username", username)]).send()?;
861 if !resp.status().is_success() {
862 let status = resp.status();
863 let text = resp.text()?;
864 return Err(format!("GitLab GET {url} failed: {status}: {text}").into());
865 }
866 let users: Vec<User> = resp.json()?;
867 Ok(users.into_iter().next())
868}
869
870#[derive(Debug, Clone, Deserialize, Serialize)]
874pub struct Event {
875 pub id: u64,
876 pub project_id: u64,
877 pub action_name: String,
878 #[serde(default)]
879 pub target_type: Option<String>,
880 #[serde(default)]
881 pub target_iid: Option<u64>,
882 #[serde(default)]
883 pub target_title: Option<String>,
884 pub created_at: String,
885 #[serde(default)]
886 pub note: Option<EventNote>,
887 #[serde(default)]
888 pub push_data: Option<EventPushData>,
889}
890
891#[derive(Debug, Clone, Deserialize, Serialize)]
893pub struct EventNote {
894 #[serde(default)]
895 pub noteable_type: Option<String>,
896 #[serde(default)]
897 pub noteable_iid: Option<u64>,
898 #[serde(default)]
899 pub body: Option<String>,
900}
901
902#[derive(Debug, Clone, Deserialize, Serialize)]
904pub struct EventPushData {
905 #[serde(default)]
906 pub commit_count: u64,
907 #[serde(default)]
908 pub action: Option<String>,
909 #[serde(default)]
910 pub ref_type: Option<String>,
911 #[serde(default, rename = "ref")]
912 pub ref_name: Option<String>,
913 #[serde(default)]
914 pub commit_title: Option<String>,
915}
916
917pub fn user_events(
926 base_url: &str,
927 token: &str,
928 user_id: u64,
929 action: Option<&str>,
930 after: chrono::NaiveDate,
931 before: chrono::NaiveDate,
932) -> Result<Vec<Event>, Box<dyn std::error::Error>> {
933 let http = build_http_client(token)?;
934 let endpoint = format!(
935 "{}/api/v4/users/{}/events",
936 base_url.trim_end_matches('/'),
937 user_id
938 );
939 let after_str = after.to_string();
940 let before_str = before.to_string();
941 let mut out: Vec<Event> = Vec::new();
942 let mut page = 1u32;
943 loop {
944 let page_str = page.to_string();
945 let mut query: Vec<(&str, &str)> = vec![
946 ("per_page", "100"),
947 ("page", &page_str),
948 ("after", &after_str),
949 ("before", &before_str),
950 ];
951 if let Some(a) = action {
952 query.push(("action", a));
953 }
954 let resp = http.get(&endpoint).query(&query).send()?;
955 if !resp.status().is_success() {
956 let status = resp.status();
957 let text = resp.text()?;
958 return Err(format!("GitLab GET {endpoint} failed: {status}: {text}").into());
959 }
960 let batch: Vec<Event> = resp.json()?;
961 let n = batch.len();
962 out.extend(batch);
963 if n < 100 {
964 break;
965 }
966 page += 1;
967 }
968 Ok(out)
969}
970
971#[derive(Debug, Clone, Deserialize, Serialize)]
974pub struct ProjectSummary {
975 pub id: u64,
976 pub path_with_namespace: String,
977 pub web_url: String,
978}
979
980pub fn project_summary(
983 base_url: &str,
984 token: &str,
985 project_id: u64,
986) -> Result<ProjectSummary, Box<dyn std::error::Error>> {
987 let http = build_http_client(token)?;
988 let url = format!(
989 "{}/api/v4/projects/{}",
990 base_url.trim_end_matches('/'),
991 project_id
992 );
993 let resp = http.get(&url).send()?;
994 if !resp.status().is_success() {
995 let status = resp.status();
996 let text = resp.text()?;
997 return Err(format!("GitLab GET {url} failed: {status}: {text}").into());
998 }
999 Ok(resp.json()?)
1000}
1001
1002pub fn count_authored_commits(
1013 base_url: &str,
1014 token: &str,
1015 project_id: u64,
1016 author: &str,
1017 since: chrono::NaiveDate,
1018 until: chrono::NaiveDate,
1019) -> Result<u64, Box<dyn std::error::Error>> {
1020 let http = build_http_client(token)?;
1021 let endpoint = format!(
1022 "{}/api/v4/projects/{}/repository/commits",
1023 base_url.trim_end_matches('/'),
1024 project_id
1025 );
1026 let since_str = format!("{since}T00:00:00Z");
1029 let until_str = format!("{until}T23:59:59Z");
1030 let mut total: u64 = 0;
1031 let mut page = 1u32;
1032 loop {
1033 let page_str = page.to_string();
1034 let query: Vec<(&str, &str)> = vec![
1035 ("per_page", "100"),
1036 ("page", &page_str),
1037 ("author", author),
1038 ("since", &since_str),
1039 ("until", &until_str),
1040 ];
1041 let resp = http.get(&endpoint).query(&query).send()?;
1042 if !resp.status().is_success() {
1043 let status = resp.status();
1044 let text = resp.text()?;
1045 return Err(format!("GitLab GET {endpoint} failed: {status}: {text}").into());
1046 }
1047 let batch: Vec<serde_json::Value> = resp.json()?;
1050 let n = batch.len() as u64;
1051 total += n;
1052 if n < 100 {
1053 break;
1054 }
1055 page += 1;
1056 }
1057 Ok(total)
1058}
1059
1060#[derive(Debug, Clone, Deserialize, Serialize)]
1065pub struct Tag {
1066 pub name: String,
1067 pub created_at: String,
1072}
1073
1074pub fn list_tags(
1079 base_url: &str,
1080 token: &str,
1081 project_id: u64,
1082) -> Result<Vec<Tag>, Box<dyn std::error::Error>> {
1083 let http = build_http_client(token)?;
1084 let endpoint = format!(
1085 "{}/api/v4/projects/{}/repository/tags",
1086 base_url.trim_end_matches('/'),
1087 project_id
1088 );
1089 let mut out: Vec<Tag> = Vec::new();
1090 let mut page = 1u32;
1091 loop {
1092 let page_str = page.to_string();
1093 let query: Vec<(&str, &str)> = vec![
1094 ("per_page", "100"),
1095 ("page", &page_str),
1096 ("order_by", "updated"),
1097 ("sort", "desc"),
1098 ];
1099 let resp = http.get(&endpoint).query(&query).send()?;
1100 if resp.status().as_u16() == 404 {
1101 break;
1102 }
1103 if !resp.status().is_success() {
1104 let status = resp.status();
1105 let text = resp.text()?;
1106 return Err(format!("GitLab GET {endpoint} failed: {status}: {text}").into());
1107 }
1108 let batch: Vec<Tag> = resp.json()?;
1109 let n = batch.len();
1110 out.extend(batch);
1111 if n < 100 {
1112 break;
1113 }
1114 page += 1;
1115 }
1116 Ok(out)
1117}
1118
1119#[derive(Debug, Clone, Deserialize, Serialize)]
1122pub struct Release {
1123 pub tag_name: String,
1124 #[serde(default)]
1125 pub name: Option<String>,
1126 #[serde(default)]
1127 pub description: Option<String>,
1128 pub released_at: String,
1129 pub author: ReleaseAuthor,
1130 #[serde(default, rename = "_links")]
1131 pub links: Option<ReleaseLinks>,
1132 #[serde(default)]
1133 pub upcoming_release: bool,
1134}
1135
1136#[derive(Debug, Clone, Deserialize, Serialize)]
1139pub struct ReleaseAuthor {
1140 pub id: u64,
1141 pub username: String,
1142 #[serde(default)]
1143 pub name: Option<String>,
1144}
1145
1146#[derive(Debug, Clone, Deserialize, Serialize)]
1149pub struct ReleaseLinks {
1150 #[serde(default, rename = "self")]
1151 pub self_url: Option<String>,
1152}
1153
1154pub fn project_releases(
1158 base_url: &str,
1159 token: &str,
1160 project_id: u64,
1161) -> Result<Vec<Release>, Box<dyn std::error::Error>> {
1162 let http = build_http_client(token)?;
1163 let endpoint = format!(
1164 "{}/api/v4/projects/{}/releases",
1165 base_url.trim_end_matches('/'),
1166 project_id
1167 );
1168 let mut out: Vec<Release> = Vec::new();
1169 let mut page = 1u32;
1170 loop {
1171 let page_str = page.to_string();
1172 let query: Vec<(&str, &str)> = vec![("per_page", "100"), ("page", &page_str)];
1173 let resp = http.get(&endpoint).query(&query).send()?;
1174 if resp.status().as_u16() == 404 {
1175 break;
1176 }
1177 if !resp.status().is_success() {
1178 let status = resp.status();
1179 let text = resp.text()?;
1180 return Err(format!("GitLab GET {endpoint} failed: {status}: {text}").into());
1181 }
1182 let batch: Vec<Release> = resp.json()?;
1183 let n = batch.len();
1184 out.extend(batch);
1185 if n < 100 {
1186 break;
1187 }
1188 page += 1;
1189 }
1190 Ok(out)
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195 use super::*;
1196
1197 #[test]
1198 fn new_rejects_plaintext_remote() {
1199 assert!(Client::new("http://gitlab.example.com", "g/p", "tok").is_err());
1201 assert!(GroupClient::new("http://gitlab.example.com", "g", "tok").is_err());
1202 }
1203
1204 #[test]
1205 fn test_parse_project_url() {
1206 let (base, path) =
1207 parse_project_url("https://gitlab.com/CentOS/Hyperscale/rpms/perf").unwrap();
1208 assert_eq!(base, "https://gitlab.com");
1209 assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
1210 }
1211
1212 #[test]
1213 fn test_parse_project_url_trailing_slash() {
1214 let (base, path) = parse_project_url("https://gitlab.com/group/project/").unwrap();
1215 assert_eq!(base, "https://gitlab.com");
1216 assert_eq!(path, "group/project");
1217 }
1218
1219 #[test]
1220 fn test_parse_project_url_http() {
1221 let (base, path) = parse_project_url("http://gitlab.example.com/group/project").unwrap();
1222 assert_eq!(base, "http://gitlab.example.com");
1223 assert_eq!(path, "group/project");
1224 }
1225
1226 #[test]
1227 fn test_parse_project_url_no_scheme() {
1228 assert!(parse_project_url("gitlab.com/group/project").is_err());
1229 }
1230
1231 #[test]
1232 fn test_parse_project_url_no_path() {
1233 assert!(parse_project_url("https://gitlab.com/").is_err());
1234 assert!(parse_project_url("https://gitlab.com").is_err());
1235 }
1236
1237 #[test]
1238 fn test_issues_url() {
1239 let client = Client::new(
1240 "https://gitlab.com",
1241 "CentOS/Hyperscale/rpms/perf",
1242 "fake-token",
1243 )
1244 .unwrap();
1245 assert_eq!(
1246 client.issues_url(),
1247 "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
1248 );
1249 }
1250
1251 #[test]
1252 fn test_issue_update_serialization() {
1253 let update = IssueUpdate {
1254 title: Some("new title".into()),
1255 add_labels: Some("bug".into()),
1256 ..Default::default()
1257 };
1258 let json = serde_json::to_value(&update).unwrap();
1259 assert_eq!(json["title"], "new title");
1260 assert_eq!(json["add_labels"], "bug");
1261 assert!(json.get("description").is_none());
1262 assert!(json.get("state_event").is_none());
1263 }
1264
1265 #[test]
1266 fn test_issue_deserialize() {
1267 let json = r#"{
1268 "iid": 42,
1269 "title": "Test issue",
1270 "description": "Some description",
1271 "state": "opened",
1272 "web_url": "https://gitlab.com/group/project/-/issues/42",
1273 "assignees": [
1274 {"username": "alice"},
1275 {"username": "bob"}
1276 ]
1277 }"#;
1278 let issue: Issue = serde_json::from_str(json).unwrap();
1279 assert_eq!(issue.iid, 42);
1280 assert_eq!(issue.title, "Test issue");
1281 assert_eq!(issue.description.as_deref(), Some("Some description"));
1282 assert_eq!(issue.state, "opened");
1283 assert_eq!(issue.assignees.len(), 2);
1284 assert_eq!(issue.assignees[0].username, "alice");
1285 assert_eq!(issue.assignees[1].username, "bob");
1286 }
1287
1288 #[test]
1289 fn test_issue_deserialize_no_assignees() {
1290 let json =
1291 r#"{"iid": 1, "title": "t", "description": null, "state": "opened", "web_url": "u"}"#;
1292 let issue: Issue = serde_json::from_str(json).unwrap();
1293 assert!(issue.description.is_none());
1294 assert!(issue.assignees.is_empty());
1295 }
1296
1297 #[test]
1298 fn test_graphql_url() {
1299 let client = Client::new(
1300 "https://gitlab.com",
1301 "CentOS/Hyperscale/rpms/perf",
1302 "fake-token",
1303 )
1304 .unwrap();
1305 assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
1306 }
1307
1308 #[test]
1309 fn test_parse_work_item_status_found() {
1310 let json: serde_json::Value = serde_json::from_str(
1311 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"STATUS","status":{"name":"To do"}}]}]}}}}"#,
1312 ).unwrap();
1313 assert_eq!(parse_work_item_status(&json).as_deref(), Some("To do"));
1314 }
1315
1316 #[test]
1317 fn test_parse_work_item_status_in_progress() {
1318 let json: serde_json::Value = serde_json::from_str(
1319 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":{"name":"In progress"}}]}]}}}}"#,
1320 ).unwrap();
1321 assert_eq!(
1322 parse_work_item_status(&json).as_deref(),
1323 Some("In progress")
1324 );
1325 }
1326
1327 #[test]
1328 fn test_parse_work_item_status_no_status_widget() {
1329 let json: serde_json::Value = serde_json::from_str(
1330 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"LABELS"}]}]}}}}"#,
1331 ).unwrap();
1332 assert!(parse_work_item_status(&json).is_none());
1333 }
1334
1335 #[test]
1336 fn test_parse_work_item_status_empty_nodes() {
1337 let json: serde_json::Value =
1338 serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
1339 assert!(parse_work_item_status(&json).is_none());
1340 }
1341
1342 #[test]
1343 fn test_parse_work_item_status_null_status() {
1344 let json: serde_json::Value = serde_json::from_str(
1345 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":null}]}]}}}}"#,
1346 ).unwrap();
1347 assert!(parse_work_item_status(&json).is_none());
1348 }
1349
1350 #[test]
1351 fn test_package_from_issue_url() {
1352 assert_eq!(
1353 package_from_issue_url("https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"),
1354 Some("ethtool")
1355 );
1356 assert_eq!(
1357 package_from_issue_url("https://gitlab.com/group/project/-/issues/42"),
1358 Some("project")
1359 );
1360 }
1361
1362 #[test]
1363 fn test_package_from_issue_url_no_issues_path() {
1364 assert_eq!(
1365 package_from_issue_url("https://gitlab.com/group/project"),
1366 Some("project")
1367 );
1368 }
1369
1370 #[test]
1371 fn test_package_from_issue_url_empty() {
1372 assert_eq!(package_from_issue_url(""), None);
1373 }
1374
1375 #[test]
1376 fn test_package_from_issue_url_work_items_form() {
1377 assert_eq!(
1378 package_from_issue_url(
1379 "https://gitlab.com/CentOS/proposed_updates/rpms/PackageKit/-/work_items/1"
1380 ),
1381 Some("PackageKit"),
1382 );
1383 }
1384
1385 #[test]
1386 fn test_project_path_from_issue_url_work_items_form() {
1387 assert_eq!(
1388 project_path_from_issue_url(
1389 "https://gitlab.com/CentOS/proposed_updates/rpms/PackageKit/-/work_items/1"
1390 )
1391 .as_deref(),
1392 Some("CentOS/proposed_updates/rpms/PackageKit"),
1393 );
1394 }
1395
1396 #[test]
1397 fn test_project_path_from_issue_url() {
1398 assert_eq!(
1399 project_path_from_issue_url(
1400 "https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"
1401 )
1402 .as_deref(),
1403 Some("CentOS/Hyperscale/rpms/ethtool")
1404 );
1405 }
1406
1407 #[test]
1408 fn test_project_path_from_issue_url_no_issues() {
1409 assert_eq!(
1410 project_path_from_issue_url("https://gitlab.com/group/project").as_deref(),
1411 Some("group/project")
1412 );
1413 }
1414
1415 #[test]
1416 fn test_project_path_from_issue_url_no_scheme() {
1417 assert!(project_path_from_issue_url("gitlab.com/group/project").is_none());
1418 }
1419
1420 #[test]
1421 fn test_parse_work_item_id_found() {
1422 let json: serde_json::Value = serde_json::from_str(
1423 r#"{"data":{"project":{"workItems":{"nodes":[{"id":"gid://gitlab/WorkItem/42"}]}}}}"#,
1424 )
1425 .unwrap();
1426 assert_eq!(
1427 parse_work_item_id(&json).as_deref(),
1428 Some("gid://gitlab/WorkItem/42")
1429 );
1430 }
1431
1432 #[test]
1433 fn test_parse_work_item_id_empty() {
1434 let json: serde_json::Value =
1435 serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
1436 assert!(parse_work_item_id(&json).is_none());
1437 }
1438
1439 #[test]
1440 fn test_parse_mutation_errors_none() {
1441 let json: serde_json::Value =
1442 serde_json::from_str(r#"{"data":{"workItemUpdate":{"errors":[]}}}"#).unwrap();
1443 assert!(parse_mutation_errors(&json).is_none());
1444 }
1445
1446 #[test]
1447 fn test_parse_mutation_errors_present() {
1448 let json: serde_json::Value = serde_json::from_str(
1449 r#"{"data":{"workItemUpdate":{"errors":["something went wrong"]}}}"#,
1450 )
1451 .unwrap();
1452 let errors = parse_mutation_errors(&json).unwrap();
1453 assert_eq!(errors, vec!["something went wrong"]);
1454 }
1455
1456 #[test]
1457 fn test_parse_status_id_found() {
1458 let json: serde_json::Value = serde_json::from_str(
1459 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"}]}]}]}}}}"#,
1460 ).unwrap();
1461 assert_eq!(
1462 parse_status_id(&json, "In progress").as_deref(),
1463 Some("gid://gitlab/WorkItems::Statuses::Custom::Status/2")
1464 );
1465 }
1466
1467 #[test]
1468 fn test_parse_status_id_not_found() {
1469 let json: serde_json::Value = serde_json::from_str(
1470 r#"{"data":{"project":{"workItemTypes":{"nodes":[{"widgetDefinitions":[{"type":"STATUS","allowedStatuses":[{"id":"gid://id/1","name":"To do"}]}]}]}}}}"#,
1471 ).unwrap();
1472 assert!(parse_status_id(&json, "In progress").is_none());
1473 }
1474
1475 #[test]
1476 fn test_group_client_issues_url() {
1477 let client =
1478 GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
1479 assert_eq!(
1480 client.issues_url(),
1481 "https://gitlab.com/api/v4/groups/CentOS%2FHyperscale%2Frpms/issues"
1482 );
1483 }
1484
1485 #[test]
1486 fn test_group_client_graphql_url() {
1487 let client =
1488 GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
1489 assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
1490 }
1491
1492 #[test]
1493 fn test_add_note_success() {
1494 let mut server = mockito::Server::new();
1495 let mock = server
1496 .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
1497 .match_header("private-token", "tok")
1498 .match_body(mockito::Matcher::Json(serde_json::json!({"body": "hello"})))
1499 .with_status(201)
1500 .with_body("{}")
1501 .create();
1502 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1503 client.add_note(1, "hello").unwrap();
1504 mock.assert();
1505 }
1506
1507 #[test]
1508 fn test_add_note_error() {
1509 let mut server = mockito::Server::new();
1510 let mock = server
1511 .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
1512 .with_status(403)
1513 .with_body("forbidden")
1514 .create();
1515 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1516 let err = client.add_note(1, "x").unwrap_err();
1517 assert!(err.to_string().contains("403"), "{}", err);
1518 mock.assert();
1519 }
1520
1521 #[test]
1522 fn test_edit_issue_success() {
1523 let mut server = mockito::Server::new();
1524 let mock = server
1525 .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
1526 .match_header("private-token", "tok")
1527 .with_status(200)
1528 .with_header("content-type", "application/json")
1529 .with_body(r#"{"iid":5,"title":"t","description":null,"state":"closed","web_url":"https://example.com/-/issues/5"}"#)
1530 .create();
1531 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1532 let updates = IssueUpdate {
1533 state_event: Some("close".into()),
1534 ..Default::default()
1535 };
1536 let issue = client.edit_issue(5, &updates).unwrap();
1537 assert_eq!(issue.state, "closed");
1538 mock.assert();
1539 }
1540
1541 #[test]
1542 fn test_edit_issue_error() {
1543 let mut server = mockito::Server::new();
1544 let mock = server
1545 .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
1546 .with_status(404)
1547 .with_body("not found")
1548 .create();
1549 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1550 let updates = IssueUpdate::default();
1551 let err = client.edit_issue(5, &updates).unwrap_err();
1552 assert!(err.to_string().contains("404"), "{}", err);
1553 mock.assert();
1554 }
1555
1556 #[test]
1557 fn test_create_issue_success() {
1558 let mut server = mockito::Server::new();
1559 let mock = server
1560 .mock("POST", "/api/v4/projects/g%2Fp/issues")
1561 .match_header("private-token", "tok")
1562 .with_status(201)
1563 .with_header("content-type", "application/json")
1564 .with_body(r#"{"iid":10,"title":"new issue","description":"desc","state":"opened","web_url":"https://example.com/-/issues/10"}"#)
1565 .create();
1566 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1567 let issue = client
1568 .create_issue("new issue", Some("desc"), Some("bug"))
1569 .unwrap();
1570 assert_eq!(issue.iid, 10);
1571 assert_eq!(issue.title, "new issue");
1572 mock.assert();
1573 }
1574
1575 #[test]
1576 fn test_list_issues_success() {
1577 let mut server = mockito::Server::new();
1578 let mock = server
1579 .mock("GET", "/api/v4/projects/g%2Fp/issues")
1580 .match_query(mockito::Matcher::AllOf(vec![
1581 mockito::Matcher::UrlEncoded("labels".into(), "relmon".into()),
1582 mockito::Matcher::UrlEncoded("state".into(), "opened".into()),
1583 ]))
1584 .with_status(200)
1585 .with_header("content-type", "application/json")
1586 .with_body(
1587 r#"[{"iid":1,"title":"t","description":null,"state":"opened","web_url":"u"}]"#,
1588 )
1589 .create();
1590 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1591 let issues = client.list_issues("relmon", Some("opened")).unwrap();
1592 assert_eq!(issues.len(), 1);
1593 assert_eq!(issues[0].iid, 1);
1594 mock.assert();
1595 }
1596
1597 #[test]
1598 fn test_list_issues_error() {
1599 let mut server = mockito::Server::new();
1600 let mock = server
1601 .mock("GET", "/api/v4/projects/g%2Fp/issues")
1602 .match_query(mockito::Matcher::Any)
1603 .with_status(500)
1604 .with_body("internal error")
1605 .create();
1606 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1607 let err = client.list_issues("relmon", None).unwrap_err();
1608 assert!(err.to_string().contains("500"), "{}", err);
1609 mock.assert();
1610 }
1611
1612 #[test]
1615 fn parse_mr_url_standard() {
1616 let (base, project, iid) =
1617 parse_mr_url("https://gitlab.com/redhat/centos-stream/rpms/xz/-/merge_requests/42")
1618 .unwrap();
1619 assert_eq!(base, "https://gitlab.com");
1620 assert_eq!(project, "redhat/centos-stream/rpms/xz");
1621 assert_eq!(iid, 42);
1622 }
1623
1624 #[test]
1625 fn parse_mr_url_strips_trailing_slash() {
1626 let (_, _, iid) =
1627 parse_mr_url("https://gitlab.com/redhat/centos-stream/rpms/xz/-/merge_requests/42/")
1628 .unwrap();
1629 assert_eq!(iid, 42);
1630 }
1631
1632 #[test]
1633 fn parse_mr_url_strips_query() {
1634 let (_, _, iid) =
1635 parse_mr_url("https://gitlab.com/a/b/-/merge_requests/7?commit_id=abc").unwrap();
1636 assert_eq!(iid, 7);
1637 }
1638
1639 #[test]
1640 fn parse_mr_url_strips_fragment() {
1641 let (_, _, iid) =
1642 parse_mr_url("https://gitlab.com/a/b/-/merge_requests/7#note_123").unwrap();
1643 assert_eq!(iid, 7);
1644 }
1645
1646 #[test]
1647 fn parse_mr_url_rejects_issue_url() {
1648 assert!(parse_mr_url("https://gitlab.com/a/b/-/issues/1").is_err());
1649 }
1650
1651 #[test]
1652 fn parse_mr_url_rejects_non_numeric_iid() {
1653 assert!(parse_mr_url("https://gitlab.com/a/b/-/merge_requests/abc").is_err());
1654 }
1655
1656 #[test]
1657 fn parse_mr_url_rejects_no_scheme() {
1658 assert!(parse_mr_url("gitlab.com/a/b/-/merge_requests/1").is_err());
1659 }
1660
1661 #[test]
1662 fn parse_issue_url_handles_legacy_form() {
1663 let (base, project, iid) =
1664 parse_issue_url("https://gitlab.com/group/project/-/issues/42").unwrap();
1665 assert_eq!(base, "https://gitlab.com");
1666 assert_eq!(project, "group/project");
1667 assert_eq!(iid, 42);
1668 }
1669
1670 #[test]
1671 fn parse_issue_url_handles_work_items_form() {
1672 let (base, project, iid) =
1673 parse_issue_url("https://gitlab.com/CentOS/proposed_updates/rpms/xz/-/work_items/1")
1674 .unwrap();
1675 assert_eq!(base, "https://gitlab.com");
1676 assert_eq!(project, "CentOS/proposed_updates/rpms/xz");
1677 assert_eq!(iid, 1);
1678 }
1679
1680 #[test]
1681 fn parse_issue_url_strips_query_and_fragment() {
1682 let (_, _, iid) =
1683 parse_issue_url("https://gitlab.com/a/b/-/work_items/7?note=123#xyz").unwrap();
1684 assert_eq!(iid, 7);
1685 }
1686
1687 #[test]
1688 fn parse_issue_url_rejects_mr_url() {
1689 assert!(parse_issue_url("https://gitlab.com/a/b/-/merge_requests/1").is_err());
1690 }
1691
1692 #[test]
1693 fn parse_issue_url_rejects_non_numeric_iid() {
1694 assert!(parse_issue_url("https://gitlab.com/a/b/-/issues/xyz").is_err());
1695 }
1696
1697 #[test]
1698 fn user_by_username_returns_first_match() {
1699 let mut server = mockito::Server::new();
1700 let mock = server
1701 .mock("GET", "/api/v4/users?username=alice")
1702 .match_header("private-token", "tok")
1703 .with_status(200)
1704 .with_body(r#"[{"id": 42, "username": "alice"}]"#)
1705 .create();
1706 let user = user_by_username(&server.url(), "tok", "alice").unwrap();
1707 assert_eq!(user.as_ref().map(|u| u.id), Some(42));
1708 assert_eq!(user.as_ref().map(|u| u.username.as_str()), Some("alice"));
1709 mock.assert();
1710 }
1711
1712 #[test]
1713 fn user_by_username_empty_list_is_none() {
1714 let mut server = mockito::Server::new();
1715 let mock = server
1716 .mock("GET", "/api/v4/users?username=ghost")
1717 .with_status(200)
1718 .with_body("[]")
1719 .create();
1720 let user = user_by_username(&server.url(), "tok", "ghost").unwrap();
1721 assert!(user.is_none());
1722 mock.assert();
1723 }
1724
1725 #[test]
1726 fn user_events_single_page() {
1727 let mut server = mockito::Server::new();
1728 let mock = server
1729 .mock("GET", mockito::Matcher::Any)
1730 .match_query(mockito::Matcher::AllOf(vec![
1731 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
1732 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
1733 mockito::Matcher::UrlEncoded("after".into(), "2026-01-01".into()),
1734 mockito::Matcher::UrlEncoded("before".into(), "2026-03-31".into()),
1735 mockito::Matcher::UrlEncoded("action".into(), "created".into()),
1736 ]))
1737 .with_status(200)
1738 .with_body(
1739 r#"[{"id": 1, "project_id": 10, "action_name": "opened",
1740 "target_type": "MergeRequest", "target_iid": 123,
1741 "target_title": "Fix X", "created_at": "2026-02-15T10:00:00Z"}]"#,
1742 )
1743 .create();
1744 let events = user_events(
1745 &server.url(),
1746 "tok",
1747 42,
1748 Some("created"),
1749 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1750 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
1751 )
1752 .unwrap();
1753 assert_eq!(events.len(), 1);
1754 assert_eq!(events[0].target_iid, Some(123));
1755 assert_eq!(events[0].action_name, "opened");
1756 mock.assert();
1757 }
1758
1759 #[test]
1760 fn event_deserializes_push_data() {
1761 let json = r#"{
1762 "id": 5,
1763 "project_id": 10,
1764 "action_name": "pushed to",
1765 "created_at": "2026-02-15T10:00:00Z",
1766 "push_data": {"commit_count": 3, "ref": "main", "action": "pushed",
1767 "ref_type": "branch", "commit_title": "Fix typo"}
1768 }"#;
1769 let e: Event = serde_json::from_str(json).unwrap();
1770 let push = e.push_data.unwrap();
1771 assert_eq!(push.commit_count, 3);
1772 assert_eq!(push.ref_name.as_deref(), Some("main"));
1773 }
1774
1775 #[test]
1776 fn count_authored_commits_paginates_and_sums() {
1777 let mut server = mockito::Server::new();
1778 let mock_p1 = server
1779 .mock("GET", "/api/v4/projects/10/repository/commits")
1780 .match_query(mockito::Matcher::AllOf(vec![
1781 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
1782 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
1783 mockito::Matcher::UrlEncoded("author".into(), "michel-slm".into()),
1784 ]))
1785 .with_status(200)
1786 .with_body(format!("[{}]", vec!["{}"; 100].join(",")))
1788 .create();
1789 let mock_p2 = server
1790 .mock("GET", "/api/v4/projects/10/repository/commits")
1791 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
1792 .with_status(200)
1793 .with_body("[{},{},{}]")
1794 .create();
1795 let n = count_authored_commits(
1796 &server.url(),
1797 "tok",
1798 10,
1799 "michel-slm",
1800 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1801 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
1802 )
1803 .unwrap();
1804 assert_eq!(n, 103);
1805 mock_p1.assert();
1806 mock_p2.assert();
1807 }
1808
1809 #[test]
1810 fn project_summary_returns_path() {
1811 let mut server = mockito::Server::new();
1812 let mock = server
1813 .mock("GET", "/api/v4/projects/10")
1814 .with_status(200)
1815 .with_body(
1816 r#"{"id": 10, "path_with_namespace": "CentOS/Hyperscale/rpms/perf",
1817 "web_url": "https://gitlab.com/CentOS/Hyperscale/rpms/perf"}"#,
1818 )
1819 .create();
1820 let p = project_summary(&server.url(), "tok", 10).unwrap();
1821 assert_eq!(p.path_with_namespace, "CentOS/Hyperscale/rpms/perf");
1822 mock.assert();
1823 }
1824}