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>
160 lines
4.5 KiB
Rust
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,
|
|
}
|