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