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