use anyhow::{Context, Result};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubWebhookPayload {
pub action: String,
#[serde(default)]
pub issue: Option<GitHubIssue>,
#[serde(default)]
pub pull_request: Option<GitHubPullRequest>,
pub repository: GitHubRepository,
#[serde(default)]
pub sender: Option<GitHubUser>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubIssue {
pub number: u64,
pub title: String,
#[serde(default)]
pub body: Option<String>,
pub state: String,
pub html_url: String,
#[serde(default)]
pub user: Option<GitHubUser>,
#[serde(default)]
pub assignee: Option<GitHubUser>,
#[serde(default)]
pub assignees: Vec<GitHubUser>,
#[serde(default)]
pub labels: Vec<GitHubLabel>,
#[serde(default)]
pub milestone: Option<GitHubMilestone>,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub closed_at: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubPullRequest {
pub number: u64,
pub title: String,
#[serde(default)]
pub body: Option<String>,
pub state: String,
pub html_url: String,
#[serde(default)]
pub user: Option<GitHubUser>,
#[serde(default)]
pub assignee: Option<GitHubUser>,
#[serde(default)]
pub assignees: Vec<GitHubUser>,
#[serde(default)]
pub labels: Vec<GitHubLabel>,
#[serde(default)]
pub milestone: Option<GitHubMilestone>,
pub base: GitHubBranch,
pub head: GitHubBranch,
#[serde(default)]
pub merged: bool,
#[serde(default)]
pub merge_commit_sha: Option<String>,
#[serde(default)]
pub commits: Option<u32>,
#[serde(default)]
pub additions: Option<u32>,
#[serde(default)]
pub deletions: Option<u32>,
#[serde(default)]
pub changed_files: Option<u32>,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub closed_at: Option<String>,
#[serde(default)]
pub merged_at: Option<String>,
#[serde(default)]
pub draft: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubRepository {
pub id: u64,
pub name: String,
pub full_name: String,
#[serde(default)]
pub description: Option<String>,
pub html_url: String,
pub owner: GitHubUser,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubUser {
pub id: u64,
pub login: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubLabel {
pub id: u64,
pub name: String,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubMilestone {
pub number: u64,
pub title: String,
#[serde(default)]
pub description: Option<String>,
pub state: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubBranch {
#[serde(rename = "ref")]
pub branch_ref: String,
pub sha: String,
#[serde(default)]
pub repo: Option<GitHubRepository>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubCommit {
pub sha: String,
pub commit: GitHubCommitData,
pub html_url: String,
#[serde(default)]
pub author: Option<GitHubUser>,
#[serde(default)]
pub committer: Option<GitHubUser>,
#[serde(default)]
pub stats: Option<GitHubCommitStats>,
#[serde(default)]
pub files: Option<Vec<GitHubCommitFile>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubCommitData {
pub message: String,
pub author: GitHubCommitAuthor,
pub committer: GitHubCommitAuthor,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubCommitAuthor {
pub name: String,
pub email: String,
pub date: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubCommitStats {
#[serde(default)]
pub additions: u32,
#[serde(default)]
pub deletions: u32,
#[serde(default)]
pub total: u32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubCommitFile {
pub filename: String,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub additions: u32,
#[serde(default)]
pub deletions: u32,
#[serde(default)]
pub changes: u32,
}
pub struct GitHubWebhook {
webhook_secret: Option<String>,
}
impl GitHubWebhook {
pub fn new(webhook_secret: Option<String>) -> Self {
Self { webhook_secret }
}
pub fn verify_signature(&self, body: &[u8], signature: &str) -> Result<bool> {
let secret = match &self.webhook_secret {
Some(s) => s,
None => {
tracing::warn!("No webhook secret configured, rejecting webhook");
return Ok(false);
}
};
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).context("Invalid webhook secret")?;
mac.update(body);
let expected_sig = signature.strip_prefix("sha256=").unwrap_or(signature);
let expected_bytes = hex::decode(expected_sig).context("Invalid signature format")?;
Ok(mac.verify_slice(&expected_bytes).is_ok())
}
pub fn parse_payload(&self, body: &[u8]) -> Result<GitHubWebhookPayload> {
serde_json::from_slice(body).context("Failed to parse GitHub webhook payload")
}
pub fn issue_to_content(issue: &GitHubIssue, repo: &GitHubRepository) -> String {
let mut parts = Vec::new();
parts.push(format!("#{}: {}", issue.number, issue.title));
let mut metadata = Vec::new();
metadata.push(format!("Status: {}", issue.state));
if let Some(assignee) = &issue.assignee {
metadata.push(format!("Assignee: {}", assignee.login));
} else if !issue.assignees.is_empty() {
let names: Vec<&str> = issue.assignees.iter().map(|a| a.login.as_str()).collect();
metadata.push(format!("Assignees: {}", names.join(", ")));
}
if !issue.labels.is_empty() {
let label_names: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
metadata.push(format!("Labels: {}", label_names.join(", ")));
}
if let Some(milestone) = &issue.milestone {
metadata.push(format!("Milestone: {}", milestone.title));
}
parts.push(format!("Repo: {}", repo.full_name));
if !metadata.is_empty() {
parts.push(metadata.join(" | "));
}
if let Some(body) = &issue.body {
if !body.is_empty() {
parts.push(String::new());
parts.push(body.clone());
}
}
parts.join("\n")
}
pub fn pr_to_content(pr: &GitHubPullRequest, repo: &GitHubRepository) -> String {
let mut parts = Vec::new();
parts.push(format!("PR #{}: {}", pr.number, pr.title));
let mut metadata = Vec::new();
let status = if pr.merged {
"merged".to_string()
} else if pr.draft {
"draft".to_string()
} else {
pr.state.clone()
};
metadata.push(format!("Status: {}", status));
if let Some(user) = &pr.user {
metadata.push(format!("Author: {}", user.login));
}
metadata.push(format!("{} <- {}", pr.base.branch_ref, pr.head.branch_ref));
if !metadata.is_empty() {
parts.push(metadata.join(" | "));
}
let mut stats = Vec::new();
if let Some(files) = pr.changed_files {
stats.push(format!("{} files", files));
}
if let Some(adds) = pr.additions {
stats.push(format!("+{}", adds));
}
if let Some(dels) = pr.deletions {
stats.push(format!("-{}", dels));
}
if !stats.is_empty() {
parts.push(stats.join(" "));
}
if !pr.labels.is_empty() {
let label_names: Vec<&str> = pr.labels.iter().map(|l| l.name.as_str()).collect();
parts.push(format!("Labels: {}", label_names.join(", ")));
}
parts.push(format!("Repo: {}", repo.full_name));
if let Some(body) = &pr.body {
if !body.is_empty() {
parts.push(String::new());
parts.push(body.clone());
}
}
parts.join("\n")
}
pub fn commit_to_content(commit: &GitHubCommit, repo: &GitHubRepository) -> String {
let mut parts = Vec::new();
let short_sha = &commit.sha[..7.min(commit.sha.len())];
let first_line = commit.commit.message.lines().next().unwrap_or("");
parts.push(format!("Commit {}: {}", short_sha, first_line));
parts.push(format!(
"Author: {} <{}>",
commit.commit.author.name, commit.commit.author.email
));
parts.push(format!("Date: {}", commit.commit.author.date));
if let Some(stats) = &commit.stats {
parts.push(format!(
"+{} -{} ({} total)",
stats.additions, stats.deletions, stats.total
));
}
if let Some(files) = &commit.files {
if !files.is_empty() {
let file_count = files.len();
parts.push(format!("{} files changed", file_count));
for file in files.iter().take(5) {
let status = file.status.as_deref().unwrap_or("modified");
parts.push(format!(" {} {}", status, file.filename));
}
if file_count > 5 {
parts.push(format!(" ... and {} more", file_count - 5));
}
}
}
parts.push(format!("Repo: {}", repo.full_name));
let lines: Vec<&str> = commit.commit.message.lines().collect();
if lines.len() > 1 {
parts.push(String::new());
parts.push(commit.commit.message.clone());
}
parts.join("\n")
}
pub fn issue_to_tags(issue: &GitHubIssue, repo: &GitHubRepository) -> Vec<String> {
let mut tags = vec![
"github".to_string(),
"issue".to_string(),
repo.full_name.clone(),
format!("#{}", issue.number),
issue.state.clone(),
];
for label in &issue.labels {
tags.push(label.name.clone());
}
if let Some(assignee) = &issue.assignee {
tags.push(assignee.login.clone());
}
if let Some(milestone) = &issue.milestone {
tags.push(milestone.title.clone());
}
tags
}
pub fn pr_to_tags(pr: &GitHubPullRequest, repo: &GitHubRepository) -> Vec<String> {
let mut tags = vec![
"github".to_string(),
"pr".to_string(),
"pull-request".to_string(),
repo.full_name.clone(),
format!("#{}", pr.number),
];
if pr.merged {
tags.push("merged".to_string());
} else if pr.draft {
tags.push("draft".to_string());
} else {
tags.push(pr.state.clone());
}
for label in &pr.labels {
tags.push(label.name.clone());
}
if let Some(user) = &pr.user {
tags.push(user.login.clone());
}
tags.push(pr.base.branch_ref.clone());
tags.push(pr.head.branch_ref.clone());
tags
}
pub fn commit_to_tags(commit: &GitHubCommit, repo: &GitHubRepository) -> Vec<String> {
let mut tags = vec![
"github".to_string(),
"commit".to_string(),
repo.full_name.clone(),
commit.sha[..7.min(commit.sha.len())].to_string(),
];
tags.push(commit.commit.author.name.clone());
if commit.commit.committer.name != commit.commit.author.name {
tags.push(commit.commit.committer.name.clone());
}
if let Some(author) = &commit.author {
tags.push(author.login.clone());
}
tags
}
pub fn determine_change_type(action: &str, is_pr: bool) -> String {
match action {
"opened" => "created".to_string(),
"closed" | "merged" => "status_changed".to_string(),
"reopened" => "status_changed".to_string(),
"labeled" | "unlabeled" => "tags_updated".to_string(),
"edited" => "content_updated".to_string(),
"assigned" | "unassigned" => "content_updated".to_string(),
"review_requested" | "review_request_removed" if is_pr => "content_updated".to_string(),
"synchronize" if is_pr => "content_updated".to_string(), _ => "content_updated".to_string(),
}
}
pub fn issue_external_id(repo: &GitHubRepository, issue_number: u64) -> String {
format!("github:{}#issue-{}", repo.full_name, issue_number)
}
pub fn pr_external_id(repo: &GitHubRepository, pr_number: u64) -> String {
format!("github:{}#pr-{}", repo.full_name, pr_number)
}
pub fn commit_external_id(repo: &GitHubRepository, sha: &str) -> String {
format!("github:{}#commit-{}", repo.full_name, sha)
}
}
#[derive(Debug, Deserialize)]
pub struct GitHubSyncRequest {
pub user_id: String,
pub token: String,
pub owner: String,
pub repo: String,
#[serde(default = "default_true")]
pub sync_issues: bool,
#[serde(default = "default_true")]
pub sync_prs: bool,
#[serde(default)]
pub sync_commits: bool,
#[serde(default = "default_state")]
pub state: String,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub branch: Option<String>,
}
fn default_true() -> bool {
true
}
fn default_state() -> String {
"all".to_string()
}
#[derive(Debug, Serialize)]
pub struct GitHubSyncResponse {
pub synced_count: usize,
pub issues_synced: usize,
pub prs_synced: usize,
pub commits_synced: usize,
pub created_count: usize,
pub updated_count: usize,
pub error_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<String>,
}
pub struct GitHubClient {
token: String,
api_url: String,
client: reqwest::Client,
}
impl GitHubClient {
const DEFAULT_API_URL: &'static str = "https://api.github.com";
pub fn new(token: String) -> Self {
let api_url =
std::env::var("GITHUB_API_URL").unwrap_or_else(|_| Self::DEFAULT_API_URL.to_string());
Self {
token,
api_url,
client: reqwest::Client::new(),
}
}
pub async fn fetch_issues(
&self,
owner: &str,
repo: &str,
state: &str,
limit: Option<usize>,
) -> Result<Vec<GitHubIssue>> {
let per_page = limit.unwrap_or(100).min(100);
let url = format!(
"{}/repos/{}/{}/issues?state={}&per_page={}&sort=updated&direction=desc",
self.api_url, owner, repo, state, per_page
);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "shodh-memory")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.context("Failed to send request to GitHub API")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("GitHub API error: {} - {}", status, body);
}
let issues: Vec<GitHubIssue> = response
.json()
.await
.context("Failed to parse GitHub issues response")?;
Ok(issues)
}
pub async fn fetch_pull_requests(
&self,
owner: &str,
repo: &str,
state: &str,
limit: Option<usize>,
) -> Result<Vec<GitHubPullRequest>> {
let per_page = limit.unwrap_or(100).min(100);
let url = format!(
"{}/repos/{}/{}/pulls?state={}&per_page={}&sort=updated&direction=desc",
self.api_url, owner, repo, state, per_page
);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "shodh-memory")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.context("Failed to send request to GitHub API")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("GitHub API error: {} - {}", status, body);
}
let prs: Vec<GitHubPullRequest> = response
.json()
.await
.context("Failed to parse GitHub PRs response")?;
Ok(prs)
}
pub async fn fetch_commits(
&self,
owner: &str,
repo: &str,
branch: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<GitHubCommit>> {
let per_page = limit.unwrap_or(100).min(100);
let mut url = format!(
"{}/repos/{}/{}/commits?per_page={}",
self.api_url, owner, repo, per_page
);
if let Some(branch) = branch {
url.push_str(&format!("&sha={}", branch));
}
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "shodh-memory")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.context("Failed to send request to GitHub API")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("GitHub API error: {} - {}", status, body);
}
let commits: Vec<GitHubCommit> = response
.json()
.await
.context("Failed to parse GitHub commits response")?;
Ok(commits)
}
pub async fn get_repository(&self, owner: &str, repo: &str) -> Result<GitHubRepository> {
let url = format!("{}/repos/{}/{}", self.api_url, owner, repo);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "shodh-memory")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.context("Failed to send request to GitHub API")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("GitHub API error: {} - {}", status, body);
}
response
.json()
.await
.context("Failed to parse GitHub repository response")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issue_to_content() {
let repo = GitHubRepository {
id: 1,
name: "shodh-memory".to_string(),
full_name: "varun29ankuS/shodh-memory".to_string(),
description: None,
html_url: "https://github.com/varun29ankuS/shodh-memory".to_string(),
owner: GitHubUser {
id: 1,
login: "varun29ankuS".to_string(),
name: None,
avatar_url: None,
},
};
let issue = GitHubIssue {
number: 123,
title: "Fix authentication bug".to_string(),
body: Some("The auth is broken".to_string()),
state: "open".to_string(),
html_url: "https://github.com/varun29ankuS/shodh-memory/issues/123".to_string(),
user: Some(GitHubUser {
id: 1,
login: "varun29ankuS".to_string(),
name: None,
avatar_url: None,
}),
assignee: Some(GitHubUser {
id: 1,
login: "varun29ankuS".to_string(),
name: None,
avatar_url: None,
}),
assignees: vec![],
labels: vec![GitHubLabel {
id: 1,
name: "bug".to_string(),
color: None,
description: None,
}],
milestone: None,
created_at: "2025-01-01T00:00:00Z".to_string(),
updated_at: "2025-01-01T00:00:00Z".to_string(),
closed_at: None,
};
let content = GitHubWebhook::issue_to_content(&issue, &repo);
assert!(content.contains("#123: Fix authentication bug"));
assert!(content.contains("Status: open"));
assert!(content.contains("Assignee: varun29ankuS"));
assert!(content.contains("Labels: bug"));
assert!(content.contains("The auth is broken"));
}
#[test]
fn test_issue_external_id() {
let repo = GitHubRepository {
id: 1,
name: "shodh-memory".to_string(),
full_name: "varun29ankuS/shodh-memory".to_string(),
description: None,
html_url: "https://github.com/varun29ankuS/shodh-memory".to_string(),
owner: GitHubUser {
id: 1,
login: "varun29ankuS".to_string(),
name: None,
avatar_url: None,
},
};
let external_id = GitHubWebhook::issue_external_id(&repo, 123);
assert_eq!(external_id, "github:varun29ankuS/shodh-memory#issue-123");
let pr_id = GitHubWebhook::pr_external_id(&repo, 456);
assert_eq!(pr_id, "github:varun29ankuS/shodh-memory#pr-456");
}
#[test]
fn test_commit_external_id() {
let repo = GitHubRepository {
id: 1,
name: "shodh-memory".to_string(),
full_name: "varun29ankuS/shodh-memory".to_string(),
description: None,
html_url: "https://github.com/varun29ankuS/shodh-memory".to_string(),
owner: GitHubUser {
id: 1,
login: "varun29ankuS".to_string(),
name: None,
avatar_url: None,
},
};
let commit_id = GitHubWebhook::commit_external_id(&repo, "abc123def456");
assert_eq!(
commit_id,
"github:varun29ankuS/shodh-memory#commit-abc123def456"
);
}
#[test]
fn test_commit_to_content() {
let repo = GitHubRepository {
id: 1,
name: "shodh-memory".to_string(),
full_name: "varun29ankuS/shodh-memory".to_string(),
description: None,
html_url: "https://github.com/varun29ankuS/shodh-memory".to_string(),
owner: GitHubUser {
id: 1,
login: "varun29ankuS".to_string(),
name: None,
avatar_url: None,
},
};
let commit = GitHubCommit {
sha: "abc123def456789".to_string(),
html_url: "https://github.com/varun29ankuS/shodh-memory/commit/abc123".to_string(),
commit: GitHubCommitData {
message: "feat: add commit sync\n\nThis adds commit history sync support."
.to_string(),
author: GitHubCommitAuthor {
name: "Varun".to_string(),
email: "varun@example.com".to_string(),
date: "2025-01-01T00:00:00Z".to_string(),
},
committer: GitHubCommitAuthor {
name: "Varun".to_string(),
email: "varun@example.com".to_string(),
date: "2025-01-01T00:00:00Z".to_string(),
},
},
author: Some(GitHubUser {
id: 1,
login: "varun29ankuS".to_string(),
name: Some("Varun".to_string()),
avatar_url: None,
}),
committer: None,
stats: Some(GitHubCommitStats {
additions: 100,
deletions: 20,
total: 120,
}),
files: None,
};
let content = GitHubWebhook::commit_to_content(&commit, &repo);
assert!(content.contains("Commit abc123d: feat: add commit sync"));
assert!(content.contains("Author: Varun <varun@example.com>"));
assert!(content.contains("+100 -20 (120 total)"));
}
}