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