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