Skip to main content

shodh_memory/integrations/
github.rs

1//! GitHub integration for syncing Issues, PRs, and Commits to Shodh memory
2//!
3//! Provides:
4//! - Webhook receiver for real-time issue/PR updates
5//! - Bulk sync for importing existing issues, PRs, and commits
6//! - HMAC-SHA256 signature verification
7
8use anyhow::{Context, Result};
9use hmac::{Hmac, Mac};
10use serde::{Deserialize, Serialize};
11use sha2::Sha256;
12
13type HmacSha256 = Hmac<Sha256>;
14
15// =============================================================================
16// GITHUB WEBHOOK TYPES
17// =============================================================================
18
19/// GitHub webhook payload - unified structure for Issues and PRs
20#[derive(Debug, Clone, Deserialize)]
21pub struct GitHubWebhookPayload {
22    /// Action: "opened", "edited", "closed", "reopened", "labeled", "merged", etc.
23    pub action: String,
24    /// Issue data (for issue events)
25    #[serde(default)]
26    pub issue: Option<GitHubIssue>,
27    /// Pull request data (for PR events)
28    #[serde(default)]
29    pub pull_request: Option<GitHubPullRequest>,
30    /// Repository info
31    pub repository: GitHubRepository,
32    /// Sender (who triggered the event)
33    #[serde(default)]
34    pub sender: Option<GitHubUser>,
35}
36
37/// GitHub Issue
38#[derive(Debug, Clone, Deserialize)]
39pub struct GitHubIssue {
40    pub number: u64,
41    pub title: String,
42    #[serde(default)]
43    pub body: Option<String>,
44    pub state: String,
45    pub html_url: String,
46    #[serde(default)]
47    pub user: Option<GitHubUser>,
48    #[serde(default)]
49    pub assignee: Option<GitHubUser>,
50    #[serde(default)]
51    pub assignees: Vec<GitHubUser>,
52    #[serde(default)]
53    pub labels: Vec<GitHubLabel>,
54    #[serde(default)]
55    pub milestone: Option<GitHubMilestone>,
56    pub created_at: String,
57    pub updated_at: String,
58    #[serde(default)]
59    pub closed_at: Option<String>,
60}
61
62/// GitHub Pull Request
63#[derive(Debug, Clone, Deserialize)]
64pub struct GitHubPullRequest {
65    pub number: u64,
66    pub title: String,
67    #[serde(default)]
68    pub body: Option<String>,
69    pub state: String,
70    pub html_url: String,
71    #[serde(default)]
72    pub user: Option<GitHubUser>,
73    #[serde(default)]
74    pub assignee: Option<GitHubUser>,
75    #[serde(default)]
76    pub assignees: Vec<GitHubUser>,
77    #[serde(default)]
78    pub labels: Vec<GitHubLabel>,
79    #[serde(default)]
80    pub milestone: Option<GitHubMilestone>,
81    /// Base branch info
82    pub base: GitHubBranch,
83    /// Head branch info
84    pub head: GitHubBranch,
85    /// Whether PR is merged
86    #[serde(default)]
87    pub merged: bool,
88    /// Merge commit SHA
89    #[serde(default)]
90    pub merge_commit_sha: Option<String>,
91    /// Number of commits
92    #[serde(default)]
93    pub commits: Option<u32>,
94    /// Additions
95    #[serde(default)]
96    pub additions: Option<u32>,
97    /// Deletions
98    #[serde(default)]
99    pub deletions: Option<u32>,
100    /// Changed files count
101    #[serde(default)]
102    pub changed_files: Option<u32>,
103    pub created_at: String,
104    pub updated_at: String,
105    #[serde(default)]
106    pub closed_at: Option<String>,
107    #[serde(default)]
108    pub merged_at: Option<String>,
109    /// Draft PR
110    #[serde(default)]
111    pub draft: bool,
112}
113
114#[derive(Debug, Clone, Deserialize)]
115pub struct GitHubRepository {
116    pub id: u64,
117    pub name: String,
118    pub full_name: String,
119    #[serde(default)]
120    pub description: Option<String>,
121    pub html_url: String,
122    pub owner: GitHubUser,
123}
124
125#[derive(Debug, Clone, Deserialize)]
126pub struct GitHubUser {
127    pub id: u64,
128    pub login: String,
129    #[serde(default)]
130    pub name: Option<String>,
131    #[serde(default)]
132    pub avatar_url: Option<String>,
133}
134
135#[derive(Debug, Clone, Deserialize)]
136pub struct GitHubLabel {
137    pub id: u64,
138    pub name: String,
139    #[serde(default)]
140    pub color: Option<String>,
141    #[serde(default)]
142    pub description: Option<String>,
143}
144
145#[derive(Debug, Clone, Deserialize)]
146pub struct GitHubMilestone {
147    pub number: u64,
148    pub title: String,
149    #[serde(default)]
150    pub description: Option<String>,
151    pub state: String,
152}
153
154#[derive(Debug, Clone, Deserialize)]
155pub struct GitHubBranch {
156    #[serde(rename = "ref")]
157    pub branch_ref: String,
158    pub sha: String,
159    #[serde(default)]
160    pub repo: Option<GitHubRepository>,
161}
162
163/// GitHub Commit
164#[derive(Debug, Clone, Deserialize)]
165pub struct GitHubCommit {
166    pub sha: String,
167    pub commit: GitHubCommitData,
168    pub html_url: String,
169    #[serde(default)]
170    pub author: Option<GitHubUser>,
171    #[serde(default)]
172    pub committer: Option<GitHubUser>,
173    #[serde(default)]
174    pub stats: Option<GitHubCommitStats>,
175    #[serde(default)]
176    pub files: Option<Vec<GitHubCommitFile>>,
177}
178
179/// Inner commit data (message, author info)
180#[derive(Debug, Clone, Deserialize)]
181pub struct GitHubCommitData {
182    pub message: String,
183    pub author: GitHubCommitAuthor,
184    pub committer: GitHubCommitAuthor,
185}
186
187/// Commit author/committer info
188#[derive(Debug, Clone, Deserialize)]
189pub struct GitHubCommitAuthor {
190    pub name: String,
191    pub email: String,
192    pub date: String,
193}
194
195/// Commit statistics (additions/deletions)
196#[derive(Debug, Clone, Deserialize)]
197pub struct GitHubCommitStats {
198    #[serde(default)]
199    pub additions: u32,
200    #[serde(default)]
201    pub deletions: u32,
202    #[serde(default)]
203    pub total: u32,
204}
205
206/// File changed in a commit
207#[derive(Debug, Clone, Deserialize)]
208pub struct GitHubCommitFile {
209    pub filename: String,
210    #[serde(default)]
211    pub status: Option<String>,
212    #[serde(default)]
213    pub additions: u32,
214    #[serde(default)]
215    pub deletions: u32,
216    #[serde(default)]
217    pub changes: u32,
218}
219
220// =============================================================================
221// WEBHOOK HANDLER
222// =============================================================================
223
224/// GitHub webhook handler
225pub struct GitHubWebhook {
226    /// Webhook secret for HMAC verification
227    webhook_secret: Option<String>,
228}
229
230impl GitHubWebhook {
231    /// Create a new webhook handler
232    pub fn new(webhook_secret: Option<String>) -> Self {
233        Self { webhook_secret }
234    }
235
236    /// Verify webhook signature using HMAC-SHA256
237    ///
238    /// GitHub sends the signature in the `X-Hub-Signature-256` header
239    pub fn verify_signature(&self, body: &[u8], signature: &str) -> Result<bool> {
240        let secret = match &self.webhook_secret {
241            Some(s) => s,
242            None => {
243                tracing::warn!("No webhook secret configured, rejecting webhook");
244                return Ok(false);
245            }
246        };
247
248        let mut mac =
249            HmacSha256::new_from_slice(secret.as_bytes()).context("Invalid webhook secret")?;
250        mac.update(body);
251
252        // GitHub signature format: "sha256=<hex>"
253        let expected_sig = signature.strip_prefix("sha256=").unwrap_or(signature);
254
255        let expected_bytes = hex::decode(expected_sig).context("Invalid signature format")?;
256
257        Ok(mac.verify_slice(&expected_bytes).is_ok())
258    }
259
260    /// Parse webhook payload
261    pub fn parse_payload(&self, body: &[u8]) -> Result<GitHubWebhookPayload> {
262        serde_json::from_slice(body).context("Failed to parse GitHub webhook payload")
263    }
264
265    /// Transform GitHub issue to memory content
266    pub fn issue_to_content(issue: &GitHubIssue, repo: &GitHubRepository) -> String {
267        let mut parts = Vec::new();
268
269        // Header: #{number}: {title}
270        parts.push(format!("#{}: {}", issue.number, issue.title));
271
272        // Metadata
273        let mut metadata = Vec::new();
274        metadata.push(format!("Status: {}", issue.state));
275
276        if let Some(assignee) = &issue.assignee {
277            metadata.push(format!("Assignee: {}", assignee.login));
278        } else if !issue.assignees.is_empty() {
279            let names: Vec<&str> = issue.assignees.iter().map(|a| a.login.as_str()).collect();
280            metadata.push(format!("Assignees: {}", names.join(", ")));
281        }
282
283        if !issue.labels.is_empty() {
284            let label_names: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
285            metadata.push(format!("Labels: {}", label_names.join(", ")));
286        }
287
288        if let Some(milestone) = &issue.milestone {
289            metadata.push(format!("Milestone: {}", milestone.title));
290        }
291
292        parts.push(format!("Repo: {}", repo.full_name));
293
294        if !metadata.is_empty() {
295            parts.push(metadata.join(" | "));
296        }
297
298        // Body
299        if let Some(body) = &issue.body {
300            if !body.is_empty() {
301                parts.push(String::new());
302                parts.push(body.clone());
303            }
304        }
305
306        parts.join("\n")
307    }
308
309    /// Transform GitHub PR to memory content
310    pub fn pr_to_content(pr: &GitHubPullRequest, repo: &GitHubRepository) -> String {
311        let mut parts = Vec::new();
312
313        // Header: PR #{number}: {title}
314        parts.push(format!("PR #{}: {}", pr.number, pr.title));
315
316        // Metadata
317        let mut metadata = Vec::new();
318
319        let status = if pr.merged {
320            "merged".to_string()
321        } else if pr.draft {
322            "draft".to_string()
323        } else {
324            pr.state.clone()
325        };
326        metadata.push(format!("Status: {}", status));
327
328        if let Some(user) = &pr.user {
329            metadata.push(format!("Author: {}", user.login));
330        }
331
332        // Branch info
333        metadata.push(format!("{} <- {}", pr.base.branch_ref, pr.head.branch_ref));
334
335        if !metadata.is_empty() {
336            parts.push(metadata.join(" | "));
337        }
338
339        // Stats
340        let mut stats = Vec::new();
341        if let Some(files) = pr.changed_files {
342            stats.push(format!("{} files", files));
343        }
344        if let Some(adds) = pr.additions {
345            stats.push(format!("+{}", adds));
346        }
347        if let Some(dels) = pr.deletions {
348            stats.push(format!("-{}", dels));
349        }
350        if !stats.is_empty() {
351            parts.push(stats.join(" "));
352        }
353
354        // Labels
355        if !pr.labels.is_empty() {
356            let label_names: Vec<&str> = pr.labels.iter().map(|l| l.name.as_str()).collect();
357            parts.push(format!("Labels: {}", label_names.join(", ")));
358        }
359
360        parts.push(format!("Repo: {}", repo.full_name));
361
362        // Body
363        if let Some(body) = &pr.body {
364            if !body.is_empty() {
365                parts.push(String::new());
366                parts.push(body.clone());
367            }
368        }
369
370        parts.join("\n")
371    }
372
373    /// Transform GitHub commit to memory content
374    pub fn commit_to_content(commit: &GitHubCommit, repo: &GitHubRepository) -> String {
375        let mut parts = Vec::new();
376
377        // Header: commit SHA (short) and first line of message
378        let short_sha = &commit.sha[..7.min(commit.sha.len())];
379        let first_line = commit.commit.message.lines().next().unwrap_or("");
380        parts.push(format!("Commit {}: {}", short_sha, first_line));
381
382        // Author info
383        parts.push(format!(
384            "Author: {} <{}>",
385            commit.commit.author.name, commit.commit.author.email
386        ));
387        parts.push(format!("Date: {}", commit.commit.author.date));
388
389        // Stats if available
390        if let Some(stats) = &commit.stats {
391            parts.push(format!(
392                "+{} -{} ({} total)",
393                stats.additions, stats.deletions, stats.total
394            ));
395        }
396
397        // Files changed if available
398        if let Some(files) = &commit.files {
399            if !files.is_empty() {
400                let file_count = files.len();
401                parts.push(format!("{} files changed", file_count));
402                // List first few files
403                for file in files.iter().take(5) {
404                    let status = file.status.as_deref().unwrap_or("modified");
405                    parts.push(format!("  {} {}", status, file.filename));
406                }
407                if file_count > 5 {
408                    parts.push(format!("  ... and {} more", file_count - 5));
409                }
410            }
411        }
412
413        parts.push(format!("Repo: {}", repo.full_name));
414
415        // Full commit message if multiline
416        let lines: Vec<&str> = commit.commit.message.lines().collect();
417        if lines.len() > 1 {
418            parts.push(String::new());
419            parts.push(commit.commit.message.clone());
420        }
421
422        parts.join("\n")
423    }
424
425    /// Extract tags from GitHub issue
426    pub fn issue_to_tags(issue: &GitHubIssue, repo: &GitHubRepository) -> Vec<String> {
427        let mut tags = vec![
428            "github".to_string(),
429            "issue".to_string(),
430            repo.full_name.clone(),
431            format!("#{}", issue.number),
432            issue.state.clone(),
433        ];
434
435        // Add labels
436        for label in &issue.labels {
437            tags.push(label.name.clone());
438        }
439
440        // Add assignee
441        if let Some(assignee) = &issue.assignee {
442            tags.push(assignee.login.clone());
443        }
444
445        // Add milestone
446        if let Some(milestone) = &issue.milestone {
447            tags.push(milestone.title.clone());
448        }
449
450        tags
451    }
452
453    /// Extract tags from GitHub PR
454    pub fn pr_to_tags(pr: &GitHubPullRequest, repo: &GitHubRepository) -> Vec<String> {
455        let mut tags = vec![
456            "github".to_string(),
457            "pr".to_string(),
458            "pull-request".to_string(),
459            repo.full_name.clone(),
460            format!("#{}", pr.number),
461        ];
462
463        // Status
464        if pr.merged {
465            tags.push("merged".to_string());
466        } else if pr.draft {
467            tags.push("draft".to_string());
468        } else {
469            tags.push(pr.state.clone());
470        }
471
472        // Add labels
473        for label in &pr.labels {
474            tags.push(label.name.clone());
475        }
476
477        // Add author
478        if let Some(user) = &pr.user {
479            tags.push(user.login.clone());
480        }
481
482        // Add branch names
483        tags.push(pr.base.branch_ref.clone());
484        tags.push(pr.head.branch_ref.clone());
485
486        tags
487    }
488
489    /// Extract tags from GitHub commit
490    pub fn commit_to_tags(commit: &GitHubCommit, repo: &GitHubRepository) -> Vec<String> {
491        let mut tags = vec![
492            "github".to_string(),
493            "commit".to_string(),
494            repo.full_name.clone(),
495            commit.sha[..7.min(commit.sha.len())].to_string(),
496        ];
497
498        // Add author
499        tags.push(commit.commit.author.name.clone());
500
501        // Add committer if different from author
502        if commit.commit.committer.name != commit.commit.author.name {
503            tags.push(commit.commit.committer.name.clone());
504        }
505
506        // Add GitHub user login if available
507        if let Some(author) = &commit.author {
508            tags.push(author.login.clone());
509        }
510
511        tags
512    }
513
514    /// Determine change type from webhook action
515    pub fn determine_change_type(action: &str, is_pr: bool) -> String {
516        match action {
517            "opened" => "created".to_string(),
518            "closed" | "merged" => "status_changed".to_string(),
519            "reopened" => "status_changed".to_string(),
520            "labeled" | "unlabeled" => "tags_updated".to_string(),
521            "edited" => "content_updated".to_string(),
522            "assigned" | "unassigned" => "content_updated".to_string(),
523            "review_requested" | "review_request_removed" if is_pr => "content_updated".to_string(),
524            "synchronize" if is_pr => "content_updated".to_string(), // New commits pushed
525            _ => "content_updated".to_string(),
526        }
527    }
528
529    /// Build external_id for issue
530    pub fn issue_external_id(repo: &GitHubRepository, issue_number: u64) -> String {
531        format!("github:{}#issue-{}", repo.full_name, issue_number)
532    }
533
534    /// Build external_id for PR
535    pub fn pr_external_id(repo: &GitHubRepository, pr_number: u64) -> String {
536        format!("github:{}#pr-{}", repo.full_name, pr_number)
537    }
538
539    /// Build external_id for commit
540    pub fn commit_external_id(repo: &GitHubRepository, sha: &str) -> String {
541        format!("github:{}#commit-{}", repo.full_name, sha)
542    }
543}
544
545// =============================================================================
546// BULK SYNC TYPES
547// =============================================================================
548
549/// Request for bulk syncing GitHub issues/PRs/commits
550#[derive(Debug, Deserialize)]
551pub struct GitHubSyncRequest {
552    /// User ID to associate memories with
553    pub user_id: String,
554    /// GitHub personal access token
555    pub token: String,
556    /// Repository owner (user or org)
557    pub owner: String,
558    /// Repository name
559    pub repo: String,
560    /// Sync issues (default: true)
561    #[serde(default = "default_true")]
562    pub sync_issues: bool,
563    /// Sync pull requests (default: true)
564    #[serde(default = "default_true")]
565    pub sync_prs: bool,
566    /// Sync commits (default: false)
567    #[serde(default)]
568    pub sync_commits: bool,
569    /// Only sync items with state (open, closed, all) - default: all
570    #[serde(default = "default_state")]
571    pub state: String,
572    /// Limit number of items to sync per type
573    #[serde(default)]
574    pub limit: Option<usize>,
575    /// Branch to sync commits from (default: default branch)
576    #[serde(default)]
577    pub branch: Option<String>,
578}
579
580fn default_true() -> bool {
581    true
582}
583
584fn default_state() -> String {
585    "all".to_string()
586}
587
588/// Response from bulk sync
589#[derive(Debug, Serialize)]
590pub struct GitHubSyncResponse {
591    /// Total items synced
592    pub synced_count: usize,
593    /// Issues synced
594    pub issues_synced: usize,
595    /// PRs synced
596    pub prs_synced: usize,
597    /// Commits synced
598    pub commits_synced: usize,
599    /// Items created (new)
600    pub created_count: usize,
601    /// Items updated (existing)
602    pub updated_count: usize,
603    /// Items that failed
604    pub error_count: usize,
605    /// Error messages if any
606    #[serde(skip_serializing_if = "Vec::is_empty")]
607    pub errors: Vec<String>,
608}
609
610// =============================================================================
611// GITHUB REST API CLIENT
612// =============================================================================
613
614/// Simple GitHub REST API client for bulk sync
615pub struct GitHubClient {
616    token: String,
617    api_url: String,
618    client: reqwest::Client,
619}
620
621impl GitHubClient {
622    const DEFAULT_API_URL: &'static str = "https://api.github.com";
623
624    pub fn new(token: String) -> Self {
625        let api_url =
626            std::env::var("GITHUB_API_URL").unwrap_or_else(|_| Self::DEFAULT_API_URL.to_string());
627        Self {
628            token,
629            api_url,
630            client: reqwest::Client::new(),
631        }
632    }
633
634    /// Fetch issues from GitHub
635    pub async fn fetch_issues(
636        &self,
637        owner: &str,
638        repo: &str,
639        state: &str,
640        limit: Option<usize>,
641    ) -> Result<Vec<GitHubIssue>> {
642        let per_page = limit.unwrap_or(100).min(100);
643        let url = format!(
644            "{}/repos/{}/{}/issues?state={}&per_page={}&sort=updated&direction=desc",
645            self.api_url, owner, repo, state, per_page
646        );
647
648        let response = self
649            .client
650            .get(&url)
651            .header("Authorization", format!("Bearer {}", self.token))
652            .header("Accept", "application/vnd.github+json")
653            .header("User-Agent", "shodh-memory")
654            .header("X-GitHub-Api-Version", "2022-11-28")
655            .send()
656            .await
657            .context("Failed to send request to GitHub API")?;
658
659        if !response.status().is_success() {
660            let status = response.status();
661            let body = response.text().await.unwrap_or_default();
662            anyhow::bail!("GitHub API error: {} - {}", status, body);
663        }
664
665        let issues: Vec<GitHubIssue> = response
666            .json()
667            .await
668            .context("Failed to parse GitHub issues response")?;
669
670        // Filter out PRs (GitHub API returns PRs in issues endpoint if they have "pull_request" field)
671        // We handle this by checking if they're actual issues (no pull_request key in JSON)
672        // The deserialization handles this - PRs won't deserialize as issues properly
673        Ok(issues)
674    }
675
676    /// Fetch pull requests from GitHub
677    pub async fn fetch_pull_requests(
678        &self,
679        owner: &str,
680        repo: &str,
681        state: &str,
682        limit: Option<usize>,
683    ) -> Result<Vec<GitHubPullRequest>> {
684        let per_page = limit.unwrap_or(100).min(100);
685        let url = format!(
686            "{}/repos/{}/{}/pulls?state={}&per_page={}&sort=updated&direction=desc",
687            self.api_url, owner, repo, state, per_page
688        );
689
690        let response = self
691            .client
692            .get(&url)
693            .header("Authorization", format!("Bearer {}", self.token))
694            .header("Accept", "application/vnd.github+json")
695            .header("User-Agent", "shodh-memory")
696            .header("X-GitHub-Api-Version", "2022-11-28")
697            .send()
698            .await
699            .context("Failed to send request to GitHub API")?;
700
701        if !response.status().is_success() {
702            let status = response.status();
703            let body = response.text().await.unwrap_or_default();
704            anyhow::bail!("GitHub API error: {} - {}", status, body);
705        }
706
707        let prs: Vec<GitHubPullRequest> = response
708            .json()
709            .await
710            .context("Failed to parse GitHub PRs response")?;
711
712        Ok(prs)
713    }
714
715    /// Fetch commits from GitHub
716    pub async fn fetch_commits(
717        &self,
718        owner: &str,
719        repo: &str,
720        branch: Option<&str>,
721        limit: Option<usize>,
722    ) -> Result<Vec<GitHubCommit>> {
723        let per_page = limit.unwrap_or(100).min(100);
724        let mut url = format!(
725            "{}/repos/{}/{}/commits?per_page={}",
726            self.api_url, owner, repo, per_page
727        );
728
729        if let Some(branch) = branch {
730            url.push_str(&format!("&sha={}", branch));
731        }
732
733        let response = self
734            .client
735            .get(&url)
736            .header("Authorization", format!("Bearer {}", self.token))
737            .header("Accept", "application/vnd.github+json")
738            .header("User-Agent", "shodh-memory")
739            .header("X-GitHub-Api-Version", "2022-11-28")
740            .send()
741            .await
742            .context("Failed to send request to GitHub API")?;
743
744        if !response.status().is_success() {
745            let status = response.status();
746            let body = response.text().await.unwrap_or_default();
747            anyhow::bail!("GitHub API error: {} - {}", status, body);
748        }
749
750        let commits: Vec<GitHubCommit> = response
751            .json()
752            .await
753            .context("Failed to parse GitHub commits response")?;
754
755        Ok(commits)
756    }
757
758    /// Get repository info
759    pub async fn get_repository(&self, owner: &str, repo: &str) -> Result<GitHubRepository> {
760        let url = format!("{}/repos/{}/{}", self.api_url, owner, repo);
761
762        let response = self
763            .client
764            .get(&url)
765            .header("Authorization", format!("Bearer {}", self.token))
766            .header("Accept", "application/vnd.github+json")
767            .header("User-Agent", "shodh-memory")
768            .header("X-GitHub-Api-Version", "2022-11-28")
769            .send()
770            .await
771            .context("Failed to send request to GitHub API")?;
772
773        if !response.status().is_success() {
774            let status = response.status();
775            let body = response.text().await.unwrap_or_default();
776            anyhow::bail!("GitHub API error: {} - {}", status, body);
777        }
778
779        response
780            .json()
781            .await
782            .context("Failed to parse GitHub repository response")
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    #[test]
791    fn test_issue_to_content() {
792        let repo = GitHubRepository {
793            id: 1,
794            name: "shodh-memory".to_string(),
795            full_name: "varun29ankuS/shodh-memory".to_string(),
796            description: None,
797            html_url: "https://github.com/varun29ankuS/shodh-memory".to_string(),
798            owner: GitHubUser {
799                id: 1,
800                login: "varun29ankuS".to_string(),
801                name: None,
802                avatar_url: None,
803            },
804        };
805
806        let issue = GitHubIssue {
807            number: 123,
808            title: "Fix authentication bug".to_string(),
809            body: Some("The auth is broken".to_string()),
810            state: "open".to_string(),
811            html_url: "https://github.com/varun29ankuS/shodh-memory/issues/123".to_string(),
812            user: Some(GitHubUser {
813                id: 1,
814                login: "varun29ankuS".to_string(),
815                name: None,
816                avatar_url: None,
817            }),
818            assignee: Some(GitHubUser {
819                id: 1,
820                login: "varun29ankuS".to_string(),
821                name: None,
822                avatar_url: None,
823            }),
824            assignees: vec![],
825            labels: vec![GitHubLabel {
826                id: 1,
827                name: "bug".to_string(),
828                color: None,
829                description: None,
830            }],
831            milestone: None,
832            created_at: "2025-01-01T00:00:00Z".to_string(),
833            updated_at: "2025-01-01T00:00:00Z".to_string(),
834            closed_at: None,
835        };
836
837        let content = GitHubWebhook::issue_to_content(&issue, &repo);
838        assert!(content.contains("#123: Fix authentication bug"));
839        assert!(content.contains("Status: open"));
840        assert!(content.contains("Assignee: varun29ankuS"));
841        assert!(content.contains("Labels: bug"));
842        assert!(content.contains("The auth is broken"));
843    }
844
845    #[test]
846    fn test_issue_external_id() {
847        let repo = GitHubRepository {
848            id: 1,
849            name: "shodh-memory".to_string(),
850            full_name: "varun29ankuS/shodh-memory".to_string(),
851            description: None,
852            html_url: "https://github.com/varun29ankuS/shodh-memory".to_string(),
853            owner: GitHubUser {
854                id: 1,
855                login: "varun29ankuS".to_string(),
856                name: None,
857                avatar_url: None,
858            },
859        };
860
861        let external_id = GitHubWebhook::issue_external_id(&repo, 123);
862        assert_eq!(external_id, "github:varun29ankuS/shodh-memory#issue-123");
863
864        let pr_id = GitHubWebhook::pr_external_id(&repo, 456);
865        assert_eq!(pr_id, "github:varun29ankuS/shodh-memory#pr-456");
866    }
867
868    #[test]
869    fn test_commit_external_id() {
870        let repo = GitHubRepository {
871            id: 1,
872            name: "shodh-memory".to_string(),
873            full_name: "varun29ankuS/shodh-memory".to_string(),
874            description: None,
875            html_url: "https://github.com/varun29ankuS/shodh-memory".to_string(),
876            owner: GitHubUser {
877                id: 1,
878                login: "varun29ankuS".to_string(),
879                name: None,
880                avatar_url: None,
881            },
882        };
883
884        let commit_id = GitHubWebhook::commit_external_id(&repo, "abc123def456");
885        assert_eq!(
886            commit_id,
887            "github:varun29ankuS/shodh-memory#commit-abc123def456"
888        );
889    }
890
891    #[test]
892    fn test_commit_to_content() {
893        let repo = GitHubRepository {
894            id: 1,
895            name: "shodh-memory".to_string(),
896            full_name: "varun29ankuS/shodh-memory".to_string(),
897            description: None,
898            html_url: "https://github.com/varun29ankuS/shodh-memory".to_string(),
899            owner: GitHubUser {
900                id: 1,
901                login: "varun29ankuS".to_string(),
902                name: None,
903                avatar_url: None,
904            },
905        };
906
907        let commit = GitHubCommit {
908            sha: "abc123def456789".to_string(),
909            html_url: "https://github.com/varun29ankuS/shodh-memory/commit/abc123".to_string(),
910            commit: GitHubCommitData {
911                message: "feat: add commit sync\n\nThis adds commit history sync support."
912                    .to_string(),
913                author: GitHubCommitAuthor {
914                    name: "Varun".to_string(),
915                    email: "varun@example.com".to_string(),
916                    date: "2025-01-01T00:00:00Z".to_string(),
917                },
918                committer: GitHubCommitAuthor {
919                    name: "Varun".to_string(),
920                    email: "varun@example.com".to_string(),
921                    date: "2025-01-01T00:00:00Z".to_string(),
922                },
923            },
924            author: Some(GitHubUser {
925                id: 1,
926                login: "varun29ankuS".to_string(),
927                name: Some("Varun".to_string()),
928                avatar_url: None,
929            }),
930            committer: None,
931            stats: Some(GitHubCommitStats {
932                additions: 100,
933                deletions: 20,
934                total: 120,
935            }),
936            files: None,
937        };
938
939        let content = GitHubWebhook::commit_to_content(&commit, &repo);
940        assert!(content.contains("Commit abc123d: feat: add commit sync"));
941        assert!(content.contains("Author: Varun <varun@example.com>"));
942        assert!(content.contains("+100 -20 (120 total)"));
943    }
944}