use crate::config::{EventType, ProjectConfig};
use crate::error::{HeraldError, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub id: String,
pub event_type: EventType,
pub project: String,
pub title: String,
pub description: Option<String>,
pub version: Option<String>,
pub url: Option<String>,
pub timestamp: DateTime<Utc>,
pub author: Option<String>,
#[serde(default)]
pub context: EventContext,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EventContext {
#[serde(default)]
pub highlights: Vec<String>,
#[serde(default)]
pub breaking_changes: Vec<String>,
#[serde(default)]
pub contributors: Vec<String>,
#[serde(default)]
pub stats: Option<EventStats>,
#[serde(default)]
pub links: Vec<EventLink>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventStats {
pub downloads: Option<u64>,
pub stars: Option<u64>,
pub forks: Option<u64>,
pub lines_changed: Option<i64>,
pub files_changed: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventLink {
pub title: String,
pub url: String,
}
pub struct EventDetector {
client: reqwest::Client,
github_token: Option<String>,
}
impl EventDetector {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
github_token: std::env::var("GITHUB_TOKEN").ok(),
}
}
pub fn with_github_token(token: String) -> Self {
Self {
client: reqwest::Client::new(),
github_token: Some(token),
}
}
pub async fn detect(&self, project: &ProjectConfig) -> Result<Vec<Event>> {
let mut events = Vec::new();
if let Some(ref repo) = project.github {
if project.events.contains(&EventType::Release) {
if let Ok(releases) = self.get_github_releases(repo).await {
events.extend(releases);
}
}
}
if let Some(ref crate_name) = project.crates_io {
if let Ok(crate_events) = self.get_crates_io_releases(crate_name, &project.name).await {
events.extend(crate_events);
}
}
Ok(events)
}
async fn get_github_releases(&self, repo: &str) -> Result<Vec<Event>> {
let url = format!("https://api.github.com/repos/{}/releases?per_page=5", repo);
let mut request = self.client
.get(&url)
.header("User-Agent", "herald-cli")
.header("Accept", "application/vnd.github.v3+json");
if let Some(ref token) = self.github_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(HeraldError::Git(format!(
"GitHub API error: {}",
response.status()
)));
}
let releases: Vec<GitHubRelease> = response.json().await?;
Ok(releases
.into_iter()
.map(|r| Event {
id: r.id.to_string(),
event_type: EventType::Release,
project: repo.split('/').last().unwrap_or(repo).to_string(),
title: r.name.unwrap_or_else(|| r.tag_name.clone()),
description: r.body,
version: Some(r.tag_name),
url: Some(r.html_url),
timestamp: r.published_at.unwrap_or_else(Utc::now),
author: r.author.map(|a| a.login),
context: EventContext::default(),
})
.collect())
}
async fn get_crates_io_releases(&self, crate_name: &str, project_name: &str) -> Result<Vec<Event>> {
let url = format!("https://crates.io/api/v1/crates/{}/versions", crate_name);
let response = self.client
.get(&url)
.header("User-Agent", "herald-cli (https://github.com/moltenlabs/herald)")
.send()
.await?;
if !response.status().is_success() {
return Err(HeraldError::Config(format!(
"crates.io API error: {}",
response.status()
)));
}
let data: CratesIoResponse = response.json().await?;
Ok(data.versions
.into_iter()
.take(3)
.map(|v| Event {
id: format!("crates-{}-{}", crate_name, v.num),
event_type: EventType::Release,
project: project_name.to_string(),
title: format!("{} v{}", crate_name, v.num),
description: None,
version: Some(v.num.clone()),
url: Some(format!("https://crates.io/crates/{}/{}", crate_name, v.num)),
timestamp: v.created_at,
author: None,
context: EventContext {
stats: Some(EventStats {
downloads: Some(v.downloads),
stars: None,
forks: None,
lines_changed: None,
files_changed: None,
}),
..Default::default()
},
})
.collect())
}
pub fn create_manual_event(
project: &str,
title: &str,
description: Option<&str>,
url: Option<&str>,
event_type: EventType,
) -> Event {
Event {
id: uuid::Uuid::new_v4().to_string(),
event_type,
project: project.to_string(),
title: title.to_string(),
description: description.map(String::from),
version: None,
url: url.map(String::from),
timestamp: Utc::now(),
author: None,
context: EventContext::default(),
}
}
}
impl Default for EventDetector {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Deserialize)]
struct GitHubRelease {
id: u64,
tag_name: String,
name: Option<String>,
body: Option<String>,
html_url: String,
published_at: Option<DateTime<Utc>>,
author: Option<GitHubUser>,
}
#[derive(Debug, Deserialize)]
struct GitHubUser {
login: String,
}
#[derive(Debug, Deserialize)]
struct CratesIoResponse {
versions: Vec<CratesIoVersion>,
}
#[derive(Debug, Deserialize)]
struct CratesIoVersion {
num: String,
downloads: u64,
created_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_manual_event() {
let event = EventDetector::create_manual_event(
"herald",
"Herald v0.1.0 released!",
Some("Automated tweet generation for developers"),
Some("https://github.com/moltenlabs/herald"),
EventType::Release,
);
assert_eq!(event.project, "herald");
assert_eq!(event.event_type, EventType::Release);
assert!(event.url.is_some());
}
#[test]
fn test_event_serialization() {
let event = Event {
id: "test-123".to_string(),
event_type: EventType::Release,
project: "test".to_string(),
title: "Test Release".to_string(),
description: None,
version: Some("1.0.0".to_string()),
url: None,
timestamp: Utc::now(),
author: None,
context: EventContext::default(),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: Event = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, "test-123");
}
#[test]
fn test_event_context() {
let context = EventContext {
highlights: vec!["Fast".to_string(), "Safe".to_string()],
breaking_changes: vec![],
contributors: vec!["alice".to_string()],
stats: Some(EventStats {
downloads: Some(1000),
stars: Some(50),
forks: None,
lines_changed: None,
files_changed: None,
}),
links: vec![],
tags: vec!["rust".to_string()],
};
assert_eq!(context.highlights.len(), 2);
assert!(context.stats.is_some());
}
}