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