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