1use async_trait::async_trait;
4use devboy_core::{
5 AssetCapabilities, AssetMeta, CodePosition, Comment, ContextCapabilities, CreateCommentInput,
6 CreateIssueInput, CreateMergeRequestInput, Discussion, Error, FailedJob, FileDiff,
7 GetPipelineInput, Issue, IssueFilter, IssueProvider, JobLogMode, JobLogOptions, JobLogOutput,
8 MergeRequest, MergeRequestProvider, MrFilter, PipelineInfo, PipelineJob, PipelineProvider,
9 PipelineStage, PipelineStatus, PipelineSummary, Provider, ProviderResult, Result,
10 UpdateIssueInput, User, parse_markdown_attachments,
11};
12use secrecy::{ExposeSecret, SecretString};
13use tracing::{debug, warn};
14
15use crate::DEFAULT_GITLAB_URL;
16use crate::types::{
17 CreateDiscussionRequest, CreateIssueRequest, CreateMergeRequestRequest, CreateNoteRequest,
18 DiscussionPosition, GitLabDiff, GitLabDiscussion, GitLabIssue, GitLabMergeRequest,
19 GitLabMergeRequestChanges, GitLabNote, GitLabNotePosition, GitLabUser, UpdateIssueRequest,
20};
21
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub enum AuthScheme {
41 #[default]
43 Auto,
44 PrivateToken,
46 Bearer,
48}
49
50pub struct GitLabClient {
51 base_url: String,
52 project_id: String,
53 token: SecretString,
54 auth_scheme: AuthScheme,
55 proxy_headers: Option<std::collections::HashMap<String, String>>,
56 client: reqwest::Client,
57}
58
59fn is_pat_prefix(token: &str) -> bool {
64 token.starts_with("glpat-")
65 || token.starts_with("gloas-")
66 || token.starts_with("gldt-")
67 || token.starts_with("glrt-")
68}
69
70impl GitLabClient {
71 pub fn new(project_id: impl Into<String>, token: SecretString) -> Self {
73 Self::with_base_url(DEFAULT_GITLAB_URL, project_id, token)
74 }
75
76 pub fn with_base_url(
78 base_url: impl Into<String>,
79 project_id: impl Into<String>,
80 token: SecretString,
81 ) -> Self {
82 Self {
83 base_url: base_url.into().trim_end_matches('/').to_string(),
84 project_id: project_id.into(),
85 token,
86 auth_scheme: AuthScheme::default(),
87 proxy_headers: None,
88 client: reqwest::Client::new(),
89 }
90 }
91
92 pub fn with_auth_scheme(mut self, scheme: AuthScheme) -> Self {
98 self.auth_scheme = scheme;
99 self
100 }
101
102 pub fn base_url(&self) -> &str {
106 &self.base_url
107 }
108
109 pub fn http_client(&self) -> &reqwest::Client {
112 &self.client
113 }
114
115 pub fn with_proxy(mut self, headers: std::collections::HashMap<String, String>) -> Self {
119 self.proxy_headers = Some(headers);
120 self
121 }
122
123 fn request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
128 let mut req = self.client.request(method, url);
129 if let Some(headers) = &self.proxy_headers {
130 for (key, value) in headers {
131 req = req.header(key.as_str(), value.as_str());
132 }
133 } else {
134 let tok = self.token.expose_secret();
135 let use_bearer = match self.auth_scheme {
136 AuthScheme::Bearer => true,
137 AuthScheme::PrivateToken => false,
138 AuthScheme::Auto => !is_pat_prefix(tok),
139 };
140 if use_bearer {
141 req = req.header("Authorization", format!("Bearer {tok}"));
142 } else {
143 req = req.header("PRIVATE-TOKEN", tok);
144 }
145 }
146 req
147 }
148
149 fn project_url(&self, endpoint: &str) -> String {
151 format!(
152 "{}/api/v4/projects/{}{}",
153 self.base_url, self.project_id, endpoint
154 )
155 }
156
157 fn api_url(&self, endpoint: &str) -> String {
159 format!("{}/api/v4{}", self.base_url, endpoint)
160 }
161
162 async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
164 debug!(url = url, "GitLab GET request");
165
166 let response = self
167 .request(reqwest::Method::GET, url)
168 .send()
169 .await
170 .map_err(|e| Error::Http(e.to_string()))?;
171
172 self.handle_response(response).await
173 }
174
175 async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
177 &self,
178 url: &str,
179 body: &B,
180 ) -> Result<T> {
181 debug!(url = url, "GitLab POST request");
182
183 let response = self
184 .request(reqwest::Method::POST, url)
185 .json(body)
186 .send()
187 .await
188 .map_err(|e| Error::Http(e.to_string()))?;
189
190 self.handle_response(response).await
191 }
192
193 async fn put<T: serde::de::DeserializeOwned, B: serde::Serialize>(
195 &self,
196 url: &str,
197 body: &B,
198 ) -> Result<T> {
199 debug!(url = url, "GitLab PUT request");
200
201 let response = self
202 .request(reqwest::Method::PUT, url)
203 .json(body)
204 .send()
205 .await
206 .map_err(|e| Error::Http(e.to_string()))?;
207
208 self.handle_response(response).await
209 }
210
211 async fn get_with_pagination<T: serde::de::DeserializeOwned>(
213 &self,
214 url: &str,
215 filter_offset: Option<u32>,
216 filter_limit: Option<u32>,
217 ) -> Result<(T, Option<devboy_core::Pagination>)> {
218 debug!(url = url, "GitLab GET request (with pagination)");
219
220 let response = self
221 .request(reqwest::Method::GET, url)
222 .send()
223 .await
224 .map_err(|e| Error::Http(e.to_string()))?;
225
226 let status = response.status();
227 if !status.is_success() {
228 let status_code = status.as_u16();
229 let message = response.text().await.unwrap_or_default();
230 warn!(
231 status = status_code,
232 message = message,
233 "GitLab API error response"
234 );
235 return Err(Error::from_status(status_code, message));
236 }
237
238 let pagination = Self::extract_pagination(&response, filter_offset, filter_limit);
240
241 let data: T = response
242 .json()
243 .await
244 .map_err(|e| Error::InvalidData(format!("Failed to parse response: {}", e)))?;
245
246 Ok((data, pagination))
247 }
248
249 fn extract_pagination(
251 response: &reqwest::Response,
252 offset: Option<u32>,
253 limit: Option<u32>,
254 ) -> Option<devboy_core::Pagination> {
255 let headers = response.headers();
256
257 let x_total = headers
258 .get("x-total")
259 .and_then(|v| v.to_str().ok())
260 .and_then(|v| v.parse::<u32>().ok());
261
262 let x_page = headers
263 .get("x-page")
264 .and_then(|v| v.to_str().ok())
265 .and_then(|v| v.parse::<u32>().ok());
266
267 let x_total_pages = headers
268 .get("x-total-pages")
269 .and_then(|v| v.to_str().ok())
270 .and_then(|v| v.parse::<u32>().ok());
271
272 let limit = limit.unwrap_or(20);
273 let offset = offset.unwrap_or(0);
274
275 let has_more = match (x_page, x_total_pages) {
276 (Some(page), Some(total_pages)) => page < total_pages,
277 _ => false,
278 };
279
280 Some(devboy_core::Pagination {
281 offset,
282 limit,
283 total: x_total,
284 has_more,
285 next_cursor: None,
286 })
287 }
288
289 async fn upload_project_file(&self, filename: &str, data: &[u8]) -> Result<String> {
297 let url = self.project_url("/uploads");
298
299 let part = reqwest::multipart::Part::bytes(data.to_vec())
300 .file_name(filename.to_string())
301 .mime_str("application/octet-stream")
302 .map_err(|e| Error::Http(format!("failed to build multipart: {e}")))?;
303 let form = reqwest::multipart::Form::new().part("file", part);
304
305 let response = self
306 .request(reqwest::Method::POST, &url)
307 .multipart(form)
308 .send()
309 .await
310 .map_err(|e| Error::Http(e.to_string()))?;
311
312 let status = response.status();
313 if !status.is_success() {
314 let message = response.text().await.unwrap_or_default();
315 return Err(Error::from_status(status.as_u16(), message));
316 }
317
318 let body: serde_json::Value = response
319 .json()
320 .await
321 .map_err(|e| Error::InvalidData(format!("failed to parse upload response: {e}")))?;
322
323 let relative = body
327 .get("full_path")
328 .or_else(|| body.get("url"))
329 .and_then(|v| v.as_str())
330 .filter(|s| !s.is_empty())
331 .ok_or_else(|| {
332 Error::InvalidData(
333 "GitLab upload response contains no usable url or full_path".to_string(),
334 )
335 })?;
336 Ok(absolutize_gitlab_url(&self.base_url, relative))
337 }
338
339 async fn handle_response<T: serde::de::DeserializeOwned>(
341 &self,
342 response: reqwest::Response,
343 ) -> Result<T> {
344 let status = response.status();
345
346 if !status.is_success() {
347 let status_code = status.as_u16();
348 let message = response.text().await.unwrap_or_default();
349 warn!(
350 status = status_code,
351 message = message,
352 "GitLab API error response"
353 );
354 return Err(Error::from_status(status_code, message));
355 }
356
357 response
358 .json()
359 .await
360 .map_err(|e| Error::InvalidData(format!("Failed to parse response: {}", e)))
361 }
362
363 async fn download_trusted_url(&self, url: &str) -> Result<Vec<u8>> {
370 let request = if is_same_origin(&self.base_url, url) {
371 self.request(reqwest::Method::GET, url)
372 } else {
373 tracing::warn!(
374 url,
375 "downloading cross-origin attachment without auth headers"
376 );
377 self.client.get(url)
378 };
379 let response = request
380 .send()
381 .await
382 .map_err(|e| Error::Http(e.to_string()))?;
383 let status = response.status();
384 if !status.is_success() {
385 let message = response.text().await.unwrap_or_default();
386 return Err(Error::from_status(status.as_u16(), message));
387 }
388 let bytes = response
389 .bytes()
390 .await
391 .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
392 Ok(bytes.to_vec())
393 }
394}
395
396fn is_same_origin(base_url: &str, url: &str) -> bool {
404 if !url.contains("://") && !url.starts_with("//") {
405 return true; }
407 let (base_scheme, base_host) = split_scheme_host(base_url);
408 let (url_scheme, url_host) = split_scheme_host(url);
409
410 base_scheme.eq_ignore_ascii_case(&url_scheme) && base_host.eq_ignore_ascii_case(&url_host)
411}
412
413fn split_scheme_host(url: &str) -> (String, String) {
416 let (scheme, rest) = match url.split_once("://") {
417 Some((s, r)) => (s.to_ascii_lowercase(), r),
418 None => return (String::new(), String::new()),
419 };
420 let host = rest.split('/').next().unwrap_or("").to_ascii_lowercase();
421 (scheme, host)
422}
423
424fn map_user(gl_user: Option<&GitLabUser>) -> Option<User> {
429 gl_user.map(|u| User {
430 id: u.id.to_string(),
431 username: u.username.clone(),
432 name: u.name.clone(),
433 email: None, avatar_url: u.avatar_url.clone(),
435 })
436}
437
438fn map_user_required(gl_user: Option<&GitLabUser>) -> User {
439 map_user(gl_user).unwrap_or_else(|| User {
440 id: "unknown".to_string(),
441 username: "unknown".to_string(),
442 name: Some("Unknown".to_string()),
443 ..Default::default()
444 })
445}
446
447fn map_issue(gl_issue: &GitLabIssue, base_url: &str) -> Issue {
448 let attachments_count = gl_issue
450 .description
451 .as_deref()
452 .map(|body| {
453 parse_markdown_attachments(body)
454 .iter()
455 .filter(|a| is_gitlab_upload_url(base_url, &a.url))
456 .count() as u32
457 })
458 .filter(|&c| c > 0);
459
460 Issue {
461 custom_fields: std::collections::HashMap::new(),
462 key: format!("gitlab#{}", gl_issue.iid),
463 title: gl_issue.title.clone(),
464 description: gl_issue.description.clone(),
465 state: gl_issue.state.clone(),
466 status: None, status_category: None,
468 source: "gitlab".to_string(),
469 priority: None, labels: gl_issue.labels.clone(),
471 author: map_user(gl_issue.author.as_ref()),
472 assignees: gl_issue
473 .assignees
474 .iter()
475 .map(|u| map_user_required(Some(u)))
476 .collect(),
477 url: Some(gl_issue.web_url.clone()),
478 created_at: Some(gl_issue.created_at.clone()),
479 updated_at: Some(gl_issue.updated_at.clone()),
480 attachments_count,
481 parent: None,
482 subtasks: vec![],
483 }
484}
485
486fn map_merge_request(gl_mr: &GitLabMergeRequest) -> MergeRequest {
487 let state = if gl_mr.merged_at.is_some() {
489 "merged".to_string()
490 } else if gl_mr.state == "closed" {
491 "closed".to_string()
492 } else if gl_mr.draft || gl_mr.work_in_progress {
493 "draft".to_string()
494 } else {
495 gl_mr.state.clone() };
497
498 MergeRequest {
499 key: format!("mr#{}", gl_mr.iid),
500 title: gl_mr.title.clone(),
501 description: gl_mr.description.clone(),
502 state,
503 source: "gitlab".to_string(),
504 source_branch: gl_mr.source_branch.clone(),
505 target_branch: gl_mr.target_branch.clone(),
506 author: map_user(gl_mr.author.as_ref()),
507 assignees: gl_mr
508 .assignees
509 .iter()
510 .map(|u| map_user_required(Some(u)))
511 .collect(),
512 reviewers: gl_mr
513 .reviewers
514 .iter()
515 .map(|u| map_user_required(Some(u)))
516 .collect(),
517 labels: gl_mr.labels.clone(),
518 draft: gl_mr.draft || gl_mr.work_in_progress,
519 url: Some(gl_mr.web_url.clone()),
520 created_at: Some(gl_mr.created_at.clone()),
521 updated_at: Some(gl_mr.updated_at.clone()),
522 }
523}
524
525fn map_note(gl_note: &GitLabNote) -> Comment {
526 let position = gl_note.position.as_ref().and_then(map_position);
527
528 Comment {
529 id: gl_note.id.to_string(),
530 body: gl_note.body.clone(),
531 author: map_user(gl_note.author.as_ref()),
532 created_at: Some(gl_note.created_at.clone()),
533 updated_at: gl_note.updated_at.clone(),
534 position,
535 }
536}
537
538fn map_position(gl_position: &GitLabNotePosition) -> Option<CodePosition> {
539 let (file_path, line, line_type) = if let Some(new_line) = gl_position.new_line {
541 let path = gl_position
542 .new_path
543 .clone()
544 .unwrap_or_else(|| gl_position.old_path.clone().unwrap_or_default());
545 (path, new_line, "new".to_string())
546 } else if let Some(old_line) = gl_position.old_line {
547 let path = gl_position
548 .old_path
549 .clone()
550 .unwrap_or_else(|| gl_position.new_path.clone().unwrap_or_default());
551 (path, old_line, "old".to_string())
552 } else {
553 return None;
554 };
555
556 Some(CodePosition {
557 file_path,
558 line,
559 line_type,
560 commit_sha: None,
561 })
562}
563
564fn map_discussion(gl_discussion: &GitLabDiscussion) -> Discussion {
565 let notes: Vec<&GitLabNote> = gl_discussion.notes.iter().filter(|n| !n.system).collect();
567
568 if notes.is_empty() {
569 return Discussion {
570 id: gl_discussion.id.clone(),
571 resolved: false,
572 resolved_by: None,
573 comments: vec![],
574 position: None,
575 };
576 }
577
578 let comments: Vec<Comment> = notes.iter().map(|n| map_note(n)).collect();
579 let position = comments.first().and_then(|c| c.position.clone());
580
581 let first_resolvable = notes.iter().find(|n| n.resolvable);
583 let resolved = first_resolvable.is_some_and(|n| n.resolved);
584 let resolved_by = first_resolvable.and_then(|n| map_user(n.resolved_by.as_ref()));
585
586 Discussion {
587 id: gl_discussion.id.clone(),
588 resolved,
589 resolved_by,
590 comments,
591 position,
592 }
593}
594
595fn map_diff(gl_diff: &GitLabDiff) -> FileDiff {
596 FileDiff {
597 file_path: gl_diff.new_path.clone(),
598 old_path: if gl_diff.renamed_file {
599 Some(gl_diff.old_path.clone())
600 } else {
601 None
602 },
603 new_file: gl_diff.new_file,
604 deleted_file: gl_diff.deleted_file,
605 renamed_file: gl_diff.renamed_file,
606 diff: gl_diff.diff.clone(),
607 additions: None, deletions: None,
609 }
610}
611
612fn parse_issue_key(key: &str) -> Result<u64> {
618 key.strip_prefix("gitlab#")
619 .and_then(|s| s.parse::<u64>().ok())
620 .ok_or_else(|| Error::InvalidData(format!("Invalid issue key: {}", key)))
621}
622
623fn parse_mr_key(key: &str) -> Result<u64> {
625 key.strip_prefix("mr#")
626 .and_then(|s| s.parse::<u64>().ok())
627 .ok_or_else(|| Error::InvalidData(format!("Invalid MR key: {}", key)))
628}
629
630#[async_trait]
635impl IssueProvider for GitLabClient {
636 async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
637 let mut url = self.project_url("/issues");
638 let mut params = vec![];
639
640 if let Some(state) = &filter.state {
641 let gl_state = match state.as_str() {
642 "open" | "opened" => "opened",
643 "closed" => "closed",
644 "all" => "all",
645 _ => "opened",
646 };
647 params.push(format!("state={}", gl_state));
648 }
649
650 if let Some(search) = &filter.search {
651 params.push(format!("search={}", search));
652 }
653
654 if let Some(labels) = &filter.labels
655 && !labels.is_empty()
656 {
657 params.push(format!("labels={}", labels.join(",")));
658 }
659
660 if let Some(assignee) = &filter.assignee {
661 params.push(format!("assignee_username={}", assignee));
662 }
663
664 if let Some(limit) = filter.limit {
665 params.push(format!("per_page={}", limit.min(100)));
666 }
667
668 if let Some(offset) = filter.offset {
669 let per_page = filter.limit.unwrap_or(20);
670 let page = (offset / per_page) + 1;
671 params.push(format!("page={}", page));
672 }
673
674 if let Some(sort_by) = &filter.sort_by {
675 let gl_sort = match sort_by.as_str() {
676 "created_at" | "created" => "created_at",
677 "updated_at" | "updated" => "updated_at",
678 _ => "updated_at",
679 };
680 params.push(format!("order_by={}", gl_sort));
681 }
682
683 if let Some(order) = &filter.sort_order {
684 params.push(format!("sort={}", order));
685 }
686
687 if !params.is_empty() {
688 url.push_str(&format!("?{}", params.join("&")));
689 }
690
691 let (gl_issues, pagination): (Vec<GitLabIssue>, _) = self
692 .get_with_pagination(&url, filter.offset, filter.limit)
693 .await?;
694 let issues: Vec<Issue> = gl_issues
695 .iter()
696 .map(|i| map_issue(i, &self.base_url))
697 .collect();
698 let mut result = ProviderResult::new(issues);
699 result.pagination = pagination;
700 result.sort_info = Some(devboy_core::SortInfo {
701 sort_by: Some(filter.sort_by.as_deref().unwrap_or("updated_at").into()),
702 sort_order: match filter.sort_order.as_deref() {
703 Some("asc") => devboy_core::SortOrder::Asc,
704 _ => devboy_core::SortOrder::Desc,
705 },
706 available_sorts: vec!["created_at".into(), "updated_at".into()],
707 });
708 Ok(result)
709 }
710
711 async fn get_issue(&self, key: &str) -> Result<Issue> {
712 let iid = parse_issue_key(key)?;
713 let url = self.project_url(&format!("/issues/{}", iid));
714 let gl_issue: GitLabIssue = self.get(&url).await?;
715 Ok(map_issue(&gl_issue, &self.base_url))
716 }
717
718 async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
719 let url = self.project_url("/issues");
720 let labels = if input.labels.is_empty() {
721 None
722 } else {
723 Some(input.labels.join(","))
724 };
725
726 let request = CreateIssueRequest {
727 title: input.title,
728 description: input.description,
729 labels,
730 assignee_ids: None, };
732
733 let gl_issue: GitLabIssue = self.post(&url, &request).await?;
734 Ok(map_issue(&gl_issue, &self.base_url))
735 }
736
737 async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
738 let iid = parse_issue_key(key)?;
739 let url = self.project_url(&format!("/issues/{}", iid));
740
741 let state_event = input.state.map(|s| match s.as_str() {
743 "opened" | "open" => "reopen".to_string(),
744 "closed" | "close" => "close".to_string(),
745 _ => s,
746 });
747
748 let labels = input.labels.map(|l| l.join(","));
749
750 let request = UpdateIssueRequest {
751 title: input.title,
752 description: input.description,
753 state_event,
754 labels,
755 assignee_ids: None,
756 };
757
758 let gl_issue: GitLabIssue = self.put(&url, &request).await?;
759 Ok(map_issue(&gl_issue, &self.base_url))
760 }
761
762 async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
763 let iid = parse_issue_key(issue_key)?;
764 let url = self.project_url(&format!("/issues/{}/notes", iid));
765 let gl_notes: Vec<GitLabNote> = self.get(&url).await?;
766
767 let comments: Vec<Comment> = gl_notes
769 .iter()
770 .filter(|n| !n.system)
771 .map(map_note)
772 .collect();
773 Ok(comments.into())
774 }
775
776 async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
777 let iid = parse_issue_key(issue_key)?;
778 let url = self.project_url(&format!("/issues/{}/notes", iid));
779 let request = CreateNoteRequest {
780 body: body.to_string(),
781 };
782
783 let gl_note: GitLabNote = self.post(&url, &request).await?;
784 Ok(map_note(&gl_note))
785 }
786
787 async fn upload_attachment(
788 &self,
789 issue_key: &str,
790 filename: &str,
791 data: &[u8],
792 ) -> Result<String> {
793 let upload_url = self.upload_project_file(filename, data).await?;
797
798 let iid = parse_issue_key(issue_key)?;
802 let note_url = self.project_url(&format!("/issues/{}/notes", iid));
803 let markdown = format!("", filename, upload_url);
804 let request = CreateNoteRequest { body: markdown };
805 if let Err(err) = self.post::<GitLabNote, _>(¬e_url, &request).await {
806 warn!(
807 error = ?err,
808 issue_key,
809 "Failed to attach upload comment to issue"
810 );
811 }
812
813 Ok(upload_url)
814 }
815
816 async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
817 let issue = self.get_issue(issue_key).await?;
820 let comments = self.get_comments(issue_key).await?;
821
822 let mut attachments: Vec<AssetMeta> = Vec::new();
823 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
824
825 let mut collect = |source: &str| {
826 for att in parse_markdown_attachments(source) {
827 if is_gitlab_upload_url(&self.base_url, &att.url) && seen.insert(att.url.clone()) {
831 attachments.push(markdown_to_meta(&att, &self.base_url));
832 }
833 }
834 };
835
836 if let Some(body) = issue.description.as_deref() {
837 collect(body);
838 }
839 for comment in &comments.items {
840 collect(&comment.body);
841 }
842
843 Ok(attachments)
844 }
845
846 async fn download_attachment(&self, _issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
847 let url = if asset_id.starts_with("/uploads/") {
852 self.project_url(asset_id)
853 } else {
854 absolutize_gitlab_url(&self.base_url, asset_id)
855 };
856 self.download_trusted_url(&url).await
857 }
858
859 fn asset_capabilities(&self) -> AssetCapabilities {
860 let caps = ContextCapabilities {
862 upload: true,
863 download: true,
864 delete: false,
865 list: true,
866 max_file_size: None,
867 allowed_types: Vec::new(),
868 };
869 AssetCapabilities {
870 issue: caps.clone(),
871 issue_comment: caps.clone(),
872 merge_request: caps.clone(),
873 mr_comment: caps,
874 }
875 }
876
877 fn provider_name(&self) -> &'static str {
878 "gitlab"
879 }
880}
881
882#[async_trait]
883impl MergeRequestProvider for GitLabClient {
884 async fn get_merge_requests(&self, filter: MrFilter) -> Result<ProviderResult<MergeRequest>> {
885 let mut url = self.project_url("/merge_requests");
886 let mut params = vec![];
887
888 if let Some(state) = &filter.state {
889 let gl_state = match state.as_str() {
890 "open" | "opened" => "opened",
891 "closed" => "closed",
892 "merged" => "merged",
893 "all" => "all",
894 _ => "opened",
895 };
896 params.push(format!("state={}", gl_state));
897 }
898
899 if let Some(source_branch) = &filter.source_branch {
900 params.push(format!("source_branch={}", source_branch));
901 }
902
903 if let Some(target_branch) = &filter.target_branch {
904 params.push(format!("target_branch={}", target_branch));
905 }
906
907 if let Some(author) = &filter.author {
908 params.push(format!("author_username={}", author));
909 }
910
911 if let Some(labels) = &filter.labels
912 && !labels.is_empty()
913 {
914 params.push(format!("labels={}", labels.join(",")));
915 }
916
917 if let Some(limit) = filter.limit {
918 params.push(format!("per_page={}", limit.min(100)));
919 }
920
921 let order_by = filter.sort_by.as_deref().unwrap_or("updated_at");
922 let sort_order = filter.sort_order.as_deref().unwrap_or("desc");
923 params.push(format!("order_by={}", order_by));
924 params.push(format!("sort={}", sort_order));
925
926 if let Some(offset) = filter.offset {
927 let page = (offset / filter.limit.unwrap_or(20)) + 1;
928 params.push(format!("page={}", page));
929 }
930
931 if !params.is_empty() {
932 url.push_str(&format!("?{}", params.join("&")));
933 }
934
935 let (gl_mrs, pagination): (Vec<GitLabMergeRequest>, _) = self
936 .get_with_pagination(&url, filter.offset, filter.limit)
937 .await?;
938 let mrs: Vec<MergeRequest> = gl_mrs.iter().map(map_merge_request).collect();
939 let mut result = ProviderResult::new(mrs);
940 result.pagination = pagination;
941 result.sort_info = Some(devboy_core::SortInfo {
942 sort_by: Some(order_by.into()),
943 sort_order: match sort_order {
944 "asc" => devboy_core::SortOrder::Asc,
945 _ => devboy_core::SortOrder::Desc,
946 },
947 available_sorts: vec!["created_at".into(), "updated_at".into()],
948 });
949 Ok(result)
950 }
951
952 async fn get_merge_request(&self, key: &str) -> Result<MergeRequest> {
953 let iid = parse_mr_key(key)?;
954 let url = self.project_url(&format!("/merge_requests/{}", iid));
955 let gl_mr: GitLabMergeRequest = self.get(&url).await?;
956 Ok(map_merge_request(&gl_mr))
957 }
958
959 async fn get_discussions(&self, mr_key: &str) -> Result<ProviderResult<Discussion>> {
960 let iid = parse_mr_key(mr_key)?;
961 let url = self.project_url(&format!("/merge_requests/{}/discussions", iid));
962 let gl_discussions: Vec<GitLabDiscussion> = self.get(&url).await?;
963
964 let discussions: Vec<Discussion> = gl_discussions
966 .iter()
967 .map(map_discussion)
968 .filter(|d| !d.comments.is_empty())
969 .collect();
970 Ok(discussions.into())
971 }
972
973 async fn get_diffs(&self, mr_key: &str) -> Result<ProviderResult<FileDiff>> {
974 let iid = parse_mr_key(mr_key)?;
975 let url = self.project_url(&format!("/merge_requests/{}/changes", iid));
977 let gl_changes: GitLabMergeRequestChanges = self.get(&url).await?;
978 Ok(gl_changes
979 .changes
980 .iter()
981 .map(map_diff)
982 .collect::<Vec<_>>()
983 .into())
984 }
985
986 async fn add_comment(&self, mr_key: &str, input: CreateCommentInput) -> Result<Comment> {
987 let iid = parse_mr_key(mr_key)?;
988
989 if let Some(discussion_id) = &input.discussion_id {
991 let url = self.project_url(&format!(
992 "/merge_requests/{}/discussions/{}/notes",
993 iid, discussion_id
994 ));
995 let request = CreateNoteRequest { body: input.body };
996 let gl_note: GitLabNote = self.post(&url, &request).await?;
997 return Ok(map_note(&gl_note));
998 }
999
1000 if let Some(position) = &input.position {
1002 let mr_url = self.project_url(&format!("/merge_requests/{}", iid));
1004 let gl_mr: GitLabMergeRequest = self.get(&mr_url).await?;
1005
1006 let diff_refs = gl_mr.diff_refs.ok_or_else(|| {
1007 Error::InvalidData("MR has no diff_refs, cannot create inline comment".to_string())
1008 })?;
1009
1010 let (new_line, old_line, new_path, old_path) = if position.line_type == "old" {
1011 (
1012 None,
1013 Some(position.line),
1014 None,
1015 Some(position.file_path.clone()),
1016 )
1017 } else {
1018 (
1019 Some(position.line),
1020 None,
1021 Some(position.file_path.clone()),
1022 None,
1023 )
1024 };
1025
1026 let url = self.project_url(&format!("/merge_requests/{}/discussions", iid));
1027 let request = CreateDiscussionRequest {
1028 body: input.body,
1029 position: Some(DiscussionPosition {
1030 position_type: "text".to_string(),
1031 base_sha: diff_refs.base_sha,
1032 start_sha: diff_refs.start_sha,
1033 head_sha: diff_refs.head_sha,
1034 new_path,
1035 old_path,
1036 new_line,
1037 old_line,
1038 }),
1039 };
1040
1041 let gl_discussion: GitLabDiscussion = self.post(&url, &request).await?;
1042 let first_note = gl_discussion.notes.first().ok_or_else(|| {
1043 Error::InvalidData("Discussion created with no notes".to_string())
1044 })?;
1045 return Ok(map_note(first_note));
1046 }
1047
1048 let url = self.project_url(&format!("/merge_requests/{}/notes", iid));
1050 let request = CreateNoteRequest { body: input.body };
1051
1052 let gl_note: GitLabNote = self.post(&url, &request).await?;
1053 Ok(map_note(&gl_note))
1054 }
1055
1056 async fn create_merge_request(&self, input: CreateMergeRequestInput) -> Result<MergeRequest> {
1057 let url = self.project_url("/merge_requests");
1058
1059 let labels = if input.labels.is_empty() {
1060 None
1061 } else {
1062 Some(input.labels.join(","))
1063 };
1064
1065 let title = if input.draft && !input.title.starts_with("Draft:") {
1067 format!("Draft: {}", input.title)
1068 } else {
1069 input.title
1070 };
1071
1072 if !input.reviewers.is_empty() {
1073 warn!(
1074 "GitLab reviewers require user IDs, not usernames; ignoring reviewers: {:?}",
1075 input.reviewers
1076 );
1077 }
1078
1079 let request = CreateMergeRequestRequest {
1080 source_branch: input.source_branch,
1081 target_branch: input.target_branch,
1082 title,
1083 description: input.description,
1084 labels,
1085 reviewer_ids: None,
1086 };
1087
1088 let gl_mr: GitLabMergeRequest = self.post(&url, &request).await?;
1089 Ok(map_merge_request(&gl_mr))
1090 }
1091
1092 async fn update_merge_request(
1093 &self,
1094 key: &str,
1095 input: devboy_core::UpdateMergeRequestInput,
1096 ) -> Result<MergeRequest> {
1097 let iid = parse_mr_key(key)?;
1098 let url = self.project_url(&format!("/merge_requests/{}", iid));
1099
1100 let state_event = input.state.map(|s| match s.as_str() {
1101 "opened" | "open" | "reopen" => "reopen".to_string(),
1102 "closed" | "close" => "close".to_string(),
1103 _ => s,
1104 });
1105
1106 let labels = input.labels.map(|l| l.join(","));
1107
1108 let request = crate::types::UpdateMergeRequestRequest {
1109 title: input.title,
1110 description: input.description,
1111 state_event,
1112 labels,
1113 };
1114
1115 let gl_mr: GitLabMergeRequest = self.put(&url, &request).await?;
1116 Ok(map_merge_request(&gl_mr))
1117 }
1118
1119 async fn get_mr_attachments(&self, mr_key: &str) -> Result<Vec<AssetMeta>> {
1120 let mr = self.get_merge_request(mr_key).await?;
1121 let discussions = self.get_discussions(mr_key).await?;
1122
1123 let mut attachments: Vec<AssetMeta> = Vec::new();
1124 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1125
1126 let mut collect = |source: &str| {
1127 for att in parse_markdown_attachments(source) {
1128 if is_gitlab_upload_url(&self.base_url, &att.url) && seen.insert(att.url.clone()) {
1129 attachments.push(markdown_to_meta(&att, &self.base_url));
1130 }
1131 }
1132 };
1133
1134 if let Some(body) = mr.description.as_deref() {
1135 collect(body);
1136 }
1137 for discussion in &discussions.items {
1138 for comment in &discussion.comments {
1139 collect(&comment.body);
1140 }
1141 }
1142
1143 Ok(attachments)
1144 }
1145
1146 async fn download_mr_attachment(&self, _mr_key: &str, asset_id: &str) -> Result<Vec<u8>> {
1147 let url = if asset_id.starts_with("/uploads/") {
1148 self.project_url(asset_id)
1149 } else {
1150 absolutize_gitlab_url(&self.base_url, asset_id)
1151 };
1152 self.download_trusted_url(&url).await
1153 }
1154
1155 fn provider_name(&self) -> &'static str {
1156 "gitlab"
1157 }
1158}
1159
1160fn is_gitlab_upload_url(base_url: &str, url: &str) -> bool {
1173 if !url.contains("/uploads/") {
1174 return false;
1175 }
1176 if url.starts_with('/') {
1178 return true;
1179 }
1180 match (extract_host(base_url), extract_host(url)) {
1182 (Some(base_host), Some(url_host)) => base_host == url_host,
1183 _ => false,
1184 }
1185}
1186
1187fn extract_host(url: &str) -> Option<&str> {
1189 let after_scheme = url
1190 .strip_prefix("https://")
1191 .or_else(|| url.strip_prefix("http://"))?;
1192 Some(after_scheme.split('/').next().unwrap_or(after_scheme))
1193}
1194
1195fn absolutize_gitlab_url(base: &str, url_or_path: &str) -> String {
1196 if url_or_path.starts_with("http://") || url_or_path.starts_with("https://") {
1197 return url_or_path.to_string();
1198 }
1199 let base = base.trim_end_matches('/');
1200 if url_or_path.starts_with('/') {
1201 format!("{base}{url_or_path}")
1202 } else {
1203 format!("{base}/{url_or_path}")
1204 }
1205}
1206
1207fn markdown_to_meta(att: &devboy_core::MarkdownAttachment, base_url: &str) -> AssetMeta {
1209 let absolute = absolutize_gitlab_url(base_url, &att.url);
1210 AssetMeta {
1211 id: att.url.clone(),
1214 filename: att.filename.clone(),
1215 mime_type: None,
1216 size: None,
1217 url: Some(absolute),
1218 created_at: None,
1219 author: None,
1220 cached: false,
1221 local_path: None,
1222 checksum_sha256: None,
1223 analysis: None,
1224 }
1225}
1226
1227#[derive(Debug, serde::Deserialize)]
1232struct GlPipeline {
1233 id: u64,
1234 status: String,
1235 #[serde(rename = "ref")]
1236 ref_name: String,
1237 sha: String,
1238 web_url: Option<String>,
1239 duration: Option<u64>,
1240 coverage: Option<String>,
1241}
1242
1243#[derive(Debug, serde::Deserialize)]
1244struct GlJob {
1245 id: u64,
1246 name: String,
1247 status: String,
1248 stage: String,
1249 web_url: Option<String>,
1250 duration: Option<f64>,
1251}
1252
1253fn map_gl_pipeline_status(status: &str) -> PipelineStatus {
1254 match status {
1255 "success" => PipelineStatus::Success,
1256 "failed" => PipelineStatus::Failed,
1257 "running" => PipelineStatus::Running,
1258 "pending" | "waiting_for_resource" | "preparing" => PipelineStatus::Pending,
1259 "canceled" => PipelineStatus::Canceled,
1260 "skipped" => PipelineStatus::Skipped,
1261 "manual" => PipelineStatus::Pending,
1262 _ => PipelineStatus::Unknown,
1263 }
1264}
1265
1266fn strip_ansi(text: &str) -> String {
1268 let mut result = String::with_capacity(text.len());
1269 let mut chars = text.chars().peekable();
1270 while let Some(ch) = chars.next() {
1271 if ch == '\x1b' {
1272 while let Some(&next) = chars.peek() {
1273 chars.next();
1274 if next.is_ascii_alphabetic() {
1275 break;
1276 }
1277 }
1278 } else {
1279 result.push(ch);
1280 }
1281 }
1282 result
1283}
1284
1285fn extract_errors(log: &str, max_lines: usize) -> Option<String> {
1287 let patterns = [
1288 "error[",
1289 "error:",
1290 "FAILED",
1291 "Error:",
1292 "panic",
1293 "FATAL",
1294 "AssertionError",
1295 "TypeError",
1296 "Cannot find",
1297 "not found",
1298 "exit code",
1299 ];
1300 let lines: Vec<&str> = log.lines().collect();
1301 let mut error_lines: Vec<String> = Vec::new();
1302
1303 for (i, line) in lines.iter().enumerate() {
1304 let stripped = strip_ansi(line);
1305 if patterns.iter().any(|p| stripped.contains(p)) {
1306 let start = i.saturating_sub(2);
1307 let end = (i + 3).min(lines.len());
1308 for ctx_line_raw in &lines[start..end] {
1309 let ctx_line = strip_ansi(ctx_line_raw).trim().to_string();
1310 if !ctx_line.is_empty() && !error_lines.contains(&ctx_line) {
1311 error_lines.push(ctx_line);
1312 }
1313 }
1314 if error_lines.len() >= max_lines {
1315 break;
1316 }
1317 }
1318 }
1319
1320 if error_lines.is_empty() {
1321 let tail: Vec<String> = lines
1322 .iter()
1323 .rev()
1324 .filter_map(|l| {
1325 let s = strip_ansi(l).trim().to_string();
1326 if s.is_empty() { None } else { Some(s) }
1327 })
1328 .take(10)
1329 .collect();
1330 if tail.is_empty() {
1331 None
1332 } else {
1333 Some(tail.into_iter().rev().collect::<Vec<_>>().join("\n"))
1334 }
1335 } else {
1336 Some(error_lines.join("\n"))
1337 }
1338}
1339
1340#[allow(dead_code)]
1342fn extract_section(log: &str, section_name: &str) -> Option<String> {
1343 let start_marker = "section_start:";
1344 let end_marker = "section_end:";
1345 let lines: Vec<&str> = log.lines().collect();
1346 let mut in_section = false;
1347 let mut section_lines = Vec::new();
1348
1349 for line in &lines {
1350 let stripped = strip_ansi(line);
1351 if stripped.contains(start_marker) && stripped.contains(section_name) {
1352 in_section = true;
1353 continue;
1354 }
1355 if stripped.contains(end_marker) && stripped.contains(section_name) {
1356 break;
1357 }
1358 if in_section {
1359 section_lines.push(strip_ansi(line).trim().to_string());
1360 }
1361 }
1362
1363 if section_lines.is_empty() {
1364 None
1365 } else {
1366 Some(section_lines.join("\n"))
1367 }
1368}
1369
1370#[allow(dead_code)]
1372fn list_sections(log: &str) -> Vec<String> {
1373 let mut sections = Vec::new();
1374 for line in log.lines() {
1375 let stripped = strip_ansi(line);
1376 if let Some(pos) = stripped.find("section_start:") {
1377 let after = &stripped[pos + "section_start:".len()..];
1379 if let Some(colon_pos) = after.find(':') {
1380 let name_part = &after[colon_pos + 1..];
1381 let name = name_part
1382 .split(['\r', '\n', '\x1b'])
1383 .next()
1384 .unwrap_or("")
1385 .to_string();
1386 if !name.is_empty() && !sections.contains(&name) {
1387 sections.push(name);
1388 }
1389 }
1390 }
1391 }
1392 sections
1393}
1394
1395#[async_trait]
1396impl PipelineProvider for GitLabClient {
1397 fn provider_name(&self) -> &'static str {
1398 "gitlab"
1399 }
1400
1401 async fn get_pipeline(&self, input: GetPipelineInput) -> Result<PipelineInfo> {
1402 let pipeline: GlPipeline = if let Some(ref mr_key) = input.mr_key {
1404 let iid = parse_mr_key(mr_key)?;
1406 let url = self.project_url(&format!("/merge_requests/{iid}/pipelines?per_page=1"));
1407 let pipelines: Vec<GlPipeline> = self.get(&url).await?;
1408 pipelines
1409 .into_iter()
1410 .next()
1411 .ok_or_else(|| Error::NotFound(format!("No pipeline found for MR !{iid}")))?
1412 } else {
1413 let ref_name = input.branch.as_deref().unwrap_or("main");
1414 let url = self.project_url(&format!(
1416 "/pipelines?ref={}&per_page=1&order_by=id&sort=desc",
1417 urlencoding::encode(ref_name)
1418 ));
1419 let pipelines: Vec<GlPipeline> = self.get(&url).await?;
1420
1421 if let Some(p) = pipelines.into_iter().next() {
1422 p
1423 } else {
1424 let mrs_url = self.project_url(&format!(
1426 "/merge_requests?source_branch={}&state=opened&per_page=1",
1427 urlencoding::encode(ref_name)
1428 ));
1429 let mrs: Vec<GitLabMergeRequest> = self.get(&mrs_url).await?;
1430 if let Some(mr) = mrs.first() {
1431 let mr_pipes_url = self
1432 .project_url(&format!("/merge_requests/{}/pipelines?per_page=1", mr.iid));
1433 let mr_pipelines: Vec<GlPipeline> = self.get(&mr_pipes_url).await?;
1434 mr_pipelines.into_iter().next().ok_or_else(|| {
1435 Error::NotFound(format!("No pipeline found for branch '{ref_name}'"))
1436 })?
1437 } else {
1438 return Err(Error::NotFound(format!(
1439 "No pipeline found for branch '{ref_name}'"
1440 )));
1441 }
1442 }
1443 };
1444
1445 let jobs_url = self.project_url(&format!("/pipelines/{}/jobs?per_page=100", pipeline.id));
1447 let gl_jobs: Vec<GlJob> = self.get(&jobs_url).await?;
1448
1449 let mut summary = PipelineSummary {
1451 total: gl_jobs.len() as u32,
1452 ..Default::default()
1453 };
1454
1455 let mut stages_map: std::collections::BTreeMap<String, Vec<PipelineJob>> =
1456 std::collections::BTreeMap::new();
1457 let mut failed_job_ids: Vec<(u64, String)> = Vec::new();
1458
1459 for job in &gl_jobs {
1460 let status = map_gl_pipeline_status(&job.status);
1461 match status {
1462 PipelineStatus::Success => summary.success += 1,
1463 PipelineStatus::Failed => {
1464 summary.failed += 1;
1465 failed_job_ids.push((job.id, job.name.clone()));
1466 }
1467 PipelineStatus::Running => summary.running += 1,
1468 PipelineStatus::Pending => summary.pending += 1,
1469 PipelineStatus::Canceled => summary.canceled += 1,
1470 PipelineStatus::Skipped => summary.skipped += 1,
1471 PipelineStatus::Unknown => {}
1472 }
1473
1474 stages_map
1475 .entry(job.stage.clone())
1476 .or_default()
1477 .push(PipelineJob {
1478 id: job.id.to_string(),
1479 name: job.name.clone(),
1480 status,
1481 url: job.web_url.clone(),
1482 duration: job.duration.map(|d| d as u64),
1483 });
1484 }
1485
1486 let stages: Vec<PipelineStage> = stages_map
1487 .into_iter()
1488 .map(|(name, jobs)| PipelineStage { name, jobs })
1489 .collect();
1490
1491 let mut failed_jobs: Vec<FailedJob> = Vec::new();
1493 if input.include_failed_logs {
1494 for (job_id, job_name) in failed_job_ids.iter().take(5) {
1495 let trace_url = self.project_url(&format!("/jobs/{job_id}/trace"));
1496 let error_snippet =
1497 match self.request(reqwest::Method::GET, &trace_url).send().await {
1498 Ok(resp) if resp.status().is_success() => {
1499 let log_text = resp.text().await.unwrap_or_default();
1500 extract_errors(&log_text, 20)
1501 }
1502 _ => None,
1503 };
1504 failed_jobs.push(FailedJob {
1505 id: job_id.to_string(),
1506 name: job_name.clone(),
1507 url: None,
1508 error_snippet,
1509 });
1510 }
1511 }
1512
1513 let coverage = pipeline.coverage.and_then(|c| c.parse::<f64>().ok());
1514
1515 Ok(PipelineInfo {
1516 id: pipeline.id.to_string(),
1517 status: map_gl_pipeline_status(&pipeline.status),
1518 reference: pipeline.ref_name,
1519 sha: pipeline.sha,
1520 url: pipeline.web_url,
1521 duration: pipeline.duration,
1522 coverage,
1523 summary,
1524 stages,
1525 failed_jobs,
1526 })
1527 }
1528
1529 async fn get_job_logs(&self, job_id: &str, options: JobLogOptions) -> Result<JobLogOutput> {
1530 let trace_url = self.project_url(&format!("/jobs/{job_id}/trace"));
1531 let resp = self
1532 .request(reqwest::Method::GET, &trace_url)
1533 .send()
1534 .await
1535 .map_err(|e| Error::Network(e.to_string()))?;
1536
1537 if !resp.status().is_success() {
1538 return Err(Error::from_status(
1539 resp.status().as_u16(),
1540 format!("Failed to fetch job logs for job {job_id}"),
1541 ));
1542 }
1543
1544 let raw_log = resp
1545 .text()
1546 .await
1547 .map_err(|e| Error::Network(e.to_string()))?;
1548 let log = strip_ansi(&raw_log);
1549 let lines: Vec<&str> = log.lines().collect();
1550 let total_lines = lines.len();
1551
1552 let (content, mode_name) = match options.mode {
1553 JobLogMode::Smart => {
1554 let extracted = extract_errors(&log, 30).unwrap_or_else(|| {
1555 lines
1556 .iter()
1557 .rev()
1558 .take(20)
1559 .copied()
1560 .collect::<Vec<_>>()
1561 .into_iter()
1562 .rev()
1563 .collect::<Vec<_>>()
1564 .join("\n")
1565 });
1566 (extracted, "smart")
1567 }
1568 JobLogMode::Search {
1569 ref pattern,
1570 context,
1571 max_matches,
1572 } => {
1573 let re = regex::Regex::new(pattern)
1574 .unwrap_or_else(|_| regex::Regex::new(®ex::escape(pattern)).unwrap());
1575 let mut matches = Vec::new();
1576 for (i, line) in lines.iter().enumerate() {
1577 if re.is_match(line) {
1578 let start = i.saturating_sub(context);
1579 let end = (i + context + 1).min(total_lines);
1580 matches.push(format!("--- Match at line {} ---", i + 1));
1581 for (j, ctx_line) in lines[start..end].iter().enumerate() {
1582 let line_num = start + j;
1583 let marker = if line_num == i { ">>>" } else { " " };
1584 matches.push(format!("{} {}: {}", marker, line_num + 1, ctx_line));
1585 }
1586 if matches.len() / (context * 2 + 2) >= max_matches {
1587 break;
1588 }
1589 }
1590 }
1591 (matches.join("\n"), "search")
1592 }
1593 JobLogMode::Paginated { offset, limit } => {
1594 let page: Vec<&str> = lines.iter().skip(offset).take(limit).copied().collect();
1595 (page.join("\n"), "paginated")
1596 }
1597 JobLogMode::Full { max_lines } => {
1598 let truncated: Vec<&str> = lines.iter().take(max_lines).copied().collect();
1599 (truncated.join("\n"), "full")
1600 }
1601 };
1602
1603 Ok(JobLogOutput {
1604 job_id: job_id.to_string(),
1605 job_name: None,
1606 content,
1607 mode: mode_name.to_string(),
1608 total_lines: Some(total_lines),
1609 })
1610 }
1611}
1612
1613#[async_trait]
1614impl Provider for GitLabClient {
1615 async fn get_current_user(&self) -> Result<User> {
1616 let url = self.api_url("/user");
1617 let gl_user: GitLabUser = self.get(&url).await?;
1618 Ok(map_user_required(Some(&gl_user)))
1619 }
1620}
1621
1622#[cfg(test)]
1623mod tests {
1624 use super::*;
1625 use crate::types::{GitLabDiffRefs, GitLabNotePosition};
1626
1627 #[test]
1628 fn test_parse_issue_key() {
1629 assert_eq!(parse_issue_key("gitlab#123").unwrap(), 123);
1630 assert_eq!(parse_issue_key("gitlab#1").unwrap(), 1);
1631 assert!(parse_issue_key("mr#123").is_err());
1632 assert!(parse_issue_key("gh#123").is_err());
1633 assert!(parse_issue_key("123").is_err());
1634 assert!(parse_issue_key("gitlab#").is_err());
1635 }
1636
1637 #[test]
1638 fn test_parse_mr_key() {
1639 assert_eq!(parse_mr_key("mr#456").unwrap(), 456);
1640 assert_eq!(parse_mr_key("mr#1").unwrap(), 1);
1641 assert!(parse_mr_key("gitlab#123").is_err());
1642 assert!(parse_mr_key("pr#123").is_err());
1643 assert!(parse_mr_key("456").is_err());
1644 }
1645
1646 #[test]
1647 fn test_map_user() {
1648 let gl_user = GitLabUser {
1649 id: 42,
1650 username: "testuser".to_string(),
1651 name: Some("Test User".to_string()),
1652 avatar_url: Some("https://gitlab.com/avatar.png".to_string()),
1653 web_url: Some("https://gitlab.com/testuser".to_string()),
1654 };
1655
1656 let user = map_user(Some(&gl_user)).unwrap();
1657 assert_eq!(user.id, "42");
1658 assert_eq!(user.username, "testuser");
1659 assert_eq!(user.name, Some("Test User".to_string()));
1660 assert_eq!(
1661 user.avatar_url,
1662 Some("https://gitlab.com/avatar.png".to_string())
1663 );
1664 assert_eq!(user.email, None); }
1666
1667 #[test]
1668 fn test_map_user_none() {
1669 assert!(map_user(None).is_none());
1670 }
1671
1672 #[test]
1673 fn test_map_user_required_none() {
1674 let user = map_user_required(None);
1675 assert_eq!(user.id, "unknown");
1676 assert_eq!(user.username, "unknown");
1677 }
1678
1679 #[test]
1680 fn test_map_issue() {
1681 let gl_issue = GitLabIssue {
1682 id: 1,
1683 iid: 42,
1684 title: "Test Issue".to_string(),
1685 description: Some("Issue body".to_string()),
1686 state: "opened".to_string(),
1687 labels: vec!["bug".to_string(), "urgent".to_string()],
1688 author: Some(GitLabUser {
1689 id: 1,
1690 username: "author".to_string(),
1691 name: None,
1692 avatar_url: None,
1693 web_url: None,
1694 }),
1695 assignees: vec![],
1696 web_url: "https://gitlab.com/group/project/-/issues/42".to_string(),
1697 created_at: "2024-01-01T00:00:00Z".to_string(),
1698 updated_at: "2024-01-02T00:00:00Z".to_string(),
1699 };
1700
1701 let issue = map_issue(&gl_issue, "https://gitlab.com");
1702 assert_eq!(issue.key, "gitlab#42");
1703 assert_eq!(issue.title, "Test Issue");
1704 assert_eq!(issue.description, Some("Issue body".to_string()));
1705 assert_eq!(issue.state, "opened");
1706 assert_eq!(issue.source, "gitlab");
1707 assert_eq!(issue.labels, vec!["bug", "urgent"]);
1708 assert!(issue.author.is_some());
1709 assert_eq!(
1710 issue.url,
1711 Some("https://gitlab.com/group/project/-/issues/42".to_string())
1712 );
1713 }
1714
1715 #[test]
1716 fn test_map_merge_request_states() {
1717 let base_mr = || GitLabMergeRequest {
1718 id: 1,
1719 iid: 10,
1720 title: "Test MR".to_string(),
1721 description: None,
1722 state: "opened".to_string(),
1723 source_branch: "feature".to_string(),
1724 target_branch: "main".to_string(),
1725 author: None,
1726 assignees: vec![],
1727 reviewers: vec![],
1728 labels: vec![],
1729 draft: false,
1730 work_in_progress: false,
1731 merged_at: None,
1732 web_url: "https://gitlab.com/group/project/-/merge_requests/10".to_string(),
1733 sha: Some("abc123".to_string()),
1734 diff_refs: Some(GitLabDiffRefs {
1735 base_sha: "base".to_string(),
1736 head_sha: "head".to_string(),
1737 start_sha: "start".to_string(),
1738 }),
1739 created_at: "2024-01-01T00:00:00Z".to_string(),
1740 updated_at: "2024-01-02T00:00:00Z".to_string(),
1741 };
1742
1743 let mr = map_merge_request(&base_mr());
1745 assert_eq!(mr.state, "opened");
1746 assert_eq!(mr.key, "mr#10");
1747 assert_eq!(mr.source, "gitlab");
1748 assert!(!mr.draft);
1749
1750 let mut draft_mr = base_mr();
1752 draft_mr.draft = true;
1753 let mr = map_merge_request(&draft_mr);
1754 assert_eq!(mr.state, "draft");
1755 assert!(mr.draft);
1756
1757 let mut wip_mr = base_mr();
1759 wip_mr.work_in_progress = true;
1760 let mr = map_merge_request(&wip_mr);
1761 assert_eq!(mr.state, "draft");
1762 assert!(mr.draft);
1763
1764 let mut merged_mr = base_mr();
1766 merged_mr.merged_at = Some("2024-01-03T00:00:00Z".to_string());
1767 merged_mr.state = "merged".to_string();
1768 let mr = map_merge_request(&merged_mr);
1769 assert_eq!(mr.state, "merged");
1770
1771 let mut closed_mr = base_mr();
1773 closed_mr.state = "closed".to_string();
1774 let mr = map_merge_request(&closed_mr);
1775 assert_eq!(mr.state, "closed");
1776 }
1777
1778 #[test]
1779 fn test_map_note() {
1780 let gl_note = GitLabNote {
1781 id: 100,
1782 body: "Test comment".to_string(),
1783 author: Some(GitLabUser {
1784 id: 1,
1785 username: "commenter".to_string(),
1786 name: Some("Commenter".to_string()),
1787 avatar_url: None,
1788 web_url: None,
1789 }),
1790 created_at: "2024-01-01T00:00:00Z".to_string(),
1791 updated_at: Some("2024-01-02T00:00:00Z".to_string()),
1792 system: false,
1793 resolvable: false,
1794 resolved: false,
1795 resolved_by: None,
1796 position: None,
1797 };
1798
1799 let comment = map_note(&gl_note);
1800 assert_eq!(comment.id, "100");
1801 assert_eq!(comment.body, "Test comment");
1802 assert!(comment.author.is_some());
1803 assert_eq!(comment.author.unwrap().username, "commenter");
1804 assert!(comment.position.is_none());
1805 }
1806
1807 #[test]
1808 fn test_map_note_with_position() {
1809 let gl_note = GitLabNote {
1810 id: 101,
1811 body: "Inline comment".to_string(),
1812 author: None,
1813 created_at: "2024-01-01T00:00:00Z".to_string(),
1814 updated_at: None,
1815 system: false,
1816 resolvable: true,
1817 resolved: false,
1818 resolved_by: None,
1819 position: Some(GitLabNotePosition {
1820 position_type: "text".to_string(),
1821 new_path: Some("src/main.rs".to_string()),
1822 old_path: Some("src/main.rs".to_string()),
1823 new_line: Some(42),
1824 old_line: None,
1825 }),
1826 };
1827
1828 let comment = map_note(&gl_note);
1829 assert!(comment.position.is_some());
1830 let pos = comment.position.unwrap();
1831 assert_eq!(pos.file_path, "src/main.rs");
1832 assert_eq!(pos.line, 42);
1833 assert_eq!(pos.line_type, "new");
1834 }
1835
1836 #[test]
1837 fn test_map_position_old_line() {
1838 let pos = GitLabNotePosition {
1839 position_type: "text".to_string(),
1840 new_path: Some("new.rs".to_string()),
1841 old_path: Some("old.rs".to_string()),
1842 new_line: None,
1843 old_line: Some(10),
1844 };
1845
1846 let mapped = map_position(&pos).unwrap();
1847 assert_eq!(mapped.file_path, "old.rs");
1848 assert_eq!(mapped.line, 10);
1849 assert_eq!(mapped.line_type, "old");
1850 }
1851
1852 #[test]
1853 fn test_map_position_no_lines() {
1854 let pos = GitLabNotePosition {
1855 position_type: "text".to_string(),
1856 new_path: Some("file.rs".to_string()),
1857 old_path: None,
1858 new_line: None,
1859 old_line: None,
1860 };
1861
1862 assert!(map_position(&pos).is_none());
1863 }
1864
1865 #[test]
1866 fn test_map_diff() {
1867 let gl_diff = GitLabDiff {
1868 old_path: "src/old.rs".to_string(),
1869 new_path: "src/new.rs".to_string(),
1870 new_file: false,
1871 renamed_file: true,
1872 deleted_file: false,
1873 diff: "@@ -1,3 +1,4 @@\n+added line\n context\n".to_string(),
1874 };
1875
1876 let diff = map_diff(&gl_diff);
1877 assert_eq!(diff.file_path, "src/new.rs");
1878 assert_eq!(diff.old_path, Some("src/old.rs".to_string()));
1879 assert!(diff.renamed_file);
1880 assert!(!diff.new_file);
1881 assert!(!diff.deleted_file);
1882 assert!(diff.diff.contains("+added line"));
1883 }
1884
1885 #[test]
1886 fn test_map_diff_new_file() {
1887 let gl_diff = GitLabDiff {
1888 old_path: "dev/null".to_string(),
1889 new_path: "src/new.rs".to_string(),
1890 new_file: true,
1891 renamed_file: false,
1892 deleted_file: false,
1893 diff: "+fn main() {}\n".to_string(),
1894 };
1895
1896 let diff = map_diff(&gl_diff);
1897 assert_eq!(diff.file_path, "src/new.rs");
1898 assert!(diff.old_path.is_none()); assert!(diff.new_file);
1900 }
1901
1902 #[test]
1903 fn test_map_discussion() {
1904 let gl_discussion = GitLabDiscussion {
1905 id: "abc123".to_string(),
1906 notes: vec![
1907 GitLabNote {
1908 id: 1,
1909 body: "First comment".to_string(),
1910 author: None,
1911 created_at: "2024-01-01T00:00:00Z".to_string(),
1912 updated_at: None,
1913 system: false,
1914 resolvable: true,
1915 resolved: true,
1916 resolved_by: Some(GitLabUser {
1917 id: 1,
1918 username: "resolver".to_string(),
1919 name: None,
1920 avatar_url: None,
1921 web_url: None,
1922 }),
1923 position: Some(GitLabNotePosition {
1924 position_type: "text".to_string(),
1925 new_path: Some("src/lib.rs".to_string()),
1926 old_path: None,
1927 new_line: Some(5),
1928 old_line: None,
1929 }),
1930 },
1931 GitLabNote {
1932 id: 2,
1933 body: "Reply".to_string(),
1934 author: None,
1935 created_at: "2024-01-02T00:00:00Z".to_string(),
1936 updated_at: None,
1937 system: false,
1938 resolvable: false,
1939 resolved: false,
1940 resolved_by: None,
1941 position: None,
1942 },
1943 ],
1944 };
1945
1946 let discussion = map_discussion(&gl_discussion);
1947 assert_eq!(discussion.id, "abc123");
1948 assert!(discussion.resolved);
1949 assert!(discussion.resolved_by.is_some());
1950 assert_eq!(discussion.comments.len(), 2);
1951 assert!(discussion.position.is_some());
1952 assert_eq!(discussion.position.unwrap().file_path, "src/lib.rs");
1953 }
1954
1955 #[test]
1956 fn test_map_discussion_filters_system_notes() {
1957 let gl_discussion = GitLabDiscussion {
1958 id: "def456".to_string(),
1959 notes: vec![
1960 GitLabNote {
1961 id: 1,
1962 body: "System note: assigned to @user".to_string(),
1963 author: None,
1964 created_at: "2024-01-01T00:00:00Z".to_string(),
1965 updated_at: None,
1966 system: true,
1967 resolvable: false,
1968 resolved: false,
1969 resolved_by: None,
1970 position: None,
1971 },
1972 GitLabNote {
1973 id: 2,
1974 body: "Actual comment".to_string(),
1975 author: None,
1976 created_at: "2024-01-01T00:00:00Z".to_string(),
1977 updated_at: None,
1978 system: false,
1979 resolvable: false,
1980 resolved: false,
1981 resolved_by: None,
1982 position: None,
1983 },
1984 ],
1985 };
1986
1987 let discussion = map_discussion(&gl_discussion);
1988 assert_eq!(discussion.comments.len(), 1);
1989 assert_eq!(discussion.comments[0].body, "Actual comment");
1990 }
1991
1992 mod integration {
1997 use super::*;
1998 use httpmock::prelude::*;
1999
2000 fn token(s: &str) -> SecretString {
2001 SecretString::from(s.to_string())
2002 }
2003
2004 fn create_test_client(server: &MockServer) -> GitLabClient {
2005 GitLabClient::with_base_url(server.base_url(), "123", token("glpat-test-token"))
2006 }
2007
2008 #[tokio::test]
2009 async fn test_get_issues() {
2010 let server = MockServer::start();
2011
2012 server.mock(|when, then| {
2013 when.method(GET)
2014 .path("/api/v4/projects/123/issues")
2015 .query_param("state", "opened")
2016 .query_param("per_page", "10")
2017 .header("PRIVATE-TOKEN", "glpat-test-token");
2018 then.status(200).json_body(serde_json::json!([
2019 {
2020 "id": 1,
2021 "iid": 42,
2022 "title": "Test Issue",
2023 "description": "Body",
2024 "state": "opened",
2025 "labels": ["bug"],
2026 "author": {
2027 "id": 1,
2028 "username": "author",
2029 "name": "Author Name"
2030 },
2031 "assignees": [],
2032 "web_url": "https://gitlab.com/group/project/-/issues/42",
2033 "created_at": "2024-01-01T00:00:00Z",
2034 "updated_at": "2024-01-02T00:00:00Z"
2035 }
2036 ]));
2037 });
2038
2039 let client = create_test_client(&server);
2040 let issues = client
2041 .get_issues(IssueFilter {
2042 state: Some("opened".to_string()),
2043 limit: Some(10),
2044 ..Default::default()
2045 })
2046 .await
2047 .unwrap()
2048 .items;
2049
2050 assert_eq!(issues.len(), 1);
2051 assert_eq!(issues[0].key, "gitlab#42");
2052 assert_eq!(issues[0].title, "Test Issue");
2053 assert_eq!(issues[0].state, "opened");
2054 assert_eq!(issues[0].labels, vec!["bug"]);
2055 }
2056
2057 #[tokio::test]
2058 async fn test_get_issue() {
2059 let server = MockServer::start();
2060
2061 server.mock(|when, then| {
2062 when.method(GET)
2063 .path("/api/v4/projects/123/issues/42")
2064 .header("PRIVATE-TOKEN", "glpat-test-token");
2065 then.status(200).json_body(serde_json::json!({
2066 "id": 1,
2067 "iid": 42,
2068 "title": "Single Issue",
2069 "description": "Details",
2070 "state": "closed",
2071 "labels": [],
2072 "author": {"id": 1, "username": "author"},
2073 "assignees": [{"id": 2, "username": "assignee", "name": "Assignee"}],
2074 "web_url": "https://gitlab.com/group/project/-/issues/42",
2075 "created_at": "2024-01-01T00:00:00Z",
2076 "updated_at": "2024-01-03T00:00:00Z"
2077 }));
2078 });
2079
2080 let client = create_test_client(&server);
2081 let issue = client.get_issue("gitlab#42").await.unwrap();
2082
2083 assert_eq!(issue.key, "gitlab#42");
2084 assert_eq!(issue.title, "Single Issue");
2085 assert_eq!(issue.state, "closed");
2086 assert_eq!(issue.assignees.len(), 1);
2087 assert_eq!(issue.assignees[0].username, "assignee");
2088 }
2089
2090 #[tokio::test]
2091 async fn test_create_issue() {
2092 let server = MockServer::start();
2093
2094 server.mock(|when, then| {
2095 when.method(POST)
2096 .path("/api/v4/projects/123/issues")
2097 .header("PRIVATE-TOKEN", "glpat-test-token")
2098 .body_includes("\"title\":\"New Issue\"")
2099 .body_includes("\"labels\":\"bug,feature\"");
2100 then.status(201).json_body(serde_json::json!({
2101 "id": 10,
2102 "iid": 99,
2103 "title": "New Issue",
2104 "description": "Description",
2105 "state": "opened",
2106 "labels": ["bug", "feature"],
2107 "author": {"id": 1, "username": "creator"},
2108 "assignees": [],
2109 "web_url": "https://gitlab.com/group/project/-/issues/99",
2110 "created_at": "2024-02-01T00:00:00Z",
2111 "updated_at": "2024-02-01T00:00:00Z"
2112 }));
2113 });
2114
2115 let client = create_test_client(&server);
2116 let issue = client
2117 .create_issue(CreateIssueInput {
2118 title: "New Issue".to_string(),
2119 description: Some("Description".to_string()),
2120 labels: vec!["bug".to_string(), "feature".to_string()],
2121 ..Default::default()
2122 })
2123 .await
2124 .unwrap();
2125
2126 assert_eq!(issue.key, "gitlab#99");
2127 assert_eq!(issue.title, "New Issue");
2128 }
2129
2130 #[tokio::test]
2131 async fn test_update_issue() {
2132 let server = MockServer::start();
2133
2134 server.mock(|when, then| {
2135 when.method(PUT)
2136 .path("/api/v4/projects/123/issues/42")
2137 .header("PRIVATE-TOKEN", "glpat-test-token")
2138 .body_includes("\"state_event\":\"close\"");
2139 then.status(200).json_body(serde_json::json!({
2140 "id": 1,
2141 "iid": 42,
2142 "title": "Updated Issue",
2143 "state": "closed",
2144 "labels": [],
2145 "assignees": [],
2146 "web_url": "https://gitlab.com/group/project/-/issues/42",
2147 "created_at": "2024-01-01T00:00:00Z",
2148 "updated_at": "2024-01-05T00:00:00Z"
2149 }));
2150 });
2151
2152 let client = create_test_client(&server);
2153 let issue = client
2154 .update_issue(
2155 "gitlab#42",
2156 UpdateIssueInput {
2157 state: Some("closed".to_string()),
2158 ..Default::default()
2159 },
2160 )
2161 .await
2162 .unwrap();
2163
2164 assert_eq!(issue.state, "closed");
2165 }
2166
2167 #[tokio::test]
2168 async fn test_get_merge_requests() {
2169 let server = MockServer::start();
2170
2171 server.mock(|when, then| {
2172 when.method(GET)
2173 .path("/api/v4/projects/123/merge_requests")
2174 .header("PRIVATE-TOKEN", "glpat-test-token");
2175 then.status(200).json_body(serde_json::json!([
2176 {
2177 "id": 1,
2178 "iid": 50,
2179 "title": "Feature MR",
2180 "description": "MR description",
2181 "state": "opened",
2182 "source_branch": "feature/test",
2183 "target_branch": "main",
2184 "author": {"id": 1, "username": "developer"},
2185 "assignees": [],
2186 "reviewers": [{"id": 2, "username": "reviewer"}],
2187 "labels": ["review"],
2188 "draft": false,
2189 "work_in_progress": false,
2190 "merged_at": null,
2191 "web_url": "https://gitlab.com/group/project/-/merge_requests/50",
2192 "sha": "abc123",
2193 "diff_refs": {
2194 "base_sha": "base",
2195 "head_sha": "head",
2196 "start_sha": "start"
2197 },
2198 "created_at": "2024-01-01T00:00:00Z",
2199 "updated_at": "2024-01-02T00:00:00Z"
2200 }
2201 ]));
2202 });
2203
2204 let client = create_test_client(&server);
2205 let mrs = client
2206 .get_merge_requests(MrFilter::default())
2207 .await
2208 .unwrap()
2209 .items;
2210
2211 assert_eq!(mrs.len(), 1);
2212 assert_eq!(mrs[0].key, "mr#50");
2213 assert_eq!(mrs[0].title, "Feature MR");
2214 assert_eq!(mrs[0].state, "opened");
2215 assert_eq!(mrs[0].source_branch, "feature/test");
2216 assert_eq!(mrs[0].reviewers.len(), 1);
2217 }
2218
2219 #[tokio::test]
2220 async fn test_get_discussions() {
2221 let server = MockServer::start();
2222
2223 server.mock(|when, then| {
2224 when.method(GET)
2225 .path("/api/v4/projects/123/merge_requests/50/discussions")
2226 .header("PRIVATE-TOKEN", "glpat-test-token");
2227 then.status(200).json_body(serde_json::json!([
2228 {
2229 "id": "disc-1",
2230 "notes": [
2231 {
2232 "id": 100,
2233 "body": "Please fix this",
2234 "author": {"id": 1, "username": "reviewer"},
2235 "created_at": "2024-01-01T00:00:00Z",
2236 "system": false,
2237 "resolvable": true,
2238 "resolved": false,
2239 "position": {
2240 "position_type": "text",
2241 "new_path": "src/lib.rs",
2242 "old_path": "src/lib.rs",
2243 "new_line": 42,
2244 "old_line": null
2245 }
2246 },
2247 {
2248 "id": 101,
2249 "body": "Fixed!",
2250 "author": {"id": 2, "username": "developer"},
2251 "created_at": "2024-01-02T00:00:00Z",
2252 "system": false,
2253 "resolvable": false,
2254 "resolved": false
2255 }
2256 ]
2257 },
2258 {
2259 "id": "disc-system",
2260 "notes": [
2261 {
2262 "id": 200,
2263 "body": "merged",
2264 "created_at": "2024-01-03T00:00:00Z",
2265 "system": true,
2266 "resolvable": false,
2267 "resolved": false
2268 }
2269 ]
2270 }
2271 ]));
2272 });
2273
2274 let client = create_test_client(&server);
2275 let discussions = client.get_discussions("mr#50").await.unwrap().items;
2276
2277 assert_eq!(discussions.len(), 1);
2279 assert_eq!(discussions[0].id, "disc-1");
2280 assert_eq!(discussions[0].comments.len(), 2);
2281 assert!(!discussions[0].resolved);
2282 assert!(discussions[0].position.is_some());
2283 }
2284
2285 #[tokio::test]
2286 async fn test_get_diffs() {
2287 let server = MockServer::start();
2288
2289 server.mock(|when, then| {
2290 when.method(GET)
2291 .path("/api/v4/projects/123/merge_requests/50/changes")
2292 .header("PRIVATE-TOKEN", "glpat-test-token");
2293 then.status(200).json_body(serde_json::json!({
2294 "changes": [
2295 {
2296 "old_path": "src/main.rs",
2297 "new_path": "src/main.rs",
2298 "new_file": false,
2299 "renamed_file": false,
2300 "deleted_file": false,
2301 "diff": "@@ -1,3 +1,4 @@\n+use tracing;\n fn main() {\n }\n"
2302 },
2303 {
2304 "old_path": "/dev/null",
2305 "new_path": "src/new_file.rs",
2306 "new_file": true,
2307 "renamed_file": false,
2308 "deleted_file": false,
2309 "diff": "+pub fn new_fn() {}\n"
2310 }
2311 ]
2312 }));
2313 });
2314
2315 let client = create_test_client(&server);
2316 let diffs = client.get_diffs("mr#50").await.unwrap().items;
2317
2318 assert_eq!(diffs.len(), 2);
2319 assert_eq!(diffs[0].file_path, "src/main.rs");
2320 assert!(!diffs[0].new_file);
2321 assert!(diffs[0].diff.contains("+use tracing"));
2322 assert_eq!(diffs[1].file_path, "src/new_file.rs");
2323 assert!(diffs[1].new_file);
2324 }
2325
2326 #[tokio::test]
2327 async fn test_add_mr_comment_general() {
2328 let server = MockServer::start();
2329
2330 server.mock(|when, then| {
2331 when.method(POST)
2332 .path("/api/v4/projects/123/merge_requests/50/notes")
2333 .header("PRIVATE-TOKEN", "glpat-test-token")
2334 .body_includes("\"body\":\"General comment\"");
2335 then.status(201).json_body(serde_json::json!({
2336 "id": 300,
2337 "body": "General comment",
2338 "author": {"id": 1, "username": "commenter"},
2339 "created_at": "2024-01-01T00:00:00Z",
2340 "system": false,
2341 "resolvable": false,
2342 "resolved": false
2343 }));
2344 });
2345
2346 let client = create_test_client(&server);
2347 let comment = MergeRequestProvider::add_comment(
2348 &client,
2349 "mr#50",
2350 CreateCommentInput {
2351 body: "General comment".to_string(),
2352 position: None,
2353 discussion_id: None,
2354 },
2355 )
2356 .await
2357 .unwrap();
2358
2359 assert_eq!(comment.id, "300");
2360 assert_eq!(comment.body, "General comment");
2361 }
2362
2363 #[tokio::test]
2364 async fn test_add_mr_comment_inline() {
2365 let server = MockServer::start();
2366
2367 server.mock(|when, then| {
2369 when.method(GET)
2370 .path("/api/v4/projects/123/merge_requests/50");
2371 then.status(200).json_body(serde_json::json!({
2372 "id": 1,
2373 "iid": 50,
2374 "title": "Test MR",
2375 "state": "opened",
2376 "source_branch": "feature",
2377 "target_branch": "main",
2378 "web_url": "https://gitlab.com/group/project/-/merge_requests/50",
2379 "sha": "abc123",
2380 "diff_refs": {
2381 "base_sha": "base_sha_val",
2382 "head_sha": "head_sha_val",
2383 "start_sha": "start_sha_val"
2384 },
2385 "created_at": "2024-01-01T00:00:00Z",
2386 "updated_at": "2024-01-02T00:00:00Z"
2387 }));
2388 });
2389
2390 server.mock(|when, then| {
2392 when.method(POST)
2393 .path("/api/v4/projects/123/merge_requests/50/discussions")
2394 .body_includes("\"position\"")
2395 .body_includes("\"base_sha\":\"base_sha_val\"");
2396 then.status(201).json_body(serde_json::json!({
2397 "id": "new-disc",
2398 "notes": [{
2399 "id": 400,
2400 "body": "Inline comment",
2401 "author": {"id": 1, "username": "reviewer"},
2402 "created_at": "2024-01-01T00:00:00Z",
2403 "system": false,
2404 "resolvable": true,
2405 "resolved": false,
2406 "position": {
2407 "position_type": "text",
2408 "new_path": "src/lib.rs",
2409 "new_line": 10
2410 }
2411 }]
2412 }));
2413 });
2414
2415 let client = create_test_client(&server);
2416 let comment = MergeRequestProvider::add_comment(
2417 &client,
2418 "mr#50",
2419 CreateCommentInput {
2420 body: "Inline comment".to_string(),
2421 position: Some(CodePosition {
2422 file_path: "src/lib.rs".to_string(),
2423 line: 10,
2424 line_type: "new".to_string(),
2425 commit_sha: None,
2426 }),
2427 discussion_id: None,
2428 },
2429 )
2430 .await
2431 .unwrap();
2432
2433 assert_eq!(comment.id, "400");
2434 assert_eq!(comment.body, "Inline comment");
2435 assert!(comment.position.is_some());
2436 }
2437
2438 #[tokio::test]
2439 async fn test_add_mr_comment_discussion_reply() {
2440 let server = MockServer::start();
2441
2442 server.mock(|when, then| {
2443 when.method(POST)
2444 .path("/api/v4/projects/123/merge_requests/50/discussions/disc-1/notes")
2445 .header("PRIVATE-TOKEN", "glpat-test-token")
2446 .body_includes("\"body\":\"Thread reply\"");
2447 then.status(201).json_body(serde_json::json!({
2448 "id": 401,
2449 "body": "Thread reply",
2450 "author": {"id": 1, "username": "reviewer"},
2451 "created_at": "2024-01-01T00:00:00Z",
2452 "system": false,
2453 "resolvable": true,
2454 "resolved": false
2455 }));
2456 });
2457
2458 let client = create_test_client(&server);
2459 let comment = MergeRequestProvider::add_comment(
2460 &client,
2461 "mr#50",
2462 CreateCommentInput {
2463 body: "Thread reply".to_string(),
2464 position: None,
2465 discussion_id: Some("disc-1".to_string()),
2466 },
2467 )
2468 .await
2469 .unwrap();
2470
2471 assert_eq!(comment.id, "401");
2472 assert_eq!(comment.body, "Thread reply");
2473 }
2474
2475 #[tokio::test]
2476 async fn test_get_current_user() {
2477 let server = MockServer::start();
2478
2479 server.mock(|when, then| {
2480 when.method(GET)
2481 .path("/api/v4/user")
2482 .header("PRIVATE-TOKEN", "glpat-test-token");
2483 then.status(200).json_body(serde_json::json!({
2484 "id": 42,
2485 "username": "current_user",
2486 "name": "Current User",
2487 "avatar_url": "https://gitlab.com/avatar.png",
2488 "web_url": "https://gitlab.com/current_user"
2489 }));
2490 });
2491
2492 let client = create_test_client(&server);
2493 let user = client.get_current_user().await.unwrap();
2494
2495 assert_eq!(user.id, "42");
2496 assert_eq!(user.username, "current_user");
2497 assert_eq!(user.name, Some("Current User".to_string()));
2498 }
2499
2500 #[tokio::test]
2501 async fn test_api_error_handling() {
2502 let server = MockServer::start();
2503
2504 server.mock(|when, then| {
2505 when.method(GET).path("/api/v4/projects/123/issues/999");
2506 then.status(404).body("{\"message\":\"404 Not Found\"}");
2507 });
2508
2509 let client = create_test_client(&server);
2510 let result = client.get_issue("gitlab#999").await;
2511
2512 assert!(result.is_err());
2513 assert!(matches!(result.unwrap_err(), Error::NotFound(_)));
2514 }
2515
2516 #[tokio::test]
2517 async fn test_unauthorized_error() {
2518 let server = MockServer::start();
2519
2520 server.mock(|when, then| {
2521 when.method(GET).path("/api/v4/user");
2522 then.status(401).body("{\"message\":\"401 Unauthorized\"}");
2523 });
2524
2525 let client = create_test_client(&server);
2526 let result = client.get_current_user().await;
2527
2528 assert!(result.is_err());
2529 assert!(matches!(result.unwrap_err(), Error::Unauthorized(_)));
2530 }
2531
2532 #[tokio::test]
2537 async fn test_get_pipeline_by_branch() {
2538 let server = MockServer::start();
2539
2540 server.mock(|when, then| {
2541 when.method(GET)
2542 .path("/api/v4/projects/123/pipelines")
2543 .query_param("ref", "main");
2544 then.status(200).json_body(serde_json::json!([{
2545 "id": 500,
2546 "status": "failed",
2547 "ref": "main",
2548 "sha": "abc123",
2549 "web_url": "https://gitlab.com/project/-/pipelines/500",
2550 "duration": 120,
2551 "coverage": "85.5"
2552 }]));
2553 });
2554
2555 server.mock(|when, then| {
2556 when.method(GET)
2557 .path("/api/v4/projects/123/pipelines/500/jobs");
2558 then.status(200).json_body(serde_json::json!([
2559 {
2560 "id": 601,
2561 "name": "build",
2562 "status": "success",
2563 "stage": "build",
2564 "web_url": "https://gitlab.com/project/-/jobs/601",
2565 "duration": 30.0
2566 },
2567 {
2568 "id": 602,
2569 "name": "test",
2570 "status": "failed",
2571 "stage": "test",
2572 "web_url": "https://gitlab.com/project/-/jobs/602",
2573 "duration": 90.0
2574 }
2575 ]));
2576 });
2577
2578 server.mock(|when, then| {
2579 when.method(GET).path("/api/v4/projects/123/jobs/602/trace");
2580 then.status(200)
2581 .body("Running tests...\nerror: assertion failed\nDone.\n");
2582 });
2583
2584 let client = create_test_client(&server);
2585 let input = devboy_core::GetPipelineInput {
2586 branch: Some("main".into()),
2587 mr_key: None,
2588 include_failed_logs: true,
2589 };
2590
2591 let result = client.get_pipeline(input).await.unwrap();
2592
2593 assert_eq!(result.id, "500");
2594 assert_eq!(result.status, PipelineStatus::Failed);
2595 assert_eq!(result.reference, "main");
2596 assert_eq!(result.duration, Some(120));
2597 assert_eq!(result.coverage, Some(85.5));
2598 assert_eq!(result.summary.total, 2);
2599 assert_eq!(result.summary.success, 1);
2600 assert_eq!(result.summary.failed, 1);
2601 assert_eq!(result.stages.len(), 2); assert_eq!(result.failed_jobs.len(), 1);
2603 assert_eq!(result.failed_jobs[0].name, "test");
2604 assert!(
2605 result.failed_jobs[0]
2606 .error_snippet
2607 .as_ref()
2608 .unwrap()
2609 .contains("assertion failed")
2610 );
2611 }
2612
2613 #[tokio::test]
2614 async fn test_get_pipeline_by_mr_key() {
2615 let server = MockServer::start();
2616
2617 server.mock(|when, then| {
2618 when.method(GET)
2619 .path("/api/v4/projects/123/merge_requests/42/pipelines");
2620 then.status(200).json_body(serde_json::json!([{
2621 "id": 501,
2622 "status": "success",
2623 "ref": "feat/test",
2624 "sha": "def456",
2625 "web_url": null,
2626 "duration": 60,
2627 "coverage": null
2628 }]));
2629 });
2630
2631 server.mock(|when, then| {
2632 when.method(GET)
2633 .path("/api/v4/projects/123/pipelines/501/jobs");
2634 then.status(200).json_body(serde_json::json!([{
2635 "id": 701,
2636 "name": "lint",
2637 "status": "success",
2638 "stage": "verify",
2639 "duration": 15.0
2640 }]));
2641 });
2642
2643 let client = create_test_client(&server);
2644 let input = devboy_core::GetPipelineInput {
2645 branch: None,
2646 mr_key: Some("mr#42".into()),
2647 include_failed_logs: false,
2648 };
2649
2650 let result = client.get_pipeline(input).await.unwrap();
2651 assert_eq!(result.id, "501");
2652 assert_eq!(result.status, PipelineStatus::Success);
2653 assert_eq!(result.summary.total, 1);
2654 assert_eq!(result.summary.success, 1);
2655 }
2656
2657 #[tokio::test]
2658 async fn test_get_job_logs_smart() {
2659 let server = MockServer::start();
2660
2661 server.mock(|when, then| {
2662 when.method(GET)
2663 .path("/api/v4/projects/123/jobs/602/trace");
2664 then.status(200)
2665 .body("Step 1\nStep 2\nerror[E0308]: mismatched types\n --> src/main.rs:10\nStep 5\n");
2666 });
2667
2668 let client = create_test_client(&server);
2669 let options = devboy_core::JobLogOptions {
2670 mode: devboy_core::JobLogMode::Smart,
2671 };
2672
2673 let result = client.get_job_logs("602", options).await.unwrap();
2674 assert_eq!(result.mode, "smart");
2675 assert!(result.content.contains("mismatched types"));
2676 }
2677
2678 #[tokio::test]
2679 async fn test_get_job_logs_search() {
2680 let server = MockServer::start();
2681
2682 server.mock(|when, then| {
2683 when.method(GET).path("/api/v4/projects/123/jobs/602/trace");
2684 then.status(200)
2685 .body("Line 1\nLine 2\nFAILED: test_foo\nLine 4\n");
2686 });
2687
2688 let client = create_test_client(&server);
2689 let options = devboy_core::JobLogOptions {
2690 mode: devboy_core::JobLogMode::Search {
2691 pattern: "FAILED".into(),
2692 context: 1,
2693 max_matches: 5,
2694 },
2695 };
2696
2697 let result = client.get_job_logs("602", options).await.unwrap();
2698 assert_eq!(result.mode, "search");
2699 assert!(result.content.contains("FAILED: test_foo"));
2700 }
2701
2702 #[tokio::test]
2703 async fn test_get_job_logs_paginated() {
2704 let server = MockServer::start();
2705
2706 server.mock(|when, then| {
2707 when.method(GET).path("/api/v4/projects/123/jobs/602/trace");
2708 then.status(200).body("L1\nL2\nL3\nL4\nL5\n");
2709 });
2710
2711 let client = create_test_client(&server);
2712 let options = devboy_core::JobLogOptions {
2713 mode: devboy_core::JobLogMode::Paginated {
2714 offset: 2,
2715 limit: 2,
2716 },
2717 };
2718
2719 let result = client.get_job_logs("602", options).await.unwrap();
2720 assert_eq!(result.mode, "paginated");
2721 assert!(result.content.contains("L3"));
2722 assert!(result.content.contains("L4"));
2723 assert!(!result.content.contains("L1"));
2724 }
2725
2726 #[tokio::test]
2731 async fn test_upload_attachment_returns_absolute_url() {
2732 let server = MockServer::start();
2733
2734 server.mock(|when, then| {
2735 when.method(POST).path("/api/v4/projects/123/uploads");
2736 then.status(201).json_body(serde_json::json!({
2737 "alt": "screen",
2738 "url": "/uploads/abc/screen.png",
2739 "full_path": "/ns/proj/uploads/abc/screen.png",
2740 "markdown": ""
2741 }));
2742 });
2743
2744 server.mock(|when, then| {
2747 when.method(POST)
2748 .path("/api/v4/projects/123/issues/42/notes");
2749 then.status(201).json_body(serde_json::json!({
2750 "id": 99,
2751 "body": "",
2752 "system": false,
2753 "created_at": "2024-01-01T00:00:00Z"
2754 }));
2755 });
2756
2757 let client = create_test_client(&server);
2758 let url = client
2759 .upload_attachment("gitlab#42", "screen.png", b"data")
2760 .await
2761 .unwrap();
2762 assert!(url.starts_with(&server.base_url()));
2763 assert!(url.contains("/uploads/abc/screen.png"));
2764 }
2765
2766 #[tokio::test]
2767 async fn test_get_issue_attachments_parses_body_and_notes() {
2768 let server = MockServer::start();
2769
2770 server.mock(|when, then| {
2771 when.method(GET).path("/api/v4/projects/123/issues/42");
2772 then.status(200).json_body(serde_json::json!({
2773 "id": 1,
2774 "iid": 42,
2775 "title": "bug",
2776 "description": "See ",
2777 "state": "opened",
2778 "web_url": "https://example/gl/ns/proj/-/issues/42",
2779 "created_at": "2024-01-01T00:00:00Z",
2780 "updated_at": "2024-01-02T00:00:00Z"
2781 }));
2782 });
2783 server.mock(|when, then| {
2784 when.method(GET)
2785 .path("/api/v4/projects/123/issues/42/notes");
2786 then.status(200).json_body(serde_json::json!([
2787 {
2788 "id": 10,
2789 "body": "Also [log](/uploads/hash2/trace.log)",
2790 "system": false,
2791 "created_at": "2024-01-01T00:00:00Z"
2792 },
2793 {
2794 "id": 11,
2795 "body": "Duplicate ",
2796 "system": false,
2797 "created_at": "2024-01-02T00:00:00Z"
2798 }
2799 ]));
2800 });
2801
2802 let client = create_test_client(&server);
2803 let attachments = client.get_issue_attachments("gitlab#42").await.unwrap();
2804 assert_eq!(attachments.len(), 2, "duplicates should be dropped");
2805 assert_eq!(attachments[0].filename, "screen");
2806 assert!(
2807 attachments[0]
2808 .url
2809 .as_deref()
2810 .unwrap()
2811 .contains("/uploads/hash1/screen.png")
2812 );
2813 assert_eq!(attachments[1].filename, "log");
2814 }
2815
2816 #[tokio::test]
2817 async fn test_download_attachment_relative_path() {
2818 let server = MockServer::start();
2819
2820 server.mock(|when, then| {
2823 when.method(GET)
2824 .path("/api/v4/projects/123/uploads/hash/file.txt");
2825 then.status(200).body("hello");
2826 });
2827
2828 let client = create_test_client(&server);
2829 let bytes = client
2830 .download_attachment("gitlab#42", "/uploads/hash/file.txt")
2831 .await
2832 .unwrap();
2833 assert_eq!(bytes, b"hello");
2834 }
2835
2836 #[tokio::test]
2837 async fn test_gitlab_asset_capabilities() {
2838 let server = MockServer::start();
2839 let client = create_test_client(&server);
2840 let caps = client.asset_capabilities();
2841 assert!(caps.issue.upload);
2842 assert!(caps.issue.download);
2843 assert!(caps.issue.list);
2844 assert!(!caps.issue.delete);
2845 assert!(caps.merge_request.upload);
2847 assert!(caps.merge_request.list);
2848 }
2849 }
2850
2851 #[test]
2856 fn test_map_gl_pipeline_status() {
2857 assert_eq!(map_gl_pipeline_status("success"), PipelineStatus::Success);
2858 assert_eq!(map_gl_pipeline_status("failed"), PipelineStatus::Failed);
2859 assert_eq!(map_gl_pipeline_status("running"), PipelineStatus::Running);
2860 assert_eq!(map_gl_pipeline_status("pending"), PipelineStatus::Pending);
2861 assert_eq!(map_gl_pipeline_status("canceled"), PipelineStatus::Canceled);
2862 assert_eq!(map_gl_pipeline_status("skipped"), PipelineStatus::Skipped);
2863 assert_eq!(map_gl_pipeline_status("manual"), PipelineStatus::Pending);
2864 assert_eq!(map_gl_pipeline_status("unknown"), PipelineStatus::Unknown);
2865 }
2866
2867 #[test]
2868 fn test_strip_ansi_gitlab() {
2869 assert_eq!(strip_ansi("\x1b[0K\x1b[32;1mRunning\x1b[0m"), "Running");
2870 assert_eq!(strip_ansi("plain text"), "plain text");
2871 }
2872
2873 #[test]
2874 fn test_extract_errors_gitlab() {
2875 let log = "section_start:build\nCompiling...\nerror: build failed\nsection_end:build\n";
2876 let result = extract_errors(log, 10).unwrap();
2877 assert!(result.contains("build failed"));
2878 }
2879
2880 #[test]
2881 fn test_extract_section() {
2882 let log = "before\nsection_start:1234:build_script\ncompiling...\ndone\nsection_end:1234:build_script\nafter\n";
2883 let result = extract_section(log, "build_script").unwrap();
2884 assert!(result.contains("compiling"));
2885 assert!(result.contains("done"));
2886 assert!(!result.contains("before"));
2887 assert!(!result.contains("after"));
2888 }
2889
2890 #[test]
2891 fn test_list_sections() {
2892 let log = "section_start:111:prepare_script\nstuff\nsection_end:111:prepare_script\nsection_start:222:build_script\nmore\nsection_end:222:build_script\n";
2893 let sections = list_sections(log);
2894 assert!(sections.contains(&"prepare_script".to_string()));
2895 assert!(sections.contains(&"build_script".to_string()));
2896 }
2897}