Skip to main content

markdowndown/converters/
github.rs

1//! GitHub Issues and Pull Requests to markdown conversion with REST API integration.
2//!
3//! This module provides conversion of GitHub issues and pull requests to markdown format
4//! by leveraging the GitHub REST API. It handles issue fetching, comment retrieval,
5//! authentication, and proper markdown rendering.
6//!
7//! # Supported URLs
8//!
9//! - Issues: `https://github.com/{owner}/{repo}/issues/{number}`
10//! - Pull Requests: `https://github.com/{owner}/{repo}/pull/{number}`
11//!
12//! # Usage Examples
13//!
14//! ## Basic Conversion (Public Repository)
15//!
16//! ```rust
17//! use markdowndown::converters::GitHubConverter;
18//!
19//! # async fn example() -> Result<(), markdowndown::types::MarkdownError> {
20//! let converter = GitHubConverter::new();
21//! let url = "https://github.com/microsoft/vscode/issues/1234";
22//! let markdown = converter.convert(url).await?;
23//! println!("Markdown content: {}", markdown);
24//! # Ok(())
25//! # }
26//! ```
27//!
28//! ## Authenticated Conversion (Private Repository)
29//!
30//! ```rust
31//! use markdowndown::converters::GitHubConverter;
32//!
33//! # async fn example() -> Result<(), markdowndown::types::MarkdownError> {
34//! let token = std::env::var("GITHUB_TOKEN").unwrap();
35//! let converter = GitHubConverter::new_with_token(token);
36//! let url = "https://github.com/private/repo/issues/42";
37//! let markdown = converter.convert(url).await?;
38//! println!("Markdown content: {}", markdown);
39//! # Ok(())
40//! # }
41//! ```
42
43use 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
52/// Default GitHub API base URL
53const DEFAULT_GITHUB_API_BASE_URL: &str = "https://api.github.com";
54
55/// GitHub API version header value
56const GITHUB_API_VERSION: &str = "application/vnd.github.v3+json";
57
58/// User-Agent string prefix for GitHub API requests
59const USER_AGENT_PREFIX: &str = "markdowndown";
60
61/// GitHub resource types supported for conversion.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum ResourceType {
64    /// GitHub issue
65    Issue,
66    /// GitHub pull request
67    PullRequest,
68}
69
70impl ResourceType {
71    /// Returns the API path segment for this resource type.
72    pub fn api_path(&self) -> &'static str {
73        match self {
74            ResourceType::Issue => "issues",
75            ResourceType::PullRequest => "issues", // PRs use same API endpoint as issues
76        }
77    }
78
79    /// Returns the string representation for frontmatter.
80    pub fn as_str(&self) -> &'static str {
81        match self {
82            ResourceType::Issue => "issue",
83            ResourceType::PullRequest => "pull_request",
84        }
85    }
86}
87
88/// Represents a parsed GitHub URL with metadata.
89#[derive(Debug, Clone)]
90pub struct GitHubResource {
91    /// Repository owner (user or organization)
92    pub owner: String,
93    /// Repository name
94    pub repo: String,
95    /// Issue or PR number
96    pub number: u32,
97    /// Type of resource (issue or pull request)
98    pub resource_type: ResourceType,
99    /// Original URL for reference
100    pub original_url: String,
101}
102
103/// GitHub issue or pull request data from API.
104#[derive(Debug, Clone, Deserialize)]
105pub struct Issue {
106    /// Issue ID (GitHub's internal identifier)
107    pub id: u64,
108    /// Issue number
109    pub number: u32,
110    /// Issue title
111    pub title: String,
112    /// Issue body content (markdown)
113    #[serde(default)]
114    pub body: Option<String>,
115    /// Issue state (open, closed)
116    pub state: String,
117    /// User who created the issue
118    pub user: User,
119    /// Issue creation timestamp
120    pub created_at: DateTime<Utc>,
121    /// Issue update timestamp
122    pub updated_at: DateTime<Utc>,
123    /// Issue labels
124    #[serde(default)]
125    pub labels: Vec<Label>,
126    /// Whether this is a pull request
127    pub pull_request: Option<PullRequestRef>,
128}
129
130/// GitHub user information.
131#[derive(Debug, Clone, Deserialize)]
132pub struct User {
133    /// Username
134    pub login: String,
135    /// User ID
136    pub id: u64,
137}
138
139/// GitHub label information.
140#[derive(Debug, Clone, Deserialize)]
141pub struct Label {
142    /// Label name
143    pub name: String,
144    /// Label color (hex)
145    pub color: String,
146}
147
148/// Reference to pull request data (indicates if issue is actually a PR).
149#[derive(Debug, Clone, Deserialize)]
150pub struct PullRequestRef {
151    /// PR URL
152    pub url: String,
153    /// PR HTML URL
154    pub html_url: String,
155}
156
157/// GitHub comment data from API.
158#[derive(Debug, Clone, Deserialize)]
159pub struct Comment {
160    /// Comment ID
161    pub id: u64,
162    /// Comment body content (markdown)
163    #[serde(default)]
164    pub body: Option<String>,
165    /// User who created the comment
166    pub user: User,
167    /// Comment creation timestamp
168    pub created_at: DateTime<Utc>,
169    /// Comment update timestamp
170    pub updated_at: DateTime<Utc>,
171}
172
173/// GitHub reaction data.
174#[derive(Debug, Clone, Deserialize)]
175pub struct Reaction {
176    /// Reaction content (emoji shortcode)
177    pub content: String,
178    /// User who made the reaction
179    pub user: User,
180}
181
182/// Aggregated reaction counts.
183#[derive(Debug, Clone, Default)]
184pub struct ReactionCounts {
185    /// Reaction emoji to count mapping
186    pub counts: HashMap<String, u32>,
187}
188
189impl ReactionCounts {
190    /// Creates reaction counts from a list of reactions.
191    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    /// Formats reaction counts as a string for display.
200    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/// GitHub to markdown converter with REST API integration and authentication.
229///
230/// This converter handles GitHub issues and pull requests by fetching data
231/// via the GitHub REST API and rendering it as markdown with complete
232/// metadata and comment history.
233#[derive(Debug, Clone)]
234pub struct GitHubConverter {
235    /// HTTP client for making requests to GitHub API
236    client: HttpClient,
237    /// Optional GitHub personal access token for authentication
238    auth_token: Option<String>,
239    /// Base URL for GitHub API (allows for GitHub Enterprise)
240    api_base_url: String,
241}
242
243impl GitHubConverter {
244    /// Creates a new GitHub converter without authentication.
245    ///
246    /// This converter can only access public repositories and is subject
247    /// to lower rate limits (60 requests per hour per IP).
248    ///
249    /// # Examples
250    ///
251    /// ```rust
252    /// use markdowndown::converters::GitHubConverter;
253    ///
254    /// let converter = GitHubConverter::new();
255    /// ```
256    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    /// Creates a new GitHub converter with authentication token.
265    ///
266    /// This converter can access private repositories and has higher
267    /// rate limits (5000 requests per hour per token).
268    ///
269    /// # Arguments
270    ///
271    /// * `token` - GitHub personal access token
272    ///
273    /// # Examples
274    ///
275    /// ```rust
276    /// use markdowndown::converters::GitHubConverter;
277    ///
278    /// let token = "ghp_xxxxxxxxxxxxxxxxxxxx".to_string();
279    /// let converter = GitHubConverter::new_with_token(token);
280    /// ```
281    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    /// Creates a GitHub converter with custom API base URL (primarily for testing).
290    ///
291    /// # Arguments
292    ///
293    /// * `token` - Optional GitHub personal access token
294    /// * `api_base_url` - Custom API base URL (e.g., for testing with mock servers)
295    ///
296    /// # Examples
297    ///
298    /// ```rust
299    /// use markdowndown::converters::GitHubConverter;
300    ///
301    /// // Create converter with mock server URL for testing
302    /// let converter = GitHubConverter::new_with_config(
303    ///     Some("token".to_string()),
304    ///     "http://localhost:8080".to_string()
305    /// );
306    /// ```
307    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    /// Creates a GitHub converter with authentication from environment variable.
316    ///
317    /// Looks for the GITHUB_TOKEN environment variable and uses it for authentication.
318    /// Falls back to unauthenticated mode if the variable is not set.
319    ///
320    /// # Examples
321    ///
322    /// ```rust
323    /// use markdowndown::converters::GitHubConverter;
324    ///
325    /// // Set GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx in environment
326    /// let converter = GitHubConverter::from_env();
327    /// ```
328    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    /// Converts a GitHub issue or pull request URL to markdown with frontmatter.
336    ///
337    /// This method performs the complete conversion workflow:
338    /// 1. Parse and validate the GitHub URL
339    /// 2. Fetch issue/PR data from GitHub API
340    /// 3. Fetch all comments and reactions
341    /// 4. Render issue and comments as markdown
342    /// 5. Generate frontmatter with metadata
343    /// 6. Combine frontmatter with content
344    ///
345    /// # Arguments
346    ///
347    /// * `url` - The GitHub issue or pull request URL to convert
348    ///
349    /// # Returns
350    ///
351    /// Returns a `Markdown` instance containing the issue/PR content with frontmatter,
352    /// or a `MarkdownError` on failure.
353    ///
354    /// # Errors
355    ///
356    /// * `MarkdownError::InvalidUrl` - If the URL format is invalid
357    /// * `MarkdownError::AuthError` - If authentication is required but not provided
358    /// * `MarkdownError::NetworkError` - For API errors, rate limiting, or network failures
359    /// * `MarkdownError::ParseError` - If API response parsing fails
360    ///
361    /// # Examples
362    ///
363    /// ```rust
364    /// use markdowndown::converters::GitHubConverter;
365    ///
366    /// # async fn example() -> Result<(), markdowndown::types::MarkdownError> {
367    /// let converter = GitHubConverter::new();
368    /// let url = "https://github.com/microsoft/vscode/issues/1234";
369    /// let markdown = converter.convert(url).await?;
370    ///
371    /// // The result includes frontmatter with metadata
372    /// assert!(markdown.as_str().contains("source_url:"));
373    /// assert!(markdown.as_str().contains("github_issue_number:"));
374    /// # Ok(())
375    /// # }
376    /// ```
377    pub async fn convert(&self, url: &str) -> Result<Markdown, MarkdownError> {
378        // Step 1: Parse and validate the GitHub URL
379        let resource = self.parse_github_url(url)?;
380
381        // Step 2-3: Fetch issue/PR data and comments from GitHub API
382        let (issue, comments) = self.fetch_issue_and_comments(&resource).await?;
383
384        // Step 4-6: Render content and create final markdown
385        self.create_markdown_document(&resource, &issue, &comments)
386    }
387
388    /// Fetches issue/PR data and comments in parallel for better performance.
389    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        // Fetch both concurrently
397        let (issue, comments) = tokio::try_join!(issue_future, comments_future)?;
398        Ok((issue, comments))
399    }
400
401    /// Creates the final markdown document with frontmatter and content.
402    fn create_markdown_document(
403        &self,
404        resource: &GitHubResource,
405        issue: &Issue,
406        comments: &[Comment],
407    ) -> Result<Markdown, MarkdownError> {
408        // Render issue and comments as markdown
409        let content = self.render_markdown(issue, comments);
410
411        // Generate frontmatter with metadata
412        let frontmatter = self.build_frontmatter(resource, issue)?;
413
414        // Combine frontmatter with content
415        let markdown_with_frontmatter = format!("{frontmatter}\n{content}");
416
417        Markdown::new(markdown_with_frontmatter)
418    }
419
420    /// Parses a GitHub URL and extracts resource information.
421    ///
422    /// # Arguments
423    ///
424    /// * `url` - The GitHub URL to parse
425    ///
426    /// # Returns
427    ///
428    /// Returns a `GitHubResource` with parsed information, or a `MarkdownError` if parsing fails.
429    ///
430    /// # Examples
431    ///
432    /// ```rust
433    /// use markdowndown::converters::GitHubConverter;
434    ///
435    /// let converter = GitHubConverter::new();
436    /// let url = "https://github.com/microsoft/vscode/issues/1234";
437    /// let resource = converter.parse_github_url(url)?;
438    /// assert_eq!(resource.owner, "microsoft");
439    /// assert_eq!(resource.repo, "vscode");
440    /// assert_eq!(resource.number, 1234);
441    /// # Ok::<(), markdowndown::types::MarkdownError>(())
442    /// ```
443    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        // Check if this is a GitHub URL
449        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        // Parse path segments: /{owner}/{repo}/issues/{number} or /{owner}/{repo}/pull/{number}
462        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        // Determine resource type
477        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        // Parse issue/PR number
488        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    /// Fetches issue or pull request data from GitHub API.
504    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    /// Fetches all comments for an issue or pull request from GitHub API.
523    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    /// Makes an authenticated API request to GitHub.
544    async fn make_api_request(&self, url: &str) -> Result<String, MarkdownError> {
545        // Create HTTP client with proper headers
546        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        // Add authentication header if token is provided
554        if let Some(ref token) = self.auth_token {
555            headers.insert("Authorization".to_string(), format!("token {token}"));
556        }
557
558        // Make the request using the HttpClient with header support
559        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    /// Renders issue and comments as markdown.
584    fn render_markdown(&self, issue: &Issue, comments: &[Comment]) -> String {
585        let mut markdown = String::new();
586
587        // Issue title as main heading
588        markdown.push_str(&format!("# {}\n\n", issue.title));
589
590        // Issue metadata
591        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        // Labels
602        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        // Issue body
610        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        // Comments section
618        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                // Note: Reaction fetching is not yet implemented via GitHub API
636                // but the framework is in place via ReactionCounts struct
637                markdown.push('\n');
638            }
639        }
640
641        markdown.trim().to_string()
642    }
643
644    /// Builds frontmatter for the GitHub issue/PR.
645    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        // Add labels if present
672        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    /// Capitalizes the first letter of a string.
681    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    /// Creates a test user with default values.
712    fn create_test_user(login: &str, id: u64) -> User {
713        User {
714            login: login.to_string(),
715            id,
716        }
717    }
718
719    /// Creates a test issue with reasonable defaults.
720    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, // Default test ID
730            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    /// Creates a test comment with reasonable defaults.
747    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    /// Creates a test label.
767    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", // Wrong domain
832            "https://github.com/user/repo",             // Missing issue/PR part
833            "https://github.com/user/repo/issues",      // Missing number
834            "https://github.com/user/repo/issues/abc",  // Non-numeric number
835            "https://github.com/user/repo/commits/123", // Wrong resource type
836            "not-a-url",                                // Invalid URL
837        ];
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    // Edge case tests
977    #[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        // Should not contain empty body content
1013        assert!(!markdown.contains("## Comments")); // No comments section if no comments
1014    }
1015}