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:
488
crates/moments-core/src/presentation/github.rs
Normal file
488
crates/moments-core/src/presentation/github.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user