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