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