From 003f427e98efd96c463b217c4a98ae058d8019ab Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Sun, 3 May 2026 18:08:18 +0300 Subject: [PATCH] feat(api): reshape raw events into TimelineItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/events now returns the presentation form rather than raw upstream payloads. The frontend stays dumb: it renders title / subtitle / body segments and picks an icon from a small kebab-case enum. Title and subtitle are arrays of {text} | {text, url} segments so the UI can interleave plain copy with anchors without parsing. New entities (in moments-entities): TimelineItem — id, source, action, occurred_at, icon, title, subtitle, body TitleSegment — Text | Link TimelineBody — Markdown | Commits | Links CommitSummary — sha, short_sha, message, url, author TimelineIcon — kebab-case enum; UI falls back to Generic on unknowns Reshape lives in moments-core::presentation, dispatched by source. github.rs covers the event types observed on grenade's feed: PushEvent, PullRequest{,Review,ReviewComment}Event, Issues{,Comment}Event, Create/Delete/Fork/Watch/Release/CommitComment/PublicEvent. Anything else falls back to a generic " on " line. Other sources (gitea, hg, bugzilla) currently use a stub fallback; they get their own reshape modules in steps 5 and 6. 4 unit tests cover the load-bearing cases (push commit list, merged PR icon swap, issue-comment markdown body, unknown-event fallback). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/moments-api/src/main.rs | 9 +- crates/moments-core/src/lib.rs | 2 + crates/moments-core/src/presentation.rs | 31 ++ .../moments-core/src/presentation/github.rs | 488 ++++++++++++++++++ crates/moments-entities/src/lib.rs | 76 +++ 5 files changed, 602 insertions(+), 4 deletions(-) create mode 100644 crates/moments-core/src/presentation.rs create mode 100644 crates/moments-core/src/presentation/github.rs diff --git a/crates/moments-api/src/main.rs b/crates/moments-api/src/main.rs index fd7ba73..20294d8 100644 --- a/crates/moments-api/src/main.rs +++ b/crates/moments-api/src/main.rs @@ -9,9 +9,9 @@ use axum::{ }; use chrono::{DateTime, Utc}; use clap::Parser; -use moments_core::EventReader; +use moments_core::{EventReader, reshape}; use moments_data::PgStore; -use moments_entities::{Event, EventQuery, Source, SourceSummary}; +use moments_entities::{EventQuery, Source, SourceSummary, TimelineItem}; use serde::Deserialize; use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tracing::info; @@ -83,7 +83,7 @@ struct EventsQueryParams { async fn list_events( State(state): State, Query(params): Query, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let sources = params .source .as_deref() @@ -100,7 +100,8 @@ async fn list_events( }; let events = state.store.list_events(&query).await.map_err(internal)?; - Ok(Json(events)) + let items: Vec = events.iter().map(reshape).collect(); + Ok(Json(items)) } async fn list_sources( diff --git a/crates/moments-core/src/lib.rs b/crates/moments-core/src/lib.rs index 42be16d..46b8f39 100644 --- a/crates/moments-core/src/lib.rs +++ b/crates/moments-core/src/lib.rs @@ -1,5 +1,7 @@ +pub mod presentation; pub mod sources; +pub use presentation::reshape; pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_poller}; use async_trait::async_trait; diff --git a/crates/moments-core/src/presentation.rs b/crates/moments-core/src/presentation.rs new file mode 100644 index 0000000..0321a72 --- /dev/null +++ b/crates/moments-core/src/presentation.rs @@ -0,0 +1,31 @@ +//! Reshape raw stored events into the presentation shape consumed by the UI. +//! +//! Storage holds the upstream payload verbatim; transformation lives here so +//! the rendering can evolve without re-fetching upstream data. + +use moments_entities::{Event, Source, TimelineIcon, TimelineItem, TitleSegment}; + +mod github; + +pub fn reshape(event: &Event) -> TimelineItem { + match event.source { + Source::Github => github::reshape(event), + Source::Gitea | Source::Hg | Source::Bugzilla => generic_fallback(event), + } +} + +fn generic_fallback(event: &Event) -> TimelineItem { + TimelineItem { + id: event.id.clone(), + source: event.source, + action: event.action.clone(), + occurred_at: event.occurred_at, + icon: TimelineIcon::Generic, + title: vec![ + TitleSegment::text(format!("{} from ", event.action)), + TitleSegment::text(event.source.as_str().to_string()), + ], + subtitle: None, + body: None, + } +} diff --git a/crates/moments-core/src/presentation/github.rs b/crates/moments-core/src/presentation/github.rs new file mode 100644 index 0000000..08bd41c --- /dev/null +++ b/crates/moments-core/src/presentation/github.rs @@ -0,0 +1,488 @@ +use moments_entities::{ + CommitSummary, Event, Source, TimelineBody, TimelineIcon, TimelineItem, TitleSegment, +}; +use serde_json::Value; + +pub(crate) fn reshape(event: &Event) -> TimelineItem { + let p = &event.payload; + let repo_name = p.get("repo").and_then(|r| r.get("name")).and_then(Value::as_str); + let actor_login = p + .get("actor") + .and_then(|a| a.get("display_login").or_else(|| a.get("login"))) + .and_then(Value::as_str); + let inner = p.get("payload"); + + let (icon, title, subtitle, body) = match event.action.as_str() { + "PushEvent" => push(repo_name, inner), + "PullRequestEvent" => pull_request(repo_name, inner), + "PullRequestReviewEvent" => pull_request_review(repo_name, inner), + "PullRequestReviewCommentEvent" => pull_request_review_comment(repo_name, inner), + "IssuesEvent" => issues(repo_name, inner), + "IssueCommentEvent" => issue_comment(repo_name, inner), + "CreateEvent" => create(repo_name, inner), + "DeleteEvent" => delete(repo_name, inner), + "ForkEvent" => fork(repo_name, inner), + "WatchEvent" => watch(repo_name), + "ReleaseEvent" => release(repo_name, inner), + "CommitCommentEvent" => commit_comment(repo_name, inner), + "PublicEvent" => public(repo_name), + _ => fallback(repo_name, &event.action), + }; + + let title = if let Some(actor) = actor_login { + let mut segs = Vec::with_capacity(title.len() + 1); + segs.push(TitleSegment::link( + actor.to_string(), + format!("https://github.com/{actor}"), + )); + segs.push(TitleSegment::text(" ")); + segs.extend(title); + segs + } else { + title + }; + + TimelineItem { + id: event.id.clone(), + source: Source::Github, + action: event.action.clone(), + occurred_at: event.occurred_at, + icon, + title, + subtitle, + body, + } +} + +fn repo_link(repo: &str) -> TitleSegment { + TitleSegment::link(repo.to_string(), format!("https://github.com/{repo}")) +} + +fn pr_url(repo: &str, number: i64) -> String { + format!("https://github.com/{repo}/pull/{number}") +} + +fn issue_url(repo: &str, number: i64) -> String { + format!("https://github.com/{repo}/issues/{number}") +} + +fn commit_url(repo: &str, sha: &str) -> String { + format!("https://github.com/{repo}/commit/{sha}") +} + +fn ref_branch(r: &str) -> &str { + r.strip_prefix("refs/heads/").unwrap_or(r) +} + +type Reshaped = ( + TimelineIcon, + Vec, + Option>, + Option, +); + +fn push(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let size = p + .and_then(|v| v.get("distinct_size").or_else(|| v.get("size"))) + .and_then(Value::as_i64) + .unwrap_or(0); + let branch = p + .and_then(|v| v.get("ref")) + .and_then(Value::as_str) + .map(ref_branch) + .unwrap_or(""); + + let title = vec![ + TitleSegment::text(format!( + "pushed {size} commit{} to ", + if size == 1 { "" } else { "s" } + )), + repo_link(repo), + TitleSegment::text(format!(":{branch}")), + ]; + + let commits: Vec = p + .and_then(|v| v.get("commits")) + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|c| { + let sha = c.get("sha").and_then(Value::as_str)?; + let message = c + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .lines() + .next() + .unwrap_or("") + .to_string(); + let author = c + .get("author") + .and_then(|a| a.get("name")) + .and_then(Value::as_str) + .map(str::to_string); + Some(CommitSummary { + short_sha: sha.chars().take(7).collect(), + sha: sha.to_string(), + message, + url: commit_url(repo, sha), + author, + }) + }) + .collect() + }) + .unwrap_or_default(); + + let body = if commits.is_empty() { + None + } else { + Some(TimelineBody::Commits { commits }) + }; + + (TimelineIcon::GitPush, title, None, body) +} + +fn pull_request(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let action = p + .and_then(|v| v.get("action")) + .and_then(Value::as_str) + .unwrap_or("touched"); + let pr = p.and_then(|v| v.get("pull_request")); + let number = p.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0); + let pr_title = pr.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or(""); + let merged = pr + .and_then(|v| v.get("merged")) + .and_then(Value::as_bool) + .unwrap_or(false); + + let verb = if action == "closed" && merged { + "merged" + } else { + action + }; + + let icon = if verb == "merged" { + TimelineIcon::GitMerge + } else { + TimelineIcon::PullRequest + }; + + let title = vec![ + TitleSegment::text(format!("{verb} pull request ")), + TitleSegment::link(format!("#{number}"), pr_url(repo, number)), + TitleSegment::text(" in "), + repo_link(repo), + ]; + let subtitle = (!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]); + (icon, title, subtitle, None) +} + +fn pull_request_review(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let pr = p.and_then(|v| v.get("pull_request")); + let number = pr.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0); + let pr_title = pr.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or(""); + let state = p + .and_then(|v| v.get("review")) + .and_then(|r| r.get("state")) + .and_then(Value::as_str) + .unwrap_or("commented"); + + let title = vec![ + TitleSegment::text(format!("{state} review on ")), + TitleSegment::link(format!("#{number}"), pr_url(repo, number)), + TitleSegment::text(" in "), + repo_link(repo), + ]; + let subtitle = (!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]); + (TimelineIcon::PullRequest, title, subtitle, None) +} + +fn pull_request_review_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let pr = p.and_then(|v| v.get("pull_request")); + let number = pr.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0); + let pr_title = pr.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or(""); + let body_text = p + .and_then(|v| v.get("comment")) + .and_then(|c| c.get("body")) + .and_then(Value::as_str) + .unwrap_or(""); + + let title = vec![ + TitleSegment::text("commented on review of "), + TitleSegment::link(format!("#{number}"), pr_url(repo, number)), + TitleSegment::text(" in "), + repo_link(repo), + ]; + let subtitle = (!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]); + let body = (!body_text.is_empty()).then(|| TimelineBody::Markdown { + text: body_text.to_string(), + }); + (TimelineIcon::Comment, title, subtitle, body) +} + +fn issues(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let action = p + .and_then(|v| v.get("action")) + .and_then(Value::as_str) + .unwrap_or("touched"); + let issue = p.and_then(|v| v.get("issue")); + let number = issue.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0); + let issue_title = issue.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or(""); + + let title = vec![ + TitleSegment::text(format!("{action} issue ")), + TitleSegment::link(format!("#{number}"), issue_url(repo, number)), + TitleSegment::text(" in "), + repo_link(repo), + ]; + let subtitle = (!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]); + (TimelineIcon::Issue, title, subtitle, None) +} + +fn issue_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let issue = p.and_then(|v| v.get("issue")); + let number = issue.and_then(|v| v.get("number")).and_then(Value::as_i64).unwrap_or(0); + let issue_title = issue.and_then(|v| v.get("title")).and_then(Value::as_str).unwrap_or(""); + let body_text = p + .and_then(|v| v.get("comment")) + .and_then(|c| c.get("body")) + .and_then(Value::as_str) + .unwrap_or(""); + + let title = vec![ + TitleSegment::text("commented on "), + TitleSegment::link(format!("#{number}"), issue_url(repo, number)), + TitleSegment::text(" in "), + repo_link(repo), + ]; + let subtitle = (!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]); + let body = (!body_text.is_empty()).then(|| TimelineBody::Markdown { + text: body_text.to_string(), + }); + (TimelineIcon::Comment, title, subtitle, body) +} + +fn create(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let ref_type = p.and_then(|v| v.get("ref_type")).and_then(Value::as_str).unwrap_or("ref"); + let ref_name = p.and_then(|v| v.get("ref")).and_then(Value::as_str); + + let mut title = vec![TitleSegment::text(format!("created {ref_type} "))]; + if let Some(name) = ref_name { + title.push(TitleSegment::text(format!("{name} in "))); + } else { + title.push(TitleSegment::text("in ")); + } + title.push(repo_link(repo)); + (TimelineIcon::GitBranchCreate, title, None, None) +} + +fn delete(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let ref_type = p.and_then(|v| v.get("ref_type")).and_then(Value::as_str).unwrap_or("ref"); + let ref_name = p.and_then(|v| v.get("ref")).and_then(Value::as_str).unwrap_or(""); + + let title = vec![ + TitleSegment::text(format!("deleted {ref_type} {ref_name} in ")), + repo_link(repo), + ]; + (TimelineIcon::GitBranchDelete, title, None, None) +} + +fn fork(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let forkee = p.and_then(|v| v.get("forkee")); + let forkee_full = forkee.and_then(|f| f.get("full_name")).and_then(Value::as_str); + + let mut title = vec![TitleSegment::text("forked "), repo_link(repo)]; + if let Some(full) = forkee_full { + title.push(TitleSegment::text(" to ")); + title.push(TitleSegment::link( + full.to_string(), + format!("https://github.com/{full}"), + )); + } + (TimelineIcon::GitFork, title, None, None) +} + +fn watch(repo: Option<&str>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let title = vec![TitleSegment::text("starred "), repo_link(repo)]; + (TimelineIcon::Star, title, None, None) +} + +fn release(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let release = p.and_then(|v| v.get("release")); + let name = release + .and_then(|r| r.get("name").or_else(|| r.get("tag_name"))) + .and_then(Value::as_str) + .unwrap_or("(release)"); + let url = release.and_then(|r| r.get("html_url")).and_then(Value::as_str); + + let label = if let Some(u) = url { + TitleSegment::link(name.to_string(), u.to_string()) + } else { + TitleSegment::text(name.to_string()) + }; + let title = vec![ + TitleSegment::text("released "), + label, + TitleSegment::text(" in "), + repo_link(repo), + ]; + (TimelineIcon::Release, title, None, None) +} + +fn commit_comment(repo: Option<&str>, p: Option<&Value>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let body_text = p + .and_then(|v| v.get("comment")) + .and_then(|c| c.get("body")) + .and_then(Value::as_str) + .unwrap_or(""); + let title = vec![TitleSegment::text("commented on a commit in "), repo_link(repo)]; + let body = (!body_text.is_empty()).then(|| TimelineBody::Markdown { + text: body_text.to_string(), + }); + (TimelineIcon::Comment, title, None, body) +} + +fn public(repo: Option<&str>) -> Reshaped { + let repo = repo.unwrap_or("(unknown repo)"); + let title = vec![TitleSegment::text("made "), repo_link(repo), TitleSegment::text(" public")]; + (TimelineIcon::Generic, title, None, None) +} + +fn fallback(repo: Option<&str>, action: &str) -> Reshaped { + let title = match repo { + Some(r) => vec![ + TitleSegment::text(format!("{action} on ")), + repo_link(r), + ], + None => vec![TitleSegment::text(action.to_string())], + }; + (TimelineIcon::Generic, title, None, None) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use serde_json::json; + + fn ev(action: &str, payload: Value) -> Event { + Event { + id: "github:1".into(), + source: Source::Github, + action: action.into(), + occurred_at: Utc.with_ymd_and_hms(2026, 4, 14, 10, 0, 0).unwrap(), + payload, + } + } + + #[test] + fn push_event_reshape() { + let raw = json!({ + "actor": { "login": "grenade", "display_login": "grenade" }, + "repo": { "name": "grenade/vortex" }, + "payload": { + "ref": "refs/heads/main", + "size": 2, + "distinct_size": 2, + "commits": [ + { "sha": "abcdef1234567890", "message": "fix the thing", "author": { "name": "rob" } }, + { "sha": "1111111111111111", "message": "and another\nbody", "author": { "name": "rob" } } + ] + } + }); + let item = reshape(&ev("PushEvent", raw)); + assert_eq!(item.icon, TimelineIcon::GitPush); + // first segment is the actor link, then "pushed N commits to :" + assert!(matches!(item.title[0], TitleSegment::Link { .. })); + let rendered: String = item + .title + .iter() + .map(|s| match s { + TitleSegment::Text { text } => text.clone(), + TitleSegment::Link { text, .. } => text.clone(), + }) + .collect(); + assert!(rendered.contains("pushed 2 commits to grenade/vortex:main"), "got: {rendered}"); + match item.body.unwrap() { + TimelineBody::Commits { commits } => { + assert_eq!(commits.len(), 2); + assert_eq!(commits[0].short_sha, "abcdef1"); + // multi-line message gets first line only + assert_eq!(commits[1].message, "and another"); + } + _ => panic!("expected Commits body"), + } + } + + #[test] + fn merged_pr_uses_merge_icon() { + let raw = json!({ + "actor": { "login": "grenade" }, + "repo": { "name": "grenade/moments" }, + "payload": { + "action": "closed", + "number": 7, + "pull_request": { "title": "wire it up", "merged": true } + } + }); + let item = reshape(&ev("PullRequestEvent", raw)); + assert_eq!(item.icon, TimelineIcon::GitMerge); + let rendered: String = item + .title + .iter() + .map(|s| match s { + TitleSegment::Text { text } => text.clone(), + TitleSegment::Link { text, .. } => text.clone(), + }) + .collect(); + assert!(rendered.contains("merged pull request #7 in grenade/moments")); + assert_eq!( + item.subtitle.unwrap(), + vec![TitleSegment::text("wire it up")] + ); + } + + #[test] + fn issue_comment_carries_markdown_body() { + let raw = json!({ + "actor": { "login": "grenade" }, + "repo": { "name": "Nehliin/vortex" }, + "payload": { + "issue": { "number": 42, "title": "perf regression" }, + "comment": { "body": "looks like the io_uring batching changed" } + } + }); + let item = reshape(&ev("IssueCommentEvent", raw)); + assert_eq!(item.icon, TimelineIcon::Comment); + match item.body.unwrap() { + TimelineBody::Markdown { text } => { + assert!(text.contains("io_uring")); + } + _ => panic!("expected Markdown body"), + } + } + + #[test] + fn unknown_event_falls_back() { + let raw = json!({ + "actor": { "login": "grenade" }, + "repo": { "name": "grenade/x" }, + "payload": {} + }); + let item = reshape(&ev("SponsorshipEvent", raw)); + assert_eq!(item.icon, TimelineIcon::Generic); + assert_eq!(item.action, "SponsorshipEvent"); + } +} diff --git a/crates/moments-entities/src/lib.rs b/crates/moments-entities/src/lib.rs index f543c0d..080ed03 100644 --- a/crates/moments-entities/src/lib.rs +++ b/crates/moments-entities/src/lib.rs @@ -74,3 +74,79 @@ pub struct SourceSummary { pub earliest: Option>, pub latest: Option>, } + +// --------------------------------------------------------------------- +// Presentation shape — what `GET /v1/events` actually returns. +// The API reshapes raw payloads into these so the frontend stays dumb. +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineItem { + pub id: String, + pub source: Source, + pub action: String, + pub occurred_at: DateTime, + pub icon: TimelineIcon, + /// Primary headline. Mixed plain text + inline links so the UI can + /// render the right anchors without parsing. + pub title: Vec, + pub subtitle: Option>, + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum TitleSegment { + Text { text: String }, + Link { text: String, url: String }, +} + +impl TitleSegment { + pub fn text(s: impl Into) -> Self { + Self::Text { text: s.into() } + } + pub fn link(text: impl Into, url: impl Into) -> Self { + Self::Link { + text: text.into(), + url: url.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum TimelineBody { + Markdown { text: String }, + Commits { commits: Vec }, + Links { items: Vec }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitSummary { + pub sha: String, + pub short_sha: String, + pub message: String, + pub url: String, + pub author: Option, +} + +/// UI icon hint. The frontend maps these to its own icon set; new variants +/// here require a frontend update but never break existing renders (the UI +/// falls back to the generic icon for unknown values). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum TimelineIcon { + GitPush, + GitCommit, + GitMerge, + GitFork, + GitBranchCreate, + GitBranchDelete, + PullRequest, + Issue, + Comment, + Star, + Release, + Bug, + Generic, +}