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 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 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 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 fn repo_url(&self, endpoint: &str) -> String {
161 format!(
162 "{}/repos/{}/{}{}",
163 self.base_url, self.owner, self.repo, endpoint
164 )
165 }
166}
167
168fn 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 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, 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 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#[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 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 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 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 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 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 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 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 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 if let Some(state) = &filter.state {
536 let gh_state = match state.as_str() {
537 "opened" | "open" => "open",
538 "closed" => "closed",
539 "merged" => "closed", "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 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 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 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 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, resolved_by: None,
617 comments: mapped_comments,
618 position,
619 });
620 }
621
622 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 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 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 let pr: GitHubPullRequest = pr_result?;
688
689 if let Some(position) = &input.position {
691 let url = self.repo_url(&format!("/pulls/{}/comments", number));
692
693 let commit_sha = if let Some(sha) = &position.commit_sha {
695 sha.clone()
696 } else {
697 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 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 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 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 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 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 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 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 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
890const 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
905async 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
945fn 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 if url_host == "api.github.com" || url_host == "github.com" {
956 return true;
957 }
958 let (_base_scheme, base_host) = split_scheme_host(base_url);
960 url_host == base_host
961}
962
963fn 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 for trusted in GITHUB_TRUSTED_HOSTS {
976 if url_host == *trusted || url_host.ends_with(&format!(".{trusted}")) {
977 return true;
978 }
979 }
980
981 let (_base_scheme, base_host) = split_scheme_host(base_url);
983 url_host == base_host
984}
985
986fn 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
996fn 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; }
1008 if host.ends_with("githubusercontent.com") {
1010 return true;
1011 }
1012 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 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#[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#[derive(Debug, Deserialize)]
1079struct GhWorkflowRuns {
1080 workflow_runs: Vec<GhWorkflowRun>,
1081}
1082
1083#[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#[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
1123fn 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 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
1143fn 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 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 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 let branch = if let Some(ref mr_key) = input.mr_key {
1209 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 "main".to_string()
1219 };
1220
1221 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 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 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 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 let mut summary = PipelineSummary {
1256 total: gh_jobs.jobs.len() as u32,
1257 ..Default::default()
1258 };
1259
1260 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 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 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 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(®ex::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
1446fn 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
1457fn 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
1464fn 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 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 assert_eq!(parse_discussion_numeric_id("12345"), Some(12345));
1534 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"); }
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 let pr = map_pull_request(&base_pr());
2029 assert_eq!(pr.state, "open");
2030
2031 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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: ",
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 #[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}