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