use moments_entities::{ CommitSummary, Event, Source, TimelineBody, TimelineIcon, TimelineItem, TitleSegment, }; use serde_json::Value; pub(crate) fn reshape(event: &Event) -> TimelineItem { // Search-API items have a different payload shape (the search item itself // rather than a wrapped event), so dispatch them through a separate path. match event.action.as_str() { "Issue" | "PullRequest" => return search_reshape(event), "Commit" => return commit_reshape(event), _ => {} } 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 search_reshape(event: &Event) -> TimelineItem { let p = &event.payload; let html_url = p.get("html_url").and_then(Value::as_str).unwrap_or(""); let number = p.get("number").and_then(Value::as_i64).unwrap_or(0); let issue_title = p.get("title").and_then(Value::as_str).unwrap_or(""); let state = p.get("state").and_then(Value::as_str).unwrap_or(""); let pr_obj = p.get("pull_request"); let is_pr = pr_obj.is_some(); let merged = pr_obj .and_then(|pr| pr.get("merged_at")) .map(|v| !v.is_null()) .unwrap_or(false); let user_login = p .get("user") .and_then(|u| u.get("login")) .and_then(Value::as_str); let repo = repo_from_url(html_url).unwrap_or_else(|| "(unknown repo)".into()); let verb = match (is_pr, state, merged) { (true, "closed", true) => "merged", (true, "closed", false) => "closed", (true, _, _) => "opened", (false, "closed", _) => "closed", (false, _, _) => "opened", }; let kind = if is_pr { "pull request" } else { "issue" }; let icon = match (is_pr, verb) { (true, "merged") => TimelineIcon::GitMerge, (true, _) => TimelineIcon::PullRequest, (false, _) => TimelineIcon::Issue, }; let mut title = Vec::new(); if let Some(actor) = user_login { title.push(TitleSegment::link( actor.to_string(), format!("https://github.com/{actor}"), )); title.push(TitleSegment::text(" ")); } title.push(TitleSegment::text(format!("{verb} {kind} "))); title.push(TitleSegment::link(format!("#{number}"), html_url.to_string())); title.push(TitleSegment::text(" in ")); title.push(repo_link(&repo)); let subtitle = (!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]); TimelineItem { id: event.id.clone(), source: Source::Github, action: event.action.clone(), occurred_at: event.occurred_at, icon, title, subtitle, body: None, } } fn commit_reshape(event: &Event) -> TimelineItem { let p = &event.payload; let sha = p.get("sha").and_then(Value::as_str).unwrap_or(""); let short_sha: String = sha.chars().take(7).collect(); let html_url = p.get("html_url").and_then(Value::as_str).unwrap_or(""); let message_first_line = p .get("commit") .and_then(|c| c.get("message")) .and_then(Value::as_str) .unwrap_or("") .lines() .next() .unwrap_or("") .to_string(); let repo = p .get("repository") .and_then(|r| r.get("full_name")) .and_then(Value::as_str) .unwrap_or("(unknown repo)"); let author_login = p .get("author") .and_then(|a| a.get("login")) .and_then(Value::as_str); let mut title = Vec::new(); if let Some(actor) = author_login { title.push(TitleSegment::link( actor.to_string(), format!("https://github.com/{actor}"), )); title.push(TitleSegment::text(" ")); } title.push(TitleSegment::text("committed ")); title.push(TitleSegment::link(short_sha, html_url.to_string())); title.push(TitleSegment::text(" in ")); title.push(repo_link(repo)); let subtitle = (!message_first_line.is_empty()) .then(|| vec![TitleSegment::text(message_first_line)]); TimelineItem { id: event.id.clone(), source: Source::Github, action: event.action.clone(), occurred_at: event.occurred_at, icon: TimelineIcon::GitCommit, title, subtitle, body: None, } } fn repo_from_url(url: &str) -> Option { let stripped = url.strip_prefix("https://github.com/")?; let mut parts = stripped.splitn(3, '/'); let owner = parts.next()?; let repo = parts.next()?; (!owner.is_empty() && !repo.is_empty()).then(|| format!("{owner}/{repo}")) } 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(), public: true, 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 search_issue_reshape_open() { let raw = json!({ "number": 125, "title": "Feature: peer blocklist", "state": "open", "html_url": "https://github.com/Nehliin/vortex/issues/125", "user": { "login": "grenade" } }); let item = reshape(&ev("Issue", raw)); assert_eq!(item.icon, TimelineIcon::Issue); let rendered: String = item .title .iter() .map(|s| match s { TitleSegment::Text { text } => text.clone(), TitleSegment::Link { text, .. } => text.clone(), }) .collect(); assert!( rendered.contains("opened issue #125 in Nehliin/vortex"), "got: {rendered}" ); } #[test] fn search_pr_reshape_merged_uses_merge_icon() { let raw = json!({ "number": 42, "title": "wire it up", "state": "closed", "html_url": "https://github.com/grenade/moments/pull/42", "user": { "login": "grenade" }, "pull_request": { "merged_at": "2026-04-15T10:00:00Z" } }); let item = reshape(&ev("PullRequest", 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 #42 in grenade/moments"), "got: {rendered}" ); } #[test] fn commit_reshape_uses_short_sha_and_first_message_line() { let raw = json!({ "sha": "a6fcefbe909a97ad5a049b9fa48bc74309af10d9", "html_url": "https://github.com/faith1337z/Trade/commit/a6fcefbe909a97ad5a049b9fa48bc74309af10d9", "commit": { "message": "split multiline message into multiple irc messages\n\nbody body body" }, "repository": { "full_name": "faith1337z/Trade" }, "author": { "login": "grenade" } }); let item = reshape(&ev("Commit", raw)); assert_eq!(item.icon, TimelineIcon::GitCommit); let rendered: String = item .title .iter() .map(|s| match s { TitleSegment::Text { text } => text.clone(), TitleSegment::Link { text, .. } => text.clone(), }) .collect(); assert!(rendered.contains("committed a6fcefb in faith1337z/Trade"), "got: {rendered}"); // body of the commit message is dropped; only first line in subtitle assert_eq!( item.subtitle.unwrap(), vec![TitleSegment::text("split multiline message into multiple irc messages")] ); } #[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"); } }