1use crate::client::HttpClient;
44use crate::frontmatter::FrontmatterBuilder;
45use crate::types::{Markdown, MarkdownError};
46use async_trait::async_trait;
47use chrono::{DateTime, Utc};
48use serde::Deserialize;
49use std::collections::HashMap;
50use url::Url as ParsedUrl;
51
52const DEFAULT_GITHUB_API_BASE_URL: &str = "https://api.github.com";
54
55const GITHUB_API_VERSION: &str = "application/vnd.github.v3+json";
57
58const USER_AGENT_PREFIX: &str = "markdowndown";
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum ResourceType {
64 Issue,
66 PullRequest,
68}
69
70impl ResourceType {
71 pub fn api_path(&self) -> &'static str {
73 match self {
74 ResourceType::Issue => "issues",
75 ResourceType::PullRequest => "issues", }
77 }
78
79 pub fn as_str(&self) -> &'static str {
81 match self {
82 ResourceType::Issue => "issue",
83 ResourceType::PullRequest => "pull_request",
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
90pub struct GitHubResource {
91 pub owner: String,
93 pub repo: String,
95 pub number: u32,
97 pub resource_type: ResourceType,
99 pub original_url: String,
101}
102
103#[derive(Debug, Clone, Deserialize)]
105pub struct Issue {
106 pub id: u64,
108 pub number: u32,
110 pub title: String,
112 #[serde(default)]
114 pub body: Option<String>,
115 pub state: String,
117 pub user: User,
119 pub created_at: DateTime<Utc>,
121 pub updated_at: DateTime<Utc>,
123 #[serde(default)]
125 pub labels: Vec<Label>,
126 pub pull_request: Option<PullRequestRef>,
128}
129
130#[derive(Debug, Clone, Deserialize)]
132pub struct User {
133 pub login: String,
135 pub id: u64,
137}
138
139#[derive(Debug, Clone, Deserialize)]
141pub struct Label {
142 pub name: String,
144 pub color: String,
146}
147
148#[derive(Debug, Clone, Deserialize)]
150pub struct PullRequestRef {
151 pub url: String,
153 pub html_url: String,
155}
156
157#[derive(Debug, Clone, Deserialize)]
159pub struct Comment {
160 pub id: u64,
162 #[serde(default)]
164 pub body: Option<String>,
165 pub user: User,
167 pub created_at: DateTime<Utc>,
169 pub updated_at: DateTime<Utc>,
171}
172
173#[derive(Debug, Clone, Deserialize)]
175pub struct Reaction {
176 pub content: String,
178 pub user: User,
180}
181
182#[derive(Debug, Clone, Default)]
184pub struct ReactionCounts {
185 pub counts: HashMap<String, u32>,
187}
188
189impl ReactionCounts {
190 pub fn from_reactions(reactions: &[Reaction]) -> Self {
192 let mut counts = HashMap::new();
193 for reaction in reactions {
194 *counts.entry(reaction.content.clone()).or_insert(0) += 1;
195 }
196 Self { counts }
197 }
198
199 pub fn format(&self) -> String {
201 if self.counts.is_empty() {
202 return String::new();
203 }
204
205 let formatted: Vec<String> = self
206 .counts
207 .iter()
208 .map(|(emoji, count)| {
209 let display_emoji = match emoji.as_str() {
210 "+1" => "👍",
211 "-1" => "👎",
212 "laugh" => "😄",
213 "confused" => "😕",
214 "heart" => "❤️",
215 "hooray" => "🎉",
216 "rocket" => "🚀",
217 "eyes" => "👀",
218 _ => emoji,
219 };
220 format!("{display_emoji} {count}")
221 })
222 .collect();
223
224 formatted.join(" | ")
225 }
226}
227
228#[derive(Debug, Clone)]
234pub struct GitHubConverter {
235 client: HttpClient,
237 auth_token: Option<String>,
239 api_base_url: String,
241}
242
243impl GitHubConverter {
244 pub fn new() -> Self {
257 Self {
258 client: HttpClient::new(),
259 auth_token: None,
260 api_base_url: DEFAULT_GITHUB_API_BASE_URL.to_string(),
261 }
262 }
263
264 pub fn new_with_token(token: String) -> Self {
282 Self {
283 client: HttpClient::new(),
284 auth_token: Some(token),
285 api_base_url: DEFAULT_GITHUB_API_BASE_URL.to_string(),
286 }
287 }
288
289 pub fn new_with_config(token: Option<String>, api_base_url: String) -> Self {
308 Self {
309 client: HttpClient::new(),
310 auth_token: token,
311 api_base_url,
312 }
313 }
314
315 pub fn from_env() -> Self {
329 match std::env::var("GITHUB_TOKEN") {
330 Ok(token) if !token.trim().is_empty() => Self::new_with_token(token),
331 _ => Self::new(),
332 }
333 }
334
335 pub async fn convert(&self, url: &str) -> Result<Markdown, MarkdownError> {
378 let resource = self.parse_github_url(url)?;
380
381 let (issue, comments) = self.fetch_issue_and_comments(&resource).await?;
383
384 self.create_markdown_document(&resource, &issue, &comments)
386 }
387
388 async fn fetch_issue_and_comments(
390 &self,
391 resource: &GitHubResource,
392 ) -> Result<(Issue, Vec<Comment>), MarkdownError> {
393 let issue_future = self.fetch_issue(&resource.owner, &resource.repo, resource.number);
394 let comments_future = self.fetch_comments(&resource.owner, &resource.repo, resource.number);
395
396 let (issue, comments) = tokio::try_join!(issue_future, comments_future)?;
398 Ok((issue, comments))
399 }
400
401 fn create_markdown_document(
403 &self,
404 resource: &GitHubResource,
405 issue: &Issue,
406 comments: &[Comment],
407 ) -> Result<Markdown, MarkdownError> {
408 let content = self.render_markdown(issue, comments);
410
411 let frontmatter = self.build_frontmatter(resource, issue)?;
413
414 let markdown_with_frontmatter = format!("{frontmatter}\n{content}");
416
417 Markdown::new(markdown_with_frontmatter)
418 }
419
420 pub fn parse_github_url(&self, url: &str) -> Result<GitHubResource, MarkdownError> {
444 let parsed_url = ParsedUrl::parse(url.trim()).map_err(|_| MarkdownError::InvalidUrl {
445 url: url.to_string(),
446 })?;
447
448 let host = parsed_url
450 .host_str()
451 .ok_or_else(|| MarkdownError::InvalidUrl {
452 url: url.to_string(),
453 })?;
454
455 if host != "github.com" {
456 return Err(MarkdownError::InvalidUrl {
457 url: url.to_string(),
458 });
459 }
460
461 let path = parsed_url.path();
463 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
464
465 if segments.len() != 4 {
466 return Err(MarkdownError::InvalidUrl {
467 url: url.to_string(),
468 });
469 }
470
471 let owner = segments[0].to_string();
472 let repo = segments[1].to_string();
473 let resource_type_str = segments[2];
474 let number_str = segments[3];
475
476 let resource_type = match resource_type_str {
478 "issues" => ResourceType::Issue,
479 "pull" => ResourceType::PullRequest,
480 _ => {
481 return Err(MarkdownError::InvalidUrl {
482 url: url.to_string(),
483 })
484 }
485 };
486
487 let number = number_str
489 .parse::<u32>()
490 .map_err(|_| MarkdownError::InvalidUrl {
491 url: url.to_string(),
492 })?;
493
494 Ok(GitHubResource {
495 owner,
496 repo,
497 number,
498 resource_type,
499 original_url: url.to_string(),
500 })
501 }
502
503 pub async fn fetch_issue(
505 &self,
506 owner: &str,
507 repo: &str,
508 number: u32,
509 ) -> Result<Issue, MarkdownError> {
510 let url = format!(
511 "{}/repos/{}/{}/issues/{}",
512 self.api_base_url, owner, repo, number
513 );
514
515 let response_text = self.make_api_request(&url).await?;
516
517 serde_json::from_str::<Issue>(&response_text).map_err(|e| MarkdownError::ParseError {
518 message: format!("Failed to parse GitHub issue response: {e}"),
519 })
520 }
521
522 pub async fn fetch_comments(
524 &self,
525 owner: &str,
526 repo: &str,
527 number: u32,
528 ) -> Result<Vec<Comment>, MarkdownError> {
529 let url = format!(
530 "{}/repos/{}/{}/issues/{}/comments",
531 self.api_base_url, owner, repo, number
532 );
533
534 let response_text = self.make_api_request(&url).await?;
535
536 serde_json::from_str::<Vec<Comment>>(&response_text).map_err(|e| {
537 MarkdownError::ParseError {
538 message: format!("Failed to parse GitHub comments response: {e}"),
539 }
540 })
541 }
542
543 async fn make_api_request(&self, url: &str) -> Result<String, MarkdownError> {
545 let mut headers = HashMap::new();
547 headers.insert(
548 "User-Agent".to_string(),
549 format!("{USER_AGENT_PREFIX}/{}", env!("CARGO_PKG_VERSION")),
550 );
551 headers.insert("Accept".to_string(), GITHUB_API_VERSION.to_string());
552
553 if let Some(ref token) = self.auth_token {
555 headers.insert("Authorization".to_string(), format!("token {token}"));
556 }
557
558 match self.client.get_text_with_headers(url, &headers).await {
560 Ok(response) => Ok(response),
561 Err(MarkdownError::AuthError { message }) => {
562 Err(MarkdownError::AuthError {
563 message: format!("GitHub API authentication failed: {message}. Consider setting GITHUB_TOKEN environment variable.")
564 })
565 }
566 Err(MarkdownError::NetworkError { message }) => {
567 if message.contains("403") {
568 Err(MarkdownError::AuthError {
569 message: "GitHub API rate limit exceeded or access denied. Consider setting GITHUB_TOKEN environment variable.".to_string()
570 })
571 } else if message.contains("404") {
572 Err(MarkdownError::NetworkError {
573 message: "GitHub issue/repository not found or not accessible.".to_string()
574 })
575 } else {
576 Err(MarkdownError::NetworkError { message })
577 }
578 }
579 Err(e) => Err(e),
580 }
581 }
582
583 fn render_markdown(&self, issue: &Issue, comments: &[Comment]) -> String {
585 let mut markdown = String::new();
586
587 markdown.push_str(&format!("# {}\n\n", issue.title));
589
590 markdown.push_str(&format!("**Author:** @{} \n", issue.user.login));
592 markdown.push_str(&format!(
593 "**Created:** {} \n",
594 issue.created_at.format("%Y-%m-%d %H:%M:%S UTC")
595 ));
596 markdown.push_str(&format!(
597 "**State:** {} \n",
598 self.capitalize_first(&issue.state)
599 ));
600
601 if !issue.labels.is_empty() {
603 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
604 markdown.push_str(&format!("**Labels:** {} \n", labels.join(", ")));
605 }
606
607 markdown.push('\n');
608
609 if let Some(ref body) = issue.body {
611 if !body.trim().is_empty() {
612 markdown.push_str(body.trim());
613 markdown.push_str("\n\n");
614 }
615 }
616
617 if !comments.is_empty() {
619 markdown.push_str("## Comments\n\n");
620
621 for comment in comments {
622 markdown.push_str(&format!(
623 "### Comment by @{} ({})\n\n",
624 comment.user.login,
625 comment.created_at.format("%Y-%m-%d %H:%M:%S UTC")
626 ));
627
628 if let Some(ref body) = comment.body {
629 if !body.trim().is_empty() {
630 markdown.push_str(body.trim());
631 markdown.push('\n');
632 }
633 }
634
635 markdown.push('\n');
638 }
639 }
640
641 markdown.trim().to_string()
642 }
643
644 fn build_frontmatter(
646 &self,
647 resource: &GitHubResource,
648 issue: &Issue,
649 ) -> Result<String, MarkdownError> {
650 let now = Utc::now();
651 let mut builder = FrontmatterBuilder::new(resource.original_url.clone())
652 .exporter(format!("markdowndown-github-{}", env!("CARGO_PKG_VERSION")))
653 .download_date(now)
654 .additional_field("title".to_string(), issue.title.clone())
655 .additional_field("url".to_string(), resource.original_url.clone())
656 .additional_field("converter".to_string(), "GitHubIssueConverter".to_string())
657 .additional_field("converted_at".to_string(), now.to_rfc3339())
658 .additional_field("conversion_type".to_string(), "github".to_string())
659 .additional_field("github_issue_number".to_string(), issue.number.to_string())
660 .additional_field(
661 "github_repository".to_string(),
662 format!("{}/{}", resource.owner, resource.repo),
663 )
664 .additional_field("github_state".to_string(), issue.state.clone())
665 .additional_field("github_author".to_string(), issue.user.login.clone())
666 .additional_field(
667 "resource_type".to_string(),
668 resource.resource_type.as_str().to_string(),
669 );
670
671 if !issue.labels.is_empty() {
673 let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
674 builder = builder.additional_field("github_labels".to_string(), labels.join(", "));
675 }
676
677 builder.build()
678 }
679
680 fn capitalize_first(&self, s: &str) -> String {
682 let mut chars = s.chars();
683 match chars.next() {
684 None => String::new(),
685 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
686 }
687 }
688}
689
690impl Default for GitHubConverter {
691 fn default() -> Self {
692 Self::new()
693 }
694}
695
696#[async_trait]
697impl super::Converter for GitHubConverter {
698 async fn convert(&self, url: &str) -> Result<Markdown, MarkdownError> {
699 self.convert(url).await
700 }
701
702 fn name(&self) -> &'static str {
703 "GitHub Issue"
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 fn create_test_user(login: &str, id: u64) -> User {
713 User {
714 login: login.to_string(),
715 id,
716 }
717 }
718
719 fn create_test_issue(
721 number: u32,
722 title: &str,
723 body: Option<&str>,
724 state: &str,
725 user_login: &str,
726 labels: Vec<Label>,
727 ) -> Issue {
728 Issue {
729 id: 123456789, number,
731 title: title.to_string(),
732 body: body.map(|s| s.to_string()),
733 state: state.to_string(),
734 user: create_test_user(user_login, 1),
735 created_at: DateTime::parse_from_rfc3339("2023-01-15T10:00:00Z")
736 .unwrap()
737 .with_timezone(&Utc),
738 updated_at: DateTime::parse_from_rfc3339("2023-01-15T10:00:00Z")
739 .unwrap()
740 .with_timezone(&Utc),
741 labels,
742 pull_request: None,
743 }
744 }
745
746 fn create_test_comment(
748 id: u64,
749 body: Option<&str>,
750 user_login: &str,
751 created_at: &str,
752 ) -> Comment {
753 Comment {
754 id,
755 body: body.map(|s| s.to_string()),
756 user: create_test_user(user_login, id),
757 created_at: DateTime::parse_from_rfc3339(created_at)
758 .unwrap()
759 .with_timezone(&Utc),
760 updated_at: DateTime::parse_from_rfc3339(created_at)
761 .unwrap()
762 .with_timezone(&Utc),
763 }
764 }
765
766 fn create_test_label(name: &str, color: &str) -> Label {
768 Label {
769 name: name.to_string(),
770 color: color.to_string(),
771 }
772 }
773
774 #[test]
775 fn test_resource_type_api_path() {
776 assert_eq!(ResourceType::Issue.api_path(), "issues");
777 assert_eq!(ResourceType::PullRequest.api_path(), "issues");
778 }
779
780 #[test]
781 fn test_resource_type_as_str() {
782 assert_eq!(ResourceType::Issue.as_str(), "issue");
783 assert_eq!(ResourceType::PullRequest.as_str(), "pull_request");
784 }
785
786 #[test]
787 fn test_github_converter_new() {
788 let converter = GitHubConverter::new();
789 assert!(converter.auth_token.is_none());
790 assert_eq!(converter.api_base_url, DEFAULT_GITHUB_API_BASE_URL);
791 }
792
793 #[test]
794 fn test_github_converter_with_token() {
795 let token = "ghp_test_token".to_string();
796 let converter = GitHubConverter::new_with_token(token.clone());
797 assert_eq!(converter.auth_token, Some(token));
798 }
799
800 #[test]
801 fn test_parse_github_issue_url() {
802 let converter = GitHubConverter::new();
803 let url = "https://github.com/microsoft/vscode/issues/12345";
804 let result = converter.parse_github_url(url).unwrap();
805
806 assert_eq!(result.owner, "microsoft");
807 assert_eq!(result.repo, "vscode");
808 assert_eq!(result.number, 12345);
809 assert_eq!(result.resource_type, ResourceType::Issue);
810 assert_eq!(result.original_url, url);
811 }
812
813 #[test]
814 fn test_parse_github_pull_request_url() {
815 let converter = GitHubConverter::new();
816 let url = "https://github.com/rust-lang/rust/pull/98765";
817 let result = converter.parse_github_url(url).unwrap();
818
819 assert_eq!(result.owner, "rust-lang");
820 assert_eq!(result.repo, "rust");
821 assert_eq!(result.number, 98765);
822 assert_eq!(result.resource_type, ResourceType::PullRequest);
823 assert_eq!(result.original_url, url);
824 }
825
826 #[test]
827 fn test_parse_invalid_github_urls() {
828 let converter = GitHubConverter::new();
829
830 let invalid_urls = [
831 "https://example.com/user/repo/issues/123", "https://github.com/user/repo", "https://github.com/user/repo/issues", "https://github.com/user/repo/issues/abc", "https://github.com/user/repo/commits/123", "not-a-url", ];
838
839 for url in &invalid_urls {
840 let result = converter.parse_github_url(url);
841 assert!(result.is_err(), "Should fail for URL: {url}");
842 }
843 }
844
845 #[test]
846 fn test_reaction_counts_empty() {
847 let reactions = vec![];
848 let counts = ReactionCounts::from_reactions(&reactions);
849 assert!(counts.counts.is_empty());
850 assert_eq!(counts.format(), "");
851 }
852
853 #[test]
854 fn test_reaction_counts_with_reactions() {
855 let user = create_test_user("test", 1);
856 let reactions = vec![
857 Reaction {
858 content: "+1".to_string(),
859 user: user.clone(),
860 },
861 Reaction {
862 content: "+1".to_string(),
863 user: user.clone(),
864 },
865 Reaction {
866 content: "laugh".to_string(),
867 user: user.clone(),
868 },
869 ];
870
871 let counts = ReactionCounts::from_reactions(&reactions);
872 assert_eq!(counts.counts.get("+1"), Some(&2));
873 assert_eq!(counts.counts.get("laugh"), Some(&1));
874
875 let formatted = counts.format();
876 assert!(formatted.contains("👍 2"));
877 assert!(formatted.contains("😄 1"));
878 }
879
880 #[test]
881 fn test_capitalize_first() {
882 let converter = GitHubConverter::new();
883 assert_eq!(converter.capitalize_first("hello"), "Hello");
884 assert_eq!(converter.capitalize_first("WORLD"), "WORLD");
885 assert_eq!(converter.capitalize_first(""), "");
886 assert_eq!(converter.capitalize_first("a"), "A");
887 }
888
889 #[test]
890 fn test_render_markdown_basic() {
891 let converter = GitHubConverter::new();
892
893 let issue = create_test_issue(
894 123,
895 "Test Issue",
896 Some("This is a test issue body."),
897 "open",
898 "testuser",
899 vec![],
900 );
901
902 let comments = vec![];
903 let markdown = converter.render_markdown(&issue, &comments);
904
905 assert!(markdown.contains("# Test Issue"));
906 assert!(markdown.contains("**Author:** @testuser"));
907 assert!(markdown.contains("**State:** Open"));
908 assert!(markdown.contains("This is a test issue body."));
909 }
910
911 #[test]
912 fn test_render_markdown_with_comments() {
913 let converter = GitHubConverter::new();
914
915 let issue = create_test_issue(
916 123,
917 "Test Issue",
918 Some("Issue body"),
919 "closed",
920 "author",
921 vec![],
922 );
923
924 let comments = vec![
925 create_test_comment(
926 1,
927 Some("First comment"),
928 "commenter1",
929 "2023-01-15T11:00:00Z",
930 ),
931 create_test_comment(
932 2,
933 Some("Second comment"),
934 "commenter2",
935 "2023-01-15T12:00:00Z",
936 ),
937 ];
938
939 let markdown = converter.render_markdown(&issue, &comments);
940
941 assert!(markdown.contains("## Comments"));
942 assert!(markdown.contains("### Comment by @commenter1"));
943 assert!(markdown.contains("First comment"));
944 assert!(markdown.contains("### Comment by @commenter2"));
945 assert!(markdown.contains("Second comment"));
946 }
947
948 #[test]
949 fn test_render_markdown_with_labels() {
950 let converter = GitHubConverter::new();
951
952 let issue = create_test_issue(
953 123,
954 "Bug Report",
955 Some("Found a bug"),
956 "open",
957 "reporter",
958 vec![
959 create_test_label("bug", "d73a49"),
960 create_test_label("help wanted", "008672"),
961 ],
962 );
963
964 let comments = vec![];
965 let markdown = converter.render_markdown(&issue, &comments);
966
967 assert!(markdown.contains("**Labels:** bug, help wanted"));
968 }
969
970 #[test]
971 fn test_default_implementation() {
972 let converter = GitHubConverter::default();
973 assert!(converter.auth_token.is_none());
974 }
975
976 #[test]
978 fn test_parse_github_url_with_fragments() {
979 let converter = GitHubConverter::new();
980 let url = "https://github.com/owner/repo/issues/123#issuecomment-456";
981 let result = converter.parse_github_url(url).unwrap();
982
983 assert_eq!(result.owner, "owner");
984 assert_eq!(result.repo, "repo");
985 assert_eq!(result.number, 123);
986 assert_eq!(result.resource_type, ResourceType::Issue);
987 }
988
989 #[test]
990 fn test_parse_github_url_with_query_params() {
991 let converter = GitHubConverter::new();
992 let url = "https://github.com/owner/repo/pull/456?tab=files";
993 let result = converter.parse_github_url(url).unwrap();
994
995 assert_eq!(result.owner, "owner");
996 assert_eq!(result.repo, "repo");
997 assert_eq!(result.number, 456);
998 assert_eq!(result.resource_type, ResourceType::PullRequest);
999 }
1000
1001 #[test]
1002 fn test_render_markdown_empty_body() {
1003 let converter = GitHubConverter::new();
1004
1005 let issue = create_test_issue(123, "Empty Issue", None, "open", "user", vec![]);
1006
1007 let comments = vec![];
1008 let markdown = converter.render_markdown(&issue, &comments);
1009
1010 assert!(markdown.contains("# Empty Issue"));
1011 assert!(markdown.contains("**Author:** @user"));
1012 assert!(!markdown.contains("## Comments")); }
1015}