Skip to main content

devboy_github/
client.rs

1//! GitHub 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, UpdateMergeRequestInput, User, parse_markdown_attachments,
11};
12use secrecy::{ExposeSecret, SecretString};
13use serde::Deserialize;
14use tracing::{debug, warn};
15
16use crate::DEFAULT_GITHUB_URL;
17use crate::types::{
18    CreateCommentRequest, CreateIssueRequest, CreatePullRequestRequest, CreateReviewCommentRequest,
19    GitHubComment, GitHubFile, GitHubIssue, GitHubLabel, GitHubPullRequest, GitHubReview,
20    GitHubReviewComment, GitHubUser, UpdateIssueRequest, UpdatePullRequestRequest,
21};
22
23pub struct GitHubClient {
24    base_url: String,
25    owner: String,
26    repo: String,
27    token: SecretString,
28    client: reqwest::Client,
29}
30
31impl GitHubClient {
32    /// Create a new GitHub client.
33    pub fn new(owner: impl Into<String>, repo: impl Into<String>, token: SecretString) -> Self {
34        Self::with_base_url(DEFAULT_GITHUB_URL, owner, repo, token)
35    }
36
37    /// Create a new GitHub client with a custom base URL.
38    pub fn with_base_url(
39        base_url: impl Into<String>,
40        owner: impl Into<String>,
41        repo: impl Into<String>,
42        token: SecretString,
43    ) -> Self {
44        Self {
45            base_url: base_url.into().trim_end_matches('/').to_string(),
46            owner: owner.into(),
47            repo: repo.into(),
48            token,
49            client: reqwest::Client::builder()
50                .user_agent("devboy-tools")
51                .build()
52                .expect("Failed to create HTTP client"),
53        }
54    }
55
56    /// Base URL the client was configured against. Public so the
57    /// liveness probe (and any future sibling module) can build
58    /// its own requests without re-walking the constructor.
59    pub fn base_url(&self) -> &str {
60        &self.base_url
61    }
62
63    /// Borrow the underlying [`reqwest::Client`]. Same rationale as
64    /// [`Self::base_url`] — the liveness probe issues its own
65    /// auth-introspection request and reuses the connection pool.
66    pub fn http_client(&self) -> &reqwest::Client {
67        &self.client
68    }
69
70    /// Build request with common headers.
71    fn request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
72        let mut builder = self
73            .client
74            .request(method, url)
75            .header("Accept", "application/vnd.github+json")
76            .header("X-GitHub-Api-Version", "2022-11-28");
77
78        let token = self.token.expose_secret();
79        if !token.is_empty() {
80            builder = builder.header("Authorization", format!("Bearer {}", token));
81        }
82
83        builder
84    }
85
86    /// Make an authenticated GET request.
87    async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
88        debug!(url = url, "GitHub GET request");
89
90        let response = self
91            .request(reqwest::Method::GET, url)
92            .send()
93            .await
94            .map_err(|e| Error::Http(e.to_string()))?;
95
96        self.handle_response(response).await
97    }
98
99    /// GET every page of a list endpoint. Without explicit paging GitHub
100    /// returns only the first 30 items and the result looks complete —
101    /// discussion threads past page 1 were silently invisible to callers.
102    /// A short page terminates the loop; the page cap is a runaway guard
103    /// (callers of multi-thousand-item endpoints should pass filters instead).
104    async fn get_all_pages<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<Vec<T>> {
105        const PER_PAGE: usize = 100;
106        const MAX_PAGES: usize = 30;
107
108        let sep = if url.contains('?') { '&' } else { '?' };
109        let mut items: Vec<T> = Vec::new();
110        for page in 1..=MAX_PAGES {
111            let paged_url = format!("{}{}per_page={}&page={}", url, sep, PER_PAGE, page);
112            let batch: Vec<T> = self.get(&paged_url).await?;
113            let last_page = batch.len() < PER_PAGE;
114            items.extend(batch);
115            if last_page {
116                break;
117            }
118        }
119        Ok(items)
120    }
121
122    /// Make an authenticated POST request.
123    async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
124        &self,
125        url: &str,
126        body: &B,
127    ) -> Result<T> {
128        debug!(url = url, "GitHub POST request");
129
130        let response = self
131            .request(reqwest::Method::POST, url)
132            .json(body)
133            .send()
134            .await
135            .map_err(|e| Error::Http(e.to_string()))?;
136
137        self.handle_response(response).await
138    }
139
140    /// Make an authenticated PATCH request.
141    async fn patch<T: serde::de::DeserializeOwned, B: serde::Serialize>(
142        &self,
143        url: &str,
144        body: &B,
145    ) -> Result<T> {
146        debug!(url = url, "GitHub PATCH request");
147
148        let response = self
149            .request(reqwest::Method::PATCH, url)
150            .json(body)
151            .send()
152            .await
153            .map_err(|e| Error::Http(e.to_string()))?;
154
155        self.handle_response(response).await
156    }
157
158    /// Handle response and map errors.
159    async fn handle_response<T: serde::de::DeserializeOwned>(
160        &self,
161        response: reqwest::Response,
162    ) -> Result<T> {
163        let status = response.status();
164
165        if !status.is_success() {
166            let status_code = status.as_u16();
167            let message = response.text().await.unwrap_or_default();
168            warn!(
169                status = status_code,
170                message = message,
171                "GitHub API error response"
172            );
173            return Err(Error::from_status(status_code, message));
174        }
175
176        response
177            .json()
178            .await
179            .map_err(|e| Error::InvalidData(format!("Failed to parse response: {}", e)))
180    }
181
182    /// Build repo API URL.
183    fn repo_url(&self, endpoint: &str) -> String {
184        format!(
185            "{}/repos/{}/{}{}",
186            self.base_url, self.owner, self.repo, endpoint
187        )
188    }
189}
190
191// =============================================================================
192// Mapping functions: GitHub types -> Unified types
193// =============================================================================
194
195fn map_user(gh_user: Option<&GitHubUser>) -> Option<User> {
196    gh_user.map(|u| User {
197        id: u.id.to_string(),
198        username: u.login.clone(),
199        name: u.name.clone(),
200        email: u.email.clone(),
201        avatar_url: u.avatar_url.clone(),
202    })
203}
204
205fn map_user_required(gh_user: Option<&GitHubUser>) -> User {
206    map_user(gh_user).unwrap_or_else(|| User {
207        id: "unknown".to_string(),
208        username: "unknown".to_string(),
209        name: Some("Unknown".to_string()),
210        ..Default::default()
211    })
212}
213
214fn map_labels(labels: &[GitHubLabel]) -> Vec<String> {
215    labels.iter().map(|l| l.name.clone()).collect()
216}
217
218fn map_issue(gh_issue: &GitHubIssue) -> Issue {
219    // Count GitHub attachment references in the body (no extra API call).
220    // Uses the same detection logic as `is_github_attachment_url` so
221    // both CDN hosts and `github.com/user-attachments/` URLs are counted.
222    let attachments_count = gh_issue
223        .body
224        .as_deref()
225        .map(|body| {
226            parse_markdown_attachments(body)
227                .iter()
228                .filter(|a| is_github_attachment_url("https://github.com", &a.url))
229                .count() as u32
230        })
231        .filter(|&c| c > 0);
232
233    Issue {
234        custom_fields: std::collections::HashMap::new(),
235        key: format!("gh#{}", gh_issue.number),
236        title: gh_issue.title.clone(),
237        description: gh_issue.body.clone(),
238        state: gh_issue.state.clone(),
239        status: None, // GitHub status is binary (open/closed) → `state` covers it (DEV-1578)
240        status_category: None,
241        source: "github".to_string(),
242        priority: None, // GitHub doesn't have built-in priority
243        labels: map_labels(&gh_issue.labels),
244        author: map_user(gh_issue.user.as_ref()),
245        assignees: gh_issue
246            .assignees
247            .iter()
248            .map(|u| map_user_required(Some(u)))
249            .collect(),
250        url: Some(gh_issue.html_url.clone()),
251        created_at: Some(gh_issue.created_at.clone()),
252        updated_at: Some(gh_issue.updated_at.clone()),
253        attachments_count,
254        parent: None,
255        subtasks: vec![],
256    }
257}
258
259fn map_pull_request(gh_pr: &GitHubPullRequest) -> MergeRequest {
260    // Determine state
261    let state = if gh_pr.merged || gh_pr.merged_at.is_some() {
262        "merged".to_string()
263    } else if gh_pr.state == "closed" {
264        "closed".to_string()
265    } else if gh_pr.draft {
266        "draft".to_string()
267    } else {
268        "open".to_string()
269    };
270
271    MergeRequest {
272        key: format!("pr#{}", gh_pr.number),
273        title: gh_pr.title.clone(),
274        description: gh_pr.body.clone(),
275        state,
276        source: "github".to_string(),
277        source_branch: gh_pr.head.ref_name.clone(),
278        target_branch: gh_pr.base.ref_name.clone(),
279        author: map_user(gh_pr.user.as_ref()),
280        assignees: gh_pr
281            .assignees
282            .iter()
283            .map(|u| map_user_required(Some(u)))
284            .collect(),
285        reviewers: gh_pr
286            .requested_reviewers
287            .iter()
288            .map(|u| map_user_required(Some(u)))
289            .collect(),
290        labels: map_labels(&gh_pr.labels),
291        draft: gh_pr.draft,
292        url: Some(gh_pr.html_url.clone()),
293        created_at: Some(gh_pr.created_at.clone()),
294        updated_at: Some(gh_pr.updated_at.clone()),
295    }
296}
297
298fn map_comment(gh_comment: &GitHubComment) -> Comment {
299    Comment {
300        id: gh_comment.id.to_string(),
301        body: gh_comment.body.clone(),
302        author: map_user(gh_comment.user.as_ref()),
303        created_at: Some(gh_comment.created_at.clone()),
304        updated_at: gh_comment.updated_at.clone(),
305        position: None,
306    }
307}
308
309fn map_review_comment(gh_comment: &GitHubReviewComment) -> Comment {
310    let position = gh_comment
311        .line
312        .or(gh_comment.original_line)
313        .map(|line| CodePosition {
314            file_path: gh_comment.path.clone(),
315            line,
316            line_type: gh_comment
317                .side
318                .as_ref()
319                .map(|s| if s == "LEFT" { "old" } else { "new" })
320                .unwrap_or("new")
321                .to_string(),
322            commit_sha: gh_comment
323                .commit_id
324                .clone()
325                .or_else(|| gh_comment.original_commit_id.clone()),
326        });
327
328    Comment {
329        id: gh_comment.id.to_string(),
330        body: gh_comment.body.clone(),
331        author: map_user(gh_comment.user.as_ref()),
332        created_at: Some(gh_comment.created_at.clone()),
333        updated_at: gh_comment.updated_at.clone(),
334        position,
335    }
336}
337
338fn map_file(gh_file: &GitHubFile) -> FileDiff {
339    FileDiff {
340        file_path: gh_file.filename.clone(),
341        old_path: gh_file.previous_filename.clone(),
342        new_file: gh_file.status == "added",
343        deleted_file: gh_file.status == "removed",
344        renamed_file: gh_file.status == "renamed",
345        diff: gh_file.patch.clone().unwrap_or_default(),
346        additions: Some(gh_file.additions),
347        deletions: Some(gh_file.deletions),
348    }
349}
350
351// =============================================================================
352// Trait implementations
353// =============================================================================
354
355#[async_trait]
356impl IssueProvider for GitHubClient {
357    async fn get_issues(&self, filter: IssueFilter) -> Result<ProviderResult<Issue>> {
358        let mut url = self.repo_url("/issues");
359        let mut params = vec![];
360
361        // Map state
362        if let Some(state) = &filter.state {
363            let gh_state = match state.as_str() {
364                "opened" | "open" => "open",
365                "closed" => "closed",
366                "all" => "all",
367                _ => "open",
368            };
369            params.push(format!("state={}", gh_state));
370        }
371
372        if let Some(labels) = &filter.labels
373            && !labels.is_empty()
374        {
375            params.push(format!("labels={}", labels.join(",")));
376        }
377
378        if let Some(assignee) = &filter.assignee {
379            params.push(format!("assignee={}", assignee));
380        }
381
382        if let Some(limit) = filter.limit {
383            params.push(format!("per_page={}", limit.min(100)));
384        }
385
386        if let Some(offset) = filter.offset {
387            // GitHub uses page-based pagination
388            let per_page = filter.limit.unwrap_or(30);
389            let page = (offset / per_page) + 1;
390            params.push(format!("page={}", page));
391        }
392
393        if let Some(sort_by) = &filter.sort_by {
394            let gh_sort = match sort_by.as_str() {
395                "created_at" | "created" => "created",
396                "updated_at" | "updated" => "updated",
397                _ => "updated",
398            };
399            params.push(format!("sort={}", gh_sort));
400        }
401
402        if let Some(order) = &filter.sort_order {
403            params.push(format!("direction={}", order));
404        }
405
406        if !params.is_empty() {
407            url.push_str(&format!("?{}", params.join("&")));
408        }
409
410        let gh_issues: Vec<GitHubIssue> = self.get(&url).await?;
411
412        // Filter out pull requests (GitHub returns PRs in /issues endpoint)
413        let issues: Vec<Issue> = gh_issues
414            .iter()
415            .filter(|i| i.pull_request.is_none())
416            .map(map_issue)
417            .collect();
418
419        Ok(issues.into())
420    }
421
422    async fn get_issue(&self, key: &str) -> Result<Issue> {
423        let number = parse_issue_key(key)?;
424        let url = self.repo_url(&format!("/issues/{}", number));
425        let gh_issue: GitHubIssue = self.get(&url).await?;
426
427        // Make sure it's not a PR
428        if gh_issue.pull_request.is_some() {
429            return Err(Error::InvalidData(format!(
430                "{} is a pull request, not an issue",
431                key
432            )));
433        }
434
435        Ok(map_issue(&gh_issue))
436    }
437
438    async fn create_issue(&self, input: CreateIssueInput) -> Result<Issue> {
439        let url = self.repo_url("/issues");
440        let request = CreateIssueRequest {
441            title: input.title,
442            body: input.description,
443            labels: input.labels,
444            assignees: input.assignees,
445        };
446
447        let gh_issue: GitHubIssue = self.post(&url, &request).await?;
448        Ok(map_issue(&gh_issue))
449    }
450
451    async fn update_issue(&self, key: &str, input: UpdateIssueInput) -> Result<Issue> {
452        let number = parse_issue_key(key)?;
453        let url = self.repo_url(&format!("/issues/{}", number));
454
455        // Map state
456        let state = input.state.map(|s| match s.as_str() {
457            "opened" | "open" => "open".to_string(),
458            "closed" => "closed".to_string(),
459            _ => s,
460        });
461
462        let request = UpdateIssueRequest {
463            title: input.title,
464            body: input.description,
465            state,
466            labels: input.labels,
467            assignees: input.assignees,
468        };
469
470        let gh_issue: GitHubIssue = self.patch(&url, &request).await?;
471        Ok(map_issue(&gh_issue))
472    }
473
474    async fn get_comments(&self, issue_key: &str) -> Result<ProviderResult<Comment>> {
475        let number = parse_issue_key(issue_key)?;
476        let url = self.repo_url(&format!("/issues/{}/comments", number));
477        let gh_comments: Vec<GitHubComment> = self.get(&url).await?;
478        Ok(gh_comments
479            .iter()
480            .map(map_comment)
481            .collect::<Vec<_>>()
482            .into())
483    }
484
485    async fn add_comment(&self, issue_key: &str, body: &str) -> Result<Comment> {
486        let number = parse_issue_key(issue_key)?;
487        let url = self.repo_url(&format!("/issues/{}/comments", number));
488        let request = CreateCommentRequest {
489            body: body.to_string(),
490        };
491
492        let gh_comment: GitHubComment = self.post(&url, &request).await?;
493        Ok(map_comment(&gh_comment))
494    }
495
496    async fn get_issue_attachments(&self, issue_key: &str) -> Result<Vec<AssetMeta>> {
497        // GitHub does not expose an attachment API for issues; we parse the
498        // issue body and all comment bodies for markdown-embedded files.
499        let issue = self.get_issue(issue_key).await?;
500        let comments = self.get_comments(issue_key).await?;
501
502        let mut attachments: Vec<AssetMeta> = Vec::new();
503        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
504        let base = self.base_url.clone();
505        let mut collect = |source: &str| {
506            for att in parse_markdown_attachments(source) {
507                // Only include URLs that point to known GitHub CDN /
508                // upload hosts. Ordinary markdown links (docs, issues,
509                // dashboards) must not appear as downloadable attachments.
510                if is_github_attachment_url(&base, &att.url) && seen.insert(att.url.clone()) {
511                    attachments.push(markdown_to_meta(&att));
512                }
513            }
514        };
515        if let Some(body) = issue.description.as_deref() {
516            collect(body);
517        }
518        for comment in &comments.items {
519            collect(&comment.body);
520        }
521        Ok(attachments)
522    }
523
524    async fn download_attachment(&self, _issue_key: &str, asset_id: &str) -> Result<Vec<u8>> {
525        download_github_url(&self.client, &self.base_url, &self.token, asset_id).await
526    }
527
528    fn asset_capabilities(&self) -> AssetCapabilities {
529        // GitHub has no public file upload API for issues / PRs (files are
530        // only uploaded through the web UI to a CDN). We support download
531        // and list via markdown parsing; upload / delete stay false.
532        let caps = ContextCapabilities {
533            upload: false,
534            download: true,
535            delete: false,
536            list: true,
537            max_file_size: None,
538            allowed_types: Vec::new(),
539        };
540        AssetCapabilities {
541            issue: caps.clone(),
542            issue_comment: caps.clone(),
543            merge_request: caps.clone(),
544            mr_comment: caps,
545        }
546    }
547
548    fn provider_name(&self) -> &'static str {
549        "github"
550    }
551}
552
553#[async_trait]
554impl MergeRequestProvider for GitHubClient {
555    async fn get_merge_requests(&self, filter: MrFilter) -> Result<ProviderResult<MergeRequest>> {
556        let mut url = self.repo_url("/pulls");
557        let mut params = vec![];
558
559        // Map state
560        if let Some(state) = &filter.state {
561            let gh_state = match state.as_str() {
562                "opened" | "open" => "open",
563                "closed" => "closed",
564                "merged" => "closed", // GitHub doesn't have merged state in filter
565                "all" => "all",
566                _ => "open",
567            };
568            params.push(format!("state={}", gh_state));
569        }
570
571        if let Some(source_branch) = &filter.source_branch {
572            params.push(format!("head={}", source_branch));
573        }
574
575        if let Some(target_branch) = &filter.target_branch {
576            params.push(format!("base={}", target_branch));
577        }
578
579        if let Some(limit) = filter.limit {
580            params.push(format!("per_page={}", limit.min(100)));
581        }
582
583        params.push("sort=updated".to_string());
584        params.push("direction=desc".to_string());
585
586        if !params.is_empty() {
587            url.push_str(&format!("?{}", params.join("&")));
588        }
589
590        let gh_prs: Vec<GitHubPullRequest> = self.get(&url).await?;
591
592        let mut prs: Vec<MergeRequest> = gh_prs.iter().map(map_pull_request).collect();
593
594        // Filter by merged state if requested
595        if filter.state.as_deref() == Some("merged") {
596            prs.retain(|pr| pr.state == "merged");
597        }
598
599        Ok(prs.into())
600    }
601
602    async fn get_merge_request(&self, key: &str) -> Result<MergeRequest> {
603        let number = parse_pr_key(key)?;
604        let url = self.repo_url(&format!("/pulls/{}", number));
605        let gh_pr: GitHubPullRequest = self.get(&url).await?;
606        Ok(map_pull_request(&gh_pr))
607    }
608
609    async fn get_discussions(&self, mr_key: &str) -> Result<ProviderResult<Discussion>> {
610        let number = parse_pr_key(mr_key)?;
611
612        // Fetch reviews, review comments, and general comments
613        let reviews_url = self.repo_url(&format!("/pulls/{}/reviews", number));
614        let review_comments_url = self.repo_url(&format!("/pulls/{}/comments", number));
615        let issue_comments_url = self.repo_url(&format!("/issues/{}/comments", number));
616
617        let reviews: Vec<GitHubReview> = self.get_all_pages(&reviews_url).await?;
618        let review_comments: Vec<GitHubReviewComment> =
619            self.get_all_pages(&review_comments_url).await?;
620        let issue_comments: Vec<GitHubComment> = self.get_all_pages(&issue_comments_url).await?;
621
622        let mut discussions = Vec::new();
623
624        // Group review comments by thread
625        let mut comment_threads: std::collections::HashMap<u64, Vec<&GitHubReviewComment>> =
626            std::collections::HashMap::new();
627
628        for comment in &review_comments {
629            let thread_id = comment.in_reply_to_id.unwrap_or(comment.id);
630            comment_threads.entry(thread_id).or_default().push(comment);
631        }
632
633        // Create discussions from threads
634        for (thread_id, comments) in comment_threads {
635            let mapped_comments: Vec<Comment> =
636                comments.iter().map(|c| map_review_comment(c)).collect();
637            let position = mapped_comments.first().and_then(|c| c.position.clone());
638
639            discussions.push(Discussion {
640                id: format!("thread-{}", thread_id),
641                resolved: false, // GitHub doesn't have resolved state for review comments
642                resolved_by: None,
643                comments: mapped_comments,
644                position,
645            });
646        }
647
648        // Add reviews as discussions
649        for review in &reviews {
650            let mut comments = Vec::new();
651            if let Some(body) = &review.body
652                && !body.is_empty()
653            {
654                comments.push(Comment {
655                    id: review.id.to_string(),
656                    body: body.clone(),
657                    author: map_user(review.user.as_ref()),
658                    created_at: review.submitted_at.clone(),
659                    updated_at: None,
660                    position: None,
661                });
662            }
663
664            if !comments.is_empty() || !review.state.is_empty() {
665                discussions.push(Discussion {
666                    id: format!("review-{}", review.id),
667                    resolved: false,
668                    resolved_by: None,
669                    comments,
670                    position: None,
671                });
672            }
673        }
674
675        // Add general PR comments
676        for comment in &issue_comments {
677            discussions.push(Discussion {
678                id: format!("comment-{}", comment.id),
679                resolved: false,
680                resolved_by: None,
681                comments: vec![map_comment(comment)],
682                position: None,
683            });
684        }
685
686        Ok(discussions.into())
687    }
688
689    async fn get_diffs(&self, mr_key: &str) -> Result<ProviderResult<FileDiff>> {
690        let number = parse_pr_key(mr_key)?;
691        let url = self.repo_url(&format!("/pulls/{}/files", number));
692        let gh_files: Vec<GitHubFile> = self.get(&url).await?;
693        Ok(gh_files.iter().map(map_file).collect::<Vec<_>>().into())
694    }
695
696    async fn add_comment(&self, mr_key: &str, input: CreateCommentInput) -> Result<Comment> {
697        let number = parse_pr_key(mr_key)?;
698
699        // First verify that this is actually a PR, not an issue
700        let pr_url = self.repo_url(&format!("/pulls/{}", number));
701        let pr_result: Result<GitHubPullRequest> = self.get(&pr_url).await;
702
703        if let Err(Error::Http(status)) = &pr_result
704            && status.contains("404")
705        {
706            return Err(Error::InvalidData(format!(
707                "{} is not a valid pull request (it may be an issue)",
708                mr_key
709            )));
710        }
711
712        // Propagate other errors and save PR for later use
713        let pr: GitHubPullRequest = pr_result?;
714
715        // If position is provided, create a review comment
716        if let Some(position) = &input.position {
717            let url = self.repo_url(&format!("/pulls/{}/comments", number));
718
719            // If commit_sha is not provided, use the PR head commit
720            let commit_sha = if let Some(sha) = &position.commit_sha {
721                sha.clone()
722            } else {
723                // Use the already fetched PR head commit SHA
724                pr.head.sha
725            };
726
727            let request = CreateReviewCommentRequest {
728                body: input.body,
729                commit_id: commit_sha,
730                path: position.file_path.clone(),
731                line: Some(position.line),
732                side: Some(if position.line_type == "old" {
733                    "LEFT".to_string()
734                } else {
735                    "RIGHT".to_string()
736                }),
737                // Unified `Discussion.id` is prefixed (`review-<n>` for a
738                // review thread, `comment-<n>` for a general issue
739                // comment) — see `get_discussions` below. Strip either
740                // prefix before parsing so callers can feed the id they
741                // received from `get_merge_request_discussions` straight
742                // back into `create_merge_request_comment` and have the
743                // new comment actually thread into the existing review.
744                in_reply_to: input
745                    .discussion_id
746                    .as_deref()
747                    .and_then(parse_discussion_numeric_id),
748            };
749
750            let gh_comment: GitHubReviewComment = self.post(&url, &request).await?;
751            return Ok(map_review_comment(&gh_comment));
752        }
753
754        // Otherwise create a general comment using PR endpoint
755        let url = self.repo_url(&format!("/issues/{}/comments", number));
756        let request = CreateCommentRequest { body: input.body };
757
758        let gh_comment: GitHubComment = self.post(&url, &request).await?;
759        Ok(map_comment(&gh_comment))
760    }
761
762    async fn create_merge_request(&self, input: CreateMergeRequestInput) -> Result<MergeRequest> {
763        let url = self.repo_url("/pulls");
764
765        let request = CreatePullRequestRequest {
766            title: input.title,
767            body: input.description,
768            head: input.source_branch,
769            base: input.target_branch,
770            draft: if input.draft { Some(true) } else { None },
771        };
772
773        let gh_pr: GitHubPullRequest = self.post(&url, &request).await?;
774
775        // Add labels if provided (best-effort: PR is already created)
776        if !input.labels.is_empty() {
777            let labels_url = self.repo_url(&format!("/issues/{}/labels", gh_pr.number));
778            let result: Result<serde_json::Value> = self
779                .post(&labels_url, &serde_json::json!({ "labels": input.labels }))
780                .await;
781            if let Err(err) = result {
782                warn!(
783                    error = ?err,
784                    pr_number = gh_pr.number,
785                    "Failed to add labels to GitHub pull request"
786                );
787            }
788        }
789
790        // Add reviewers if provided (best-effort: PR is already created)
791        if !input.reviewers.is_empty() {
792            let reviewers_url =
793                self.repo_url(&format!("/pulls/{}/requested_reviewers", gh_pr.number));
794            let result: Result<serde_json::Value> = self
795                .post(
796                    &reviewers_url,
797                    &serde_json::json!({ "reviewers": input.reviewers }),
798                )
799                .await;
800            if let Err(err) = result {
801                warn!(
802                    error = ?err,
803                    pr_number = gh_pr.number,
804                    "Failed to add reviewers to GitHub pull request"
805                );
806            }
807        }
808
809        // Re-fetch the PR to get updated labels/reviewers (best-effort)
810        if !input.labels.is_empty() || !input.reviewers.is_empty() {
811            let pr_url = self.repo_url(&format!("/pulls/{}", gh_pr.number));
812            match self.get::<GitHubPullRequest>(&pr_url).await {
813                Ok(updated_pr) => return Ok(map_pull_request(&updated_pr)),
814                Err(err) => {
815                    warn!(
816                        error = ?err,
817                        pr_number = gh_pr.number,
818                        "Failed to re-fetch GitHub pull request"
819                    );
820                }
821            }
822        }
823
824        Ok(map_pull_request(&gh_pr))
825    }
826
827    async fn update_merge_request(
828        &self,
829        key: &str,
830        input: UpdateMergeRequestInput,
831    ) -> Result<MergeRequest> {
832        let number = parse_pr_key(key)?;
833        let url = self.repo_url(&format!("/pulls/{}", number));
834
835        // Map state: GitHub uses "open" / "closed".
836        let state = input.state.map(|s| match s.as_str() {
837            "opened" | "open" | "reopen" => "open".to_string(),
838            "closed" | "close" => "closed".to_string(),
839            _ => s,
840        });
841
842        let request = UpdatePullRequestRequest {
843            title: input.title,
844            body: input.description,
845            state,
846            draft: input.draft,
847        };
848
849        let gh_pr: GitHubPullRequest = self.patch(&url, &request).await?;
850
851        // Update labels if provided (best-effort: PR is already updated).
852        if let Some(labels) = input.labels {
853            let labels_url = self.repo_url(&format!("/issues/{}/labels", number));
854            let result: Result<serde_json::Value> = self
855                .patch(&labels_url, &serde_json::json!({ "labels": labels }))
856                .await;
857            if let Err(err) = result {
858                warn!(
859                    error = ?err,
860                    pr_number = number,
861                    "Failed to update labels on GitHub pull request"
862                );
863            }
864
865            // Re-fetch to include updated labels.
866            let pr_url = self.repo_url(&format!("/pulls/{}", number));
867            match self.get::<GitHubPullRequest>(&pr_url).await {
868                Ok(updated_pr) => return Ok(map_pull_request(&updated_pr)),
869                Err(err) => {
870                    warn!(
871                        error = ?err,
872                        pr_number = number,
873                        "Failed to re-fetch GitHub pull request"
874                    );
875                }
876            }
877        }
878
879        Ok(map_pull_request(&gh_pr))
880    }
881
882    async fn get_mr_attachments(&self, mr_key: &str) -> Result<Vec<AssetMeta>> {
883        let mr = self.get_merge_request(mr_key).await?;
884        let discussions = self.get_discussions(mr_key).await?;
885
886        let mut attachments: Vec<AssetMeta> = Vec::new();
887        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
888        let base = self.base_url.clone();
889        let mut collect = |source: &str| {
890            for att in parse_markdown_attachments(source) {
891                if is_github_attachment_url(&base, &att.url) && seen.insert(att.url.clone()) {
892                    attachments.push(markdown_to_meta(&att));
893                }
894            }
895        };
896        if let Some(body) = mr.description.as_deref() {
897            collect(body);
898        }
899        for discussion in &discussions.items {
900            for comment in &discussion.comments {
901                collect(&comment.body);
902            }
903        }
904        Ok(attachments)
905    }
906
907    async fn download_mr_attachment(&self, _mr_key: &str, asset_id: &str) -> Result<Vec<u8>> {
908        download_github_url(&self.client, &self.base_url, &self.token, asset_id).await
909    }
910
911    fn provider_name(&self) -> &'static str {
912        "github"
913    }
914}
915
916/// Convert a parsed markdown attachment into an [`AssetMeta`] record.
917///
918/// GitHub has no stable attachment id — the URL itself doubles as both the
919/// lookup key and the download target.
920/// Known GitHub-owned hosts that are safe to send auth headers to.
921const GITHUB_TRUSTED_HOSTS: &[&str] = &[
922    "github.com",
923    "api.github.com",
924    "githubusercontent.com",
925    "user-images.githubusercontent.com",
926    "raw.githubusercontent.com",
927    "objects.githubusercontent.com",
928    "camo.githubusercontent.com",
929];
930
931/// Download a URL, attaching GitHub auth headers only when the host
932/// requires it. CDN hosts (`*.githubusercontent.com`) serve content
933/// anonymously — sending a Bearer token to their S3 backend causes
934/// `400 Unsupported Authorization Type`.
935async fn download_github_url(
936    client: &reqwest::Client,
937    base_url: &str,
938    token: &SecretString,
939    url: &str,
940) -> Result<Vec<u8>> {
941    let needs_auth = is_github_api_host(base_url, url);
942    let mut request = client
943        .get(url)
944        .header("Accept", "application/octet-stream")
945        .header("User-Agent", "devboy-tools");
946    let token_value = token.expose_secret();
947    if needs_auth && !token_value.is_empty() {
948        request = request.header("Authorization", format!("Bearer {token_value}"));
949    } else if !is_github_trusted_host(base_url, url) {
950        tracing::warn!(
951            url,
952            "downloading cross-origin attachment without auth headers"
953        );
954    }
955    let response = request
956        .send()
957        .await
958        .map_err(|e| Error::Http(e.to_string()))?;
959    let status = response.status();
960    if !status.is_success() {
961        let message = response.text().await.unwrap_or_default();
962        return Err(Error::from_status(status.as_u16(), message));
963    }
964    let bytes = response
965        .bytes()
966        .await
967        .map_err(|e| Error::Http(format!("failed to read attachment bytes: {e}")))?;
968    Ok(bytes.to_vec())
969}
970
971/// Check whether a URL points to a GitHub API host that needs
972/// Authorization headers. CDN hosts (*.githubusercontent.com) do NOT
973/// need auth — they serve content anonymously via S3-style presigned
974/// URLs and reject Bearer tokens.
975fn is_github_api_host(base_url: &str, url: &str) -> bool {
976    let (url_scheme, url_host) = split_scheme_host(url);
977    if url_scheme != "https" {
978        return false;
979    }
980    // API hosts that accept Bearer tokens.
981    if url_host == "api.github.com" || url_host == "github.com" {
982        return true;
983    }
984    // GitHub Enterprise: base_url host.
985    let (_base_scheme, base_host) = split_scheme_host(base_url);
986    url_host == base_host
987}
988
989/// Check whether a URL is a known GitHub host or matches the configured
990/// base URL (for GitHub Enterprise instances).
991///
992/// Only HTTPS URLs are trusted — a `http://github.com/...` link would
993/// send credentials over plaintext and is rejected.
994fn is_github_trusted_host(base_url: &str, url: &str) -> bool {
995    let (url_scheme, url_host) = split_scheme_host(url);
996    if url_scheme != "https" {
997        return false;
998    }
999
1000    // Check against well-known GitHub CDN hosts.
1001    for trusted in GITHUB_TRUSTED_HOSTS {
1002        if url_host == *trusted || url_host.ends_with(&format!(".{trusted}")) {
1003            return true;
1004        }
1005    }
1006
1007    // Check against the configured base URL (GitHub Enterprise).
1008    let (_base_scheme, base_host) = split_scheme_host(base_url);
1009    url_host == base_host
1010}
1011
1012/// Extract (scheme, host) from a URL string, both lowercased.
1013fn split_scheme_host(url: &str) -> (String, String) {
1014    let (scheme, rest) = match url.split_once("://") {
1015        Some((s, r)) => (s.to_ascii_lowercase(), r),
1016        None => return (String::new(), String::new()),
1017    };
1018    let host = rest.split('/').next().unwrap_or("").to_ascii_lowercase();
1019    (scheme, host)
1020}
1021
1022/// Check whether a URL looks like a real GitHub file attachment (CDN
1023/// upload, user-content image, etc.) as opposed to an ordinary markdown
1024/// link to a docs page, issue, or dashboard.
1025///
1026/// GitHub user-uploaded attachments are hosted on `githubusercontent.com`
1027/// subdomains. We also accept `/assets/` paths on the configured host
1028/// (GitHub Enterprise may serve uploads from the same domain).
1029fn is_github_attachment_url(base_url: &str, url: &str) -> bool {
1030    let (scheme, host) = split_scheme_host(url);
1031    if scheme.is_empty() {
1032        return false; // relative path — not a CDN upload
1033    }
1034    // Well-known GitHub CDN hosts for user-uploaded content.
1035    if host.ends_with("githubusercontent.com") {
1036        return true;
1037    }
1038    // github.com/user-attachments/assets/ — new upload format (Web UI).
1039    if host == "github.com" {
1040        let path = url
1041            .split("://")
1042            .nth(1)
1043            .unwrap_or("")
1044            .split_once('/')
1045            .map(|(_, p)| p)
1046            .unwrap_or("");
1047        if path.starts_with("user-attachments/assets/")
1048            || path.starts_with("user-attachments/files/")
1049        {
1050            return true;
1051        }
1052    }
1053    // On the base host: only `/assets/` paths are real uploads.
1054    let (_base_scheme, base_host) = split_scheme_host(base_url);
1055    if host == base_host {
1056        let path = url
1057            .split("://")
1058            .nth(1)
1059            .unwrap_or("")
1060            .split_once('/')
1061            .map(|(_, p)| p)
1062            .unwrap_or("");
1063        return path.contains("/assets/");
1064    }
1065    false
1066}
1067
1068fn markdown_to_meta(att: &devboy_core::MarkdownAttachment) -> AssetMeta {
1069    AssetMeta {
1070        id: att.url.clone(),
1071        filename: att.filename.clone(),
1072        mime_type: None,
1073        size: None,
1074        url: Some(att.url.clone()),
1075        created_at: None,
1076        author: None,
1077        cached: false,
1078        local_path: None,
1079        checksum_sha256: None,
1080        analysis: None,
1081    }
1082}
1083
1084// =============================================================================
1085// Pipeline Provider (GitHub Actions)
1086// =============================================================================
1087
1088/// GitHub Actions workflow run.
1089#[derive(Debug, Deserialize)]
1090struct GhWorkflowRun {
1091    id: u64,
1092    name: Option<String>,
1093    status: Option<String>,
1094    conclusion: Option<String>,
1095    #[allow(dead_code)]
1096    head_branch: Option<String>,
1097    head_sha: String,
1098    html_url: String,
1099    run_started_at: Option<String>,
1100    updated_at: Option<String>,
1101}
1102
1103/// GitHub Actions workflow runs list.
1104#[derive(Debug, Deserialize)]
1105struct GhWorkflowRuns {
1106    workflow_runs: Vec<GhWorkflowRun>,
1107}
1108
1109/// GitHub Actions job.
1110#[derive(Debug, Deserialize)]
1111struct GhJob {
1112    id: u64,
1113    name: String,
1114    status: Option<String>,
1115    conclusion: Option<String>,
1116    html_url: Option<String>,
1117    started_at: Option<String>,
1118    completed_at: Option<String>,
1119}
1120
1121/// GitHub Actions jobs list.
1122#[derive(Debug, Deserialize)]
1123struct GhJobs {
1124    jobs: Vec<GhJob>,
1125}
1126
1127fn map_gh_status(status: Option<&str>, conclusion: Option<&str>) -> PipelineStatus {
1128    match (status, conclusion) {
1129        (Some("completed"), Some("success")) => PipelineStatus::Success,
1130        (Some("completed"), Some("failure")) => PipelineStatus::Failed,
1131        (Some("completed"), Some("cancelled")) => PipelineStatus::Canceled,
1132        (Some("completed"), Some("skipped")) => PipelineStatus::Skipped,
1133        (Some("in_progress"), _) => PipelineStatus::Running,
1134        (Some("queued"), _) | (Some("waiting"), _) => PipelineStatus::Pending,
1135        _ => PipelineStatus::Unknown,
1136    }
1137}
1138
1139fn estimate_duration(started: Option<&str>, completed: Option<&str>) -> Option<u64> {
1140    let start = started?.parse::<chrono::DateTime<chrono::Utc>>().ok()?;
1141    let end = completed?.parse::<chrono::DateTime<chrono::Utc>>().ok()?;
1142    Some(
1143        end.signed_duration_since(start)
1144            .num_seconds()
1145            .unsigned_abs(),
1146    )
1147}
1148
1149/// Strip ANSI escape codes from log text.
1150fn strip_ansi(text: &str) -> String {
1151    let mut result = String::with_capacity(text.len());
1152    let mut chars = text.chars().peekable();
1153    while let Some(ch) = chars.next() {
1154        if ch == '\x1b' {
1155            // Skip until 'm' (SGR) or letter
1156            while let Some(&next) = chars.peek() {
1157                chars.next();
1158                if next.is_ascii_alphabetic() {
1159                    break;
1160                }
1161            }
1162        } else {
1163            result.push(ch);
1164        }
1165    }
1166    result
1167}
1168
1169/// Extract error lines from job log using common patterns.
1170fn extract_errors(log: &str, max_lines: usize) -> Option<String> {
1171    let patterns = [
1172        "error[",
1173        "error:",
1174        "FAILED",
1175        "Error:",
1176        "panic",
1177        "FATAL",
1178        "AssertionError",
1179        "TypeError",
1180        "Cannot find",
1181        "not found",
1182        "exit code",
1183    ];
1184    let lines: Vec<&str> = log.lines().collect();
1185    let mut error_lines: Vec<String> = Vec::new();
1186
1187    for (i, line) in lines.iter().enumerate() {
1188        let stripped = strip_ansi(line);
1189        if patterns.iter().any(|p| stripped.contains(p)) {
1190            // Add context: 2 lines before + match + 2 lines after
1191            let start = i.saturating_sub(2);
1192            let end = (i + 3).min(lines.len());
1193            for ctx_line_raw in &lines[start..end] {
1194                let ctx_line = strip_ansi(ctx_line_raw).trim().to_string();
1195                if !ctx_line.is_empty() && !error_lines.contains(&ctx_line) {
1196                    error_lines.push(ctx_line);
1197                }
1198            }
1199            if error_lines.len() >= max_lines {
1200                break;
1201            }
1202        }
1203    }
1204
1205    if error_lines.is_empty() {
1206        // Fallback: last 10 non-empty lines
1207        let tail: Vec<String> = lines
1208            .iter()
1209            .rev()
1210            .filter_map(|l| {
1211                let s = strip_ansi(l).trim().to_string();
1212                if s.is_empty() { None } else { Some(s) }
1213            })
1214            .take(10)
1215            .collect();
1216        if tail.is_empty() {
1217            None
1218        } else {
1219            Some(tail.into_iter().rev().collect::<Vec<_>>().join("\n"))
1220        }
1221    } else {
1222        Some(error_lines.join("\n"))
1223    }
1224}
1225
1226#[async_trait]
1227impl PipelineProvider for GitHubClient {
1228    fn provider_name(&self) -> &'static str {
1229        "github"
1230    }
1231
1232    async fn get_pipeline(&self, input: GetPipelineInput) -> Result<PipelineInfo> {
1233        // Resolve which branch to query
1234        let branch = if let Some(ref mr_key) = input.mr_key {
1235            // pr#123 → get PR head branch
1236            let number = parse_pr_key(mr_key)?;
1237            let pr_url = self.repo_url(&format!("/pulls/{number}"));
1238            let pr: GitHubPullRequest = self.get(&pr_url).await?;
1239            pr.head.ref_name
1240        } else if let Some(ref branch) = input.branch {
1241            branch.clone()
1242        } else {
1243            // Default: main branch
1244            "main".to_string()
1245        };
1246
1247        // Get latest workflow run for this branch
1248        let runs_url = self.repo_url(&format!(
1249            "/actions/runs?branch={}&per_page=1&status=completed",
1250            urlencoding::encode(&branch)
1251        ));
1252        let runs: GhWorkflowRuns = self.get(&runs_url).await?;
1253
1254        // Also check in-progress runs
1255        let active_runs_url = self.repo_url(&format!(
1256            "/actions/runs?branch={}&per_page=1&status=in_progress",
1257            urlencoding::encode(&branch)
1258        ));
1259        let active_runs: GhWorkflowRuns =
1260            self.get(&active_runs_url).await.unwrap_or(GhWorkflowRuns {
1261                workflow_runs: vec![],
1262            });
1263
1264        // Pick the most recent run (prefer in-progress over completed)
1265        let run = active_runs
1266            .workflow_runs
1267            .into_iter()
1268            .chain(runs.workflow_runs)
1269            .next()
1270            .ok_or_else(|| {
1271                Error::NotFound(format!("No workflow runs found for branch '{branch}'"))
1272            })?;
1273
1274        let run_status = map_gh_status(run.status.as_deref(), run.conclusion.as_deref());
1275
1276        // Get jobs for this run
1277        let jobs_url = self.repo_url(&format!("/actions/runs/{}/jobs?per_page=100", run.id));
1278        let gh_jobs: GhJobs = self.get(&jobs_url).await?;
1279
1280        // Build summary
1281        let mut summary = PipelineSummary {
1282            total: gh_jobs.jobs.len() as u32,
1283            ..Default::default()
1284        };
1285
1286        // Group jobs by workflow name (use run name as single stage)
1287        let mut jobs: Vec<PipelineJob> = Vec::new();
1288        let mut failed_job_ids: Vec<(u64, String)> = Vec::new();
1289
1290        for job in &gh_jobs.jobs {
1291            let status = map_gh_status(job.status.as_deref(), job.conclusion.as_deref());
1292            match status {
1293                PipelineStatus::Success => summary.success += 1,
1294                PipelineStatus::Failed => {
1295                    summary.failed += 1;
1296                    failed_job_ids.push((job.id, job.name.clone()));
1297                }
1298                PipelineStatus::Running => summary.running += 1,
1299                PipelineStatus::Pending => summary.pending += 1,
1300                PipelineStatus::Manual => summary.manual += 1,
1301                PipelineStatus::Canceled => summary.canceled += 1,
1302                PipelineStatus::Skipped => summary.skipped += 1,
1303                PipelineStatus::Unknown => {}
1304            }
1305
1306            let duration =
1307                estimate_duration(job.started_at.as_deref(), job.completed_at.as_deref());
1308
1309            jobs.push(PipelineJob {
1310                id: job.id.to_string(),
1311                name: job.name.clone(),
1312                status,
1313                url: job.html_url.clone(),
1314                duration,
1315            });
1316        }
1317
1318        // Fetch error snippets for failed jobs (max 5)
1319        let mut failed_jobs: Vec<FailedJob> = Vec::new();
1320        if input.include_failed_logs {
1321            for (job_id, job_name) in failed_job_ids.iter().take(5) {
1322                let log_url = self.repo_url(&format!("/actions/jobs/{job_id}/logs"));
1323                let error_snippet = match self.request(reqwest::Method::GET, &log_url).send().await
1324                {
1325                    Ok(resp) if resp.status().is_success() => {
1326                        let log_text = resp.text().await.unwrap_or_default();
1327                        extract_errors(&log_text, 20)
1328                    }
1329                    _ => None,
1330                };
1331                failed_jobs.push(FailedJob {
1332                    id: job_id.to_string(),
1333                    name: job_name.clone(),
1334                    url: None,
1335                    error_snippet,
1336                });
1337            }
1338        }
1339
1340        let duration = estimate_duration(run.run_started_at.as_deref(), run.updated_at.as_deref());
1341
1342        let stage_name = run.name.unwrap_or_else(|| "CI".to_string());
1343
1344        Ok(PipelineInfo {
1345            id: run.id.to_string(),
1346            status: run_status,
1347            reference: branch,
1348            sha: run.head_sha,
1349            url: Some(run.html_url),
1350            duration,
1351            coverage: None,
1352            summary,
1353            stages: vec![PipelineStage {
1354                name: stage_name,
1355                jobs,
1356            }],
1357            failed_jobs,
1358        })
1359    }
1360
1361    async fn get_job_logs(&self, job_id: &str, options: JobLogOptions) -> Result<JobLogOutput> {
1362        let log_url = self.repo_url(&format!("/actions/jobs/{job_id}/logs"));
1363        let resp = self
1364            .request(reqwest::Method::GET, &log_url)
1365            .send()
1366            .await
1367            .map_err(|e| Error::Network(e.to_string()))?;
1368
1369        if !resp.status().is_success() {
1370            return Err(Error::from_status(
1371                resp.status().as_u16(),
1372                format!("Failed to fetch job logs for job {job_id}"),
1373            ));
1374        }
1375
1376        // GitHub may return plain text or redirect to ZIP.
1377        // Check Content-Type to detect binary/ZIP responses.
1378        let content_type = resp
1379            .headers()
1380            .get("content-type")
1381            .and_then(|v| v.to_str().ok())
1382            .unwrap_or("")
1383            .to_string();
1384
1385        let raw_log = if content_type.contains("application/zip")
1386            || content_type.contains("application/octet-stream")
1387        {
1388            // Binary/ZIP response — return error message instead of garbled output
1389            return Err(Error::InvalidData(
1390                "Job logs returned as ZIP archive. This typically happens for large logs. \
1391                 Try using pattern search mode to find specific errors."
1392                    .to_string(),
1393            ));
1394        } else {
1395            resp.text()
1396                .await
1397                .map_err(|e| Error::Network(e.to_string()))?
1398        };
1399        let log = strip_ansi(&raw_log);
1400        let lines: Vec<&str> = log.lines().collect();
1401        let total_lines = lines.len();
1402
1403        let (content, mode_name) = match options.mode {
1404            JobLogMode::Smart => {
1405                let extracted = extract_errors(&log, 30).unwrap_or_else(|| {
1406                    lines
1407                        .iter()
1408                        .rev()
1409                        .take(20)
1410                        .copied()
1411                        .collect::<Vec<_>>()
1412                        .into_iter()
1413                        .rev()
1414                        .collect::<Vec<_>>()
1415                        .join("\n")
1416                });
1417                (extracted, "smart")
1418            }
1419            JobLogMode::Search {
1420                ref pattern,
1421                context,
1422                max_matches,
1423            } => {
1424                let re = regex::Regex::new(pattern)
1425                    .unwrap_or_else(|_| regex::Regex::new(&regex::escape(pattern)).unwrap());
1426                let mut matches = Vec::new();
1427                for (i, line) in lines.iter().enumerate() {
1428                    if re.is_match(line) {
1429                        let start = i.saturating_sub(context);
1430                        let end = (i + context + 1).min(total_lines);
1431                        matches.push(format!("--- Match at line {} ---", i + 1));
1432                        for (j, ctx_line) in lines[start..end].iter().enumerate() {
1433                            let line_num = start + j;
1434                            let marker = if line_num == i { ">>>" } else { "   " };
1435                            matches.push(format!("{} {}: {}", marker, line_num + 1, ctx_line));
1436                        }
1437                        if matches.len() / (context * 2 + 2) >= max_matches {
1438                            break;
1439                        }
1440                    }
1441                }
1442                (matches.join("\n"), "search")
1443            }
1444            JobLogMode::Paginated { offset, limit } => {
1445                let page: Vec<&str> = lines.iter().skip(offset).take(limit).copied().collect();
1446                (page.join("\n"), "paginated")
1447            }
1448            JobLogMode::Full { max_lines } => {
1449                let truncated: Vec<&str> = lines.iter().take(max_lines).copied().collect();
1450                (truncated.join("\n"), "full")
1451            }
1452        };
1453
1454        Ok(JobLogOutput {
1455            job_id: job_id.to_string(),
1456            job_name: None,
1457            content,
1458            mode: mode_name.to_string(),
1459            total_lines: Some(total_lines),
1460        })
1461    }
1462}
1463
1464#[async_trait]
1465impl Provider for GitHubClient {
1466    async fn get_current_user(&self) -> Result<User> {
1467        let url = format!("{}/user", self.base_url);
1468        let gh_user: GitHubUser = self.get(&url).await?;
1469        Ok(map_user_required(Some(&gh_user)))
1470    }
1471}
1472
1473// =============================================================================
1474// Helper functions
1475// =============================================================================
1476
1477/// Parse issue key like "gh#123" to get issue number.
1478fn parse_issue_key(key: &str) -> Result<u64> {
1479    key.strip_prefix("gh#")
1480        .and_then(|s| s.parse::<u64>().ok())
1481        .ok_or_else(|| Error::InvalidData(format!("Invalid issue key: {}", key)))
1482}
1483
1484/// Parse PR key like "pr#123" to get PR number.
1485fn parse_pr_key(key: &str) -> Result<u64> {
1486    key.strip_prefix("pr#")
1487        .and_then(|s| s.parse::<u64>().ok())
1488        .ok_or_else(|| Error::InvalidData(format!("Invalid PR key: {}", key)))
1489}
1490
1491/// Turn a unified `Discussion.id` back into the numeric comment id
1492/// GitHub expects in `in_reply_to`. `get_discussions` emits three
1493/// prefix shapes:
1494///
1495/// - `thread-<n>` for multi-comment review threads (one per root
1496///   review comment, grouped by `in_reply_to_id`) — this is the id
1497///   most skills actually feed back into `create_merge_request_comment`
1498///   when they want their reply to thread.
1499/// - `review-<n>` for single-comment review bodies.
1500/// - `comment-<n>` for general PR comments (note: GitHub itself does
1501///   not thread those, but stripping the prefix keeps the parser
1502///   lossless and lets the caller pass the numeric id elsewhere).
1503///
1504/// Raw numeric strings pass through unchanged for forward
1505/// compatibility and for test fixtures constructed by hand.
1506fn parse_discussion_numeric_id(id: &str) -> Option<u64> {
1507    let trimmed = id
1508        .strip_prefix("thread-")
1509        .or_else(|| id.strip_prefix("review-"))
1510        .or_else(|| id.strip_prefix("comment-"))
1511        .unwrap_or(id);
1512    trimmed.parse::<u64>().ok()
1513}
1514
1515#[cfg(test)]
1516mod tests {
1517    use super::*;
1518    use crate::types::GitHubBranchRef;
1519
1520    #[test]
1521    fn test_parse_issue_key() {
1522        assert_eq!(parse_issue_key("gh#123").unwrap(), 123);
1523        assert_eq!(parse_issue_key("gh#1").unwrap(), 1);
1524        assert!(parse_issue_key("pr#123").is_err());
1525        assert!(parse_issue_key("123").is_err());
1526        assert!(parse_issue_key("gh#").is_err());
1527    }
1528
1529    #[test]
1530    fn test_parse_pr_key() {
1531        assert_eq!(parse_pr_key("pr#456").unwrap(), 456);
1532        assert_eq!(parse_pr_key("pr#1").unwrap(), 1);
1533        assert!(parse_pr_key("gh#123").is_err());
1534        assert!(parse_pr_key("456").is_err());
1535    }
1536
1537    #[test]
1538    fn test_parse_discussion_numeric_id_strips_prefixes() {
1539        // Regression for #188 bug #6/#18: Discussion.id returned by
1540        // get_discussions is prefixed. Callers feed it straight back
1541        // into create_merge_request_comment expecting it to thread.
1542        //
1543        // `get_discussions` actually emits three prefix shapes — the
1544        // `thread-` form covers multi-comment review threads and is
1545        // the one most skills pass back when they reply. All three
1546        // must decode to the numeric comment id.
1547        assert_eq!(
1548            parse_discussion_numeric_id("thread-3694869522"),
1549            Some(3694869522)
1550        );
1551        assert_eq!(
1552            parse_discussion_numeric_id("review-3694869522"),
1553            Some(3694869522)
1554        );
1555        assert_eq!(
1556            parse_discussion_numeric_id("comment-4147511088"),
1557            Some(4147511088)
1558        );
1559        // Raw numeric id passes through (forward compat).
1560        assert_eq!(parse_discussion_numeric_id("12345"), Some(12345));
1561        // Unknown prefix / non-numeric tail yields None — in_reply_to
1562        // stays unset and we fall back to a standalone comment rather
1563        // than panicking.
1564        assert_eq!(parse_discussion_numeric_id("weird-42"), None);
1565        assert_eq!(parse_discussion_numeric_id("review-notnumeric"), None);
1566        assert_eq!(parse_discussion_numeric_id(""), None);
1567    }
1568
1569    #[test]
1570    fn test_map_user() {
1571        let gh_user = GitHubUser {
1572            id: 123,
1573            login: "testuser".to_string(),
1574            name: Some("Test User".to_string()),
1575            email: Some("test@example.com".to_string()),
1576            avatar_url: Some("https://example.com/avatar.png".to_string()),
1577        };
1578
1579        let user = map_user(Some(&gh_user)).unwrap();
1580        assert_eq!(user.id, "123");
1581        assert_eq!(user.username, "testuser");
1582        assert_eq!(user.name, Some("Test User".to_string()));
1583        assert_eq!(user.email, Some("test@example.com".to_string()));
1584    }
1585
1586    #[test]
1587    fn test_map_user_none() {
1588        assert!(map_user(None).is_none());
1589    }
1590
1591    #[test]
1592    fn test_map_user_required_with_user() {
1593        let gh_user = GitHubUser {
1594            id: 1,
1595            login: "user1".to_string(),
1596            name: Some("User One".to_string()),
1597            email: None,
1598            avatar_url: None,
1599        };
1600        let user = map_user_required(Some(&gh_user));
1601        assert_eq!(user.username, "user1");
1602    }
1603
1604    #[test]
1605    fn test_map_user_required_without_user() {
1606        let user = map_user_required(None);
1607        assert_eq!(user.id, "unknown");
1608        assert_eq!(user.username, "unknown");
1609        assert_eq!(user.name, Some("Unknown".to_string()));
1610    }
1611
1612    #[test]
1613    fn test_map_labels() {
1614        let labels = vec![
1615            GitHubLabel {
1616                id: 1,
1617                name: "bug".to_string(),
1618                color: None,
1619                description: None,
1620            },
1621            GitHubLabel {
1622                id: 2,
1623                name: "feature".to_string(),
1624                color: Some("00ff00".to_string()),
1625                description: Some("Feature request".to_string()),
1626            },
1627        ];
1628        let result = map_labels(&labels);
1629        assert_eq!(result, vec!["bug", "feature"]);
1630    }
1631
1632    #[test]
1633    fn test_map_labels_empty() {
1634        let result = map_labels(&[]);
1635        assert!(result.is_empty());
1636    }
1637
1638    #[test]
1639    fn test_map_comment() {
1640        let gh_comment = GitHubComment {
1641            id: 42,
1642            body: "Nice work!".to_string(),
1643            user: Some(GitHubUser {
1644                id: 1,
1645                login: "reviewer".to_string(),
1646                name: None,
1647                email: None,
1648                avatar_url: None,
1649            }),
1650            created_at: "2024-01-15T10:00:00Z".to_string(),
1651            updated_at: Some("2024-01-15T12:00:00Z".to_string()),
1652        };
1653
1654        let comment = map_comment(&gh_comment);
1655        assert_eq!(comment.id, "42");
1656        assert_eq!(comment.body, "Nice work!");
1657        assert!(comment.author.is_some());
1658        assert_eq!(comment.author.unwrap().username, "reviewer");
1659        assert_eq!(comment.created_at, Some("2024-01-15T10:00:00Z".to_string()));
1660        assert_eq!(comment.updated_at, Some("2024-01-15T12:00:00Z".to_string()));
1661        assert!(comment.position.is_none());
1662    }
1663
1664    #[test]
1665    fn test_map_review_comment_with_line() {
1666        let gh_comment = GitHubReviewComment {
1667            id: 100,
1668            body: "Fix this".to_string(),
1669            user: Some(GitHubUser {
1670                id: 1,
1671                login: "reviewer".to_string(),
1672                name: None,
1673                email: None,
1674                avatar_url: None,
1675            }),
1676            created_at: "2024-01-15T10:00:00Z".to_string(),
1677            updated_at: None,
1678            path: "src/main.rs".to_string(),
1679            line: Some(42),
1680            original_line: None,
1681            position: None,
1682            side: Some("RIGHT".to_string()),
1683            diff_hunk: None,
1684            commit_id: Some("abc123".to_string()),
1685            original_commit_id: None,
1686            in_reply_to_id: None,
1687        };
1688
1689        let comment = map_review_comment(&gh_comment);
1690        assert_eq!(comment.id, "100");
1691        assert_eq!(comment.body, "Fix this");
1692        let pos = comment.position.unwrap();
1693        assert_eq!(pos.file_path, "src/main.rs");
1694        assert_eq!(pos.line, 42);
1695        assert_eq!(pos.line_type, "new");
1696        assert_eq!(pos.commit_sha, Some("abc123".to_string()));
1697    }
1698
1699    #[test]
1700    fn test_map_review_comment_with_left_side() {
1701        let gh_comment = GitHubReviewComment {
1702            id: 101,
1703            body: "Old code".to_string(),
1704            user: None,
1705            created_at: "2024-01-15T10:00:00Z".to_string(),
1706            updated_at: None,
1707            path: "src/lib.rs".to_string(),
1708            line: Some(10),
1709            original_line: None,
1710            position: None,
1711            side: Some("LEFT".to_string()),
1712            diff_hunk: None,
1713            commit_id: None,
1714            original_commit_id: Some("def456".to_string()),
1715            in_reply_to_id: None,
1716        };
1717
1718        let comment = map_review_comment(&gh_comment);
1719        let pos = comment.position.unwrap();
1720        assert_eq!(pos.line_type, "old");
1721        assert_eq!(pos.commit_sha, Some("def456".to_string()));
1722    }
1723
1724    #[test]
1725    fn test_map_review_comment_with_original_line_fallback() {
1726        let gh_comment = GitHubReviewComment {
1727            id: 102,
1728            body: "Outdated".to_string(),
1729            user: None,
1730            created_at: "2024-01-15T10:00:00Z".to_string(),
1731            updated_at: None,
1732            path: "src/lib.rs".to_string(),
1733            line: None,
1734            original_line: Some(5),
1735            position: None,
1736            side: None,
1737            diff_hunk: None,
1738            commit_id: None,
1739            original_commit_id: None,
1740            in_reply_to_id: None,
1741        };
1742
1743        let comment = map_review_comment(&gh_comment);
1744        let pos = comment.position.unwrap();
1745        assert_eq!(pos.line, 5);
1746        assert_eq!(pos.line_type, "new"); // default when no side
1747    }
1748
1749    #[test]
1750    fn test_map_review_comment_without_line() {
1751        let gh_comment = GitHubReviewComment {
1752            id: 103,
1753            body: "General".to_string(),
1754            user: None,
1755            created_at: "2024-01-15T10:00:00Z".to_string(),
1756            updated_at: None,
1757            path: "src/lib.rs".to_string(),
1758            line: None,
1759            original_line: None,
1760            position: None,
1761            side: None,
1762            diff_hunk: None,
1763            commit_id: None,
1764            original_commit_id: None,
1765            in_reply_to_id: None,
1766        };
1767
1768        let comment = map_review_comment(&gh_comment);
1769        assert!(comment.position.is_none());
1770    }
1771
1772    #[test]
1773    fn test_map_file() {
1774        let gh_file = GitHubFile {
1775            sha: "abc123".to_string(),
1776            filename: "src/main.rs".to_string(),
1777            status: "modified".to_string(),
1778            additions: 10,
1779            deletions: 3,
1780            changes: 13,
1781            patch: Some("@@ -1,3 +1,10 @@\n+new line".to_string()),
1782            previous_filename: None,
1783        };
1784
1785        let diff = map_file(&gh_file);
1786        assert_eq!(diff.file_path, "src/main.rs");
1787        assert!(!diff.new_file);
1788        assert!(!diff.deleted_file);
1789        assert!(!diff.renamed_file);
1790        assert_eq!(diff.additions, Some(10));
1791        assert_eq!(diff.deletions, Some(3));
1792        assert!(diff.diff.contains("+new line"));
1793    }
1794
1795    #[test]
1796    fn test_map_file_added() {
1797        let gh_file = GitHubFile {
1798            sha: "abc".to_string(),
1799            filename: "new_file.rs".to_string(),
1800            status: "added".to_string(),
1801            additions: 50,
1802            deletions: 0,
1803            changes: 50,
1804            patch: None,
1805            previous_filename: None,
1806        };
1807
1808        let diff = map_file(&gh_file);
1809        assert!(diff.new_file);
1810        assert!(!diff.deleted_file);
1811        assert!(diff.diff.is_empty());
1812    }
1813
1814    #[test]
1815    fn test_map_file_removed() {
1816        let gh_file = GitHubFile {
1817            sha: "abc".to_string(),
1818            filename: "old_file.rs".to_string(),
1819            status: "removed".to_string(),
1820            additions: 0,
1821            deletions: 30,
1822            changes: 30,
1823            patch: None,
1824            previous_filename: None,
1825        };
1826
1827        let diff = map_file(&gh_file);
1828        assert!(diff.deleted_file);
1829        assert!(!diff.new_file);
1830    }
1831
1832    #[test]
1833    fn test_map_file_renamed() {
1834        let gh_file = GitHubFile {
1835            sha: "abc".to_string(),
1836            filename: "new_name.rs".to_string(),
1837            status: "renamed".to_string(),
1838            additions: 0,
1839            deletions: 0,
1840            changes: 0,
1841            patch: None,
1842            previous_filename: Some("old_name.rs".to_string()),
1843        };
1844
1845        let diff = map_file(&gh_file);
1846        assert!(diff.renamed_file);
1847        assert_eq!(diff.old_path, Some("old_name.rs".to_string()));
1848    }
1849
1850    #[test]
1851    fn test_map_pull_request_with_full_data() {
1852        let pr = GitHubPullRequest {
1853            id: 1,
1854            number: 10,
1855            title: "Add feature".to_string(),
1856            body: Some("Description".to_string()),
1857            state: "open".to_string(),
1858            html_url: "https://github.com/test/repo/pull/10".to_string(),
1859            draft: false,
1860            merged: false,
1861            merged_at: None,
1862            user: Some(GitHubUser {
1863                id: 1,
1864                login: "author".to_string(),
1865                name: None,
1866                email: None,
1867                avatar_url: None,
1868            }),
1869            assignees: vec![GitHubUser {
1870                id: 2,
1871                login: "assignee".to_string(),
1872                name: Some("Assignee".to_string()),
1873                email: None,
1874                avatar_url: None,
1875            }],
1876            requested_reviewers: vec![GitHubUser {
1877                id: 3,
1878                login: "reviewer".to_string(),
1879                name: None,
1880                email: None,
1881                avatar_url: None,
1882            }],
1883            labels: vec![GitHubLabel {
1884                id: 1,
1885                name: "enhancement".to_string(),
1886                color: None,
1887                description: None,
1888            }],
1889            head: GitHubBranchRef {
1890                ref_name: "feature-branch".to_string(),
1891                sha: "abc123".to_string(),
1892            },
1893            base: GitHubBranchRef {
1894                ref_name: "main".to_string(),
1895                sha: "def456".to_string(),
1896            },
1897            created_at: "2024-01-01T00:00:00Z".to_string(),
1898            updated_at: "2024-01-02T00:00:00Z".to_string(),
1899        };
1900
1901        let mr = map_pull_request(&pr);
1902        assert_eq!(mr.key, "pr#10");
1903        assert_eq!(mr.title, "Add feature");
1904        assert_eq!(mr.description, Some("Description".to_string()));
1905        assert_eq!(mr.state, "open");
1906        assert_eq!(mr.source, "github");
1907        assert_eq!(mr.source_branch, "feature-branch");
1908        assert_eq!(mr.target_branch, "main");
1909        assert!(mr.author.is_some());
1910        assert_eq!(mr.assignees.len(), 1);
1911        assert_eq!(mr.assignees[0].username, "assignee");
1912        assert_eq!(mr.reviewers.len(), 1);
1913        assert_eq!(mr.reviewers[0].username, "reviewer");
1914        assert_eq!(mr.labels, vec!["enhancement"]);
1915        assert!(!mr.draft);
1916    }
1917
1918    #[test]
1919    fn test_map_pull_request_merged_at() {
1920        let pr = GitHubPullRequest {
1921            id: 1,
1922            number: 10,
1923            title: "Merged PR".to_string(),
1924            body: None,
1925            state: "closed".to_string(),
1926            html_url: "https://github.com/test/repo/pull/10".to_string(),
1927            draft: false,
1928            merged: false,
1929            merged_at: Some("2024-01-03T00:00:00Z".to_string()),
1930            user: None,
1931            assignees: vec![],
1932            requested_reviewers: vec![],
1933            labels: vec![],
1934            head: GitHubBranchRef {
1935                ref_name: "feature".to_string(),
1936                sha: "abc123".to_string(),
1937            },
1938            base: GitHubBranchRef {
1939                ref_name: "main".to_string(),
1940                sha: "def456".to_string(),
1941            },
1942            created_at: "2024-01-01T00:00:00Z".to_string(),
1943            updated_at: "2024-01-02T00:00:00Z".to_string(),
1944        };
1945
1946        let mr = map_pull_request(&pr);
1947        assert_eq!(mr.state, "merged");
1948    }
1949
1950    #[test]
1951    fn test_map_issue() {
1952        let gh_issue = GitHubIssue {
1953            id: 1,
1954            number: 42,
1955            title: "Test Issue".to_string(),
1956            body: Some("Issue body".to_string()),
1957            state: "open".to_string(),
1958            html_url: "https://github.com/test/repo/issues/42".to_string(),
1959            user: Some(GitHubUser {
1960                id: 1,
1961                login: "author".to_string(),
1962                name: None,
1963                email: None,
1964                avatar_url: None,
1965            }),
1966            assignees: vec![],
1967            labels: vec![GitHubLabel {
1968                id: 1,
1969                name: "bug".to_string(),
1970                color: None,
1971                description: None,
1972            }],
1973            created_at: "2024-01-01T00:00:00Z".to_string(),
1974            updated_at: "2024-01-02T00:00:00Z".to_string(),
1975            closed_at: None,
1976            pull_request: None,
1977        };
1978
1979        let issue = map_issue(&gh_issue);
1980        assert_eq!(issue.key, "gh#42");
1981        assert_eq!(issue.title, "Test Issue");
1982        assert_eq!(issue.state, "open");
1983        assert_eq!(issue.source, "github");
1984        assert_eq!(issue.labels, vec!["bug"]);
1985    }
1986
1987    #[test]
1988    fn test_map_issue_with_assignees() {
1989        let gh_issue = GitHubIssue {
1990            id: 1,
1991            number: 1,
1992            title: "Issue".to_string(),
1993            body: None,
1994            state: "open".to_string(),
1995            html_url: "https://github.com/test/repo/issues/1".to_string(),
1996            user: None,
1997            assignees: vec![
1998                GitHubUser {
1999                    id: 1,
2000                    login: "user1".to_string(),
2001                    name: None,
2002                    email: None,
2003                    avatar_url: None,
2004                },
2005                GitHubUser {
2006                    id: 2,
2007                    login: "user2".to_string(),
2008                    name: None,
2009                    email: None,
2010                    avatar_url: None,
2011                },
2012            ],
2013            labels: vec![],
2014            created_at: "2024-01-01T00:00:00Z".to_string(),
2015            updated_at: "2024-01-02T00:00:00Z".to_string(),
2016            closed_at: None,
2017            pull_request: None,
2018        };
2019
2020        let issue = map_issue(&gh_issue);
2021        assert_eq!(issue.assignees.len(), 2);
2022        assert_eq!(issue.assignees[0].username, "user1");
2023        assert_eq!(issue.assignees[1].username, "user2");
2024    }
2025
2026    #[test]
2027    fn test_map_pull_request_states() {
2028        let base_pr = || GitHubPullRequest {
2029            id: 1,
2030            number: 10,
2031            title: "Test PR".to_string(),
2032            body: None,
2033            state: "open".to_string(),
2034            html_url: "https://github.com/test/repo/pull/10".to_string(),
2035            draft: false,
2036            merged: false,
2037            merged_at: None,
2038            user: None,
2039            assignees: vec![],
2040            requested_reviewers: vec![],
2041            labels: vec![],
2042            head: GitHubBranchRef {
2043                ref_name: "feature".to_string(),
2044                sha: "abc123".to_string(),
2045            },
2046            base: GitHubBranchRef {
2047                ref_name: "main".to_string(),
2048                sha: "def456".to_string(),
2049            },
2050            created_at: "2024-01-01T00:00:00Z".to_string(),
2051            updated_at: "2024-01-02T00:00:00Z".to_string(),
2052        };
2053
2054        // Open PR
2055        let pr = map_pull_request(&base_pr());
2056        assert_eq!(pr.state, "open");
2057
2058        // Draft PR
2059        let mut draft_pr = base_pr();
2060        draft_pr.draft = true;
2061        let pr = map_pull_request(&draft_pr);
2062        assert_eq!(pr.state, "draft");
2063
2064        // Merged PR
2065        let mut merged_pr = base_pr();
2066        merged_pr.merged = true;
2067        let pr = map_pull_request(&merged_pr);
2068        assert_eq!(pr.state, "merged");
2069
2070        // Closed PR
2071        let mut closed_pr = base_pr();
2072        closed_pr.state = "closed".to_string();
2073        let pr = map_pull_request(&closed_pr);
2074        assert_eq!(pr.state, "closed");
2075    }
2076
2077    fn token(s: &str) -> SecretString {
2078        SecretString::from(s.to_string())
2079    }
2080
2081    #[test]
2082    fn test_repo_url() {
2083        let client =
2084            GitHubClient::with_base_url("https://api.github.com", "owner", "repo", token("token"));
2085        assert_eq!(
2086            client.repo_url("/issues"),
2087            "https://api.github.com/repos/owner/repo/issues"
2088        );
2089        assert_eq!(
2090            client.repo_url("/pulls/1"),
2091            "https://api.github.com/repos/owner/repo/pulls/1"
2092        );
2093    }
2094
2095    #[test]
2096    fn test_repo_url_strips_trailing_slash() {
2097        let client =
2098            GitHubClient::with_base_url("https://api.github.com/", "owner", "repo", token("token"));
2099        assert_eq!(
2100            client.repo_url("/issues"),
2101            "https://api.github.com/repos/owner/repo/issues"
2102        );
2103    }
2104
2105    #[test]
2106    fn test_provider_name() {
2107        let client = GitHubClient::new("owner", "repo", token("token"));
2108        assert_eq!(IssueProvider::provider_name(&client), "github");
2109        assert_eq!(MergeRequestProvider::provider_name(&client), "github");
2110    }
2111
2112    #[tokio::test]
2113    async fn test_run_pipeline_job_is_explicitly_unsupported() {
2114        let client = GitHubClient::new("owner", "repo", token("token"));
2115        let error = client
2116            .run_pipeline_job(devboy_core::RunPipelineJobInput {
2117                pipeline_id: "1".into(),
2118                job_id: "2".into(),
2119                variables: Default::default(),
2120                job_inputs: Default::default(),
2121            })
2122            .await
2123            .unwrap_err();
2124
2125        assert!(matches!(
2126            error,
2127            Error::ProviderUnsupported {
2128                ref provider,
2129                ref operation
2130            } if provider == "github" && operation == "run_pipeline_job"
2131        ));
2132    }
2133
2134    // =========================================================================
2135    // Integration tests with httpmock
2136    // =========================================================================
2137
2138    mod integration {
2139        use super::*;
2140        use httpmock::prelude::*;
2141
2142        fn create_test_client(server: &MockServer) -> GitHubClient {
2143            GitHubClient::with_base_url(server.base_url(), "owner", "repo", token("test-token"))
2144        }
2145
2146        fn sample_issue_json() -> serde_json::Value {
2147            serde_json::json!({
2148                "id": 1,
2149                "number": 42,
2150                "title": "Test Issue",
2151                "body": "Issue body",
2152                "state": "open",
2153                "html_url": "https://github.com/owner/repo/issues/42",
2154                "user": {"id": 1, "login": "author"},
2155                "assignees": [],
2156                "labels": [{"id": 1, "name": "bug"}],
2157                "created_at": "2024-01-01T00:00:00Z",
2158                "updated_at": "2024-01-02T00:00:00Z"
2159            })
2160        }
2161
2162        fn sample_pr_json() -> serde_json::Value {
2163            serde_json::json!({
2164                "id": 1,
2165                "number": 10,
2166                "title": "Test PR",
2167                "body": "PR body",
2168                "state": "open",
2169                "html_url": "https://github.com/owner/repo/pull/10",
2170                "draft": false,
2171                "merged": false,
2172                "user": {"id": 1, "login": "author"},
2173                "assignees": [],
2174                "requested_reviewers": [],
2175                "labels": [],
2176                "head": {"ref": "feature", "sha": "abc123"},
2177                "base": {"ref": "main", "sha": "def456"},
2178                "created_at": "2024-01-01T00:00:00Z",
2179                "updated_at": "2024-01-02T00:00:00Z"
2180            })
2181        }
2182
2183        #[tokio::test]
2184        async fn test_get_issues() {
2185            let server = MockServer::start();
2186
2187            server.mock(|when, then| {
2188                when.method(GET)
2189                    .path("/repos/owner/repo/issues")
2190                    .header("Authorization", "Bearer test-token");
2191                then.status(200)
2192                    .json_body(serde_json::json!([sample_issue_json()]));
2193            });
2194
2195            let client = create_test_client(&server);
2196            let issues = client
2197                .get_issues(IssueFilter {
2198                    state: Some("open".to_string()),
2199                    ..Default::default()
2200                })
2201                .await
2202                .unwrap()
2203                .items;
2204
2205            assert_eq!(issues.len(), 1);
2206            assert_eq!(issues[0].key, "gh#42");
2207            assert_eq!(issues[0].title, "Test Issue");
2208        }
2209
2210        #[tokio::test]
2211        async fn test_get_issues_filters_pull_requests() {
2212            let server = MockServer::start();
2213
2214            let mut pr_as_issue = sample_issue_json();
2215            pr_as_issue["pull_request"] = serde_json::json!({"url": "..."});
2216            pr_as_issue["number"] = serde_json::json!(99);
2217
2218            server.mock(|when, then| {
2219                when.method(GET).path("/repos/owner/repo/issues");
2220                then.status(200)
2221                    .json_body(serde_json::json!([sample_issue_json(), pr_as_issue]));
2222            });
2223
2224            let client = create_test_client(&server);
2225            let issues = client
2226                .get_issues(IssueFilter::default())
2227                .await
2228                .unwrap()
2229                .items;
2230
2231            // Only the real issue, not the PR
2232            assert_eq!(issues.len(), 1);
2233            assert_eq!(issues[0].key, "gh#42");
2234        }
2235
2236        #[tokio::test]
2237        async fn test_get_issues_with_all_filters() {
2238            let server = MockServer::start();
2239
2240            server.mock(|when, then| {
2241                when.method(GET)
2242                    .path("/repos/owner/repo/issues")
2243                    .query_param("state", "closed")
2244                    .query_param("labels", "bug,feature")
2245                    .query_param("assignee", "user1")
2246                    .query_param("per_page", "10")
2247                    .query_param("page", "2")
2248                    .query_param("sort", "created")
2249                    .query_param("direction", "asc");
2250                then.status(200).json_body(serde_json::json!([]));
2251            });
2252
2253            let client = create_test_client(&server);
2254            let issues = client
2255                .get_issues(IssueFilter {
2256                    state: Some("closed".to_string()),
2257                    labels: Some(vec!["bug".to_string(), "feature".to_string()]),
2258                    assignee: Some("user1".to_string()),
2259                    limit: Some(10),
2260                    offset: Some(10),
2261                    sort_by: Some("created_at".to_string()),
2262                    sort_order: Some("asc".to_string()),
2263                    ..Default::default()
2264                })
2265                .await
2266                .unwrap()
2267                .items;
2268
2269            assert!(issues.is_empty());
2270        }
2271
2272        #[tokio::test]
2273        async fn test_get_issue() {
2274            let server = MockServer::start();
2275
2276            server.mock(|when, then| {
2277                when.method(GET).path("/repos/owner/repo/issues/42");
2278                then.status(200).json_body(sample_issue_json());
2279            });
2280
2281            let client = create_test_client(&server);
2282            let issue = client.get_issue("gh#42").await.unwrap();
2283
2284            assert_eq!(issue.key, "gh#42");
2285            assert_eq!(issue.title, "Test Issue");
2286        }
2287
2288        #[tokio::test]
2289        async fn test_get_issue_rejects_pr() {
2290            let server = MockServer::start();
2291
2292            let mut issue_json = sample_issue_json();
2293            issue_json["pull_request"] = serde_json::json!({"url": "..."});
2294
2295            server.mock(|when, then| {
2296                when.method(GET).path("/repos/owner/repo/issues/42");
2297                then.status(200).json_body(issue_json);
2298            });
2299
2300            let client = create_test_client(&server);
2301            let result = client.get_issue("gh#42").await;
2302            assert!(result.is_err());
2303        }
2304
2305        #[tokio::test]
2306        async fn test_create_issue() {
2307            let server = MockServer::start();
2308
2309            server.mock(|when, then| {
2310                when.method(POST)
2311                    .path("/repos/owner/repo/issues")
2312                    .body_includes("\"title\":\"New Issue\"");
2313                then.status(201).json_body(sample_issue_json());
2314            });
2315
2316            let client = create_test_client(&server);
2317            let issue = client
2318                .create_issue(CreateIssueInput {
2319                    title: "New Issue".to_string(),
2320                    description: Some("Body".to_string()),
2321                    labels: vec!["bug".to_string()],
2322                    ..Default::default()
2323                })
2324                .await
2325                .unwrap();
2326
2327            assert_eq!(issue.key, "gh#42");
2328        }
2329
2330        #[tokio::test]
2331        async fn test_update_issue() {
2332            let server = MockServer::start();
2333
2334            server.mock(|when, then| {
2335                when.method(PATCH)
2336                    .path("/repos/owner/repo/issues/42")
2337                    .body_includes("\"state\":\"closed\"");
2338                then.status(200).json_body(sample_issue_json());
2339            });
2340
2341            let client = create_test_client(&server);
2342            let issue = client
2343                .update_issue(
2344                    "gh#42",
2345                    UpdateIssueInput {
2346                        state: Some("closed".to_string()),
2347                        ..Default::default()
2348                    },
2349                )
2350                .await
2351                .unwrap();
2352
2353            assert_eq!(issue.key, "gh#42");
2354        }
2355
2356        #[tokio::test]
2357        async fn test_update_issue_state_mapping() {
2358            let server = MockServer::start();
2359
2360            server.mock(|when, then| {
2361                when.method(PATCH)
2362                    .path("/repos/owner/repo/issues/42")
2363                    .body_includes("\"state\":\"open\"");
2364                then.status(200).json_body(sample_issue_json());
2365            });
2366
2367            let client = create_test_client(&server);
2368            let result = client
2369                .update_issue(
2370                    "gh#42",
2371                    UpdateIssueInput {
2372                        state: Some("opened".to_string()),
2373                        ..Default::default()
2374                    },
2375                )
2376                .await;
2377
2378            assert!(result.is_ok());
2379        }
2380
2381        #[tokio::test]
2382        async fn test_get_comments() {
2383            let server = MockServer::start();
2384
2385            server.mock(|when, then| {
2386                when.method(GET)
2387                    .path("/repos/owner/repo/issues/42/comments");
2388                then.status(200).json_body(serde_json::json!([{
2389                    "id": 1,
2390                    "body": "Comment text",
2391                    "user": {"id": 1, "login": "commenter"},
2392                    "created_at": "2024-01-15T10:00:00Z"
2393                }]));
2394            });
2395
2396            let client = create_test_client(&server);
2397            let comments = client.get_comments("gh#42").await.unwrap().items;
2398
2399            assert_eq!(comments.len(), 1);
2400            assert_eq!(comments[0].body, "Comment text");
2401        }
2402
2403        #[tokio::test]
2404        async fn test_add_comment() {
2405            let server = MockServer::start();
2406
2407            server.mock(|when, then| {
2408                when.method(POST)
2409                    .path("/repos/owner/repo/issues/42/comments")
2410                    .body_includes("\"body\":\"My comment\"");
2411                then.status(201).json_body(serde_json::json!({
2412                    "id": 1,
2413                    "body": "My comment",
2414                    "user": {"id": 1, "login": "me"},
2415                    "created_at": "2024-01-15T10:00:00Z"
2416                }));
2417            });
2418
2419            let client = create_test_client(&server);
2420            let comment = IssueProvider::add_comment(&client, "gh#42", "My comment")
2421                .await
2422                .unwrap();
2423
2424            assert_eq!(comment.body, "My comment");
2425        }
2426
2427        #[tokio::test]
2428        async fn test_get_pull_request() {
2429            let server = MockServer::start();
2430
2431            server.mock(|when, then| {
2432                when.method(GET).path("/repos/owner/repo/pulls/10");
2433                then.status(200).json_body(sample_pr_json());
2434            });
2435
2436            let client = create_test_client(&server);
2437            let mr = client.get_merge_request("pr#10").await.unwrap();
2438
2439            assert_eq!(mr.key, "pr#10");
2440            assert_eq!(mr.title, "Test PR");
2441            assert_eq!(mr.source_branch, "feature");
2442            assert_eq!(mr.target_branch, "main");
2443        }
2444
2445        #[tokio::test]
2446        async fn test_get_pull_requests() {
2447            let server = MockServer::start();
2448
2449            server.mock(|when, then| {
2450                when.method(GET).path("/repos/owner/repo/pulls");
2451                then.status(200)
2452                    .json_body(serde_json::json!([sample_pr_json()]));
2453            });
2454
2455            let client = create_test_client(&server);
2456            let mrs = client
2457                .get_merge_requests(MrFilter::default())
2458                .await
2459                .unwrap()
2460                .items;
2461
2462            assert_eq!(mrs.len(), 1);
2463            assert_eq!(mrs[0].key, "pr#10");
2464        }
2465
2466        #[tokio::test]
2467        async fn test_get_pull_requests_with_filters() {
2468            let server = MockServer::start();
2469
2470            server.mock(|when, then| {
2471                when.method(GET)
2472                    .path("/repos/owner/repo/pulls")
2473                    .query_param("state", "closed")
2474                    .query_param("head", "feature")
2475                    .query_param("base", "main")
2476                    .query_param("per_page", "5");
2477                then.status(200).json_body(serde_json::json!([]));
2478            });
2479
2480            let client = create_test_client(&server);
2481            let mrs = client
2482                .get_merge_requests(MrFilter {
2483                    state: Some("closed".to_string()),
2484                    source_branch: Some("feature".to_string()),
2485                    target_branch: Some("main".to_string()),
2486                    limit: Some(5),
2487                    ..Default::default()
2488                })
2489                .await
2490                .unwrap()
2491                .items;
2492
2493            assert!(mrs.is_empty());
2494        }
2495
2496        #[tokio::test]
2497        async fn test_get_pull_requests_merged_filter() {
2498            let server = MockServer::start();
2499
2500            let mut merged_pr = sample_pr_json();
2501            merged_pr["merged"] = serde_json::json!(true);
2502            merged_pr["state"] = serde_json::json!("closed");
2503
2504            let open_pr = sample_pr_json();
2505
2506            server.mock(|when, then| {
2507                when.method(GET)
2508                    .path("/repos/owner/repo/pulls")
2509                    .query_param("state", "closed");
2510                then.status(200)
2511                    .json_body(serde_json::json!([merged_pr, open_pr]));
2512            });
2513
2514            let client = create_test_client(&server);
2515            let mrs = client
2516                .get_merge_requests(MrFilter {
2517                    state: Some("merged".to_string()),
2518                    ..Default::default()
2519                })
2520                .await
2521                .unwrap()
2522                .items;
2523
2524            // Only merged PRs returned
2525            assert_eq!(mrs.len(), 1);
2526            assert_eq!(mrs[0].state, "merged");
2527        }
2528
2529        #[tokio::test]
2530        async fn test_get_discussions() {
2531            let server = MockServer::start();
2532
2533            // Reviews
2534            server.mock(|when, then| {
2535                when.method(GET).path("/repos/owner/repo/pulls/10/reviews");
2536                then.status(200).json_body(serde_json::json!([{
2537                    "id": 1,
2538                    "user": {"id": 1, "login": "reviewer"},
2539                    "body": "LGTM",
2540                    "state": "APPROVED",
2541                    "submitted_at": "2024-01-15T10:00:00Z"
2542                }]));
2543            });
2544
2545            // Review comments
2546            server.mock(|when, then| {
2547                when.method(GET).path("/repos/owner/repo/pulls/10/comments");
2548                then.status(200).json_body(serde_json::json!([{
2549                    "id": 100,
2550                    "body": "Fix this line",
2551                    "user": {"id": 2, "login": "reviewer2"},
2552                    "created_at": "2024-01-15T11:00:00Z",
2553                    "path": "src/main.rs",
2554                    "line": 42,
2555                    "side": "RIGHT"
2556                }]));
2557            });
2558
2559            // Issue comments
2560            server.mock(|when, then| {
2561                when.method(GET)
2562                    .path("/repos/owner/repo/issues/10/comments");
2563                then.status(200).json_body(serde_json::json!([{
2564                    "id": 200,
2565                    "body": "General comment",
2566                    "user": {"id": 3, "login": "user3"},
2567                    "created_at": "2024-01-15T12:00:00Z"
2568                }]));
2569            });
2570
2571            let client = create_test_client(&server);
2572            let discussions = client.get_discussions("pr#10").await.unwrap().items;
2573
2574            // 1 review comment thread + 1 review + 1 general comment = 3
2575            assert_eq!(discussions.len(), 3);
2576        }
2577
2578        #[tokio::test]
2579        async fn test_get_discussions_pages_past_first_page() {
2580            // Regression (DEV-5447): without per_page/page GitHub returns the
2581            // first 30 items and the result LOOKS complete. A full page (100)
2582            // must trigger fetching the next one.
2583            let server = MockServer::start();
2584
2585            server.mock(|when, then| {
2586                when.method(GET).path("/repos/owner/repo/pulls/10/reviews");
2587                then.status(200).json_body(serde_json::json!([]));
2588            });
2589            let comment = |i: u64| {
2590                serde_json::json!({
2591                    "id": i,
2592                    "body": format!("comment {i}"),
2593                    "created_at": "2024-01-15T11:00:00Z",
2594                    "path": "src/main.rs"
2595                })
2596            };
2597            let page1: Vec<_> = (0..100).map(comment).collect();
2598            let page2: Vec<_> = (100..105).map(comment).collect();
2599            let m1 = server.mock(|when, then| {
2600                when.method(GET)
2601                    .path("/repos/owner/repo/pulls/10/comments")
2602                    .query_param("per_page", "100")
2603                    .query_param("page", "1");
2604                then.status(200).json_body(serde_json::json!(page1));
2605            });
2606            let m2 = server.mock(|when, then| {
2607                when.method(GET)
2608                    .path("/repos/owner/repo/pulls/10/comments")
2609                    .query_param("per_page", "100")
2610                    .query_param("page", "2");
2611                then.status(200).json_body(serde_json::json!(page2));
2612            });
2613            server.mock(|when, then| {
2614                when.method(GET)
2615                    .path("/repos/owner/repo/issues/10/comments");
2616                then.status(200).json_body(serde_json::json!([]));
2617            });
2618
2619            let client = create_test_client(&server);
2620            let discussions = client.get_discussions("pr#10").await.unwrap().items;
2621
2622            // Comments have distinct ids and no in_reply_to → one thread each.
2623            assert_eq!(discussions.len(), 105, "both pages merged");
2624            m1.assert();
2625            m2.assert();
2626        }
2627
2628        #[tokio::test]
2629        async fn test_get_diffs() {
2630            let server = MockServer::start();
2631
2632            server.mock(|when, then| {
2633                when.method(GET).path("/repos/owner/repo/pulls/10/files");
2634                then.status(200).json_body(serde_json::json!([{
2635                    "sha": "abc123",
2636                    "filename": "src/main.rs",
2637                    "status": "modified",
2638                    "additions": 10,
2639                    "deletions": 3,
2640                    "changes": 13,
2641                    "patch": "@@ +new code"
2642                }]));
2643            });
2644
2645            let client = create_test_client(&server);
2646            let diffs = client.get_diffs("pr#10").await.unwrap().items;
2647
2648            assert_eq!(diffs.len(), 1);
2649            assert_eq!(diffs[0].file_path, "src/main.rs");
2650            assert_eq!(diffs[0].additions, Some(10));
2651        }
2652
2653        #[tokio::test]
2654        async fn test_add_mr_comment_general() {
2655            let server = MockServer::start();
2656
2657            // PR lookup
2658            server.mock(|when, then| {
2659                when.method(GET).path("/repos/owner/repo/pulls/10");
2660                then.status(200).json_body(sample_pr_json());
2661            });
2662
2663            // Create comment
2664            server.mock(|when, then| {
2665                when.method(POST)
2666                    .path("/repos/owner/repo/issues/10/comments");
2667                then.status(201).json_body(serde_json::json!({
2668                    "id": 1,
2669                    "body": "General comment",
2670                    "user": {"id": 1, "login": "me"},
2671                    "created_at": "2024-01-15T10:00:00Z"
2672                }));
2673            });
2674
2675            let client = create_test_client(&server);
2676            let comment = MergeRequestProvider::add_comment(
2677                &client,
2678                "pr#10",
2679                CreateCommentInput {
2680                    body: "General comment".to_string(),
2681                    position: None,
2682                    discussion_id: None,
2683                },
2684            )
2685            .await
2686            .unwrap();
2687
2688            assert_eq!(comment.body, "General comment");
2689        }
2690
2691        #[tokio::test]
2692        async fn test_add_mr_comment_inline() {
2693            let server = MockServer::start();
2694
2695            // PR lookup
2696            server.mock(|when, then| {
2697                when.method(GET).path("/repos/owner/repo/pulls/10");
2698                then.status(200).json_body(sample_pr_json());
2699            });
2700
2701            // Create review comment
2702            server.mock(|when, then| {
2703                when.method(POST)
2704                    .path("/repos/owner/repo/pulls/10/comments")
2705                    .body_includes("\"path\":\"src/main.rs\"")
2706                    .body_includes("\"line\":42");
2707                then.status(201).json_body(serde_json::json!({
2708                    "id": 1,
2709                    "body": "Inline comment",
2710                    "user": {"id": 1, "login": "me"},
2711                    "created_at": "2024-01-15T10:00:00Z",
2712                    "path": "src/main.rs",
2713                    "line": 42,
2714                    "side": "RIGHT"
2715                }));
2716            });
2717
2718            let client = create_test_client(&server);
2719            let comment = MergeRequestProvider::add_comment(
2720                &client,
2721                "pr#10",
2722                CreateCommentInput {
2723                    body: "Inline comment".to_string(),
2724                    position: Some(CodePosition {
2725                        file_path: "src/main.rs".to_string(),
2726                        line: 42,
2727                        line_type: "new".to_string(),
2728                        commit_sha: Some("abc123".to_string()),
2729                    }),
2730                    discussion_id: None,
2731                },
2732            )
2733            .await
2734            .unwrap();
2735
2736            assert_eq!(comment.body, "Inline comment");
2737        }
2738
2739        #[tokio::test]
2740        async fn test_handle_response_401() {
2741            let server = MockServer::start();
2742
2743            server.mock(|when, then| {
2744                when.method(GET).path("/repos/owner/repo/issues");
2745                then.status(401).body("Bad credentials");
2746            });
2747
2748            let client = create_test_client(&server);
2749            let result = client.get_issues(IssueFilter::default()).await;
2750
2751            assert!(result.is_err());
2752            let err = result.unwrap_err();
2753            assert!(matches!(err, Error::Unauthorized(_)));
2754        }
2755
2756        #[tokio::test]
2757        async fn test_handle_response_404() {
2758            let server = MockServer::start();
2759
2760            server.mock(|when, then| {
2761                when.method(GET).path("/repos/owner/repo/issues/999");
2762                then.status(404).body("Not Found");
2763            });
2764
2765            let client = create_test_client(&server);
2766            let result = client.get_issue("gh#999").await;
2767
2768            assert!(result.is_err());
2769            let err = result.unwrap_err();
2770            assert!(matches!(err, Error::NotFound(_)));
2771        }
2772
2773        #[tokio::test]
2774        async fn test_handle_response_500() {
2775            let server = MockServer::start();
2776
2777            server.mock(|when, then| {
2778                when.method(GET).path("/repos/owner/repo/issues");
2779                then.status(500).body("Internal Server Error");
2780            });
2781
2782            let client = create_test_client(&server);
2783            let result = client.get_issues(IssueFilter::default()).await;
2784
2785            assert!(result.is_err());
2786            let err = result.unwrap_err();
2787            assert!(matches!(err, Error::ServerError { .. }));
2788        }
2789
2790        #[tokio::test]
2791        async fn test_get_current_user() {
2792            let server = MockServer::start();
2793
2794            server.mock(|when, then| {
2795                when.method(GET).path("/user");
2796                then.status(200).json_body(serde_json::json!({
2797                    "id": 1,
2798                    "login": "testuser",
2799                    "name": "Test User",
2800                    "email": "test@example.com"
2801                }));
2802            });
2803
2804            let client = create_test_client(&server);
2805            let user = client.get_current_user().await.unwrap();
2806
2807            assert_eq!(user.username, "testuser");
2808            assert_eq!(user.name, Some("Test User".to_string()));
2809        }
2810
2811        // =====================================================================
2812        // Pipeline tests
2813        // =====================================================================
2814
2815        fn sample_workflow_run_json() -> serde_json::Value {
2816            serde_json::json!({
2817                "id": 100,
2818                "name": "CI",
2819                "status": "completed",
2820                "conclusion": "failure",
2821                "head_branch": "feat/test",
2822                "head_sha": "abc123def456",
2823                "html_url": "https://github.com/owner/repo/actions/runs/100",
2824                "run_started_at": "2024-01-01T00:00:00Z",
2825                "updated_at": "2024-01-01T00:01:00Z"
2826            })
2827        }
2828
2829        fn sample_jobs_json() -> serde_json::Value {
2830            serde_json::json!({
2831                "jobs": [
2832                    {
2833                        "id": 201,
2834                        "name": "Build",
2835                        "status": "completed",
2836                        "conclusion": "success",
2837                        "html_url": "https://github.com/owner/repo/actions/runs/100/job/201",
2838                        "started_at": "2024-01-01T00:00:00Z",
2839                        "completed_at": "2024-01-01T00:00:30Z"
2840                    },
2841                    {
2842                        "id": 202,
2843                        "name": "Test",
2844                        "status": "completed",
2845                        "conclusion": "failure",
2846                        "html_url": "https://github.com/owner/repo/actions/runs/100/job/202",
2847                        "started_at": "2024-01-01T00:00:00Z",
2848                        "completed_at": "2024-01-01T00:00:45Z"
2849                    }
2850                ]
2851            })
2852        }
2853
2854        #[tokio::test]
2855        async fn test_get_pipeline_by_branch() {
2856            let server = MockServer::start();
2857
2858            // Mock: completed runs for branch
2859            server.mock(|when, then| {
2860                when.method(GET)
2861                    .path("/repos/owner/repo/actions/runs")
2862                    .query_param("branch", "main")
2863                    .query_param("status", "completed");
2864                then.status(200).json_body(serde_json::json!({
2865                    "workflow_runs": [sample_workflow_run_json()]
2866                }));
2867            });
2868
2869            // Mock: in-progress runs (empty)
2870            server.mock(|when, then| {
2871                when.method(GET)
2872                    .path("/repos/owner/repo/actions/runs")
2873                    .query_param("status", "in_progress");
2874                then.status(200)
2875                    .json_body(serde_json::json!({ "workflow_runs": [] }));
2876            });
2877
2878            // Mock: jobs
2879            server.mock(|when, then| {
2880                when.method(GET)
2881                    .path("/repos/owner/repo/actions/runs/100/jobs");
2882                then.status(200).json_body(sample_jobs_json());
2883            });
2884
2885            // Mock: failed job log
2886            server.mock(|when, then| {
2887                when.method(GET)
2888                    .path("/repos/owner/repo/actions/jobs/202/logs");
2889                then.status(200)
2890                    .body("Step 1\nerror: test failed\nStep 3\n");
2891            });
2892
2893            let client = create_test_client(&server);
2894            let input = devboy_core::GetPipelineInput {
2895                branch: Some("main".into()),
2896                mr_key: None,
2897                include_failed_logs: true,
2898            };
2899
2900            let result = client.get_pipeline(input).await.unwrap();
2901
2902            assert_eq!(result.id, "100");
2903            assert_eq!(result.status, PipelineStatus::Failed);
2904            assert_eq!(result.reference, "main");
2905            assert_eq!(result.summary.total, 2);
2906            assert_eq!(result.summary.success, 1);
2907            assert_eq!(result.summary.failed, 1);
2908            assert_eq!(result.stages.len(), 1);
2909            assert_eq!(result.stages[0].name, "CI");
2910            assert_eq!(result.stages[0].jobs.len(), 2);
2911            assert_eq!(result.failed_jobs.len(), 1);
2912            assert_eq!(result.failed_jobs[0].name, "Test");
2913            assert!(result.failed_jobs[0].error_snippet.is_some());
2914        }
2915
2916        #[tokio::test]
2917        async fn test_get_pipeline_by_mr_key() {
2918            let server = MockServer::start();
2919
2920            // Mock: get PR to resolve branch
2921            server.mock(|when, then| {
2922                when.method(GET).path("/repos/owner/repo/pulls/42");
2923                then.status(200).json_body(sample_pr_json());
2924            });
2925
2926            // Mock: completed runs
2927            server.mock(|when, then| {
2928                when.method(GET)
2929                    .path("/repos/owner/repo/actions/runs")
2930                    .query_param("status", "completed");
2931                then.status(200).json_body(serde_json::json!({
2932                    "workflow_runs": [sample_workflow_run_json()]
2933                }));
2934            });
2935
2936            // Mock: in-progress runs
2937            server.mock(|when, then| {
2938                when.method(GET)
2939                    .path("/repos/owner/repo/actions/runs")
2940                    .query_param("status", "in_progress");
2941                then.status(200)
2942                    .json_body(serde_json::json!({ "workflow_runs": [] }));
2943            });
2944
2945            // Mock: jobs
2946            server.mock(|when, then| {
2947                when.method(GET)
2948                    .path("/repos/owner/repo/actions/runs/100/jobs");
2949                then.status(200).json_body(sample_jobs_json());
2950            });
2951
2952            let client = create_test_client(&server);
2953            let input = devboy_core::GetPipelineInput {
2954                branch: None,
2955                mr_key: Some("pr#42".into()),
2956                include_failed_logs: false,
2957            };
2958
2959            let result = client.get_pipeline(input).await.unwrap();
2960            assert_eq!(result.id, "100");
2961        }
2962
2963        #[tokio::test]
2964        async fn test_get_job_logs_smart_mode() {
2965            let server = MockServer::start();
2966
2967            server.mock(|when, then| {
2968                when.method(GET)
2969                    .path("/repos/owner/repo/actions/jobs/202/logs");
2970                then.status(200)
2971                    .body("Building...\nCompiling...\nerror: cannot find module 'foo'\nDone.\n");
2972            });
2973
2974            let client = create_test_client(&server);
2975            let options = devboy_core::JobLogOptions {
2976                mode: devboy_core::JobLogMode::Smart,
2977            };
2978
2979            let result = client.get_job_logs("202", options).await.unwrap();
2980            assert_eq!(result.job_id, "202");
2981            assert_eq!(result.mode, "smart");
2982            assert!(result.content.contains("cannot find module"));
2983        }
2984
2985        #[tokio::test]
2986        async fn test_get_job_logs_search_mode() {
2987            let server = MockServer::start();
2988
2989            server.mock(|when, then| {
2990                when.method(GET)
2991                    .path("/repos/owner/repo/actions/jobs/202/logs");
2992                then.status(200)
2993                    .body("Line 1\nLine 2\nERROR: something broke\nLine 4\nLine 5\n");
2994            });
2995
2996            let client = create_test_client(&server);
2997            let options = devboy_core::JobLogOptions {
2998                mode: devboy_core::JobLogMode::Search {
2999                    pattern: "ERROR".into(),
3000                    context: 1,
3001                    max_matches: 5,
3002                },
3003            };
3004
3005            let result = client.get_job_logs("202", options).await.unwrap();
3006            assert_eq!(result.mode, "search");
3007            assert!(result.content.contains("ERROR: something broke"));
3008            assert!(result.content.contains("Match at line 3"));
3009        }
3010
3011        #[tokio::test]
3012        async fn test_get_job_logs_paginated_mode() {
3013            let server = MockServer::start();
3014
3015            server.mock(|when, then| {
3016                when.method(GET)
3017                    .path("/repos/owner/repo/actions/jobs/202/logs");
3018                then.status(200)
3019                    .body("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n");
3020            });
3021
3022            let client = create_test_client(&server);
3023            let options = devboy_core::JobLogOptions {
3024                mode: devboy_core::JobLogMode::Paginated {
3025                    offset: 1,
3026                    limit: 2,
3027                },
3028            };
3029
3030            let result = client.get_job_logs("202", options).await.unwrap();
3031            assert_eq!(result.mode, "paginated");
3032            assert!(result.content.contains("Line 2"));
3033            assert!(result.content.contains("Line 3"));
3034            assert!(!result.content.contains("Line 1"));
3035            assert!(!result.content.contains("Line 4"));
3036        }
3037
3038        // =================================================================
3039        // Attachment tests (Phase 2)
3040        // =================================================================
3041
3042        #[tokio::test]
3043        async fn test_get_issue_attachments_parses_body_and_comments() {
3044            let server = MockServer::start();
3045
3046            server.mock(|when, then| {
3047                when.method(GET).path("/repos/owner/repo/issues/42");
3048                then.status(200).json_body(serde_json::json!({
3049                    "id": 1,
3050                    "number": 42,
3051                    "title": "bug",
3052                    "body": "Error: ![screen](https://user-images.githubusercontent.com/1/screen.png)",
3053                    "state": "open",
3054                    "html_url": "https://github.com/owner/repo/issues/42",
3055                    "created_at": "2024-01-01T00:00:00Z",
3056                    "updated_at": "2024-01-02T00:00:00Z"
3057                }));
3058            });
3059            server.mock(|when, then| {
3060                when.method(GET)
3061                    .path("/repos/owner/repo/issues/42/comments");
3062                then.status(200).json_body(serde_json::json!([
3063                    {
3064                        "id": 10,
3065                        "body": "Log [here](https://user-images.githubusercontent.com/1/log.txt)",
3066                        "html_url": "https://github.com/owner/repo/issues/42#issuecomment-10",
3067                        "created_at": "2024-01-03T00:00:00Z",
3068                        "updated_at": "2024-01-03T00:00:00Z"
3069                    }
3070                ]));
3071            });
3072
3073            let client = create_test_client(&server);
3074            let attachments = client.get_issue_attachments("gh#42").await.unwrap();
3075            assert_eq!(attachments.len(), 2);
3076            assert_eq!(attachments[0].filename, "screen");
3077            assert_eq!(attachments[1].filename, "here");
3078        }
3079
3080        #[tokio::test]
3081        async fn test_download_attachment_fetches_url() {
3082            let server = MockServer::start();
3083
3084            server.mock(|when, then| {
3085                when.method(GET).path("/cdn/file.txt");
3086                then.status(200).body("github-bytes");
3087            });
3088
3089            let client = create_test_client(&server);
3090            let url = format!("{}/cdn/file.txt", server.base_url());
3091            let bytes = client.download_attachment("gh#42", &url).await.unwrap();
3092            assert_eq!(bytes, b"github-bytes");
3093        }
3094
3095        #[tokio::test]
3096        async fn test_github_asset_capabilities() {
3097            let server = MockServer::start();
3098            let client = create_test_client(&server);
3099            let caps = client.asset_capabilities();
3100            assert!(!caps.issue.upload, "GitHub has no public upload API");
3101            assert!(caps.issue.download);
3102            assert!(caps.issue.list);
3103            assert!(!caps.issue.delete);
3104            assert!(!caps.merge_request.upload);
3105            assert!(caps.merge_request.download);
3106        }
3107    }
3108
3109    // =========================================================================
3110    // Pipeline utility unit tests
3111    // =========================================================================
3112
3113    #[test]
3114    fn test_map_gh_status() {
3115        assert_eq!(
3116            map_gh_status(Some("completed"), Some("success")),
3117            PipelineStatus::Success
3118        );
3119        assert_eq!(
3120            map_gh_status(Some("completed"), Some("failure")),
3121            PipelineStatus::Failed
3122        );
3123        assert_eq!(
3124            map_gh_status(Some("in_progress"), None),
3125            PipelineStatus::Running
3126        );
3127        assert_eq!(map_gh_status(Some("queued"), None), PipelineStatus::Pending);
3128        assert_eq!(
3129            map_gh_status(Some("completed"), Some("cancelled")),
3130            PipelineStatus::Canceled
3131        );
3132        assert_eq!(map_gh_status(None, None), PipelineStatus::Unknown);
3133    }
3134
3135    #[test]
3136    fn test_strip_ansi() {
3137        assert_eq!(strip_ansi("\x1b[31merror\x1b[0m"), "error");
3138        assert_eq!(strip_ansi("no ansi here"), "no ansi here");
3139        assert_eq!(strip_ansi("\x1b[1m\x1b[32mgreen\x1b[0m"), "green");
3140    }
3141
3142    #[test]
3143    fn test_extract_errors_finds_patterns() {
3144        let log = "Step 1: build\nStep 2: test\nerror: test failed at line 42\nStep 4: done\n";
3145        let result = extract_errors(log, 10).unwrap();
3146        assert!(result.contains("error: test failed"));
3147    }
3148
3149    #[test]
3150    fn test_extract_errors_fallback_to_tail() {
3151        let log = "Line 1\nLine 2\nLine 3\n";
3152        let result = extract_errors(log, 10).unwrap();
3153        assert!(result.contains("Line 3"));
3154    }
3155
3156    #[test]
3157    fn test_extract_errors_empty_log() {
3158        assert!(extract_errors("", 10).is_none());
3159    }
3160
3161    #[test]
3162    fn test_estimate_duration() {
3163        let d = estimate_duration(Some("2024-01-01T00:00:00Z"), Some("2024-01-01T00:01:30Z"));
3164        assert_eq!(d, Some(90));
3165    }
3166
3167    #[test]
3168    fn test_estimate_duration_invalid() {
3169        assert!(estimate_duration(None, Some("2024-01-01T00:00:00Z")).is_none());
3170        assert!(estimate_duration(Some("not-a-date"), Some("2024-01-01T00:00:00Z")).is_none());
3171    }
3172}