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:
2026-05-03 19:41:55 +03:00
parent 4355353395
commit f750e8de47
5 changed files with 739 additions and 2 deletions

View File

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

View 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}");
}
}