Files
rhema-claw/integrations/rhema-forge-integration/src/event.rs
T
vhaudiquet 81da26b955 feat(rhema-code): add the colors setting with four palettes
The stored `colors` key names a palette — rhema (the default,
Jadeite), aquamarine, black-and-white, or dracula — and the
Appearance page of /settings cycles it live, like the theme and
icon toggles beside it. Each palette carries a dark and a light
appearance under the existing `theme` key; dracula's light side is
derived, the official palette ships only dark.

The appearance decision moves off token comparison: Theme now
answers is_dark() from the ground/text luminance, so palettes
beyond the original two get the right per-profile fallback tones.
2026-10-17 04:28:31 +08:00

510 lines
18 KiB
Rust

//! The normalized event vocabulary shared by every forge backend.
//!
//! Three REST dialects reduce to one shape here. Constraints the
//! normalization pins (each backend module documents how it meets them):
//! - An event is a thread-level fact ([`EventKind::Opened`], `Reopened`,
//! `Closed`, `Merged`), a comment ([`EventKind::Comment`]), or a
//! submitted review ([`EventKind::Review`]). Whether the thread is an
//! issue or a pull/merge request rides on [`ForgeEvent::is_pr`], so the
//! kind list stays small and the "issue comment vs PR comment" split is
//! `EventKind::Comment` + `is_pr`.
//! - [`ForgeEvent::id`] is the dedup id the RSD dispatcher keys on: the
//! feed's item id plus, for thread-level rows, the row's updated time —
//! stable while a row is unchanged, new the moment it changes, exactly
//! the "thread's updated_at as dedup id" scheme of RSD §8. Comment and
//! review rows are immutable enough that their own ids suffice.
//! - Thread-level polls read row snapshots, not transitions: the actor on
//! such an event is the thread author, never the (unknowable from a
//! poll) closer or merger.
use core::cmp::Ordering;
use core::fmt;
use serde::de::{self, Deserializer};
use serde::ser::Serializer;
use serde::{Deserialize, Serialize};
/// What happened to a thread.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
/// The thread was created.
Opened,
/// A closed thread returned to open.
Reopened,
/// The thread was closed (a pull/merge request: closed unmerged).
Closed,
/// A pull/merge request was merged. Never an issue.
Merged,
/// A comment was posted; [`ForgeEvent::is_pr`] separates issue from
/// pull-request comments.
Comment,
/// A formal review was submitted on a pull/merge request.
Review,
}
/// The coarse state of a thread as it stands right now.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThreadState {
/// Open for business.
Open,
/// Closed; a pull/merge request closed unmerged.
Closed,
/// A merged pull/merge request.
Merged,
}
/// Repository coordinates. `owner` + `name` + a thread [`ForgeEvent::number`]
/// is the resource key the RSD dispatcher forms (`host:owner/repo#number`).
///
/// GitLab's `owner` is the whole namespace path (`group/subgroup`), so
/// [`RepoRef::path`] is always the full project path the forge addresses.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RepoRef {
/// Owner login, or the namespace path on GitLab.
pub owner: String,
/// Repository or project slug.
pub name: String,
}
impl RepoRef {
/// Coordinates for `owner`/`name`. Neither is validated: callers pass
/// forge-correct names (the adapters build these from forge data).
pub fn new(owner: impl Into<String>, name: impl Into<String>) -> Self {
Self {
owner: owner.into(),
name: name.into(),
}
}
/// `owner/name` — the full project path.
pub fn path(&self) -> String {
format!("{}/{}", self.owner, self.name)
}
}
/// A thread's current state: everything a handler needs to orient without
/// a second API round-trip.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Snapshot {
/// The thread title.
pub title: String,
/// The opening body, when the thread has one.
pub body: Option<String>,
/// Open, closed, or merged.
pub state: ThreadState,
/// Whether the thread is a pull/merge request.
pub is_pr: bool,
/// Who opened the thread.
pub author: Option<String>,
/// Label names, bare.
pub labels: Vec<String>,
/// Human-facing URL of the thread.
pub url: Option<String>,
/// The untouched wire row the snapshot came from.
pub raw: serde_json::Value,
}
/// One normalized piece of forge activity: a thread transition, a comment,
/// or a submitted review.
///
/// The shape the RSD dispatcher consumes: [`ForgeEvent::repo`] plus
/// [`ForgeEvent::number`] form the resource key, [`ForgeEvent::id`] is the
/// dedup id, and [`ForgeEvent::raw`] carries the untouched wire row for
/// handler context.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ForgeEvent {
/// Stable dedup id: the feed's item id plus, for thread-level rows,
/// the row's updated time (e.g. `github:pull:1824@2026-10-08T12:00:00Z`,
/// `github:comment:29482`). Two polls of the same unchanged row produce
/// the same id; a changed row produces a new one.
pub id: String,
/// What happened.
pub kind: EventKind,
/// The repository the thread lives in.
pub repo: RepoRef,
/// The thread's number (GitLab: the iid).
pub number: u64,
/// Whether the thread is a pull/merge request rather than an issue.
pub is_pr: bool,
/// Who acted: the thread author on thread-level events (a poll sees row
/// snapshots, not transitions, so the closer/merger is unknowable), the
/// comment or review author otherwise.
pub actor: Option<String>,
/// The comment or review text; `None` on thread-level events (the
/// thread body lives on the snapshot, not on the event).
pub body: Option<String>,
/// Human-facing URL of the event, when the wire row carries one.
pub url: Option<String>,
/// When the row was last touched (thread rows) or created/submitted
/// (comments, reviews). The poll cursor advances to the newest of
/// these.
pub timestamp: Timestamp,
/// The untouched wire row, minus the keys the event lifts.
pub raw: serde_json::Value,
}
/// Why the account was notified. The forges' own reason vocabularies,
/// reduced to the handful a triage can act on; whatever a forge says that
/// does not map rides as [`NotificationReason::Other`] with the forge's
/// own spelling.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationReason {
/// The account was asked to review a pull/merge request.
ReviewRequested,
/// New feedback landed on a thread the account authored or
/// participates in (a submitted review or a conversation comment).
ReviewFeedback,
/// The account was assigned the thread.
Assigned,
/// The account was mentioned or directly addressed.
Mention,
/// CI status on a thread the account is involved in.
CiActivity,
/// Activity on a thread the account authored.
Author,
/// Anything else, in the forge's own spelling.
Other(String),
}
impl NotificationReason {
/// The forge-independent spelling, for triage matching and prompts.
pub fn as_str(&self) -> &str {
match self {
NotificationReason::ReviewRequested => "review_requested",
NotificationReason::ReviewFeedback => "review_feedback",
NotificationReason::Assigned => "assigned",
NotificationReason::Mention => "mention",
NotificationReason::CiActivity => "ci_activity",
NotificationReason::Author => "author",
NotificationReason::Other(raw) => raw,
}
}
}
/// One account-scoped notification: something happened on a thread that
/// concerns the account the client authenticates as. Distinct from a
/// [`ForgeEvent`], which is a thread-level fact lifted from a
/// repo-scoped feed — the notification says *why the account cares*.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Notification {
/// Stable dedup id, `{source}:notification:{id}@{updated time}`
/// (e.g. `github:notification:42188@2026-10-08T12:00:00Z`): a
/// notification row that stays put produces the same id across
/// polls; a row that moves produces a new one.
pub id: String,
/// Why the account was notified.
pub reason: NotificationReason,
/// The repository the thread lives in.
pub repo: RepoRef,
/// The thread's number (GitLab: the iid).
pub number: u64,
/// Whether the thread is a pull/merge request rather than an issue.
pub is_pr: bool,
/// Who acted, when the forge names it.
pub actor: Option<String>,
/// The thread's title, when the row carries it.
pub title: Option<String>,
/// Human-facing URL of the thread, when the row carries one.
pub url: Option<String>,
/// When the notification row was last touched. The poll cursor
/// advances to the newest of these.
pub timestamp: Timestamp,
/// The untouched wire row, minus the keys the notification lifts.
pub raw: serde_json::Value,
}
/// One notifications poll's worth, with the cursor to continue from.
/// Same fold rules as [`ForgePage`]: oldest first, id-broken ties, and
/// the cursor never goes backwards.
#[derive(Debug, Clone, PartialEq)]
pub struct NotificationPage {
/// The notifications, oldest first, ties broken by id — a
/// deterministic order regardless of which feed produced a row.
pub notifications: Vec<Notification>,
/// The cursor for the next poll: the newest event time seen, never
/// earlier than the poll's `since`. A backend with no server-side
/// time filter ignores the cursor; repetition is harmless under the
/// dedup ids.
pub cursor: Option<Timestamp>,
}
impl NotificationPage {
/// Folds collected notifications into a page: sorts them and advances
/// the cursor past everything seen.
pub(crate) fn finish(mut notifications: Vec<Notification>, since: Option<&Timestamp>) -> Self {
notifications.sort_by(|a, b| (&a.timestamp, &a.id).cmp(&(&b.timestamp, &b.id)));
let mut cursor = since.cloned();
for timestamp in notifications.iter().map(|row| &row.timestamp) {
cursor = Some(match cursor {
Some(current) if current >= *timestamp => current,
_ => timestamp.clone(),
});
}
Self {
notifications,
cursor,
}
}
}
/// An RFC 3339 timestamp, kept in the forge's own spelling.
///
/// The raw string is what travels: it round-trips onto cursors and into
/// events unchanged. Ordering compares parsed instants, never text — the
/// forges disagree on fractional seconds, and `…:00Z` must sort before
/// `…:00.081Z`, which string comparison gets backwards.
#[derive(Debug, Clone)]
pub struct Timestamp {
raw: String,
/// Whole seconds since the Unix epoch (negative before 1970).
secs: i64,
/// Sub-second nanoseconds, `[0, 1_000_000_000)`.
nanos: u32,
}
impl Timestamp {
/// Parses an RFC 3339 timestamp: `YYYY-MM-DDThh:mm:ss[.frac]` followed
/// by `Z` (either case) or a `±hh:mm` offset. `None` on anything else.
///
/// Validation is structural, not calendrical (day-of-month is only
/// bounds-checked): the forges are the source of truth and only the
/// ordering key matters here.
pub fn parse(raw: &str) -> Option<Self> {
let bytes = raw.as_bytes();
// `YYYY-MM-DDThh:mm:ss` is the 19-char floor; an offset must follow.
if bytes.len() < 20 || bytes[4] != b'-' || bytes[7] != b'-' {
return None;
}
if bytes[10] != b'T' && bytes[10] != b't' {
return None;
}
if bytes[13] != b':' || bytes[16] != b':' {
return None;
}
let year = digits(raw, 0..4)?;
let month = digits(raw, 5..7)?;
let day = digits(raw, 8..10)?;
let hour = digits(raw, 11..13)?;
let minute = digits(raw, 14..16)?;
let second = digits(raw, 17..19)?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
// 60 accepts the leap seconds RFC 3339 allows.
if hour > 23 || minute > 59 || second > 60 {
return None;
}
let mut index = 19;
let mut nanos: u32 = 0;
if bytes[index] == b'.' {
index += 1;
let start = index;
while bytes.get(index).is_some_and(|b| b.is_ascii_digit()) {
index += 1;
}
if index == start {
return None;
}
// Truncate past nanosecond precision, pad short fractions.
let mut fraction = raw[start..index].to_owned();
fraction.truncate(9);
while fraction.len() < 9 {
fraction.push('0');
}
nanos = fraction.parse().ok()?;
}
let offset: i64 = match bytes.get(index) {
Some(b'Z') | Some(b'z') => {
index += 1;
0
}
Some(sign @ (b'+' | b'-')) => {
index += 1;
if bytes.get(index + 2) != Some(&b':') {
return None;
}
let hours = digits(raw, index..index + 2)?;
let minutes = digits(raw, index + 3..index + 5)?;
if hours > 23 || minutes > 59 {
return None;
}
index += 5;
let total = hours * 3600 + minutes * 60;
if *sign == b'+' { total } else { -total }
}
_ => return None,
};
if index != bytes.len() {
return None; // trailing garbage
}
let secs = days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second
- offset;
Some(Self {
raw: raw.to_owned(),
secs,
nanos,
})
}
/// The raw string, in the forge's own spelling — the form cursors and
/// `since`-style query parameters carry.
pub fn raw(&self) -> &str {
&self.raw
}
/// Whole seconds since the Unix epoch.
pub fn epoch_secs(&self) -> i64 {
self.secs
}
}
/// A run of ASCII digits at `range`, as its number.
fn digits(raw: &str, range: core::ops::Range<usize>) -> Option<i64> {
let slice = raw.get(range)?;
if slice.is_empty() || !slice.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
slice.parse().ok()
}
/// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm).
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
impl PartialEq for Timestamp {
fn eq(&self, other: &Self) -> bool {
self.secs == other.secs && self.nanos == other.nanos
}
}
impl Eq for Timestamp {}
impl PartialOrd for Timestamp {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Timestamp {
fn cmp(&self, other: &Self) -> Ordering {
(self.secs, self.nanos).cmp(&(other.secs, other.nanos))
}
}
impl fmt::Display for Timestamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.raw)
}
}
impl Serialize for Timestamp {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.raw)
}
}
impl<'de> Deserialize<'de> for Timestamp {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Timestamp::parse(&raw)
.ok_or_else(|| de::Error::custom(format!("not an RFC 3339 timestamp: {raw:?}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_the_shapes_the_forges_emit() {
// GitHub's shape.
let zulu = Timestamp::parse("2026-10-08T00:00:00Z").expect("zulu parses");
assert_eq!(zulu.epoch_secs(), 1_791_417_600);
// GitLab's shape: fractional seconds.
let millis = Timestamp::parse("2026-10-08T00:00:00.081Z").expect("millis parse");
assert_eq!(millis.epoch_secs(), zulu.epoch_secs());
assert!(millis > zulu);
// Offsets compare by instant, not spelling.
let shifted = Timestamp::parse("2026-10-08T02:00:00+02:00").expect("offset parse");
assert_eq!(shifted, zulu);
// RFC 3339 allows the lowercase forms.
assert_eq!(
Timestamp::parse("1970-01-01t00:00:00z")
.expect("lowercase parse")
.epoch_secs(),
0
);
assert_eq!(
Timestamp::parse("2016-01-04T15:31:51.081Z")
.expect("gitlab fixture parse")
.epoch_secs(),
1_451_921_511
);
}
#[test]
fn rejects_malformed_timestamps() {
for raw in [
"",
"2026-10-08",
"2026-10-08T00:00:00",
"2026-10-08 00:00:00Z",
"2026-13-01T00:00:00Z",
"2026-10-32T00:00:00Z",
"2026-10-08T24:00:00Z",
"2026-10-08T00:60:00Z",
"2026-10-08T00:00:00Z ",
"2026-10-08T00:00:00+0200",
"2026-10-08T00:00:00.Z",
"2026-10-08T00:00:00Q",
] {
assert!(Timestamp::parse(raw).is_none(), "for {raw:?}");
}
}
#[test]
fn serde_keeps_the_raw_string() {
let ts = Timestamp::parse("2026-10-08T00:00:00.081Z").expect("parses");
let encoded = serde_json::to_value(&ts).expect("serializes");
assert_eq!(encoded, json!("2026-10-08T00:00:00.081Z"));
assert_eq!(
serde_json::from_value::<Timestamp>(encoded).expect("deserializes"),
ts
);
assert!(serde_json::from_value::<Timestamp>(json!("nonsense")).is_err());
}
#[test]
fn forge_events_round_trip_through_serde() {
let event = ForgeEvent {
id: "github:issue:7@2026-10-08T00:00:00Z".into(),
kind: EventKind::Closed,
repo: RepoRef::new("acme", "widgets"),
number: 7,
is_pr: false,
actor: Some("alice".into()),
body: None,
url: None,
timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"),
raw: json!({ "number": 7 }),
};
let round: ForgeEvent =
serde_json::from_value(serde_json::to_value(&event).expect("serializes"))
.expect("deserializes");
assert_eq!(round, event);
}
}