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

@@ -74,3 +74,79 @@ pub struct SourceSummary {
pub earliest: 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,
}