feat(worker): add gitea activity feed poller
Hits /api/v1/users/{user}/activities/feeds?only-performed-by=true
on the configured gitea host (default git.lair.cafe). Page-1 polling
on a 10-min cadence; first run paginates back through up to 20
pages (1000 items) to seed history.
Gitea has no ETag support on this endpoint, so each tick is a fresh
fetch — relying on idempotent upsert by `gitea:<id>` for dedup.
Reshape covers the gitea op_type set:
commit_repo → "pushed N commits to repo:branch" + commits body,
parsing the JSON-encoded `content` field
push_tag → "tagged X in repo"
create_repo → "created repo"
rename/transfer/delete_branch/delete_tag/star/fork — straightforward
create/close/reopen_issue → "{verb} issue #N in repo: title"
create/close/reopen_pull_request → "{verb} pull request #N"
merge_pull_request → GitMerge icon
comment_issue, comment_pull → markdown body from comment.body
approve/reject_pull_request, publish_release
fallback for anything else (mirror_sync_*, future op_types)
Issue / PR / release events use gitea's pipe-separated
`<index>|<title>` content field; pushes have JSON-encoded content.
Host stamping: parse_gitea_event injects `_host` into each row's
payload so the reshape layer can construct web URLs without a
config dependency. Multi-host gitea would still work as long as
each source instance has its own host configured.
Worker config:
GITEA_HOST default git.lair.cafe
GITEA_USER default grenade
GITEA_TOKEN optional (raises rate limit; required
for private repo activity to surface)
GITEA_POLL_INTERVAL_SECS default 600
Tests: +2 in moments-data (commit_repo parses, private flag
captured), +4 in moments-core (commit_repo with body, create_issue
pipe-content, merge icon swap, fallback) — 27 total green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,12 +5,14 @@
|
||||
|
||||
use moments_entities::{Event, Source, TimelineIcon, TimelineItem, TitleSegment};
|
||||
|
||||
mod gitea;
|
||||
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),
|
||||
Source::Gitea => gitea::reshape(event),
|
||||
Source::Hg | Source::Bugzilla => generic_fallback(event),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
496
crates/moments-core/src/presentation/gitea.rs
Normal file
496
crates/moments-core/src/presentation/gitea.rs
Normal file
@@ -0,0 +1,496 @@
|
||||
use moments_entities::{
|
||||
CommitSummary, Event, Source, TimelineBody, TimelineIcon, TimelineItem, TitleSegment,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
const FALLBACK_HOST: &str = "git.lair.cafe";
|
||||
|
||||
pub(crate) fn reshape(event: &Event) -> TimelineItem {
|
||||
let p = &event.payload;
|
||||
let host = p
|
||||
.get("_host")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(FALLBACK_HOST);
|
||||
let repo = p
|
||||
.get("repo")
|
||||
.and_then(|r| r.get("full_name"))
|
||||
.and_then(Value::as_str);
|
||||
let actor = p
|
||||
.get("act_user")
|
||||
.and_then(|u| u.get("login"))
|
||||
.and_then(Value::as_str);
|
||||
let ref_name = p.get("ref_name").and_then(Value::as_str);
|
||||
let content = p.get("content").and_then(Value::as_str);
|
||||
let comment = p.get("comment");
|
||||
|
||||
let (icon, title, subtitle, body) = match event.action.as_str() {
|
||||
"commit_repo" => commit_repo(host, repo, ref_name, content),
|
||||
"push_tag" => push_tag(host, repo, ref_name),
|
||||
"create_repo" => create_repo(host, repo),
|
||||
"rename_repo" => rename_repo(host, repo, content),
|
||||
"transfer_repo" => transfer_repo(host, repo, content),
|
||||
"fork_repo" => fork_repo(host, repo),
|
||||
"delete_branch" => delete_branch(host, repo, ref_name),
|
||||
"delete_tag" => delete_tag(host, repo, ref_name),
|
||||
"star_repo" => star_repo(host, repo),
|
||||
"create_issue" => issue_action("opened", TimelineIcon::Issue, host, repo, content),
|
||||
"close_issue" => issue_action("closed", TimelineIcon::Issue, host, repo, content),
|
||||
"reopen_issue" => issue_action("reopened", TimelineIcon::Issue, host, repo, content),
|
||||
"comment_issue" => comment_on_issue(host, repo, content, comment),
|
||||
"create_pull_request" => {
|
||||
pr_action("opened", TimelineIcon::PullRequest, host, repo, content)
|
||||
}
|
||||
"close_pull_request" => {
|
||||
pr_action("closed", TimelineIcon::PullRequest, host, repo, content)
|
||||
}
|
||||
"reopen_pull_request" => {
|
||||
pr_action("reopened", TimelineIcon::PullRequest, host, repo, content)
|
||||
}
|
||||
"merge_pull_request" | "auto_merge_pull_request" => {
|
||||
pr_action("merged", TimelineIcon::GitMerge, host, repo, content)
|
||||
}
|
||||
"comment_pull" => comment_on_pr(host, repo, content, comment),
|
||||
"approve_pull_request" => {
|
||||
pr_action("approved", TimelineIcon::PullRequest, host, repo, content)
|
||||
}
|
||||
"reject_pull_request" => {
|
||||
pr_action(
|
||||
"requested changes on",
|
||||
TimelineIcon::PullRequest,
|
||||
host,
|
||||
repo,
|
||||
content,
|
||||
)
|
||||
}
|
||||
"publish_release" => publish_release(host, repo, content),
|
||||
_ => fallback(host, repo, &event.action),
|
||||
};
|
||||
|
||||
let title = if let Some(actor_login) = actor {
|
||||
let mut segs = Vec::with_capacity(title.len() + 2);
|
||||
segs.push(TitleSegment::link(
|
||||
actor_login.to_string(),
|
||||
format!("https://{host}/{actor_login}"),
|
||||
));
|
||||
segs.push(TitleSegment::text(" "));
|
||||
segs.extend(title);
|
||||
segs
|
||||
} else {
|
||||
title
|
||||
};
|
||||
|
||||
TimelineItem {
|
||||
id: event.id.clone(),
|
||||
source: Source::Gitea,
|
||||
action: event.action.clone(),
|
||||
occurred_at: event.occurred_at,
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
type Reshaped = (
|
||||
TimelineIcon,
|
||||
Vec<TitleSegment>,
|
||||
Option<Vec<TitleSegment>>,
|
||||
Option<TimelineBody>,
|
||||
);
|
||||
|
||||
fn repo_link(host: &str, repo: &str) -> TitleSegment {
|
||||
TitleSegment::link(repo.to_string(), format!("https://{host}/{repo}"))
|
||||
}
|
||||
|
||||
fn commit_url(host: &str, repo: &str, sha: &str) -> String {
|
||||
format!("https://{host}/{repo}/commit/{sha}")
|
||||
}
|
||||
|
||||
fn issue_url(host: &str, repo: &str, index: i64) -> String {
|
||||
format!("https://{host}/{repo}/issues/{index}")
|
||||
}
|
||||
|
||||
fn pr_url(host: &str, repo: &str, index: i64) -> String {
|
||||
format!("https://{host}/{repo}/pulls/{index}")
|
||||
}
|
||||
|
||||
fn ref_branch(r: &str) -> &str {
|
||||
r.strip_prefix("refs/heads/").unwrap_or(r)
|
||||
}
|
||||
|
||||
fn ref_tag(r: &str) -> &str {
|
||||
r.strip_prefix("refs/tags/").unwrap_or(r)
|
||||
}
|
||||
|
||||
/// Parse `<index>|<title>` content used by issue / PR / release events.
|
||||
fn parse_pipe_content(content: Option<&str>) -> Option<(i64, &str)> {
|
||||
let s = content?;
|
||||
let (idx_str, title) = s.split_once('|')?;
|
||||
let idx: i64 = idx_str.parse().ok()?;
|
||||
Some((idx, title))
|
||||
}
|
||||
|
||||
fn commit_repo(
|
||||
host: &str,
|
||||
repo: Option<&str>,
|
||||
ref_name: Option<&str>,
|
||||
content: Option<&str>,
|
||||
) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let branch = ref_name.map(ref_branch).unwrap_or("");
|
||||
|
||||
// content is JSON-encoded { Commits, HeadCommit, CompareURL, Len }.
|
||||
let parsed: Option<Value> = content.and_then(|s| serde_json::from_str(s).ok());
|
||||
let commits: Vec<CommitSummary> = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("Commits"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|c| {
|
||||
let sha = c.get("Sha1").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("AuthorName")
|
||||
.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(host, repo, sha),
|
||||
author,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let count = parsed
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("Len"))
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(commits.len() as i64);
|
||||
|
||||
let title = if count > 0 {
|
||||
let plural = if count == 1 { "" } else { "s" };
|
||||
vec![
|
||||
TitleSegment::text(format!("pushed {count} commit{plural} to ")),
|
||||
repo_link(host, repo),
|
||||
TitleSegment::text(format!(":{branch}")),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
TitleSegment::text("pushed to "),
|
||||
repo_link(host, repo),
|
||||
TitleSegment::text(format!(":{branch}")),
|
||||
]
|
||||
};
|
||||
|
||||
let body = (!commits.is_empty()).then_some(TimelineBody::Commits { commits });
|
||||
(TimelineIcon::GitPush, title, None, body)
|
||||
}
|
||||
|
||||
fn push_tag(host: &str, repo: Option<&str>, ref_name: Option<&str>) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let tag = ref_name.map(ref_tag).unwrap_or("");
|
||||
let title = vec![
|
||||
TitleSegment::text(format!("tagged {tag} in ")),
|
||||
repo_link(host, repo),
|
||||
];
|
||||
(TimelineIcon::Release, title, None, None)
|
||||
}
|
||||
|
||||
fn create_repo(host: &str, repo: Option<&str>) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let title = vec![TitleSegment::text("created "), repo_link(host, repo)];
|
||||
(TimelineIcon::GitBranchCreate, title, None, None)
|
||||
}
|
||||
|
||||
fn rename_repo(host: &str, repo: Option<&str>, content: Option<&str>) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let mut title = vec![TitleSegment::text("renamed ")];
|
||||
if let Some(old) = content {
|
||||
title.push(TitleSegment::text(format!("{old} → ")));
|
||||
}
|
||||
title.push(repo_link(host, repo));
|
||||
(TimelineIcon::Generic, title, None, None)
|
||||
}
|
||||
|
||||
fn transfer_repo(host: &str, repo: Option<&str>, content: Option<&str>) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let mut title = vec![TitleSegment::text("transferred ")];
|
||||
if let Some(prev) = content {
|
||||
title.push(TitleSegment::text(format!("{prev} to ")));
|
||||
} else {
|
||||
title.push(TitleSegment::text("to "));
|
||||
}
|
||||
title.push(repo_link(host, repo));
|
||||
(TimelineIcon::Generic, title, None, None)
|
||||
}
|
||||
|
||||
fn fork_repo(host: &str, repo: Option<&str>) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let title = vec![TitleSegment::text("forked "), repo_link(host, repo)];
|
||||
(TimelineIcon::GitFork, title, None, None)
|
||||
}
|
||||
|
||||
fn delete_branch(host: &str, repo: Option<&str>, ref_name: Option<&str>) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let branch = ref_name.map(ref_branch).unwrap_or("");
|
||||
let title = vec![
|
||||
TitleSegment::text(format!("deleted branch {branch} in ")),
|
||||
repo_link(host, repo),
|
||||
];
|
||||
(TimelineIcon::GitBranchDelete, title, None, None)
|
||||
}
|
||||
|
||||
fn delete_tag(host: &str, repo: Option<&str>, ref_name: Option<&str>) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let tag = ref_name.map(ref_tag).unwrap_or("");
|
||||
let title = vec![
|
||||
TitleSegment::text(format!("deleted tag {tag} in ")),
|
||||
repo_link(host, repo),
|
||||
];
|
||||
(TimelineIcon::GitBranchDelete, title, None, None)
|
||||
}
|
||||
|
||||
fn star_repo(host: &str, repo: Option<&str>) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let title = vec![TitleSegment::text("starred "), repo_link(host, repo)];
|
||||
(TimelineIcon::Star, title, None, None)
|
||||
}
|
||||
|
||||
fn issue_action(
|
||||
verb: &str,
|
||||
icon: TimelineIcon,
|
||||
host: &str,
|
||||
repo: Option<&str>,
|
||||
content: Option<&str>,
|
||||
) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let (idx, issue_title) = parse_pipe_content(content).unwrap_or((0, ""));
|
||||
let title = vec![
|
||||
TitleSegment::text(format!("{verb} issue ")),
|
||||
TitleSegment::link(format!("#{idx}"), issue_url(host, repo, idx)),
|
||||
TitleSegment::text(" in "),
|
||||
repo_link(host, repo),
|
||||
];
|
||||
let subtitle =
|
||||
(!issue_title.is_empty()).then(|| vec![TitleSegment::text(issue_title.to_string())]);
|
||||
(icon, title, subtitle, None)
|
||||
}
|
||||
|
||||
fn pr_action(
|
||||
verb: &str,
|
||||
icon: TimelineIcon,
|
||||
host: &str,
|
||||
repo: Option<&str>,
|
||||
content: Option<&str>,
|
||||
) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let (idx, pr_title) = parse_pipe_content(content).unwrap_or((0, ""));
|
||||
let title = vec![
|
||||
TitleSegment::text(format!("{verb} pull request ")),
|
||||
TitleSegment::link(format!("#{idx}"), pr_url(host, repo, idx)),
|
||||
TitleSegment::text(" in "),
|
||||
repo_link(host, repo),
|
||||
];
|
||||
let subtitle =
|
||||
(!pr_title.is_empty()).then(|| vec![TitleSegment::text(pr_title.to_string())]);
|
||||
(icon, title, subtitle, None)
|
||||
}
|
||||
|
||||
fn comment_on_issue(
|
||||
host: &str,
|
||||
repo: Option<&str>,
|
||||
content: Option<&str>,
|
||||
comment: Option<&Value>,
|
||||
) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let (idx, issue_title) = parse_pipe_content(content).unwrap_or((0, ""));
|
||||
let body_text = comment
|
||||
.and_then(|c| c.get("body"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let title = vec![
|
||||
TitleSegment::text("commented on "),
|
||||
TitleSegment::link(format!("#{idx}"), issue_url(host, repo, idx)),
|
||||
TitleSegment::text(" in "),
|
||||
repo_link(host, 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 comment_on_pr(
|
||||
host: &str,
|
||||
repo: Option<&str>,
|
||||
content: Option<&str>,
|
||||
comment: Option<&Value>,
|
||||
) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let (idx, pr_title) = parse_pipe_content(content).unwrap_or((0, ""));
|
||||
let body_text = comment
|
||||
.and_then(|c| c.get("body"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let title = vec![
|
||||
TitleSegment::text("commented on "),
|
||||
TitleSegment::link(format!("#{idx}"), pr_url(host, repo, idx)),
|
||||
TitleSegment::text(" in "),
|
||||
repo_link(host, 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 publish_release(host: &str, repo: Option<&str>, content: Option<&str>) -> Reshaped {
|
||||
let repo = repo.unwrap_or("(unknown repo)");
|
||||
let name = content.unwrap_or("");
|
||||
let title = if name.is_empty() {
|
||||
vec![TitleSegment::text("published a release in "), repo_link(host, repo)]
|
||||
} else {
|
||||
vec![
|
||||
TitleSegment::text(format!("released {name} in ")),
|
||||
repo_link(host, repo),
|
||||
]
|
||||
};
|
||||
(TimelineIcon::Release, title, None, None)
|
||||
}
|
||||
|
||||
fn fallback(host: &str, repo: Option<&str>, action: &str) -> Reshaped {
|
||||
let title = match repo {
|
||||
Some(r) => vec![
|
||||
TitleSegment::text(format!("{action} on ")),
|
||||
repo_link(host, 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: "gitea:1".into(),
|
||||
source: Source::Gitea,
|
||||
action: action.into(),
|
||||
occurred_at: Utc.with_ymd_and_hms(2026, 5, 3, 16, 37, 45).unwrap(),
|
||||
public: true,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(item: &TimelineItem) -> String {
|
||||
item.title
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
TitleSegment::Text { text } => text.clone(),
|
||||
TitleSegment::Link { text, .. } => text.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_repo_with_commits_body() {
|
||||
let raw = json!({
|
||||
"_host": "git.lair.cafe",
|
||||
"act_user": { "login": "grenade" },
|
||||
"repo": { "full_name": "grenade/moments" },
|
||||
"ref_name": "refs/heads/main",
|
||||
"content": "{\"Commits\":[{\"Sha1\":\"abcdef1234\",\"Message\":\"first\",\"AuthorName\":\"rob\"}],\"Len\":1}"
|
||||
});
|
||||
let item = reshape(&ev("commit_repo", raw));
|
||||
assert_eq!(item.icon, TimelineIcon::GitPush);
|
||||
let r = render(&item);
|
||||
assert!(
|
||||
r.contains("pushed 1 commit to grenade/moments:main"),
|
||||
"got: {r}"
|
||||
);
|
||||
match item.body.unwrap() {
|
||||
TimelineBody::Commits { commits } => {
|
||||
assert_eq!(commits.len(), 1);
|
||||
assert_eq!(commits[0].short_sha, "abcdef1");
|
||||
assert_eq!(
|
||||
commits[0].url,
|
||||
"https://git.lair.cafe/grenade/moments/commit/abcdef1234"
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Commits body"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_issue_uses_pipe_content() {
|
||||
let raw = json!({
|
||||
"_host": "git.lair.cafe",
|
||||
"act_user": { "login": "grenade" },
|
||||
"repo": { "full_name": "grenade/moments" },
|
||||
"content": "1|implement per-repo enumeration for full commit history"
|
||||
});
|
||||
let item = reshape(&ev("create_issue", raw));
|
||||
assert_eq!(item.icon, TimelineIcon::Issue);
|
||||
let r = render(&item);
|
||||
assert!(
|
||||
r.contains("opened issue #1 in grenade/moments"),
|
||||
"got: {r}"
|
||||
);
|
||||
assert_eq!(
|
||||
item.subtitle.unwrap(),
|
||||
vec![TitleSegment::text(
|
||||
"implement per-repo enumeration for full commit history"
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_pull_request_uses_merge_icon() {
|
||||
let raw = json!({
|
||||
"_host": "git.lair.cafe",
|
||||
"act_user": { "login": "grenade" },
|
||||
"repo": { "full_name": "grenade/moments" },
|
||||
"content": "7|wire it up"
|
||||
});
|
||||
let item = reshape(&ev("merge_pull_request", raw));
|
||||
assert_eq!(item.icon, TimelineIcon::GitMerge);
|
||||
let r = render(&item);
|
||||
assert!(
|
||||
r.contains("merged pull request #7 in grenade/moments"),
|
||||
"got: {r}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_for_unknown_op_type() {
|
||||
let raw = json!({
|
||||
"_host": "git.lair.cafe",
|
||||
"act_user": { "login": "grenade" },
|
||||
"repo": { "full_name": "grenade/x" }
|
||||
});
|
||||
let item = reshape(&ev("mirror_sync_push", raw));
|
||||
assert_eq!(item.icon, TimelineIcon::Generic);
|
||||
let r = render(&item);
|
||||
assert!(r.contains("mirror_sync_push on grenade/x"), "got: {r}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user