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