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