1use anyhow::{Context, Result};
9use hmac::{Hmac, Mac};
10use serde::{Deserialize, Serialize};
11use sha2::Sha256;
12
13type HmacSha256 = Hmac<Sha256>;
14
15#[derive(Debug, Clone, Deserialize)]
21pub struct GitHubWebhookPayload {
22 pub action: String,
24 #[serde(default)]
26 pub issue: Option<GitHubIssue>,
27 #[serde(default)]
29 pub pull_request: Option<GitHubPullRequest>,
30 pub repository: GitHubRepository,
32 #[serde(default)]
34 pub sender: Option<GitHubUser>,
35}
36
37#[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#[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 pub base: GitHubBranch,
83 pub head: GitHubBranch,
85 #[serde(default)]
87 pub merged: bool,
88 #[serde(default)]
90 pub merge_commit_sha: Option<String>,
91 #[serde(default)]
93 pub commits: Option<u32>,
94 #[serde(default)]
96 pub additions: Option<u32>,
97 #[serde(default)]
99 pub deletions: Option<u32>,
100 #[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 #[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#[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#[derive(Debug, Clone, Deserialize)]
181pub struct GitHubCommitData {
182 pub message: String,
183 pub author: GitHubCommitAuthor,
184 pub committer: GitHubCommitAuthor,
185}
186
187#[derive(Debug, Clone, Deserialize)]
189pub struct GitHubCommitAuthor {
190 pub name: String,
191 pub email: String,
192 pub date: String,
193}
194
195#[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#[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
220pub struct GitHubWebhook {
226 webhook_secret: Option<String>,
228}
229
230impl GitHubWebhook {
231 pub fn new(webhook_secret: Option<String>) -> Self {
233 Self { webhook_secret }
234 }
235
236 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 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 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 pub fn issue_to_content(issue: &GitHubIssue, repo: &GitHubRepository) -> String {
267 let mut parts = Vec::new();
268
269 parts.push(format!("#{}: {}", issue.number, issue.title));
271
272 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 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 pub fn pr_to_content(pr: &GitHubPullRequest, repo: &GitHubRepository) -> String {
311 let mut parts = Vec::new();
312
313 parts.push(format!("PR #{}: {}", pr.number, pr.title));
315
316 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 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 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 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 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 pub fn commit_to_content(commit: &GitHubCommit, repo: &GitHubRepository) -> String {
375 let mut parts = Vec::new();
376
377 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 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 if let Some(stats) = &commit.stats {
391 parts.push(format!(
392 "+{} -{} ({} total)",
393 stats.additions, stats.deletions, stats.total
394 ));
395 }
396
397 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 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 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 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 for label in &issue.labels {
437 tags.push(label.name.clone());
438 }
439
440 if let Some(assignee) = &issue.assignee {
442 tags.push(assignee.login.clone());
443 }
444
445 if let Some(milestone) = &issue.milestone {
447 tags.push(milestone.title.clone());
448 }
449
450 tags
451 }
452
453 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 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 for label in &pr.labels {
474 tags.push(label.name.clone());
475 }
476
477 if let Some(user) = &pr.user {
479 tags.push(user.login.clone());
480 }
481
482 tags.push(pr.base.branch_ref.clone());
484 tags.push(pr.head.branch_ref.clone());
485
486 tags
487 }
488
489 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 tags.push(commit.commit.author.name.clone());
500
501 if commit.commit.committer.name != commit.commit.author.name {
503 tags.push(commit.commit.committer.name.clone());
504 }
505
506 if let Some(author) = &commit.author {
508 tags.push(author.login.clone());
509 }
510
511 tags
512 }
513
514 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(), _ => "content_updated".to_string(),
526 }
527 }
528
529 pub fn issue_external_id(repo: &GitHubRepository, issue_number: u64) -> String {
531 format!("github:{}#issue-{}", repo.full_name, issue_number)
532 }
533
534 pub fn pr_external_id(repo: &GitHubRepository, pr_number: u64) -> String {
536 format!("github:{}#pr-{}", repo.full_name, pr_number)
537 }
538
539 pub fn commit_external_id(repo: &GitHubRepository, sha: &str) -> String {
541 format!("github:{}#commit-{}", repo.full_name, sha)
542 }
543}
544
545#[derive(Debug, Deserialize)]
551pub struct GitHubSyncRequest {
552 pub user_id: String,
554 pub token: String,
556 pub owner: String,
558 pub repo: String,
560 #[serde(default = "default_true")]
562 pub sync_issues: bool,
563 #[serde(default = "default_true")]
565 pub sync_prs: bool,
566 #[serde(default)]
568 pub sync_commits: bool,
569 #[serde(default = "default_state")]
571 pub state: String,
572 #[serde(default)]
574 pub limit: Option<usize>,
575 #[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#[derive(Debug, Serialize)]
590pub struct GitHubSyncResponse {
591 pub synced_count: usize,
593 pub issues_synced: usize,
595 pub prs_synced: usize,
597 pub commits_synced: usize,
599 pub created_count: usize,
601 pub updated_count: usize,
603 pub error_count: usize,
605 #[serde(skip_serializing_if = "Vec::is_empty")]
607 pub errors: Vec<String>,
608}
609
610pub 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 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 Ok(issues)
674 }
675
676 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 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 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}