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#[cfg(test)]
1033mod tests {
1034 use super::*;
1035
1036 #[test]
1037 fn test_parse_project_url() {
1038 let (base, path) =
1039 parse_project_url("https://gitlab.com/CentOS/Hyperscale/rpms/perf").unwrap();
1040 assert_eq!(base, "https://gitlab.com");
1041 assert_eq!(path, "CentOS/Hyperscale/rpms/perf");
1042 }
1043
1044 #[test]
1045 fn test_parse_project_url_trailing_slash() {
1046 let (base, path) = parse_project_url("https://gitlab.com/group/project/").unwrap();
1047 assert_eq!(base, "https://gitlab.com");
1048 assert_eq!(path, "group/project");
1049 }
1050
1051 #[test]
1052 fn test_parse_project_url_http() {
1053 let (base, path) = parse_project_url("http://gitlab.example.com/group/project").unwrap();
1054 assert_eq!(base, "http://gitlab.example.com");
1055 assert_eq!(path, "group/project");
1056 }
1057
1058 #[test]
1059 fn test_parse_project_url_no_scheme() {
1060 assert!(parse_project_url("gitlab.com/group/project").is_err());
1061 }
1062
1063 #[test]
1064 fn test_parse_project_url_no_path() {
1065 assert!(parse_project_url("https://gitlab.com/").is_err());
1066 assert!(parse_project_url("https://gitlab.com").is_err());
1067 }
1068
1069 #[test]
1070 fn test_issues_url() {
1071 let client = Client::new(
1072 "https://gitlab.com",
1073 "CentOS/Hyperscale/rpms/perf",
1074 "fake-token",
1075 )
1076 .unwrap();
1077 assert_eq!(
1078 client.issues_url(),
1079 "https://gitlab.com/api/v4/projects/CentOS%2FHyperscale%2Frpms%2Fperf/issues"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_issue_update_serialization() {
1085 let update = IssueUpdate {
1086 title: Some("new title".into()),
1087 add_labels: Some("bug".into()),
1088 ..Default::default()
1089 };
1090 let json = serde_json::to_value(&update).unwrap();
1091 assert_eq!(json["title"], "new title");
1092 assert_eq!(json["add_labels"], "bug");
1093 assert!(json.get("description").is_none());
1094 assert!(json.get("state_event").is_none());
1095 }
1096
1097 #[test]
1098 fn test_issue_deserialize() {
1099 let json = r#"{
1100 "iid": 42,
1101 "title": "Test issue",
1102 "description": "Some description",
1103 "state": "opened",
1104 "web_url": "https://gitlab.com/group/project/-/issues/42",
1105 "assignees": [
1106 {"username": "alice"},
1107 {"username": "bob"}
1108 ]
1109 }"#;
1110 let issue: Issue = serde_json::from_str(json).unwrap();
1111 assert_eq!(issue.iid, 42);
1112 assert_eq!(issue.title, "Test issue");
1113 assert_eq!(issue.description.as_deref(), Some("Some description"));
1114 assert_eq!(issue.state, "opened");
1115 assert_eq!(issue.assignees.len(), 2);
1116 assert_eq!(issue.assignees[0].username, "alice");
1117 assert_eq!(issue.assignees[1].username, "bob");
1118 }
1119
1120 #[test]
1121 fn test_issue_deserialize_no_assignees() {
1122 let json =
1123 r#"{"iid": 1, "title": "t", "description": null, "state": "opened", "web_url": "u"}"#;
1124 let issue: Issue = serde_json::from_str(json).unwrap();
1125 assert!(issue.description.is_none());
1126 assert!(issue.assignees.is_empty());
1127 }
1128
1129 #[test]
1130 fn test_graphql_url() {
1131 let client = Client::new(
1132 "https://gitlab.com",
1133 "CentOS/Hyperscale/rpms/perf",
1134 "fake-token",
1135 )
1136 .unwrap();
1137 assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
1138 }
1139
1140 #[test]
1141 fn test_parse_work_item_status_found() {
1142 let json: serde_json::Value = serde_json::from_str(
1143 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"STATUS","status":{"name":"To do"}}]}]}}}}"#,
1144 ).unwrap();
1145 assert_eq!(parse_work_item_status(&json).as_deref(), Some("To do"));
1146 }
1147
1148 #[test]
1149 fn test_parse_work_item_status_in_progress() {
1150 let json: serde_json::Value = serde_json::from_str(
1151 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":{"name":"In progress"}}]}]}}}}"#,
1152 ).unwrap();
1153 assert_eq!(
1154 parse_work_item_status(&json).as_deref(),
1155 Some("In progress")
1156 );
1157 }
1158
1159 #[test]
1160 fn test_parse_work_item_status_no_status_widget() {
1161 let json: serde_json::Value = serde_json::from_str(
1162 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"ASSIGNEES"},{"type":"LABELS"}]}]}}}}"#,
1163 ).unwrap();
1164 assert!(parse_work_item_status(&json).is_none());
1165 }
1166
1167 #[test]
1168 fn test_parse_work_item_status_empty_nodes() {
1169 let json: serde_json::Value =
1170 serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
1171 assert!(parse_work_item_status(&json).is_none());
1172 }
1173
1174 #[test]
1175 fn test_parse_work_item_status_null_status() {
1176 let json: serde_json::Value = serde_json::from_str(
1177 r#"{"data":{"project":{"workItems":{"nodes":[{"widgets":[{"type":"STATUS","status":null}]}]}}}}"#,
1178 ).unwrap();
1179 assert!(parse_work_item_status(&json).is_none());
1180 }
1181
1182 #[test]
1183 fn test_package_from_issue_url() {
1184 assert_eq!(
1185 package_from_issue_url("https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"),
1186 Some("ethtool")
1187 );
1188 assert_eq!(
1189 package_from_issue_url("https://gitlab.com/group/project/-/issues/42"),
1190 Some("project")
1191 );
1192 }
1193
1194 #[test]
1195 fn test_package_from_issue_url_no_issues_path() {
1196 assert_eq!(
1197 package_from_issue_url("https://gitlab.com/group/project"),
1198 Some("project")
1199 );
1200 }
1201
1202 #[test]
1203 fn test_package_from_issue_url_empty() {
1204 assert_eq!(package_from_issue_url(""), None);
1205 }
1206
1207 #[test]
1208 fn test_package_from_issue_url_work_items_form() {
1209 assert_eq!(
1210 package_from_issue_url(
1211 "https://gitlab.com/CentOS/proposed_updates/rpms/PackageKit/-/work_items/1"
1212 ),
1213 Some("PackageKit"),
1214 );
1215 }
1216
1217 #[test]
1218 fn test_project_path_from_issue_url_work_items_form() {
1219 assert_eq!(
1220 project_path_from_issue_url(
1221 "https://gitlab.com/CentOS/proposed_updates/rpms/PackageKit/-/work_items/1"
1222 )
1223 .as_deref(),
1224 Some("CentOS/proposed_updates/rpms/PackageKit"),
1225 );
1226 }
1227
1228 #[test]
1229 fn test_project_path_from_issue_url() {
1230 assert_eq!(
1231 project_path_from_issue_url(
1232 "https://gitlab.com/CentOS/Hyperscale/rpms/ethtool/-/issues/1"
1233 )
1234 .as_deref(),
1235 Some("CentOS/Hyperscale/rpms/ethtool")
1236 );
1237 }
1238
1239 #[test]
1240 fn test_project_path_from_issue_url_no_issues() {
1241 assert_eq!(
1242 project_path_from_issue_url("https://gitlab.com/group/project").as_deref(),
1243 Some("group/project")
1244 );
1245 }
1246
1247 #[test]
1248 fn test_project_path_from_issue_url_no_scheme() {
1249 assert!(project_path_from_issue_url("gitlab.com/group/project").is_none());
1250 }
1251
1252 #[test]
1253 fn test_parse_work_item_id_found() {
1254 let json: serde_json::Value = serde_json::from_str(
1255 r#"{"data":{"project":{"workItems":{"nodes":[{"id":"gid://gitlab/WorkItem/42"}]}}}}"#,
1256 )
1257 .unwrap();
1258 assert_eq!(
1259 parse_work_item_id(&json).as_deref(),
1260 Some("gid://gitlab/WorkItem/42")
1261 );
1262 }
1263
1264 #[test]
1265 fn test_parse_work_item_id_empty() {
1266 let json: serde_json::Value =
1267 serde_json::from_str(r#"{"data":{"project":{"workItems":{"nodes":[]}}}}"#).unwrap();
1268 assert!(parse_work_item_id(&json).is_none());
1269 }
1270
1271 #[test]
1272 fn test_parse_mutation_errors_none() {
1273 let json: serde_json::Value =
1274 serde_json::from_str(r#"{"data":{"workItemUpdate":{"errors":[]}}}"#).unwrap();
1275 assert!(parse_mutation_errors(&json).is_none());
1276 }
1277
1278 #[test]
1279 fn test_parse_mutation_errors_present() {
1280 let json: serde_json::Value = serde_json::from_str(
1281 r#"{"data":{"workItemUpdate":{"errors":["something went wrong"]}}}"#,
1282 )
1283 .unwrap();
1284 let errors = parse_mutation_errors(&json).unwrap();
1285 assert_eq!(errors, vec!["something went wrong"]);
1286 }
1287
1288 #[test]
1289 fn test_parse_status_id_found() {
1290 let json: serde_json::Value = serde_json::from_str(
1291 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"}]}]}]}}}}"#,
1292 ).unwrap();
1293 assert_eq!(
1294 parse_status_id(&json, "In progress").as_deref(),
1295 Some("gid://gitlab/WorkItems::Statuses::Custom::Status/2")
1296 );
1297 }
1298
1299 #[test]
1300 fn test_parse_status_id_not_found() {
1301 let json: serde_json::Value = serde_json::from_str(
1302 r#"{"data":{"project":{"workItemTypes":{"nodes":[{"widgetDefinitions":[{"type":"STATUS","allowedStatuses":[{"id":"gid://id/1","name":"To do"}]}]}]}}}}"#,
1303 ).unwrap();
1304 assert!(parse_status_id(&json, "In progress").is_none());
1305 }
1306
1307 #[test]
1308 fn test_group_client_issues_url() {
1309 let client =
1310 GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
1311 assert_eq!(
1312 client.issues_url(),
1313 "https://gitlab.com/api/v4/groups/CentOS%2FHyperscale%2Frpms/issues"
1314 );
1315 }
1316
1317 #[test]
1318 fn test_group_client_graphql_url() {
1319 let client =
1320 GroupClient::new("https://gitlab.com", "CentOS/Hyperscale/rpms", "fake-token").unwrap();
1321 assert_eq!(client.graphql_url(), "https://gitlab.com/api/graphql");
1322 }
1323
1324 #[test]
1325 fn test_add_note_success() {
1326 let mut server = mockito::Server::new();
1327 let mock = server
1328 .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
1329 .match_header("private-token", "tok")
1330 .match_body(mockito::Matcher::Json(serde_json::json!({"body": "hello"})))
1331 .with_status(201)
1332 .with_body("{}")
1333 .create();
1334 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1335 client.add_note(1, "hello").unwrap();
1336 mock.assert();
1337 }
1338
1339 #[test]
1340 fn test_add_note_error() {
1341 let mut server = mockito::Server::new();
1342 let mock = server
1343 .mock("POST", "/api/v4/projects/g%2Fp/issues/1/notes")
1344 .with_status(403)
1345 .with_body("forbidden")
1346 .create();
1347 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1348 let err = client.add_note(1, "x").unwrap_err();
1349 assert!(err.to_string().contains("403"), "{}", err);
1350 mock.assert();
1351 }
1352
1353 #[test]
1354 fn test_edit_issue_success() {
1355 let mut server = mockito::Server::new();
1356 let mock = server
1357 .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
1358 .match_header("private-token", "tok")
1359 .with_status(200)
1360 .with_header("content-type", "application/json")
1361 .with_body(r#"{"iid":5,"title":"t","description":null,"state":"closed","web_url":"https://example.com/-/issues/5"}"#)
1362 .create();
1363 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1364 let updates = IssueUpdate {
1365 state_event: Some("close".into()),
1366 ..Default::default()
1367 };
1368 let issue = client.edit_issue(5, &updates).unwrap();
1369 assert_eq!(issue.state, "closed");
1370 mock.assert();
1371 }
1372
1373 #[test]
1374 fn test_edit_issue_error() {
1375 let mut server = mockito::Server::new();
1376 let mock = server
1377 .mock("PUT", "/api/v4/projects/g%2Fp/issues/5")
1378 .with_status(404)
1379 .with_body("not found")
1380 .create();
1381 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1382 let updates = IssueUpdate::default();
1383 let err = client.edit_issue(5, &updates).unwrap_err();
1384 assert!(err.to_string().contains("404"), "{}", err);
1385 mock.assert();
1386 }
1387
1388 #[test]
1389 fn test_create_issue_success() {
1390 let mut server = mockito::Server::new();
1391 let mock = server
1392 .mock("POST", "/api/v4/projects/g%2Fp/issues")
1393 .match_header("private-token", "tok")
1394 .with_status(201)
1395 .with_header("content-type", "application/json")
1396 .with_body(r#"{"iid":10,"title":"new issue","description":"desc","state":"opened","web_url":"https://example.com/-/issues/10"}"#)
1397 .create();
1398 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1399 let issue = client
1400 .create_issue("new issue", Some("desc"), Some("bug"))
1401 .unwrap();
1402 assert_eq!(issue.iid, 10);
1403 assert_eq!(issue.title, "new issue");
1404 mock.assert();
1405 }
1406
1407 #[test]
1408 fn test_list_issues_success() {
1409 let mut server = mockito::Server::new();
1410 let mock = server
1411 .mock("GET", "/api/v4/projects/g%2Fp/issues")
1412 .match_query(mockito::Matcher::AllOf(vec![
1413 mockito::Matcher::UrlEncoded("labels".into(), "relmon".into()),
1414 mockito::Matcher::UrlEncoded("state".into(), "opened".into()),
1415 ]))
1416 .with_status(200)
1417 .with_header("content-type", "application/json")
1418 .with_body(
1419 r#"[{"iid":1,"title":"t","description":null,"state":"opened","web_url":"u"}]"#,
1420 )
1421 .create();
1422 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1423 let issues = client.list_issues("relmon", Some("opened")).unwrap();
1424 assert_eq!(issues.len(), 1);
1425 assert_eq!(issues[0].iid, 1);
1426 mock.assert();
1427 }
1428
1429 #[test]
1430 fn test_list_issues_error() {
1431 let mut server = mockito::Server::new();
1432 let mock = server
1433 .mock("GET", "/api/v4/projects/g%2Fp/issues")
1434 .match_query(mockito::Matcher::Any)
1435 .with_status(500)
1436 .with_body("internal error")
1437 .create();
1438 let client = Client::new(&server.url(), "g/p", "tok").unwrap();
1439 let err = client.list_issues("relmon", None).unwrap_err();
1440 assert!(err.to_string().contains("500"), "{}", err);
1441 mock.assert();
1442 }
1443
1444 #[test]
1447 fn parse_mr_url_standard() {
1448 let (base, project, iid) =
1449 parse_mr_url("https://gitlab.com/redhat/centos-stream/rpms/xz/-/merge_requests/42")
1450 .unwrap();
1451 assert_eq!(base, "https://gitlab.com");
1452 assert_eq!(project, "redhat/centos-stream/rpms/xz");
1453 assert_eq!(iid, 42);
1454 }
1455
1456 #[test]
1457 fn parse_mr_url_strips_trailing_slash() {
1458 let (_, _, iid) =
1459 parse_mr_url("https://gitlab.com/redhat/centos-stream/rpms/xz/-/merge_requests/42/")
1460 .unwrap();
1461 assert_eq!(iid, 42);
1462 }
1463
1464 #[test]
1465 fn parse_mr_url_strips_query() {
1466 let (_, _, iid) =
1467 parse_mr_url("https://gitlab.com/a/b/-/merge_requests/7?commit_id=abc").unwrap();
1468 assert_eq!(iid, 7);
1469 }
1470
1471 #[test]
1472 fn parse_mr_url_strips_fragment() {
1473 let (_, _, iid) =
1474 parse_mr_url("https://gitlab.com/a/b/-/merge_requests/7#note_123").unwrap();
1475 assert_eq!(iid, 7);
1476 }
1477
1478 #[test]
1479 fn parse_mr_url_rejects_issue_url() {
1480 assert!(parse_mr_url("https://gitlab.com/a/b/-/issues/1").is_err());
1481 }
1482
1483 #[test]
1484 fn parse_mr_url_rejects_non_numeric_iid() {
1485 assert!(parse_mr_url("https://gitlab.com/a/b/-/merge_requests/abc").is_err());
1486 }
1487
1488 #[test]
1489 fn parse_mr_url_rejects_no_scheme() {
1490 assert!(parse_mr_url("gitlab.com/a/b/-/merge_requests/1").is_err());
1491 }
1492
1493 #[test]
1494 fn parse_issue_url_handles_legacy_form() {
1495 let (base, project, iid) =
1496 parse_issue_url("https://gitlab.com/group/project/-/issues/42").unwrap();
1497 assert_eq!(base, "https://gitlab.com");
1498 assert_eq!(project, "group/project");
1499 assert_eq!(iid, 42);
1500 }
1501
1502 #[test]
1503 fn parse_issue_url_handles_work_items_form() {
1504 let (base, project, iid) =
1505 parse_issue_url("https://gitlab.com/CentOS/proposed_updates/rpms/xz/-/work_items/1")
1506 .unwrap();
1507 assert_eq!(base, "https://gitlab.com");
1508 assert_eq!(project, "CentOS/proposed_updates/rpms/xz");
1509 assert_eq!(iid, 1);
1510 }
1511
1512 #[test]
1513 fn parse_issue_url_strips_query_and_fragment() {
1514 let (_, _, iid) =
1515 parse_issue_url("https://gitlab.com/a/b/-/work_items/7?note=123#xyz").unwrap();
1516 assert_eq!(iid, 7);
1517 }
1518
1519 #[test]
1520 fn parse_issue_url_rejects_mr_url() {
1521 assert!(parse_issue_url("https://gitlab.com/a/b/-/merge_requests/1").is_err());
1522 }
1523
1524 #[test]
1525 fn parse_issue_url_rejects_non_numeric_iid() {
1526 assert!(parse_issue_url("https://gitlab.com/a/b/-/issues/xyz").is_err());
1527 }
1528
1529 #[test]
1530 fn user_by_username_returns_first_match() {
1531 let mut server = mockito::Server::new();
1532 let mock = server
1533 .mock("GET", "/api/v4/users?username=alice")
1534 .match_header("private-token", "tok")
1535 .with_status(200)
1536 .with_body(r#"[{"id": 42, "username": "alice"}]"#)
1537 .create();
1538 let user = user_by_username(&server.url(), "tok", "alice").unwrap();
1539 assert_eq!(user.as_ref().map(|u| u.id), Some(42));
1540 assert_eq!(user.as_ref().map(|u| u.username.as_str()), Some("alice"));
1541 mock.assert();
1542 }
1543
1544 #[test]
1545 fn user_by_username_empty_list_is_none() {
1546 let mut server = mockito::Server::new();
1547 let mock = server
1548 .mock("GET", "/api/v4/users?username=ghost")
1549 .with_status(200)
1550 .with_body("[]")
1551 .create();
1552 let user = user_by_username(&server.url(), "tok", "ghost").unwrap();
1553 assert!(user.is_none());
1554 mock.assert();
1555 }
1556
1557 #[test]
1558 fn user_events_single_page() {
1559 let mut server = mockito::Server::new();
1560 let mock = server
1561 .mock("GET", mockito::Matcher::Any)
1562 .match_query(mockito::Matcher::AllOf(vec![
1563 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
1564 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
1565 mockito::Matcher::UrlEncoded("after".into(), "2026-01-01".into()),
1566 mockito::Matcher::UrlEncoded("before".into(), "2026-03-31".into()),
1567 mockito::Matcher::UrlEncoded("action".into(), "created".into()),
1568 ]))
1569 .with_status(200)
1570 .with_body(
1571 r#"[{"id": 1, "project_id": 10, "action_name": "opened",
1572 "target_type": "MergeRequest", "target_iid": 123,
1573 "target_title": "Fix X", "created_at": "2026-02-15T10:00:00Z"}]"#,
1574 )
1575 .create();
1576 let events = user_events(
1577 &server.url(),
1578 "tok",
1579 42,
1580 Some("created"),
1581 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1582 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
1583 )
1584 .unwrap();
1585 assert_eq!(events.len(), 1);
1586 assert_eq!(events[0].target_iid, Some(123));
1587 assert_eq!(events[0].action_name, "opened");
1588 mock.assert();
1589 }
1590
1591 #[test]
1592 fn event_deserializes_push_data() {
1593 let json = r#"{
1594 "id": 5,
1595 "project_id": 10,
1596 "action_name": "pushed to",
1597 "created_at": "2026-02-15T10:00:00Z",
1598 "push_data": {"commit_count": 3, "ref": "main", "action": "pushed",
1599 "ref_type": "branch", "commit_title": "Fix typo"}
1600 }"#;
1601 let e: Event = serde_json::from_str(json).unwrap();
1602 let push = e.push_data.unwrap();
1603 assert_eq!(push.commit_count, 3);
1604 assert_eq!(push.ref_name.as_deref(), Some("main"));
1605 }
1606
1607 #[test]
1608 fn count_authored_commits_paginates_and_sums() {
1609 let mut server = mockito::Server::new();
1610 let mock_p1 = server
1611 .mock("GET", "/api/v4/projects/10/repository/commits")
1612 .match_query(mockito::Matcher::AllOf(vec![
1613 mockito::Matcher::UrlEncoded("page".into(), "1".into()),
1614 mockito::Matcher::UrlEncoded("per_page".into(), "100".into()),
1615 mockito::Matcher::UrlEncoded("author".into(), "michel-slm".into()),
1616 ]))
1617 .with_status(200)
1618 .with_body(format!("[{}]", vec!["{}"; 100].join(",")))
1620 .create();
1621 let mock_p2 = server
1622 .mock("GET", "/api/v4/projects/10/repository/commits")
1623 .match_query(mockito::Matcher::UrlEncoded("page".into(), "2".into()))
1624 .with_status(200)
1625 .with_body("[{},{},{}]")
1626 .create();
1627 let n = count_authored_commits(
1628 &server.url(),
1629 "tok",
1630 10,
1631 "michel-slm",
1632 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
1633 chrono::NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
1634 )
1635 .unwrap();
1636 assert_eq!(n, 103);
1637 mock_p1.assert();
1638 mock_p2.assert();
1639 }
1640
1641 #[test]
1642 fn project_summary_returns_path() {
1643 let mut server = mockito::Server::new();
1644 let mock = server
1645 .mock("GET", "/api/v4/projects/10")
1646 .with_status(200)
1647 .with_body(
1648 r#"{"id": 10, "path_with_namespace": "CentOS/Hyperscale/rpms/perf",
1649 "web_url": "https://gitlab.com/CentOS/Hyperscale/rpms/perf"}"#,
1650 )
1651 .create();
1652 let p = project_summary(&server.url(), "tok", 10).unwrap();
1653 assert_eq!(p.path_with_namespace, "CentOS/Hyperscale/rpms/perf");
1654 mock.assert();
1655 }
1656}