feat(api): reshape raw events into TimelineItem

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 "<action> on <repo>" 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:08:18 +03:00
parent 418834c960
commit 003f427e98
5 changed files with 602 additions and 4 deletions

View File

@@ -9,9 +9,9 @@ use axum::{
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use clap::Parser; use clap::Parser;
use moments_core::EventReader; use moments_core::{EventReader, reshape};
use moments_data::PgStore; use moments_data::PgStore;
use moments_entities::{Event, EventQuery, Source, SourceSummary}; use moments_entities::{EventQuery, Source, SourceSummary, TimelineItem};
use serde::Deserialize; use serde::Deserialize;
use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing::info; use tracing::info;
@@ -83,7 +83,7 @@ struct EventsQueryParams {
async fn list_events( async fn list_events(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<EventsQueryParams>, Query(params): Query<EventsQueryParams>,
) -> Result<Json<Vec<Event>>, ApiError> { ) -> Result<Json<Vec<TimelineItem>>, ApiError> {
let sources = params let sources = params
.source .source
.as_deref() .as_deref()
@@ -100,7 +100,8 @@ async fn list_events(
}; };
let events = state.store.list_events(&query).await.map_err(internal)?; let events = state.store.list_events(&query).await.map_err(internal)?;
Ok(Json(events)) let items: Vec<TimelineItem> = events.iter().map(reshape).collect();
Ok(Json(items))
} }
async fn list_sources( async fn list_sources(

View File

@@ -1,5 +1,7 @@
pub mod presentation;
pub mod sources; pub mod sources;
pub use presentation::reshape;
pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_poller}; pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_poller};
use async_trait::async_trait; use async_trait::async_trait;

View File

@@ -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,
}
}

View File

@@ -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<TitleSegment>,
Option<Vec<TitleSegment>>,
Option<TimelineBody>,
);
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<CommitSummary> = 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 <repo>:<branch>"
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");
}
}

View File

@@ -74,3 +74,79 @@ pub struct SourceSummary {
pub earliest: Option<DateTime<Utc>>, pub earliest: Option<DateTime<Utc>>,
pub latest: Option<DateTime<Utc>>, pub latest: Option<DateTime<Utc>>,
} }
// ---------------------------------------------------------------------
// 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<Utc>,
pub icon: TimelineIcon,
/// Primary headline. Mixed plain text + inline links so the UI can
/// render the right anchors without parsing.
pub title: Vec<TitleSegment>,
pub subtitle: Option<Vec<TitleSegment>>,
pub body: Option<TimelineBody>,
}
#[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<String>) -> Self {
Self::Text { text: s.into() }
}
pub fn link(text: impl Into<String>, url: impl Into<String>) -> 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<CommitSummary> },
Links { items: Vec<TitleSegment> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitSummary {
pub sha: String,
pub short_sha: String,
pub message: String,
pub url: String,
pub author: Option<String>,
}
/// 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,
}