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