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