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