Skip to main content

devboy_gitlab/
client.rs

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