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