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