Files
moments/crates/moments-entities/src/lib.rs
rob thijssen 3c0253519f feat: ingest private events; surface public-only
The DB now stores everything GitHub will give us, the API only ever
returns public events (for now).

Endpoint switch in the github poller: when GITHUB_TOKEN is set we
hit /users/{u}/events (public + private), otherwise fall back to
/users/{u}/events/public. Either way each event's top-level `public`
boolean is captured into a new column.

Schema:
  migration 0003_event_public.sql adds events.public BOOLEAN NOT NULL
  DEFAULT true, plus an index on (public, occurred_at DESC).

Wire:
  Event gains a `public: bool` field.
  EventQuery gains `include_private: bool` (default false).
  list_events and source_summaries gate on it.
  moments-api pins include_private = false at every call site —
  threading it as a query param is a future-auth concern, not now.

The default-true on the column keeps existing rows correct: the 11
events already in the DB came from /events/public and are genuinely
public.

After this change, clear poller_state so the next worker run does a
fresh backfill via /events:

  DELETE FROM poller_state WHERE source = 'github';

Tests: +2 in github poller (private flag captured, default-public
on missing field) — 10 total green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:33:40 +03:00

160 lines
4.5 KiB
Rust

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Source {
Github,
Gitea,
Hg,
Bugzilla,
}
impl Source {
pub const ALL: &'static [Source] = &[
Source::Github,
Source::Gitea,
Source::Hg,
Source::Bugzilla,
];
pub fn as_str(&self) -> &'static str {
match self {
Source::Github => "github",
Source::Gitea => "gitea",
Source::Hg => "hg",
Source::Bugzilla => "bugzilla",
}
}
}
impl std::str::FromStr for Source {
type Err = ParseSourceError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"github" => Ok(Source::Github),
"gitea" => Ok(Source::Gitea),
"hg" => Ok(Source::Hg),
"bugzilla" => Ok(Source::Bugzilla),
other => Err(ParseSourceError(other.to_string())),
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("unknown source: {0}")]
pub struct ParseSourceError(pub String);
/// Raw event as stored. The presentation reshape lives in `moments-core`
/// and runs at API request time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub id: String,
pub source: Source,
pub action: String,
pub occurred_at: DateTime<Utc>,
/// True when the upstream marks this event as visible to anyone (e.g.
/// GitHub's top-level `public` flag). The DB stores everything; the API
/// uses this to gate what gets surfaced on the public timeline.
pub public: bool,
pub payload: serde_json::Value,
}
/// Filters accepted by `GET /v1/events`.
#[derive(Debug, Clone, Default)]
pub struct EventQuery {
pub from: Option<DateTime<Utc>>,
pub to: Option<DateTime<Utc>>,
pub sources: Option<Vec<Source>>,
/// When false (default), only `public = true` rows are returned. The API
/// pins this to false today; a future authenticated path can flip it.
pub include_private: bool,
pub limit: u32,
}
/// Per-source rollup returned by `GET /v1/sources`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceSummary {
pub source: Source,
pub count: i64,
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,
}