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