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.
This commit is contained in:
commit
81da26b955
159 files changed
+50628
No files matched your search
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "rhema-calendar-integration"
|
||||
description = "The calendar integration: ICS/webcal feeds, CalDAV collections, and the Google Calendar API normalized into one occurrence vocabulary for reminder-style dispatch"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
# The resident tier is still indev: `cargo publish --workspace` skips
|
||||
# it until the claw ships.
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
# HTTP against the three feed kinds: one bounded request per poll, no
|
||||
# streaming
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
# The CalDAV adapter reads 207 multistatus bodies; the element set it
|
||||
# cares about is small enough that a pull parser beats a DOM
|
||||
quick-xml = "0.41"
|
||||
# Every wire time (RFC 3339, iCalendar's compact `YYYYMMDDTHHMMSS[Z]`,
|
||||
# `VALUE=DATE` all-days) normalizes to an OffsetDateTime, and the
|
||||
# vocabulary's instants round-trip as RFC 3339.
|
||||
time = { version = "0.3", default-features = false, features = [
|
||||
"parsing",
|
||||
"formatting",
|
||||
"serde-well-known",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
# In-process HTTP mocks for offline tests
|
||||
wiremock = "0.6"
|
||||
@@ -0,0 +1,366 @@
|
||||
//! The CalDAV adapter: one `REPORT` per poll against a calendar
|
||||
//! collection, the occurrences read out of the iCalendar objects the
|
||||
//! server answers with.
|
||||
//!
|
||||
//! Wire choices and the constraints they pin:
|
||||
//! - The request is a `CALDAV:calendar-query` `REPORT` with a
|
||||
//! `time-range` on `VEVENT` and `Depth: 1`. The URL is the calendar
|
||||
//! collection's own, a copy-paste from the server UI; principal and
|
||||
//! home-set discovery are deliberately absent, because a hand-pasted
|
||||
//! URL is one config line and discovery is a protocol zoo.
|
||||
//! - The answer is a 207 multistatus whose `calendar-data` values are
|
||||
//! whole iCalendar objects. Servers return recurring masters plus
|
||||
//! overrides for the range, so the expansion in [`crate::ics`] does
|
||||
//! the client-side work and this module only fetches and parses.
|
||||
//! Occurrence ids carry the `caldav` prefix, so a calendar moved
|
||||
//! between adapters changes its dedup ids: feed URLs and DAV
|
||||
//! collections are different identities, not spellings of one.
|
||||
//! - Auth is Basic (username plus password, the self-hosted norm), a
|
||||
//! bearer token, or anonymous, whichever the server wants.
|
||||
//! Credentials ride the `Authorization` header only, never a URL or
|
||||
//! a log line.
|
||||
//! - A `time-range` `REPORT` filters server-side by overlap, so the
|
||||
//! adapter still clips client-side to the window's own contract:
|
||||
//! starts within `[from, to)`.
|
||||
//! - Non-event objects in the collection (a `VTODO` stored beside the
|
||||
//! events) expand to nothing. A `VEVENT` without `UID` or `DTSTART`
|
||||
//! fails the poll loudly, as in the ics adapter: a shape break must
|
||||
//! surface, not shrink the calendar.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::escape::unescape;
|
||||
use quick_xml::events::Event as XmlEvent;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::calendar::{Calendar, finish};
|
||||
use crate::error::{CalendarError, ensure_success};
|
||||
use crate::event::{CalendarEvent, Window};
|
||||
use crate::ics::expand_as;
|
||||
|
||||
/// The production spelling of nothing in particular: CalDAV has no
|
||||
/// vendor base to default to, the calendar's URL is configured whole.
|
||||
pub const USER_AGENT: &str = concat!("rhema-calendar-integration/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// How the adapter authenticates.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Auth {
|
||||
/// No credentials; servers that publish openly.
|
||||
Anonymous,
|
||||
/// Username plus password, the self-hosted norm.
|
||||
Basic { user: String, password: String },
|
||||
/// A bearer token, for servers that issue them.
|
||||
Bearer(String),
|
||||
}
|
||||
|
||||
/// CalDAV backend: a collection URL, an auth choice, one reqwest
|
||||
/// client.
|
||||
pub struct CaldavCalendar {
|
||||
url: String,
|
||||
auth: Auth,
|
||||
http: reqwest::Client,
|
||||
report: reqwest::Method,
|
||||
}
|
||||
|
||||
impl CaldavCalendar {
|
||||
/// A calendar collection at `url` (`https://` or `http://`; a
|
||||
/// self-hosted collection on the LAN is a legitimate http
|
||||
/// citizen). Anonymous until told otherwise.
|
||||
pub fn new(url: impl Into<String>) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
// One connect attempt: a dead server fails fast instead of
|
||||
// stalling the poll loop.
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
// Every DAV call is a bounded request/response, so one
|
||||
// overall timeout per request is the right guard.
|
||||
.timeout(Duration::from_secs(20))
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.expect("a client builder with only timeouts cannot fail");
|
||||
Self {
|
||||
url: url.into().trim_end_matches('/').to_owned(),
|
||||
auth: Auth::Anonymous,
|
||||
http,
|
||||
// REPORT is an HTTP token, so this construction cannot
|
||||
// fail; reqwest has no const constructor for it.
|
||||
report: reqwest::Method::from_bytes(b"REPORT")
|
||||
.expect("REPORT is a valid HTTP method token"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticates with Basic credentials.
|
||||
pub fn with_basic(mut self, user: impl Into<String>, password: impl Into<String>) -> Self {
|
||||
self.auth = Auth::Basic {
|
||||
user: user.into(),
|
||||
password: password.into(),
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
/// Authenticates with a bearer token.
|
||||
pub fn with_token(mut self, token: impl Into<String>) -> Self {
|
||||
self.auth = Auth::Bearer(token.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Calendar for CaldavCalendar {
|
||||
async fn events(&self, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||
let mut request = self
|
||||
.http
|
||||
.request(self.report.clone(), &self.url)
|
||||
.header("Depth", "1")
|
||||
.header(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
"application/xml; charset=utf-8",
|
||||
)
|
||||
.header(reqwest::header::ACCEPT, "application/xml")
|
||||
.body(report_body(window));
|
||||
match &self.auth {
|
||||
Auth::Anonymous => {}
|
||||
Auth::Basic { user, password } => {
|
||||
request = request.basic_auth(user, Some(password));
|
||||
}
|
||||
Auth::Bearer(token) => {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
}
|
||||
let response = request.send().await.map_err(CalendarError::network)?;
|
||||
let body = ensure_success(response)
|
||||
.await?
|
||||
.text()
|
||||
.await
|
||||
.map_err(CalendarError::network)?;
|
||||
let mut events = Vec::new();
|
||||
for object in parse_multistatus(&body)? {
|
||||
events.extend(expand_as(&object, "caldav", window)?);
|
||||
}
|
||||
Ok(finish(events, window))
|
||||
}
|
||||
}
|
||||
|
||||
/// The `calendar-query` body: the collection's VEVENTs, bounded by the
|
||||
/// window's `time-range`. The bounds are RFC 4791's own spelling:
|
||||
/// compact, UTC, `Z`.
|
||||
fn report_body(window: &Window) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="{start}" end="{end}"/>
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>"#,
|
||||
start = compact_z(window.from),
|
||||
end = compact_z(window.to),
|
||||
)
|
||||
}
|
||||
|
||||
/// An instant as a CalDAV `time-range` bound: `YYYYMMDDTHHMMSSZ` in
|
||||
/// UTC, whatever offset the instant carried.
|
||||
fn compact_z(at: OffsetDateTime) -> String {
|
||||
let at = at.to_offset(time::UtcOffset::UTC);
|
||||
format!(
|
||||
"{:04}{:02}{:02}T{:02}{:02}{:02}Z",
|
||||
at.year(),
|
||||
u8::from(at.month()),
|
||||
at.day(),
|
||||
at.hour(),
|
||||
at.minute(),
|
||||
at.second()
|
||||
)
|
||||
}
|
||||
|
||||
/// An element's local name: CalDAV's prefixes (`D:`, `C:`) are
|
||||
/// conventions, not guarantees, and the answer's meaning lives in the
|
||||
/// namespace URIs the element binds. Matching local names inside a
|
||||
/// `prop` stat is the pragmatic read every DAV client ships.
|
||||
fn local_name(name: quick_xml::name::QName<'_>) -> &str {
|
||||
// The reader was built from a &str, so every borrowed name is
|
||||
// UTF-8 already; a decoding failure cannot say anything truer than
|
||||
// "not the element we were looking for".
|
||||
std::str::from_utf8(name.local_name().into_inner()).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Pulls every `calendar-data` value out of a 207 multistatus, in
|
||||
/// document order. Whitespace-only values are dropped; anything else
|
||||
/// the server said (hrefs, etags, statuses) is beneath the vocabulary.
|
||||
fn parse_multistatus(xml: &str) -> Result<Vec<String>, CalendarError> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut datas = Vec::new();
|
||||
let mut capturing = false;
|
||||
let mut current = String::new();
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(XmlEvent::Start(element)) => {
|
||||
if local_name(element.name()) == "calendar-data" {
|
||||
capturing = true;
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
Ok(XmlEvent::Text(text)) if capturing => {
|
||||
// Escaped fragments ride Text; references ride their
|
||||
// own event (below). A server writes ` `
|
||||
// where the iCalendar needs CRLF, and an unexpanded
|
||||
// reference would fold the whole object into one line
|
||||
// and read as quietly empty.
|
||||
let decoded = text
|
||||
.xml10_content()
|
||||
.map_err(|err| CalendarError::Parse(format!("multistatus text: {err}")))?;
|
||||
let decoded = unescape(&decoded)
|
||||
.map_err(|err| CalendarError::Parse(format!("multistatus text: {err}")))?;
|
||||
current.push_str(&decoded);
|
||||
}
|
||||
Ok(XmlEvent::GeneralRef(reference)) if capturing => {
|
||||
match reference.resolve_char_ref() {
|
||||
// ` ` and friends: one character.
|
||||
Ok(Some(ch)) => current.push(ch),
|
||||
// A named entity (`&`): the escape table's.
|
||||
Ok(None) => {
|
||||
let raw = reference.xml10_content().map_err(|err| {
|
||||
CalendarError::Parse(format!("multistatus text: {err}"))
|
||||
})?;
|
||||
let entity = format!("&{raw};");
|
||||
let decoded = unescape(&entity).map_err(|err| {
|
||||
CalendarError::Parse(format!("multistatus text: {err}"))
|
||||
})?;
|
||||
current.push_str(&decoded);
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(CalendarError::Parse(format!("multistatus text: {err}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(XmlEvent::CData(text)) if capturing => {
|
||||
current.push_str(&String::from_utf8_lossy(text.as_ref()));
|
||||
}
|
||||
Ok(XmlEvent::End(element)) => {
|
||||
if capturing && local_name(element.name()) == "calendar-data" {
|
||||
capturing = false;
|
||||
if !current.trim().is_empty() {
|
||||
datas.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(XmlEvent::Eof) => break,
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(CalendarError::Parse(format!("multistatus: {err}"))),
|
||||
}
|
||||
}
|
||||
Ok(datas)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
fn window(start: &str, hours: i64) -> Window {
|
||||
let from = OffsetDateTime::parse(start, &time::format_description::well_known::Rfc3339)
|
||||
.expect("parses");
|
||||
Window::new(from, from + time::Duration::hours(hours))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_range_bounds_are_compact_utc() {
|
||||
// An offset instant normalizes: +02:00 noon is 10:00Z.
|
||||
let at = OffsetDateTime::parse("2026-10-20T12:00:00+02:00", &Rfc3339).expect("parses");
|
||||
assert_eq!(compact_z(at), "20261020T100000Z");
|
||||
assert_eq!(
|
||||
compact_z(window("2026-10-20T00:00:00Z", 24).to),
|
||||
"20261021T000000Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_names_the_window_in_caldavs_own_spelling() {
|
||||
let body = report_body(&window("2026-10-20T00:00:00Z", 24));
|
||||
assert!(
|
||||
body.contains(r#"<C:time-range start="20261020T000000Z" end="20261021T000000Z"/>"#)
|
||||
);
|
||||
assert!(body.contains(r#"<C:comp-filter name="VEVENT">"#));
|
||||
assert!(body.contains("calendar-query"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multistatus_yields_every_calendar_data_in_order() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:response>
|
||||
<D:href>/c/me/home/a.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>"1"</D:getetag>
|
||||
<C:calendar-data>BEGIN:VCALENDAR
|
||||
FIRST:a
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
<D:response>
|
||||
<D:href>/c/me/home/b.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<C:calendar-data>BEGIN:VCALENDAR
|
||||
FIRST:b
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</D:prop>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"#;
|
||||
let datas = parse_multistatus(xml).expect("parses");
|
||||
assert_eq!(datas.len(), 2);
|
||||
assert!(datas[0].contains("FIRST:a"));
|
||||
assert!(datas[1].contains("FIRST:b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_missing_calendar_data_contribute_nothing() {
|
||||
let xml = r#"<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:response>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
<D:response>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>"2"</D:getetag>
|
||||
</D:prop>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"#;
|
||||
assert!(parse_multistatus(xml).expect("parses").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_xml_is_a_loud_parse_error() {
|
||||
let err = parse_multistatus("<not closed").expect_err("loud");
|
||||
assert!(matches!(err, CalendarError::Parse(_)), "got: {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vtodo_only_collection_expands_to_nothing() {
|
||||
// expand_as is the shared expansion; the CalDAV spelling of it
|
||||
// must treat a non-event object as quiet, not broken.
|
||||
let object = "BEGIN:VCALENDAR\nBEGIN:VTODO\nUID:t@x\nDTSTART:20261020T140000Z\n\
|
||||
END:VTODO\nEND:VCALENDAR\n";
|
||||
let events =
|
||||
expand_as(object, "caldav", &window("2026-10-20T00:00:00Z", 24)).expect("expands");
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The calendar client contract: one trait, one call.
|
||||
//!
|
||||
//! [`Calendar`] is the whole surface a consumer needs: the occurrences
|
||||
//! starting inside a window. Implementations are cheap to clone and
|
||||
//! share (the reqwest client inside is reference-counted) and
|
||||
//! object-safe so a backend can be swapped without generics.
|
||||
//!
|
||||
//! Polls are at-least-once and window-anchored. Nothing here tracks a
|
||||
//! cursor, because a calendar is addressable by time, not by history:
|
||||
//! re-asking the same window returns the same rows under the same
|
||||
//! ids, and the consumer's dispatcher dedups the repetition. There is
|
||||
//! deliberately no write half: reminders read a calendar; what a
|
||||
//! consumer does about one is an agent's business, not an adapter's.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::CalendarError;
|
||||
use crate::event::{CalendarEvent, Window};
|
||||
|
||||
/// A calendar backend: the client for one feed dialect, bound to one
|
||||
/// calendar (the URL for ICS, the calendar id for Google).
|
||||
#[async_trait]
|
||||
pub trait Calendar: Send + Sync {
|
||||
/// The occurrences whose start falls in `window`, oldest first,
|
||||
/// ties broken by id. Recurring series arrive expanded: one
|
||||
/// [`CalendarEvent`] per occurrence, overrides applied, cancelled
|
||||
/// occurrences gone.
|
||||
async fn events(&self, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError>;
|
||||
}
|
||||
|
||||
/// Folds collected events into an adapter's answer: keeps the rows
|
||||
/// starting in `window`, drops duplicate ids (first wins), and sorts
|
||||
/// by `(start, id)`. Adapters call this before returning, so a
|
||||
/// consumer reads one deterministic order regardless of which feed
|
||||
/// produced the rows.
|
||||
pub(crate) fn finish(mut events: Vec<CalendarEvent>, window: &Window) -> Vec<CalendarEvent> {
|
||||
events.retain(|event| window.starts_within(event.start));
|
||||
events.sort_by(|a, b| (&a.start, &a.id).cmp(&(&b.start, &b.id)));
|
||||
events.dedup_by(|a, b| a.id == b.id);
|
||||
events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn event(id: &str, start: &str) -> CalendarEvent {
|
||||
CalendarEvent {
|
||||
id: id.to_owned(),
|
||||
title: "x".to_owned(),
|
||||
start: time::OffsetDateTime::parse(
|
||||
start,
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.expect("parses"),
|
||||
end: None,
|
||||
all_day: false,
|
||||
location: None,
|
||||
description: None,
|
||||
attendees: Vec::new(),
|
||||
organizer: None,
|
||||
url: None,
|
||||
raw: json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_clips_sorts_and_dedups() {
|
||||
let window = Window::new(at("2026-10-20T00:00:00Z"), at("2026-10-21T00:00:00Z"));
|
||||
let events = finish(
|
||||
vec![
|
||||
event("b", "2026-10-20T10:00:00Z"),
|
||||
event("z", "2026-10-19T10:00:00Z"), // before: dropped
|
||||
event("a", "2026-10-20T10:00:00Z"),
|
||||
event("b", "2026-10-20T10:00:00Z"), // duplicate id: dropped
|
||||
event("c", "2026-10-21T00:00:00Z"), // at `to`: dropped
|
||||
],
|
||||
&window,
|
||||
);
|
||||
let ids: Vec<&str> = events.iter().map(|event| event.id.as_str()).collect();
|
||||
assert_eq!(ids, ["a", "b"]);
|
||||
}
|
||||
|
||||
fn at(raw: &str) -> time::OffsetDateTime {
|
||||
time::OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339)
|
||||
.expect("parses")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Why a calendar call failed.
|
||||
//!
|
||||
//! One error type for the whole crate: every adapter maps its transport,
|
||||
//! HTTP, and decoding failures onto it, so callers match one shape no
|
||||
//! matter which feed answered.
|
||||
|
||||
/// Everything that can go wrong between a [`crate::Calendar`] call and
|
||||
/// its answer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CalendarError {
|
||||
/// The feed answered with a non-2xx status. `body` is a bounded
|
||||
/// snippet of the response body, usually the server's error page or
|
||||
/// JSON.
|
||||
#[error("calendar api error (HTTP {status}): {body}")]
|
||||
Api {
|
||||
/// The HTTP status the feed answered with.
|
||||
status: u16,
|
||||
/// A bounded snippet of the response body.
|
||||
body: String,
|
||||
},
|
||||
/// A response body could not be decoded, or a wire row is missing a
|
||||
/// field the normalized vocabulary requires. Loud by design: feeds
|
||||
/// are machine-generated, and a shape break must surface, not
|
||||
/// silently shrink the calendar.
|
||||
#[error("malformed calendar response: {0}")]
|
||||
Parse(String),
|
||||
/// The server kept offering full pages, or offered a page cursor
|
||||
/// that leads nowhere. Polling stopped rather than looping forever.
|
||||
#[error("pagination failed: {0}")]
|
||||
Pagination(String),
|
||||
/// The request never got an answer (DNS, connect, timeout).
|
||||
#[error("network error: {0}")]
|
||||
Network(String),
|
||||
}
|
||||
|
||||
/// The longest body snippet an [`CalendarError::Api`] keeps: enough for
|
||||
/// any server's error JSON, small enough that error lines stay bounded.
|
||||
const BODY_SNIPPET_CAP: usize = 512;
|
||||
|
||||
impl CalendarError {
|
||||
/// Builds [`CalendarError::Api`] from a non-2xx status and the raw
|
||||
/// body, truncating the body to [`BODY_SNIPPET_CAP`] characters.
|
||||
pub(crate) fn api(status: reqwest::StatusCode, raw: &str) -> Self {
|
||||
let body: String = raw.chars().take(BODY_SNIPPET_CAP).collect();
|
||||
CalendarError::Api {
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a transport failure onto [`CalendarError::Network`];
|
||||
/// reqwest's error is neither `Clone` nor `Eq`, so only its display
|
||||
/// text travels.
|
||||
pub(crate) fn network(err: reqwest::Error) -> Self {
|
||||
CalendarError::Network(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A wire field the normalized vocabulary cannot do without. A missing
|
||||
/// one is a loud [`CalendarError::Parse`], not a silently skipped row.
|
||||
pub(crate) fn required<T>(field: Option<T>, what: &str) -> Result<T, CalendarError> {
|
||||
field.ok_or_else(|| CalendarError::Parse(format!("calendar row without {what}")))
|
||||
}
|
||||
|
||||
/// Passes a 2xx response through; turns anything else into
|
||||
/// [`CalendarError::Api`] with a bounded body snippet.
|
||||
pub(crate) async fn ensure_success(
|
||||
response: reqwest::Response,
|
||||
) -> Result<reqwest::Response, CalendarError> {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Err(CalendarError::api(status, &body))
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! The normalized event vocabulary shared by every calendar backend.
|
||||
//!
|
||||
//! Constraints the normalization pins (each adapter module documents how
|
||||
//! it meets them):
|
||||
//! - An event is one occurrence, never a series. Recurring masters are
|
||||
//! expanded (by the Google server with `singleEvents=true`, by the
|
||||
//! `ics` adapter client-side), so a consumer never sees a rule, only
|
||||
//! starts it can compare against its clock.
|
||||
//! - [`CalendarEvent::id`] is the dedup id a dispatcher keys on:
|
||||
//! `{provider}:{uid}@{unix start}`. Two polls of the same occurrence
|
||||
//! produce the same id; the id changes only when the occurrence
|
||||
//! itself is a different one. What a consumer does about the
|
||||
//! repetition (dispatch dedup, reminder slots) is the consumer's
|
||||
//! seam, not this crate's.
|
||||
//! - Times are instants, not wall-clock strings. A wire time with no
|
||||
//! offset reads as UTC, and an all-day date starts at its midnight
|
||||
//! UTC with [`CalendarEvent::all_day`] set, so every `start`
|
||||
//! compares against any clock without a timezone database on board.
|
||||
//! - [`CalendarEvent::end`] is exclusive, as every wire format spells
|
||||
//! it, and `None` when the row names no end.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use time::serde::rfc3339;
|
||||
|
||||
/// One occurrence on a calendar: a meeting, a birthday, a block of
|
||||
/// time. Everything a reminder needs to say, lifted from whichever
|
||||
/// dialect produced it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CalendarEvent {
|
||||
/// Stable dedup id, `{provider}:{uid}@{unix start}` (e.g.
|
||||
/// `ics:c1f3a9@1792471200`,
|
||||
/// `google:_70ojsm0k6nojajk6@1792471200`): one occurrence, one id,
|
||||
/// across polls. A recurring series expands to one event per
|
||||
/// occurrence, each with its own.
|
||||
pub id: String,
|
||||
/// The title (`SUMMARY` / `summary`); empty when the row has none.
|
||||
pub title: String,
|
||||
/// When the occurrence starts.
|
||||
#[serde(with = "rfc3339")]
|
||||
pub start: OffsetDateTime,
|
||||
/// When it ends, exclusive; `None` when the row names no end.
|
||||
#[serde(with = "rfc3339::option")]
|
||||
pub end: Option<OffsetDateTime>,
|
||||
/// Whether this is an all-day occurrence: the wire carried a date
|
||||
/// with no time, and [`CalendarEvent::start`] is that date's
|
||||
/// midnight UTC.
|
||||
pub all_day: bool,
|
||||
/// Where it happens, when the row says.
|
||||
pub location: Option<String>,
|
||||
/// The row's notes, when it has any.
|
||||
pub description: Option<String>,
|
||||
/// Who is invited, bare addresses (`mailto:` stripped, Google's
|
||||
/// `email` fields).
|
||||
pub attendees: Vec<String>,
|
||||
/// The organizer's address, when the row names one.
|
||||
pub organizer: Option<String>,
|
||||
/// Human-facing URL of the event (`URL` / `htmlLink`).
|
||||
pub url: Option<String>,
|
||||
/// The untouched wire row: the adapter's parsed property map for
|
||||
/// ICS, the JSON object for Google. Parameters the vocabulary does
|
||||
/// not lift (`TZID`, for one) ride here for a consumer that can do
|
||||
/// more with them.
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
impl CalendarEvent {
|
||||
/// The event's duration: `end - start` when the row names an end,
|
||||
/// zero otherwise. All-day rows without an `DTEND` are a zero-width
|
||||
/// midnight: the wire says the day, the duration is not this
|
||||
/// crate's guess to widen.
|
||||
pub fn duration(&self) -> time::Duration {
|
||||
self.end
|
||||
.map(|end| end - self.start)
|
||||
.unwrap_or(time::Duration::ZERO)
|
||||
}
|
||||
}
|
||||
|
||||
/// The stretch of time a poll asks about, as starts: an event belongs
|
||||
/// to a window when its [`CalendarEvent::start`] falls inside
|
||||
/// `[from, to)`. Windows are absolute (instants, not cursors), which
|
||||
/// is why a calendar poll needs no cursor: re-asking the same window
|
||||
/// returns the same rows under the same ids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
/// The earliest start included.
|
||||
pub from: OffsetDateTime,
|
||||
/// The latest start excluded.
|
||||
pub to: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// A window from `from` (inclusive) to `to` (exclusive).
|
||||
pub fn new(from: OffsetDateTime, to: OffsetDateTime) -> Self {
|
||||
Self { from, to }
|
||||
}
|
||||
|
||||
/// Whether `start` falls inside the window.
|
||||
pub fn starts_within(&self, start: OffsetDateTime) -> bool {
|
||||
start >= self.from && start < self.to
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(raw: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339)
|
||||
.expect("rfc3339 parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_bounds_by_start_inclusively_below_exclusively() {
|
||||
let window = Window::new(at("2026-10-20T00:00:00Z"), at("2026-10-21T00:00:00Z"));
|
||||
assert!(window.starts_within(at("2026-10-20T00:00:00Z")));
|
||||
assert!(window.starts_within(at("2026-10-20T23:59:59Z")));
|
||||
assert!(!window.starts_within(at("2026-10-19T23:59:59Z")));
|
||||
assert!(!window.starts_within(at("2026-10-21T00:00:00Z")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_event_round_trips_through_serde() {
|
||||
let event = CalendarEvent {
|
||||
id: "ics:abc@1".into(),
|
||||
title: "standup".into(),
|
||||
start: at("2026-10-20T09:00:00Z"),
|
||||
end: Some(at("2026-10-20T09:15:00Z")),
|
||||
all_day: false,
|
||||
location: None,
|
||||
description: None,
|
||||
attendees: vec!["a@example.net".into()],
|
||||
organizer: Some("b@example.net".into()),
|
||||
url: None,
|
||||
raw: serde_json::json!({ "SUMMARY": "standup" }),
|
||||
};
|
||||
let round: CalendarEvent =
|
||||
serde_json::from_value(serde_json::to_value(&event).expect("serializes"))
|
||||
.expect("deserializes");
|
||||
assert_eq!(round, event);
|
||||
assert_eq!(round.duration(), time::Duration::minutes(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_endless_event_has_no_duration() {
|
||||
let event = CalendarEvent {
|
||||
id: "ics:abc@1".into(),
|
||||
title: String::new(),
|
||||
start: at("2026-10-20T09:00:00Z"),
|
||||
end: None,
|
||||
all_day: true,
|
||||
location: None,
|
||||
description: None,
|
||||
attendees: Vec::new(),
|
||||
organizer: None,
|
||||
url: None,
|
||||
raw: serde_json::json!({}),
|
||||
};
|
||||
assert_eq!(event.duration(), time::Duration::ZERO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! The Google Calendar adapter: the Calendar API v3 events list.
|
||||
//!
|
||||
//! Auth is one credential, chosen by what the deployment has: an OAuth
|
||||
//! access token in `Authorization: Bearer` (the header only; the token
|
||||
//! never lands in a URL or a log line), or an API key for public
|
||||
//! calendars, which Google only accepts as the `key` query parameter.
|
||||
//!
|
||||
//! Endpoint choices and the constraints they pin:
|
||||
//! - `singleEvents=true` makes the server expand recurring masters into
|
||||
//! one row per occurrence, each with a stable `{id}_{timestamp}` id,
|
||||
//! so an occurrence's dedup id survives re-polls. The client side
|
||||
//! never evaluates a recurrence rule.
|
||||
//! - `timeMin`/`timeMax` bound the poll server-side. Google's filter is
|
||||
//! overlap-based, so the adapter still clips client-side to the
|
||||
//! window's own contract: starts within `[from, to)`.
|
||||
//! - Rows with `status: cancelled` are dropped. On a singleEvents poll
|
||||
//! a cancelled row is the tombstone of an occurrence that will not
|
||||
//! happen; keeping it would remind about a meeting that was called
|
||||
//! off.
|
||||
//! - Pagination rides `pageToken` until it disappears, with a hard
|
||||
//! page cap, so a server that keeps issuing cursors is declared
|
||||
//! misbehaving rather than polled forever.
|
||||
//! - `startTime` ordering plus `singleEvents` is the only ordering
|
||||
//! Google allows over expanded instances, which is the order the
|
||||
//! normalized answer promises anyway (oldest first, id-broken ties).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::calendar::{Calendar, finish};
|
||||
use crate::error::{CalendarError, ensure_success, required};
|
||||
use crate::event::{CalendarEvent, Window};
|
||||
|
||||
/// The production Google APIs base.
|
||||
pub const DEFAULT_BASE_URL: &str = "https://www.googleapis.com";
|
||||
|
||||
/// The user agent the adapter presents.
|
||||
const USER_AGENT: &str = concat!("rhema-calendar-integration/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// Rows requested per page: Google's own maximum.
|
||||
const MAX_RESULTS: &str = "250";
|
||||
|
||||
/// Pages fetched before a server that keeps issuing cursors is
|
||||
/// declared misbehaving rather than polled forever.
|
||||
const MAX_PAGES: usize = 40;
|
||||
|
||||
/// Which credential a client authenticates with.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Credential {
|
||||
/// An OAuth access token, in `Authorization: Bearer`.
|
||||
Bearer(String),
|
||||
/// An API key for public calendars, as the `key` parameter
|
||||
/// Google requires.
|
||||
ApiKey(String),
|
||||
}
|
||||
|
||||
/// Google Calendar backend: a credential, a calendar id, one reqwest
|
||||
/// client. The client is bound to one calendar, which is what the
|
||||
/// [`Calendar`] contract promises anyway.
|
||||
pub struct GoogleCalendar {
|
||||
base_url: String,
|
||||
calendar_id: String,
|
||||
credential: Credential,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl GoogleCalendar {
|
||||
/// A calendar reading with an OAuth access `token` (private and
|
||||
/// public calendars both).
|
||||
pub fn new(token: impl Into<String>, calendar_id: impl Into<String>) -> Self {
|
||||
Self::with(Credential::Bearer(token.into()), calendar_id)
|
||||
}
|
||||
|
||||
/// A calendar reading with an API `key`: public calendars only,
|
||||
/// Google's rule, and the key rides the `key` query parameter
|
||||
/// because that is the only form Google accepts for it.
|
||||
pub fn with_api_key(key: impl Into<String>, calendar_id: impl Into<String>) -> Self {
|
||||
Self::with(Credential::ApiKey(key.into()), calendar_id)
|
||||
}
|
||||
|
||||
fn with(credential: Credential, calendar_id: impl Into<String>) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
// One connect attempt: a dead endpoint fails fast instead
|
||||
// of stalling the poll loop.
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
// Every API call is a bounded request/response, so one
|
||||
// overall timeout per request is the right guard.
|
||||
.timeout(Duration::from_secs(20))
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.expect("a client builder with only timeouts cannot fail");
|
||||
Self {
|
||||
base_url: DEFAULT_BASE_URL.to_owned(),
|
||||
calendar_id: calendar_id.into(),
|
||||
credential,
|
||||
http,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the base URL (trailing slashes trimmed) — for a test
|
||||
/// against a mock server.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into().trim_end_matches('/').to_owned();
|
||||
self
|
||||
}
|
||||
|
||||
/// The events-list URL, with the calendar id percent-encoded by
|
||||
/// the URL builder: ids are email-shaped (`primary`, an address,
|
||||
/// a hex id), but the encoding is the builder's job, not ours.
|
||||
fn endpoint(&self) -> Result<reqwest::Url, CalendarError> {
|
||||
let mut url = reqwest::Url::parse(&self.base_url)
|
||||
.map_err(|err| CalendarError::Parse(format!("base url: {err}")))?;
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| CalendarError::Parse("base url is not a directory".to_owned()))?
|
||||
.extend(["calendar", "v3", "calendars", &self.calendar_id, "events"]);
|
||||
Ok(url)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Calendar for GoogleCalendar {
|
||||
async fn events(&self, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||
let time_min = window
|
||||
.from
|
||||
.format(&Rfc3339)
|
||||
.map_err(|err| CalendarError::Parse(format!("window start: {err}")))?;
|
||||
let time_max = window
|
||||
.to
|
||||
.format(&Rfc3339)
|
||||
.map_err(|err| CalendarError::Parse(format!("window end: {err}")))?;
|
||||
let mut rows = Vec::new();
|
||||
let mut page_token = String::new();
|
||||
for _page in 1..=MAX_PAGES {
|
||||
let mut url = self.endpoint()?;
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.append_pair("timeMin", &time_min);
|
||||
query.append_pair("timeMax", &time_max);
|
||||
query.append_pair("singleEvents", "true");
|
||||
query.append_pair("orderBy", "startTime");
|
||||
query.append_pair("maxResults", MAX_RESULTS);
|
||||
if let Credential::ApiKey(key) = &self.credential {
|
||||
query.append_pair("key", key);
|
||||
}
|
||||
if !page_token.is_empty() {
|
||||
query.append_pair("pageToken", &page_token);
|
||||
}
|
||||
}
|
||||
let mut request = self.http.get(url);
|
||||
if let Credential::Bearer(token) = &self.credential {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
let response = request.send().await.map_err(CalendarError::network)?;
|
||||
let body = ensure_success(response)
|
||||
.await?
|
||||
.text()
|
||||
.await
|
||||
.map_err(CalendarError::network)?;
|
||||
let page: EventsPage = serde_json::from_str(&body)
|
||||
.map_err(|err| CalendarError::Parse(format!("events page: {err}")))?;
|
||||
rows.extend(page.items);
|
||||
match page.next_page_token {
|
||||
Some(token) => page_token = token,
|
||||
None => return Ok(finish(try_rows(rows)?, window)),
|
||||
}
|
||||
}
|
||||
Err(CalendarError::Pagination(format!(
|
||||
"more than {MAX_PAGES} pages from the events list"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// One events-list page: the raw rows travel as JSON so each can keep
|
||||
/// riding its event's `raw` untouched.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventsPage {
|
||||
#[serde(default)]
|
||||
items: Vec<Value>,
|
||||
#[serde(rename = "nextPageToken", default)]
|
||||
next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Decodes every row of a page, dropping the tombstones.
|
||||
fn try_rows(rows: Vec<Value>) -> Result<Vec<CalendarEvent>, CalendarError> {
|
||||
rows.into_iter()
|
||||
.filter_map(|row| decode_row(row).transpose())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Decodes one wire row; `None` for a cancelled tombstone. A missing
|
||||
/// start is loud: without it there is no instant to remind about.
|
||||
fn decode_row(row: Value) -> Result<Option<CalendarEvent>, CalendarError> {
|
||||
let row: EventRow = serde_json::from_value(row)
|
||||
.map_err(|err| CalendarError::Parse(format!("event row: {err}")))?;
|
||||
if row.status.as_deref() == Some("cancelled") {
|
||||
return Ok(None);
|
||||
}
|
||||
let start_block = required(row.start, "a start")?;
|
||||
let (start, all_day) = start_block.read(&row.id)?;
|
||||
let end = match row.end_time_unspecified {
|
||||
true => None,
|
||||
false => row
|
||||
.end
|
||||
.as_ref()
|
||||
.map(|end| end.read(&row.id))
|
||||
.transpose()?
|
||||
.map(|(when, _)| when),
|
||||
};
|
||||
Ok(Some(CalendarEvent {
|
||||
id: format!("google:{}@{}", row.id, start.unix_timestamp()),
|
||||
title: row.summary.unwrap_or_default(),
|
||||
start,
|
||||
end,
|
||||
all_day,
|
||||
location: row.location,
|
||||
description: row.description,
|
||||
attendees: row
|
||||
.attendees
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|attendee| attendee.email)
|
||||
.collect(),
|
||||
organizer: row.organizer.and_then(|organizer| organizer.email),
|
||||
url: row.html_link,
|
||||
raw: row.raw,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A wire time block: `dateTime` for a timed row, `date` for an
|
||||
/// all-day one (that date's midnight UTC).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TimeBlock {
|
||||
#[serde(default)]
|
||||
date: Option<String>,
|
||||
#[serde(rename = "dateTime", default)]
|
||||
date_time: Option<String>,
|
||||
}
|
||||
|
||||
impl TimeBlock {
|
||||
/// The instant plus the all-day flag; `None` when the block names
|
||||
/// neither form. An unreadable spelling is loud, with the row's id
|
||||
/// for context.
|
||||
fn read(&self, id: &str) -> Result<(OffsetDateTime, bool), CalendarError> {
|
||||
let loud = |what: &str, err: String| {
|
||||
CalendarError::Parse(format!("event {id:?} has an unreadable {what}: {err}"))
|
||||
};
|
||||
if let Some(when) = self.date.as_deref() {
|
||||
// `YYYY-MM-DD`, at midnight UTC.
|
||||
let (year, rest) = when
|
||||
.split_once('-')
|
||||
.ok_or_else(|| loud("date", "no year separator".to_owned()))?;
|
||||
let (month, day) = rest
|
||||
.split_once('-')
|
||||
.ok_or_else(|| loud("date", "no month separator".to_owned()))?;
|
||||
let date = time::Date::from_calendar_date(
|
||||
year.parse::<i32>()
|
||||
.map_err(|err| loud("date year", err.to_string()))?,
|
||||
time::Month::try_from(
|
||||
month
|
||||
.parse::<u8>()
|
||||
.map_err(|err| loud("date month", err.to_string()))?,
|
||||
)
|
||||
.map_err(|err| loud("date month", err.to_string()))?,
|
||||
day.parse::<u8>()
|
||||
.map_err(|err| loud("date day", err.to_string()))?,
|
||||
)
|
||||
.map_err(|err| loud("date", err.to_string()))?;
|
||||
return Ok((date.with_time(time::Time::MIDNIGHT).assume_utc(), true));
|
||||
}
|
||||
let when = self
|
||||
.date_time
|
||||
.as_deref()
|
||||
.ok_or_else(|| loud("start", "neither date nor dateTime".to_owned()))?;
|
||||
OffsetDateTime::parse(when, &Rfc3339)
|
||||
.map(|start| (start, false))
|
||||
.map_err(|err| loud("dateTime", err.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire row, with the fields the vocabulary lifts named.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventRow {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
#[serde(default)]
|
||||
location: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(rename = "htmlLink", default)]
|
||||
html_link: Option<String>,
|
||||
#[serde(default)]
|
||||
start: Option<TimeBlock>,
|
||||
#[serde(default)]
|
||||
end: Option<TimeBlock>,
|
||||
#[serde(rename = "endTimeUnspecified", default)]
|
||||
end_time_unspecified: bool,
|
||||
#[serde(default)]
|
||||
attendees: Option<Vec<Party>>,
|
||||
#[serde(default)]
|
||||
organizer: Option<Party>,
|
||||
/// Kept whole for [`CalendarEvent::raw`].
|
||||
#[serde(flatten)]
|
||||
raw: Value,
|
||||
}
|
||||
|
||||
/// An attendee or organizer row; only the address is lifted.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Party {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn timed(id: &str, start: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"status": "confirmed",
|
||||
"summary": "standup",
|
||||
"start": { "dateTime": start },
|
||||
"end": { "dateTime": "2026-10-20T09:15:00Z" },
|
||||
"htmlLink": "https://calendar.google.com/x",
|
||||
"attendees": [ { "email": "a@example.net" }, { "displayName": "no address" } ]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timed_row_decodes_with_its_lifts() {
|
||||
let event = decode_row(timed("abc123", "2026-10-20T09:00:00Z"))
|
||||
.expect("decodes")
|
||||
.expect("not cancelled");
|
||||
assert_eq!(
|
||||
event.id,
|
||||
format!(
|
||||
"google:abc123@{}",
|
||||
OffsetDateTime::parse("2026-10-20T09:00:00Z", &Rfc3339)
|
||||
.expect("parses")
|
||||
.unix_timestamp()
|
||||
)
|
||||
);
|
||||
assert_eq!(event.title, "standup");
|
||||
assert!(!event.all_day);
|
||||
assert_eq!(event.attendees, vec!["a@example.net".to_owned()]);
|
||||
assert_eq!(event.url.as_deref(), Some("https://calendar.google.com/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_all_day_row_starts_at_its_midnight() {
|
||||
let row = json!({
|
||||
"id": "bday",
|
||||
"summary": "birthday",
|
||||
"start": { "date": "2026-10-20" }
|
||||
});
|
||||
let event = decode_row(row).expect("decodes").expect("not cancelled");
|
||||
assert!(event.all_day);
|
||||
assert_eq!(event.end, None);
|
||||
assert_eq!(event.start.hour(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_row_is_a_tombstone() {
|
||||
let mut row = timed("abc123", "2026-10-20T09:00:00Z");
|
||||
row["status"] = json!("cancelled");
|
||||
assert_eq!(decode_row(row).expect("decodes"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_without_a_start_is_loud() {
|
||||
let mut row = timed("abc123", "2026-10-20T09:00:00Z");
|
||||
row["start"] = json!({});
|
||||
let err = decode_row(row).expect_err("loud");
|
||||
assert!(err.to_string().contains("start"), "got: {err}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,79 @@
|
||||
//! `rhema-calendar-integration` — the calendar integration: one
|
||||
//! normalized occurrence vocabulary and one client contract for the
|
||||
//! calendar feeds a resident agent can remind about, with the adapters
|
||||
//! that read them.
|
||||
//!
|
||||
//! An integration, not an agent: no driver, no loop, and no reminder
|
||||
//! policy lives here. A consumer composes the crate with its own agent
|
||||
//! tier — it picks a [`crate::Window`], polls a [`crate::Calendar`]
|
||||
//! for the occurrences starting inside it, and decides what a start
|
||||
//! time means (the claw daemon today: one reminder slot per lead time,
|
||||
//! dispatched into the resident agent). Every [`crate::CalendarEvent`]
|
||||
//! carries an id that stays the same across polls of the same
|
||||
//! occurrence, so a consumer re-polling on its own cadence never
|
||||
//! re-fires a reminder it already fed; the repetition is the
|
||||
//! consumer's dispatcher's dedup, not this crate's problem.
|
||||
//!
|
||||
//! Recurrence is expanded before a poll answers, so a consumer never
|
||||
//! sees a rule, only starts it can compare against its clock:
|
||||
//! - The Google Calendar adapter asks the server to expand
|
||||
//! (`singleEvents=true`).
|
||||
//! - The ICS and CalDAV adapters expand client-side, covering the
|
||||
//! rule shapes servers actually emit (daily, weekly with `BYDAY`,
|
||||
//! monthly, yearly; `INTERVAL`, `COUNT`, `UNTIL`, `EXDATE`) and
|
||||
//! applying `RECURRENCE-ID` overrides. Anything outside those
|
||||
//! shapes surfaces once, at its `DTSTART`, never silently dropped.
|
||||
//! CalDAV shares the exact expansion: the server returns masters
|
||||
//! plus overrides in range, and the adapter reads them out of the
|
||||
//! multistatus. Where a server exposes both, the feed URL and the
|
||||
//! DAV collection are different identities: their occurrence ids
|
||||
//! carry different provider prefixes.
|
||||
//!
|
||||
//! Times are instants, not wall-clock strings. Every wire format (RFC
|
||||
//! 3339, iCalendar's compact `YYYYMMDDTHHMMSS[Z]`, `VALUE=DATE`
|
||||
//! all-days) normalizes to a `time::OffsetDateTime`; a floating
|
||||
//! (offset-less) time reads as UTC, and a `TZID` parameter rides the
|
||||
//! event's `raw` for a consumer that can resolve zones.
|
||||
//!
|
||||
//! ```
|
||||
//! use rhema_calendar_integration::{Calendar, IcsCalendar, Window};
|
||||
//! use time::format_description::well_known::Rfc3339;
|
||||
//!
|
||||
//! // A feed is one URL (webcal counts as https); the poll is one GET.
|
||||
//! let feed = IcsCalendar::new("https://example.net/nowhere/calendar.ics");
|
||||
//! let from = time::OffsetDateTime::parse("2026-10-20T00:00:00Z", &Rfc3339)
|
||||
//! .expect("parses");
|
||||
//! let window = Window::new(from, from + time::Duration::hours(24));
|
||||
//! // `feed.events(&window).await` answers with the occurrences whose
|
||||
//! // start falls inside the window, oldest first; this doctest only
|
||||
//! // assembles them.
|
||||
//! let _ = (&feed, &window);
|
||||
//! ```
|
||||
//!
|
||||
//! Layout:
|
||||
//! - [`event`] — the normalized vocabulary: `CalendarEvent`, `Window`
|
||||
//! - [`error`] — the crate's single calendar error type
|
||||
//! - [`calendar`] — the `Calendar` client contract
|
||||
//! - [`ics`] — the ICS/webcal adapter (one GET, expanded client-side)
|
||||
//! - [`caldav`] — the CalDAV adapter (one REPORT, the same expansion)
|
||||
//! - [`google`] — the Google Calendar API adapter (expanded server-side)
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
pub mod caldav;
|
||||
pub mod calendar;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod google;
|
||||
pub mod ics;
|
||||
|
||||
// Re-exported so implementors of the async seam can apply #[async_trait]
|
||||
// without adding the crate themselves.
|
||||
pub use async_trait::async_trait;
|
||||
pub use caldav::CaldavCalendar;
|
||||
pub use calendar::Calendar;
|
||||
pub use error::CalendarError;
|
||||
pub use event::{CalendarEvent, Window};
|
||||
pub use google::GoogleCalendar;
|
||||
pub use ics::IcsCalendar;
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Offline integration tests: the CalDAV backend against an in-process
|
||||
//! wiremock server. Zero network.
|
||||
|
||||
use rhema_calendar_integration::{CaldavCalendar, Calendar, CalendarError, Window};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use wiremock::matchers::{body_string_contains, header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn window() -> Window {
|
||||
// Oct 1 00:00 to Oct 3 00:00: wide enough that the recurring
|
||||
// master's second day lands inside, and the `time-range` bound in
|
||||
// the request reads `end=20261003T000000Z`.
|
||||
let from = OffsetDateTime::parse("2026-10-01T00:00:00Z", &Rfc3339).expect("parses");
|
||||
Window::new(from, from + time::Duration::hours(48))
|
||||
}
|
||||
|
||||
/// The 207 answer: one single event, one recurring master with a moved
|
||||
/// override. The iCalendar objects carry ` ` where CRLF
|
||||
/// belongs, because that is how servers spell an ICS inside XML, and
|
||||
/// an unexpanded reference would fold the object into one unreadable
|
||||
/// line.
|
||||
const MULTISTATUS: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:response>
|
||||
<D:href>/calendars/me/home/kickoff.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>"1"</D:getetag>
|
||||
<C:calendar-data>BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:kickoff@x DTSTART:20261001T140000Z DTEND:20261001T150000Z SUMMARY:kickoff END:VEVENT END:VCALENDAR</C:calendar-data>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
<D:response>
|
||||
<D:href>/calendars/me/home/standup.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>"2"</D:getetag>
|
||||
<C:calendar-data>BEGIN:VCALENDAR BEGIN:VEVENT UID:standup@x DTSTART:20261001T090000Z RRULE:FREQ=DAILY;COUNT=3 SUMMARY:standup END:VEVENT BEGIN:VEVENT UID:standup@x RECURRENCE-ID:20261001T090000Z DTSTART:20261001T160000Z SUMMARY:standup moved END:VEVENT END:VCALENDAR</C:calendar-data>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"#;
|
||||
|
||||
fn calendar(server: &MockServer) -> CaldavCalendar {
|
||||
CaldavCalendar::new(format!("{}/calendars/me/home/", server.uri())).with_basic("me", "app-pass")
|
||||
}
|
||||
|
||||
fn starts(events: &[rhema_calendar_integration::CalendarEvent]) -> Vec<String> {
|
||||
events
|
||||
.iter()
|
||||
.map(|event| event.start.format(&Rfc3339).expect("formats"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_poll_is_one_report_and_expands_the_answer() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/calendars/me/home"))
|
||||
.and(header("Depth", "1"))
|
||||
.and(body_string_contains(
|
||||
r#"<C:time-range start="20261001T000000Z" end="20261003T000000Z"/>"#,
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(MULTISTATUS))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let events = calendar(&server).events(&window()).await.expect("polls");
|
||||
// The master's Oct 1 occurrence is suppressed by the override that
|
||||
// moved it to 16:00; the Oct 2 occurrence is in the window, and
|
||||
// the Oct 3 one (COUNT=3) is past its end.
|
||||
assert_eq!(events.len(), 3, "{:?}", starts(&events));
|
||||
assert_eq!(events[0].title, "kickoff");
|
||||
assert_eq!(events[1].title, "standup moved");
|
||||
assert_eq!(events[2].title, "standup");
|
||||
for event in &events {
|
||||
assert!(
|
||||
event.id.starts_with("caldav:"),
|
||||
"occurrence ids spell the adapter: {}",
|
||||
event.id
|
||||
);
|
||||
}
|
||||
assert!(starts(&events)[0].contains("14:00"));
|
||||
assert!(starts(&events)[1].contains("16:00"));
|
||||
assert!(starts(&events)[2].contains("2026-10-02T09:00"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn basic_credentials_ride_the_authorization_header_only() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.and(header("Authorization", "Basic bWU6YXBwLXBhc3M="))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(MULTISTATUS))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
calendar(&server).events(&window()).await.expect("polls");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bearer_token_is_the_other_auth_spelling() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.and(header("Authorization", "Bearer sekrit"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(MULTISTATUS))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = CaldavCalendar::new(server.uri()).with_token("sekrit");
|
||||
calendar.events(&window()).await.expect("polls");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_error_is_named() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.respond_with(ResponseTemplate::new(503).set_body_string("maintenance"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = calendar(&server).events(&window()).await.expect_err("loud");
|
||||
assert!(
|
||||
matches!(err, CalendarError::Api { status: 503, .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_xml_answer_is_a_loud_parse_error() {
|
||||
let server = MockServer::start().await;
|
||||
// An HTML login page can arrive with a 200; almost none of it is
|
||||
// well-formed XML, and a shape break must surface, not read as a
|
||||
// quietly empty calendar.
|
||||
Mock::given(method("REPORT"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("<D:multistatus></D:response>"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = calendar(&server).events(&window()).await.expect_err("loud");
|
||||
assert!(matches!(err, CalendarError::Parse(_)), "got: {err:?}");
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Offline integration tests: the Google Calendar backend against an
|
||||
//! in-process wiremock server. Zero network.
|
||||
|
||||
use rhema_calendar_integration::{Calendar, CalendarError, GoogleCalendar, Window};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn window() -> Window {
|
||||
let from = OffsetDateTime::parse("2026-10-20T00:00:00Z", &Rfc3339).expect("parses");
|
||||
Window::new(from, from + time::Duration::hours(24))
|
||||
}
|
||||
|
||||
/// A timed row; `patch` overrides any field.
|
||||
fn row(id: &str, patch: Value) -> Value {
|
||||
let mut base = json!({
|
||||
"id": id,
|
||||
"status": "confirmed",
|
||||
"summary": format!("event {id}"),
|
||||
"start": { "dateTime": "2026-10-20T14:00:00+02:00" },
|
||||
"end": { "dateTime": "2026-10-20T15:00:00+02:00" },
|
||||
"htmlLink": format!("https://calendar.google.com/x/{id}")
|
||||
});
|
||||
if let (Value::Object(base), Value::Object(patch)) = (&mut base, patch) {
|
||||
for (key, value) in patch {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_poll_asks_for_expanded_instances_ordered_by_start() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/calendar/v3/calendars/primary/events"))
|
||||
.and(query_param("singleEvents", "true"))
|
||||
.and(query_param("orderBy", "startTime"))
|
||||
.and(query_param("timeMin", "2026-10-20T00:00:00Z"))
|
||||
.and(query_param("timeMax", "2026-10-21T00:00:00Z"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [
|
||||
row("a", json!({})),
|
||||
]})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri());
|
||||
let events = calendar.events(&window()).await.expect("polls");
|
||||
assert_eq!(events.len(), 1);
|
||||
// An offset start is an instant: the id pins the UTC second.
|
||||
let at = OffsetDateTime::parse("2026-10-20T12:00:00Z", &Rfc3339).expect("parses");
|
||||
assert_eq!(events[0].id, format!("google:a@{}", at.unix_timestamp()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_are_followed_until_the_cursor_ends() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(query_param("pageToken", "cursor-2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [
|
||||
row("last", json!({}))
|
||||
]})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"items": [row("first", json!({}))],
|
||||
"nextPageToken": "cursor-2"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri());
|
||||
let events = calendar.events(&window()).await.expect("polls");
|
||||
let ids: Vec<&str> = events
|
||||
.iter()
|
||||
.map(|event| event.id.split('@').next().unwrap_or(""))
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_api_key_rides_the_key_parameter_and_a_token_the_header() {
|
||||
let server = MockServer::start().await;
|
||||
// The key mock is mounted first and wins the requests it matches;
|
||||
// everything else (the keyless bearer client below) falls through
|
||||
// to the refusal.
|
||||
Mock::given(method("GET"))
|
||||
.and(query_param("key", "pub-key"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [
|
||||
row("public", json!({}))
|
||||
]})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(400))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = GoogleCalendar::with_api_key("pub-key", "primary").with_base_url(server.uri());
|
||||
assert_eq!(calendar.events(&window()).await.expect("polls").len(), 1);
|
||||
|
||||
// A client built with a token never sends `key`, so it lands on
|
||||
// the refusal: the credential kind decides the wire shape.
|
||||
let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri());
|
||||
let err = calendar
|
||||
.events(&window())
|
||||
.await
|
||||
.expect_err("the keyless request is refused");
|
||||
assert!(
|
||||
matches!(err, CalendarError::Api { status: 400, .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_rows_do_not_become_reminders() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [
|
||||
row("kept", json!({})),
|
||||
row("gone", json!({ "status": "cancelled" }))
|
||||
]})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri());
|
||||
let events = calendar.events(&window()).await.expect("polls");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(events[0].id.contains("kept"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_error_is_named() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(403).set_body_string("quota"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri());
|
||||
let err = calendar.events(&window()).await.expect_err("loud");
|
||||
assert!(
|
||||
matches!(err, CalendarError::Api { status: 403, .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Offline integration tests: the ICS backend against an in-process
|
||||
//! wiremock server. Zero network.
|
||||
|
||||
use rhema_calendar_integration::{Calendar, CalendarError, IcsCalendar, Window};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const FEED_BODY: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\nUID:meet-1@x\r\nDTSTART:20261020T140000Z\r\nDTEND:20261020T150000Z\r\n\
|
||||
SUMMARY:kickoff\\, phase one\r\nLOCATION:paris\r\n\
|
||||
ORGANIZER:mailto:boss@example.net\r\nATTENDEE:mailto:alice@example.net\r\n\
|
||||
URL:https://meet.example.net/1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
|
||||
fn window() -> Window {
|
||||
let from = OffsetDateTime::parse("2026-10-20T00:00:00Z", &Rfc3339).expect("parses");
|
||||
Window::new(from, from + time::Duration::hours(24))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_poll_is_one_get_and_normalizes_the_feed() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/remote.php/dav/calendars/personal.ics"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(FEED_BODY))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let feed = IcsCalendar::new(format!(
|
||||
"{}/remote.php/dav/calendars/personal.ics",
|
||||
server.uri()
|
||||
));
|
||||
let events = feed.events(&window()).await.expect("polls");
|
||||
assert_eq!(events.len(), 1);
|
||||
let event = &events[0];
|
||||
assert_eq!(event.title, "kickoff, phase one");
|
||||
assert_eq!(event.location.as_deref(), Some("paris"));
|
||||
assert_eq!(event.organizer.as_deref(), Some("boss@example.net"));
|
||||
assert_eq!(event.attendees, vec!["alice@example.net".to_owned()]);
|
||||
assert_eq!(event.url.as_deref(), Some("https://meet.example.net/1"));
|
||||
assert_eq!(
|
||||
event.start,
|
||||
OffsetDateTime::parse("2026-10-20T14:00:00Z", &Rfc3339).expect("parses")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_token_rides_the_authorization_header_only() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(header("Authorization", "Bearer sekrit"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(FEED_BODY))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let feed = IcsCalendar::new(server.uri()).with_token("sekrit");
|
||||
feed.events(&window()).await.expect("polls");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_error_is_named_with_a_bounded_body() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(503).set_body_string("maintenance"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let feed = IcsCalendar::new(server.uri());
|
||||
let err = feed.events(&window()).await.expect_err("loud");
|
||||
assert!(
|
||||
matches!(err, CalendarError::Api { status: 503, .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
assert!(err.to_string().contains("maintenance"), "got: {err}");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "rhema-forge-integration"
|
||||
description = "The forge integration: GitHub, Gitea, and GitLab activity normalized and ingested into rhema-rsd dispatch"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
# The resident tier is still indev: `cargo publish --workspace` skips
|
||||
# it until the claw ships.
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# The dispatch seam: watches ingest into a Dispatcher
|
||||
rhema-rsd = { path = "../../crates/rhema-rsd", version = "0.1.0" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
# HTTP against the three forge REST APIs: JSON request/response bodies,
|
||||
# no streaming
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
# In-process HTTP mocks for offline tests
|
||||
wiremock = "0.6"
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Why a forge call failed.
|
||||
//!
|
||||
//! One error type for the whole crate: every backend maps its transport,
|
||||
//! HTTP, and decoding failures onto it, so callers match one shape no
|
||||
//! matter which forge answered.
|
||||
|
||||
/// Everything that can go wrong between a [`crate::Forge`] call and its
|
||||
/// answer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ForgeError {
|
||||
/// The forge answered with a non-2xx status. `body` is a bounded
|
||||
/// snippet of the response body — usually the forge's error JSON.
|
||||
#[error("forge api error (HTTP {status}): {body}")]
|
||||
Api {
|
||||
/// The HTTP status the forge answered with.
|
||||
status: u16,
|
||||
/// A bounded snippet of the response body.
|
||||
body: String,
|
||||
},
|
||||
/// A response body could not be decoded, or a wire row is missing a
|
||||
/// field the normalized vocabulary requires. Loud by design: a shape
|
||||
/// break must surface, not silently drop events.
|
||||
#[error("malformed forge response: {0}")]
|
||||
Parse(String),
|
||||
/// The server kept offering full pages, or offered a page cursor that
|
||||
/// leads nowhere. Polling stopped rather than looping forever.
|
||||
#[error("pagination failed: {0}")]
|
||||
Pagination(String),
|
||||
/// The request never got an answer (DNS, connect, timeout).
|
||||
#[error("network error: {0}")]
|
||||
Network(String),
|
||||
}
|
||||
|
||||
/// The longest body snippet an [`ForgeError::Api`] keeps: enough for any
|
||||
/// forge's error JSON, small enough that error lines stay bounded.
|
||||
const BODY_SNIPPET_CAP: usize = 512;
|
||||
|
||||
impl ForgeError {
|
||||
/// Builds [`ForgeError::Api`] from a non-2xx status and the raw body,
|
||||
/// truncating the body to [`BODY_SNIPPET_CAP`] characters.
|
||||
pub(crate) fn api(status: reqwest::StatusCode, raw: &str) -> Self {
|
||||
let body: String = raw.chars().take(BODY_SNIPPET_CAP).collect();
|
||||
ForgeError::Api {
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a transport failure onto [`ForgeError::Network`]; reqwest's
|
||||
/// error is neither `Clone` nor `Eq`, so only its display text travels.
|
||||
pub(crate) fn network(err: reqwest::Error) -> Self {
|
||||
ForgeError::Network(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A wire field the normalized vocabulary cannot do without. A missing one
|
||||
/// is a loud [`ForgeError::Parse`], not a silently skipped row.
|
||||
pub(crate) fn required<T>(field: Option<T>, what: &str) -> Result<T, ForgeError> {
|
||||
field.ok_or_else(|| ForgeError::Parse(format!("forge row without {what}")))
|
||||
}
|
||||
|
||||
/// Passes a 2xx response through; turns anything else into
|
||||
/// [`ForgeError::Api`] with a bounded body snippet.
|
||||
pub(crate) async fn ensure_success(
|
||||
response: reqwest::Response,
|
||||
) -> Result<reqwest::Response, ForgeError> {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Err(ForgeError::api(status, &body))
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! The forge client contract: one trait, three calls.
|
||||
//!
|
||||
//! [`Forge`] is the whole surface a consumer needs: poll a repo's
|
||||
//! activity against a cursor, post a comment, and fetch a thread
|
||||
//! snapshot so an answering handler can orient without a second API
|
||||
//! round-trip. Implementations are cheap to clone and share — the
|
||||
//! reqwest client inside is reference-counted — and object-safe so a
|
||||
//! backend can be swapped without generics.
|
||||
//!
|
||||
//! Polls are at-least-once. The forges' `since`-style filters match rows
|
||||
//! at-or-after the cursor, so boundary rows repeat across polls, and a
|
||||
//! row that changes between a backend's feeds can surface twice under
|
||||
//! different ids. Both are harmless under the RSD dedup: consumers must
|
||||
//! treat the event `id` as the only dedup key, never `(repo, number)`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::ForgeError;
|
||||
use crate::event::{ForgeEvent, NotificationPage, RepoRef, Snapshot, Timestamp};
|
||||
|
||||
/// One poll's worth of activity, with the cursor to continue from.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ForgePage {
|
||||
/// The events, oldest first, ties broken by id — a deterministic
|
||||
/// order regardless of which feed produced an event.
|
||||
pub events: Vec<ForgeEvent>,
|
||||
/// The cursor for the next poll: the newest event time seen, never
|
||||
/// earlier than the poll's `since`. `None` only when the poll had no
|
||||
/// anchor and came back empty — the repo is simply quiet.
|
||||
pub cursor: Option<Timestamp>,
|
||||
}
|
||||
|
||||
impl ForgePage {
|
||||
/// Folds collected events into a page: sorts them and advances the
|
||||
/// cursor past everything seen.
|
||||
pub(crate) fn finish(mut events: Vec<ForgeEvent>, since: Option<&Timestamp>) -> Self {
|
||||
events.sort_by(|a, b| (&a.timestamp, &a.id).cmp(&(&b.timestamp, &b.id)));
|
||||
let mut cursor = since.cloned();
|
||||
for timestamp in events.iter().map(|event| &event.timestamp) {
|
||||
cursor = Some(match cursor {
|
||||
Some(current) if current >= *timestamp => current,
|
||||
_ => timestamp.clone(),
|
||||
});
|
||||
}
|
||||
Self { events, cursor }
|
||||
}
|
||||
}
|
||||
|
||||
/// A forge backend: the client for one REST dialect.
|
||||
#[async_trait]
|
||||
pub trait Forge: Send + Sync {
|
||||
/// Recent activity for `repo`, oldest first, all pages collected.
|
||||
///
|
||||
/// `since` anchors the poll: `None` lists from the beginning (a first
|
||||
/// poll); `Some(cursor)` asks for rows at-or-after the cursor, whose
|
||||
/// boundary rows may repeat — their stable ids make the repetition
|
||||
/// dedupable downstream, never duplicated work.
|
||||
async fn poll(
|
||||
&self,
|
||||
repo: &RepoRef,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<ForgePage, ForgeError>;
|
||||
|
||||
/// Posts `body` as a new comment on thread `number`.
|
||||
async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError>;
|
||||
|
||||
/// The thread's current state, so a handler can orient without a
|
||||
/// second API round-trip.
|
||||
async fn snapshot(&self, repo: &RepoRef, number: u64) -> Result<Snapshot, ForgeError>;
|
||||
|
||||
/// The account's notification feed: everything addressed to the
|
||||
/// account this client authenticates as, across every repository it
|
||||
/// can see — the account-scoped counterpart of [`Forge::poll`],
|
||||
/// whose feeds are repo-scoped. `since` anchors the poll with the
|
||||
/// same at-least-once semantics as `poll`: boundary rows repeat, and
|
||||
/// the stable ids make the repetition dedupable downstream.
|
||||
async fn notifications(
|
||||
&self,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<NotificationPage, ForgeError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::event::EventKind;
|
||||
use serde_json::json;
|
||||
|
||||
fn event(id: &str, at: &str) -> ForgeEvent {
|
||||
ForgeEvent {
|
||||
id: id.to_owned(),
|
||||
kind: EventKind::Comment,
|
||||
repo: RepoRef::new("acme", "widgets"),
|
||||
number: 7,
|
||||
is_pr: false,
|
||||
actor: None,
|
||||
body: None,
|
||||
url: None,
|
||||
timestamp: Timestamp::parse(at).expect("timestamp parses"),
|
||||
raw: json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_sort_oldest_first_with_id_ties() {
|
||||
let page = ForgePage::finish(
|
||||
vec![
|
||||
event("b", "2026-10-08T00:00:00Z"),
|
||||
event("z", "2026-10-07T00:00:00Z"),
|
||||
event("a", "2026-10-08T00:00:00Z"),
|
||||
],
|
||||
None,
|
||||
);
|
||||
let ids: Vec<&str> = page.events.iter().map(|event| event.id.as_str()).collect();
|
||||
assert_eq!(ids, ["z", "a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cursor_never_goes_backwards() {
|
||||
// A since newer than everything seen holds: the next poll repeats
|
||||
// the boundary instead of re-listing old history.
|
||||
let since = Timestamp::parse("2026-10-09T00:00:00Z").expect("parses");
|
||||
let page = ForgePage::finish(vec![event("a", "2026-10-08T00:00:00Z")], Some(&since));
|
||||
assert_eq!(page.cursor, Some(since));
|
||||
|
||||
let page = ForgePage::finish(
|
||||
vec![event("a", "2026-10-10T00:00:00Z")],
|
||||
Some(&Timestamp::parse("2026-10-09T00:00:00Z").expect("parses")),
|
||||
);
|
||||
assert_eq!(
|
||||
page.cursor,
|
||||
Some(Timestamp::parse("2026-10-10T00:00:00Z").expect("parses"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pages_keep_or_drop_the_cursor_depending_on_the_anchor() {
|
||||
let since = Timestamp::parse("2026-10-09T00:00:00Z").expect("parses");
|
||||
let page = ForgePage::finish(vec![], Some(&since));
|
||||
assert_eq!(page.cursor, Some(since));
|
||||
|
||||
let page = ForgePage::finish(vec![], None);
|
||||
assert_eq!(page.cursor, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
//! The Gitea adapter: self-hosted, so the base URL is required and points
|
||||
//! at the instance root (`https://forge.example`); every request path is
|
||||
//! prefixed with `/api/v1`. Auth is the token in
|
||||
//! `Authorization: token <token>`.
|
||||
//!
|
||||
//! The endpoints mirror GitHub's shape, with the constraints that differ:
|
||||
//! - Issues come from `GET /repos/{owner}/{repo}/issues` with
|
||||
//! `state=all&type=issues&since=<RFC 3339>`; `type=issues` is explicit
|
||||
//! so the feed is issues only, whatever the server's default.
|
||||
//! - PRs come from `GET /repos/{owner}/{repo}/pulls` with
|
||||
//! `state=all&sort=leastrecent`. Gitea's pulls list has no time filter,
|
||||
//! so the poll pages the whole list and filters `updated_at` client-side
|
||||
//! (belt and braces: `leastrecent` already orders oldest-updated first,
|
||||
//! and the final page sort is deterministic either way).
|
||||
//! - Unlike GitHub, Gitea's pulls rows carry the `merged` flag directly,
|
||||
//! so no per-PR detail fetch is needed; only reviews fan out per
|
||||
//! updated PR (`GET /repos/{owner}/{repo}/pulls/{index}/reviews`, no
|
||||
//! repo-wide reviews feed).
|
||||
//! - Comments come from the repo-wide
|
||||
//! `GET /repos/{owner}/{repo}/issues/comments` with `since=`; PR
|
||||
//! conversation comments are issue comments, `pull_request_url` set.
|
||||
//! - The `since` filters and the client-side cutoff match rows
|
||||
//! at-or-after the cursor, so boundary rows repeat across polls; stable
|
||||
//! ids make the repetition dedupable.
|
||||
//! - An updated-but-still-open thread with no close history yields no
|
||||
//! thread event: the usual cause is a comment, which the comments feed
|
||||
//! reports with its own id.
|
||||
//! - Reviews carry `submitted`, falling back to `created` on servers that
|
||||
//! leave it unset; `PENDING` reviews were never submitted and are
|
||||
//! skipped.
|
||||
//! - Pagination is `limit`/`page` until a short page, with a hard page
|
||||
//! cap. `limit=50` because Gitea's default response cap is 50 rows.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::{ForgeError, ensure_success, required};
|
||||
use crate::event::{
|
||||
EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot,
|
||||
ThreadState, Timestamp,
|
||||
};
|
||||
use crate::forge::{Forge, ForgePage};
|
||||
|
||||
/// Rows requested per page: Gitea's default response cap.
|
||||
const PER_PAGE: usize = 50;
|
||||
|
||||
/// Pages fetched before a server that keeps returning full pages is
|
||||
/// declared misbehaving rather than polled forever.
|
||||
const MAX_PAGES: usize = 1000;
|
||||
|
||||
/// Gitea backend: an instance base URL, a token, one reqwest client.
|
||||
///
|
||||
/// An empty token is accepted but useless for writes; public reads work
|
||||
/// anonymously.
|
||||
pub struct GiteaForge {
|
||||
base_url: String,
|
||||
token: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl GiteaForge {
|
||||
/// A forge authenticating with `token` against the instance at
|
||||
/// `base_url` (trailing slashes trimmed). Gitea is self-hosted: there
|
||||
/// is no default base.
|
||||
pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
// One connect attempt: a dead forge fails fast instead of
|
||||
// stalling the poll loop.
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
// Every forge call is a bounded request/response, unlike a
|
||||
// model stream, so one overall timeout per request is the
|
||||
// right guard.
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.expect("a client builder with only timeouts cannot fail");
|
||||
Self {
|
||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||
token: token.into(),
|
||||
http,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the base URL (trailing slashes trimmed) — for tests
|
||||
/// against a mock server.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into().trim_end_matches('/').to_owned();
|
||||
self
|
||||
}
|
||||
|
||||
/// A GET builder for `path` with auth applied: the token rides only in
|
||||
/// the `Authorization` header, never in a URL.
|
||||
fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
let base = &self.base_url;
|
||||
let request = self.http.get(format!("{base}/api/v1{path}"));
|
||||
self.auth(request)
|
||||
}
|
||||
|
||||
/// A POST builder for `path` with auth applied.
|
||||
fn post(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
let base = &self.base_url;
|
||||
let request = self.http.post(format!("{base}/api/v1{path}"));
|
||||
self.auth(request)
|
||||
}
|
||||
|
||||
/// Gitea's token scheme: `Authorization: token <token>`.
|
||||
fn auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if self.token.is_empty() {
|
||||
return request;
|
||||
}
|
||||
request.header("Authorization", format!("token {}", self.token))
|
||||
}
|
||||
|
||||
/// Follows `limit`/`page` pagination until a short page.
|
||||
async fn paged<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(String, String)],
|
||||
) -> Result<Vec<T>, ForgeError> {
|
||||
let mut rows = Vec::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let mut query = query.to_vec();
|
||||
query.push(("limit".to_owned(), PER_PAGE.to_string()));
|
||||
query.push(("page".to_owned(), page.to_string()));
|
||||
let response = self
|
||||
.get(path)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let batch: Vec<T> = decode(response).await?;
|
||||
let full = batch.len() == PER_PAGE;
|
||||
rows.extend(batch);
|
||||
if !full {
|
||||
return Ok(rows);
|
||||
}
|
||||
}
|
||||
Err(ForgeError::Pagination(format!(
|
||||
"more than {MAX_PAGES} full pages from {path}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes a response: non-2xx to [`ForgeError::Api`] with the body
|
||||
/// snippet, undecodable bodies to [`ForgeError::Parse`].
|
||||
async fn decode<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, ForgeError> {
|
||||
let response = ensure_success(response).await?;
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| ForgeError::Parse(format!("malformed forge response: {err}")))
|
||||
}
|
||||
|
||||
// --- Wire rows (private): the subset each event lifts, plus the
|
||||
// --- untouched row under `raw`.
|
||||
|
||||
/// A Gitea user object.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireUser {
|
||||
login: Option<String>,
|
||||
}
|
||||
|
||||
/// A label object.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireLabel {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// The `pull_request` meta on an issue row: its presence is the only PR
|
||||
/// marker there; the fields it carries are not used.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WirePullMarker {}
|
||||
|
||||
/// One row of the issues list.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireIssue {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
number: Option<u64>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
user: Option<WireUser>,
|
||||
#[serde(default)]
|
||||
labels: Vec<WireLabel>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
closed_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
html_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pull_request: Option<WirePullMarker>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One row of the pulls list; carries the `merged` flag directly.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WirePull {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
number: Option<u64>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
merged: bool,
|
||||
#[serde(default)]
|
||||
user: Option<WireUser>,
|
||||
#[serde(default)]
|
||||
labels: Vec<WireLabel>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
closed_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
html_url: Option<String>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One row of a PR's reviews.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireReview {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
user: Option<WireUser>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
submitted: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
created: Option<Timestamp>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One row of the repo-wide comments feed.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireComment {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
user: Option<WireUser>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
html_url: Option<String>,
|
||||
/// HTML URL of the parent thread; its trailing segment is the thread
|
||||
/// number.
|
||||
#[serde(default)]
|
||||
issue_url: Option<String>,
|
||||
/// Set (non-empty) exactly on PR conversation comments.
|
||||
#[serde(default)]
|
||||
pull_request_url: Option<String>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One row of the account notifications feed.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WireNotification {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
subject: Option<WireNotificationSubject>,
|
||||
#[serde(default)]
|
||||
repository: Option<WireNotificationRepo>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Timestamp>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// The subject a notification is about.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WireNotificationSubject {
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
/// API URL of the thread; its trailing segment is the thread number.
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
/// `Pull` or `Issue`.
|
||||
#[serde(rename = "type", default)]
|
||||
kind: Option<String>,
|
||||
}
|
||||
|
||||
/// The repository a notification's thread lives in.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WireNotificationRepo {
|
||||
#[serde(default)]
|
||||
full_name: Option<String>,
|
||||
}
|
||||
|
||||
// --- Mapping: wire rows -> normalized events. Pure, so the async
|
||||
// --- transport stays thin and the derivations are unit-testable.
|
||||
|
||||
/// The thread event an issues-list row carries, if any.
|
||||
fn issue_event(row: &WireIssue, repo: &RepoRef) -> Result<Option<ForgeEvent>, ForgeError> {
|
||||
let id = required(row.id, "issue id")?;
|
||||
let number = required(row.number, "issue number")?;
|
||||
let updated = required(row.updated_at.clone(), "issue updated_at")?;
|
||||
let created = required(row.created_at.clone(), "issue created_at")?;
|
||||
let kind = if row.state.as_deref() == Some("closed") {
|
||||
EventKind::Closed
|
||||
} else if row.closed_at.is_some() {
|
||||
EventKind::Reopened
|
||||
} else if created == updated {
|
||||
// Never touched since creation: opening is the only update.
|
||||
EventKind::Opened
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(ForgeEvent {
|
||||
id: format!("gitea:issue:{id}@{}", updated.raw()),
|
||||
kind,
|
||||
repo: repo.clone(),
|
||||
number,
|
||||
is_pr: false,
|
||||
actor: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
body: None,
|
||||
url: row.html_url.clone(),
|
||||
timestamp: updated,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The thread event a pulls row carries, if any. `merged` wins over the
|
||||
/// closed state: a merge closes the PR.
|
||||
fn pull_event(row: &WirePull, repo: &RepoRef) -> Result<Option<ForgeEvent>, ForgeError> {
|
||||
let id = required(row.id, "pull id")?;
|
||||
let number = required(row.number, "pull number")?;
|
||||
let updated = required(row.updated_at.clone(), "pull updated_at")?;
|
||||
let created = required(row.created_at.clone(), "pull created_at")?;
|
||||
let kind = if row.merged {
|
||||
EventKind::Merged
|
||||
} else if row.state.as_deref() == Some("closed") {
|
||||
EventKind::Closed
|
||||
} else if row.closed_at.is_some() {
|
||||
EventKind::Reopened
|
||||
} else if created == updated {
|
||||
EventKind::Opened
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(ForgeEvent {
|
||||
id: format!("gitea:pull:{id}@{}", updated.raw()),
|
||||
kind,
|
||||
repo: repo.clone(),
|
||||
number,
|
||||
is_pr: true,
|
||||
actor: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
body: None,
|
||||
url: row.html_url.clone(),
|
||||
timestamp: updated,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The review event a reviews row carries, if any: never-submitted
|
||||
/// (`PENDING`) rows and rows older than the cursor are skipped. The
|
||||
/// timestamp falls back from `submitted` to `created`, which older
|
||||
/// servers set alone.
|
||||
fn review_event(
|
||||
row: &WireReview,
|
||||
repo: &RepoRef,
|
||||
number: u64,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<Option<ForgeEvent>, ForgeError> {
|
||||
if row.state.as_deref() == Some("PENDING") {
|
||||
return Ok(None);
|
||||
}
|
||||
let timestamp = match row.submitted.clone().or_else(|| row.created.clone()) {
|
||||
Some(timestamp) => timestamp,
|
||||
None => return Ok(None),
|
||||
};
|
||||
if let Some(since) = since {
|
||||
if timestamp < *since {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let id = required(row.id, "review id")?;
|
||||
Ok(Some(ForgeEvent {
|
||||
id: format!("gitea:review:{id}"),
|
||||
kind: EventKind::Review,
|
||||
repo: repo.clone(),
|
||||
number,
|
||||
is_pr: true,
|
||||
actor: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
body: row.body.clone(),
|
||||
// Gitea review rows carry no URL of their own.
|
||||
url: None,
|
||||
timestamp,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The comment event a repo-wide comments row carries.
|
||||
fn comment_event(row: &WireComment, repo: &RepoRef) -> Result<Option<ForgeEvent>, ForgeError> {
|
||||
let id = required(row.id, "comment id")?;
|
||||
let issue_url = required(row.issue_url.clone(), "comment issue_url")?;
|
||||
let number = parent_number(&issue_url).ok_or_else(|| {
|
||||
ForgeError::Parse(format!(
|
||||
"comment issue_url without a thread number: {issue_url}"
|
||||
))
|
||||
})?;
|
||||
let timestamp = required(
|
||||
row.updated_at.clone().or_else(|| row.created_at.clone()),
|
||||
"comment timestamp",
|
||||
)?;
|
||||
let is_pr = row
|
||||
.pull_request_url
|
||||
.as_deref()
|
||||
.is_some_and(|url| !url.is_empty());
|
||||
Ok(Some(ForgeEvent {
|
||||
id: format!("gitea:comment:{id}"),
|
||||
kind: EventKind::Comment,
|
||||
repo: repo.clone(),
|
||||
number,
|
||||
is_pr,
|
||||
actor: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
body: row.body.clone(),
|
||||
url: row.html_url.clone(),
|
||||
timestamp,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The parent thread number: a forge thread URL's trailing segment.
|
||||
fn parent_number(url: &str) -> Option<u64> {
|
||||
url.trim_end_matches('/').rsplit('/').next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// The notification a notifications row carries. Gitea's feed names no
|
||||
/// per-row reason — the thread subject is all a row carries — so every
|
||||
/// notification maps to [`NotificationReason::Other`] with a fixed
|
||||
/// spelling, and the consumer's triage treats it as its default role.
|
||||
fn notification_event(row: &WireNotification) -> Result<Option<Notification>, ForgeError> {
|
||||
let id = required(row.id, "notification id")?;
|
||||
let updated = required(row.updated_at.clone(), "notification updated_at")?;
|
||||
let subject = required(row.subject.clone(), "notification subject")?;
|
||||
let repo_row = required(row.repository.clone(), "notification repository")?;
|
||||
let full_name = required(repo_row.full_name, "notification repository full_name")?;
|
||||
let (owner, name) = full_name.split_once('/').ok_or_else(|| {
|
||||
ForgeError::Parse(format!(
|
||||
"notification repository full_name without a slash: {full_name}"
|
||||
))
|
||||
})?;
|
||||
let subject_url = subject.url.clone().unwrap_or_default();
|
||||
let number = parent_number(&subject_url).ok_or_else(|| {
|
||||
ForgeError::Parse(format!(
|
||||
"notification subject url without a thread number: {subject_url}"
|
||||
))
|
||||
})?;
|
||||
Ok(Some(Notification {
|
||||
id: format!("gitea:notification:{id}@{}", updated.raw()),
|
||||
reason: NotificationReason::Other("gitea".to_owned()),
|
||||
repo: RepoRef::new(owner, name),
|
||||
number,
|
||||
is_pr: subject.kind.as_deref() == Some("Pull"),
|
||||
actor: None,
|
||||
title: subject.title.clone(),
|
||||
url: None,
|
||||
timestamp: updated,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The snapshot an issues-list row yields.
|
||||
fn snapshot_from_issue(row: &WireIssue) -> Result<Snapshot, ForgeError> {
|
||||
Ok(Snapshot {
|
||||
title: required(row.title.clone(), "issue title")?,
|
||||
body: row.body.clone(),
|
||||
state: if row.state.as_deref() == Some("closed") {
|
||||
ThreadState::Closed
|
||||
} else {
|
||||
ThreadState::Open
|
||||
},
|
||||
is_pr: false,
|
||||
author: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
labels: row
|
||||
.labels
|
||||
.iter()
|
||||
.filter_map(|label| label.name.clone())
|
||||
.collect(),
|
||||
url: row.html_url.clone(),
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// The snapshot a pulls row yields; the `merged` flag is authoritative.
|
||||
fn snapshot_from_pull(row: &WirePull) -> Result<Snapshot, ForgeError> {
|
||||
Ok(Snapshot {
|
||||
title: required(row.title.clone(), "pull title")?,
|
||||
body: row.body.clone(),
|
||||
state: if row.merged {
|
||||
ThreadState::Merged
|
||||
} else if row.state.as_deref() == Some("closed") {
|
||||
ThreadState::Closed
|
||||
} else {
|
||||
ThreadState::Open
|
||||
},
|
||||
is_pr: true,
|
||||
author: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
labels: row
|
||||
.labels
|
||||
.iter()
|
||||
.filter_map(|label| label.name.clone())
|
||||
.collect(),
|
||||
url: row.html_url.clone(),
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Forge for GiteaForge {
|
||||
async fn poll(
|
||||
&self,
|
||||
repo: &RepoRef,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<ForgePage, ForgeError> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Issue feed: server-side `since`, issues only.
|
||||
let issues_path = format!("/repos/{}/{}/issues", repo.owner, repo.name);
|
||||
let mut query = vec![
|
||||
("state".to_owned(), "all".to_owned()),
|
||||
("type".to_owned(), "issues".to_owned()),
|
||||
];
|
||||
if let Some(since) = since {
|
||||
query.push(("since".to_owned(), since.raw().to_owned()));
|
||||
}
|
||||
for row in self.paged::<WireIssue>(&issues_path, &query).await? {
|
||||
if let Some(event) = issue_event(&row, repo)? {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Pull feed: no server-side time filter exists, so the poll pages
|
||||
// the whole list and cuts off client-side.
|
||||
let pulls_path = format!("/repos/{}/{}/pulls", repo.owner, repo.name);
|
||||
let query = vec![
|
||||
("state".to_owned(), "all".to_owned()),
|
||||
("sort".to_owned(), "leastrecent".to_owned()),
|
||||
];
|
||||
for row in self.paged::<WirePull>(&pulls_path, &query).await? {
|
||||
let updated = required(row.updated_at.clone(), "pull updated_at")?;
|
||||
if let Some(since) = since {
|
||||
if updated < *since {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let number = required(row.number, "pull number")?;
|
||||
if let Some(event) = pull_event(&row, repo)? {
|
||||
events.push(event);
|
||||
}
|
||||
// Reviews fan out per updated PR: no repo-wide feed.
|
||||
let reviews_path =
|
||||
format!("/repos/{}/{}/pulls/{number}/reviews", repo.owner, repo.name);
|
||||
for review in self.paged::<WireReview>(&reviews_path, &[]).await? {
|
||||
if let Some(event) = review_event(&review, repo, number, since)? {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comment feed, repo-wide: covers issue and PR conversation
|
||||
// comments in one shape.
|
||||
let comments_path = format!("/repos/{}/{}/issues/comments", repo.owner, repo.name);
|
||||
let mut query = Vec::new();
|
||||
if let Some(since) = since {
|
||||
query.push(("since".to_owned(), since.raw().to_owned()));
|
||||
}
|
||||
for row in self.paged::<WireComment>(&comments_path, &query).await? {
|
||||
if let Some(event) = comment_event(&row, repo)? {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ForgePage::finish(events, since))
|
||||
}
|
||||
|
||||
async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError> {
|
||||
// The issues endpoint covers PRs too: every PR is an issue here.
|
||||
let path = format!(
|
||||
"/repos/{}/{}/issues/{number}/comments",
|
||||
repo.owner, repo.name
|
||||
);
|
||||
let response = self
|
||||
.post(&path)
|
||||
.json(&serde_json::json!({ "body": body }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
ensure_success(response).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn snapshot(&self, repo: &RepoRef, number: u64) -> Result<Snapshot, ForgeError> {
|
||||
let issue_path = format!("/repos/{}/{}/issues/{number}", repo.owner, repo.name);
|
||||
let response = self
|
||||
.get(&issue_path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let issue: WireIssue = decode(response).await?;
|
||||
if issue.pull_request.is_some() {
|
||||
// The issue view's PR meta is a marker; the pulls row carries
|
||||
// the authoritative merged flag.
|
||||
let pull_path = format!("/repos/{}/{}/pulls/{number}", repo.owner, repo.name);
|
||||
let response = self
|
||||
.get(&pull_path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let pull: WirePull = decode(response).await?;
|
||||
Ok(snapshot_from_pull(&pull)?)
|
||||
} else {
|
||||
Ok(snapshot_from_issue(&issue)?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn notifications(
|
||||
&self,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<NotificationPage, ForgeError> {
|
||||
// The unread-notifications feed: everything addressed to the
|
||||
// account, every repository it can see. `since` filters on the
|
||||
// row's updated time; boundary rows repeat, ids dedup them.
|
||||
let mut query = vec![("status-types".to_owned(), "unread".to_owned())];
|
||||
if let Some(since) = since {
|
||||
query.push(("since".to_owned(), since.raw().to_owned()));
|
||||
}
|
||||
let rows = self
|
||||
.paged::<WireNotification>("/notifications", &query)
|
||||
.await?;
|
||||
let mut notifications = Vec::new();
|
||||
for row in &rows {
|
||||
if let Some(notification) = notification_event(row)? {
|
||||
notifications.push(notification);
|
||||
}
|
||||
}
|
||||
Ok(NotificationPage::finish(notifications, since))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn repo() -> RepoRef {
|
||||
RepoRef::new("acme", "widgets")
|
||||
}
|
||||
|
||||
fn row(json: &str) -> WireIssue {
|
||||
serde_json::from_str(json).expect("issue fixture parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_issue_row_maps_to_closed() {
|
||||
let event = issue_event(
|
||||
&row(r#"{"id": 107, "number": 7, "state": "closed",
|
||||
"user": {"login": "alice"},
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-02T10:00:00Z",
|
||||
"closed_at": "2026-10-02T10:00:00Z",
|
||||
"html_url": "https://forge.example/acme/widgets/issues/7"}"#),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("closed row carries an event");
|
||||
assert_eq!(event.kind, EventKind::Closed);
|
||||
assert_eq!(event.id, "gitea:issue:107@2026-10-02T10:00:00Z");
|
||||
assert!(!event.is_pr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_is_derived_from_a_close_in_the_past() {
|
||||
let event = issue_event(
|
||||
&row(r#"{"id": 104, "number": 4, "state": "open",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-07T09:00:00Z",
|
||||
"closed_at": "2026-10-03T00:00:00Z"}"#),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("reopened row carries an event");
|
||||
assert_eq!(event.kind, EventKind::Reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_updated_open_row_without_close_history_emits_nothing() {
|
||||
let event = issue_event(
|
||||
&row(r#"{"id": 106, "number": 6, "state": "open",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-05T09:00:00Z",
|
||||
"closed_at": null}"#),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps");
|
||||
assert_eq!(event, None);
|
||||
}
|
||||
|
||||
fn pull(json: &str) -> WirePull {
|
||||
serde_json::from_str(json).expect("pull fixture parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_beats_closed_on_a_pull_row() {
|
||||
let merged = pull_event(
|
||||
&pull(
|
||||
r#"{"id": 203, "number": 3, "state": "closed", "merged": true,
|
||||
"created_at": "2026-10-01T08:00:00Z",
|
||||
"updated_at": "2026-10-06T12:00:00Z",
|
||||
"closed_at": "2026-10-06T12:00:00Z"}"#,
|
||||
),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("merged pull carries an event");
|
||||
assert_eq!(merged.kind, EventKind::Merged);
|
||||
assert_eq!(merged.id, "gitea:pull:203@2026-10-06T12:00:00Z");
|
||||
|
||||
let closed = pull_event(
|
||||
&pull(
|
||||
r#"{"id": 204, "number": 4, "state": "closed", "merged": false,
|
||||
"created_at": "2026-10-01T08:00:00Z",
|
||||
"updated_at": "2026-10-06T12:00:00Z",
|
||||
"closed_at": "2026-10-06T12:00:00Z"}"#,
|
||||
),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("closed pull carries an event");
|
||||
assert_eq!(closed.kind, EventKind::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_timestamp_falls_back_to_created() {
|
||||
let review: WireReview = serde_json::from_str(
|
||||
r#"{"id": 31, "state": "APPROVED", "submitted": "2026-10-06T13:00:00Z",
|
||||
"created": "2026-10-06T13:00:00Z"}"#,
|
||||
)
|
||||
.expect("review fixture parses");
|
||||
let event = review_event(&review, &repo(), 3, None)
|
||||
.expect("maps")
|
||||
.expect("review carries an event");
|
||||
assert_eq!(
|
||||
event.timestamp,
|
||||
Timestamp::parse("2026-10-06T13:00:00Z").expect("parses")
|
||||
);
|
||||
|
||||
// Older servers set `created` alone.
|
||||
let bare: WireReview = serde_json::from_str(
|
||||
r#"{"id": 33, "state": "COMMENT", "created": "2026-10-06T14:00:00Z"}"#,
|
||||
)
|
||||
.expect("review fixture parses");
|
||||
let event = review_event(&bare, &repo(), 3, None)
|
||||
.expect("maps")
|
||||
.expect("review carries an event");
|
||||
assert_eq!(
|
||||
event.timestamp,
|
||||
Timestamp::parse("2026-10-06T14:00:00Z").expect("parses")
|
||||
);
|
||||
|
||||
// Neither timestamp: never treatable as submitted.
|
||||
let timeless: WireReview = serde_json::from_str(r#"{"id": 34, "state": "COMMENT"}"#)
|
||||
.expect("review fixture parses");
|
||||
assert_eq!(
|
||||
review_event(&timeless, &repo(), 3, None).expect("maps"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pr_comment_is_detected_from_its_urls() {
|
||||
let comment: WireComment = serde_json::from_str(
|
||||
r#"{"id": 3001, "user": {"login": "erin"}, "body": "reviewed",
|
||||
"created_at": "2026-10-06T14:00:00Z",
|
||||
"updated_at": "2026-10-06T14:00:00Z",
|
||||
"issue_url": "https://forge.example/acme/widgets/issues/3",
|
||||
"pull_request_url": "https://forge.example/acme/widgets/pulls/3"}"#,
|
||||
)
|
||||
.expect("comment fixture parses");
|
||||
let event = comment_event(&comment, &repo())
|
||||
.expect("maps")
|
||||
.expect("comment carries an event");
|
||||
assert_eq!(event.kind, EventKind::Comment);
|
||||
assert!(event.is_pr);
|
||||
assert_eq!(event.number, 3);
|
||||
|
||||
// An issue comment carries no pull_request_url.
|
||||
let issue_comment: WireComment = serde_json::from_str(
|
||||
r#"{"id": 7001, "created_at": "2026-10-02T11:00:00Z",
|
||||
"updated_at": "2026-10-02T11:00:00Z",
|
||||
"issue_url": "https://forge.example/acme/widgets/issues/7",
|
||||
"pull_request_url": null}"#,
|
||||
)
|
||||
.expect("comment fixture parses");
|
||||
let event = comment_event(&issue_comment, &repo())
|
||||
.expect("maps")
|
||||
.expect("comment carries an event");
|
||||
assert!(!event.is_pr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
//! The GitHub adapter: REST v3, at [`DEFAULT_BASE_URL`] or a GitHub
|
||||
//! Enterprise Server base. Auth is the token in `Authorization: Bearer` —
|
||||
//! never a query parameter, so the token never lands in URLs or logs.
|
||||
//!
|
||||
//! Endpoint choices and the constraints they pin:
|
||||
//! - Threads come from `GET /repos/{owner}/{repo}/issues` with
|
||||
//! `state=all&sort=updated&direction=asc&since=<RFC 3339>`: the issues
|
||||
//! list is the only thread feed with a server-side time filter, and PR
|
||||
//! rows ride it behind the `pull_request` key.
|
||||
//! - The merged flag and reviews are invisible from the issues list: the
|
||||
//! pulls list has no time filter and reviews have no repo-wide feed, so
|
||||
//! every PR row the issues list returns costs one `GET /pulls/{n}`
|
||||
//! (merged detection) and one `GET /pulls/{n}/reviews` (filtered
|
||||
//! client-side by `submitted_at`).
|
||||
//! - Comments come from the repo-wide
|
||||
//! `GET /repos/{owner}/{repo}/issues/comments` with
|
||||
//! `sort=created&direction=asc&since=`; a PR conversation comment is the
|
||||
//! same row shape, its `issue_url` ending in `/pulls/`.
|
||||
//! - GitHub's `since` matches rows updated at-or-after the instant, so a
|
||||
//! re-poll on the returned cursor repeats boundary rows; stable ids make
|
||||
//! the repetition dedupable.
|
||||
//! - An updated-but-still-open thread with no close history yields no
|
||||
//! thread event: the usual cause is a comment, which the comments feed
|
||||
//! reports with its own id. Title/label edits alone surface as nothing.
|
||||
//! - A `PENDING` review was never submitted and is skipped; `DISMISSED` is
|
||||
//! activity and normalizes as [`EventKind::Review`].
|
||||
//! - A comment edit changes the row's `updated_at` but not its id: the
|
||||
//! edit reappears at the boundary and dedups away. Edits are not
|
||||
//! re-dispatched.
|
||||
//! - Pagination is `per_page`/`page` until a short page, with a hard page
|
||||
//! cap. Page offsets over an updated-ascending sort can shift while a
|
||||
//! poll is running; duplicates are absorbed by ids, misses only happen
|
||||
//! to rows that update twice inside one poll window.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::{ForgeError, ensure_success, required};
|
||||
use crate::event::{
|
||||
EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot,
|
||||
ThreadState, Timestamp,
|
||||
};
|
||||
use crate::forge::{Forge, ForgePage};
|
||||
|
||||
/// The production GitHub API base.
|
||||
pub const DEFAULT_BASE_URL: &str = "https://api.github.com";
|
||||
|
||||
/// GitHub rejects requests without a `User-Agent` outright.
|
||||
const USER_AGENT: &str = concat!("rhema-forge-integration/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// Rows requested per page.
|
||||
const PER_PAGE: usize = 100;
|
||||
|
||||
/// Pages fetched before a server that keeps returning full pages is
|
||||
/// declared misbehaving rather than polled forever.
|
||||
const MAX_PAGES: usize = 1000;
|
||||
|
||||
/// GitHub backend: a base URL, a token, one reqwest client.
|
||||
///
|
||||
/// An empty token is accepted but useless for writes; public reads work
|
||||
/// anonymously.
|
||||
pub struct GitHubForge {
|
||||
base_url: String,
|
||||
token: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl GitHubForge {
|
||||
/// A forge authenticating with `token` against [`DEFAULT_BASE_URL`].
|
||||
pub fn new(token: impl Into<String>) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
// One connect attempt: a dead forge fails fast instead of
|
||||
// stalling the poll loop.
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
// Every forge call is a bounded request/response, unlike a
|
||||
// model stream, so one overall timeout per request is the
|
||||
// right guard.
|
||||
.timeout(Duration::from_secs(20))
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.expect("a client builder with only timeouts cannot fail");
|
||||
Self {
|
||||
base_url: DEFAULT_BASE_URL.to_owned(),
|
||||
token: token.into(),
|
||||
http,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the base URL (trailing slashes trimmed) — for GitHub
|
||||
/// Enterprise Server and for tests against a mock server.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into().trim_end_matches('/').to_owned();
|
||||
self
|
||||
}
|
||||
|
||||
/// A GET builder for `path` with auth applied: the token rides only in
|
||||
/// the `Authorization: Bearer` header, never in a URL.
|
||||
fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
let base = &self.base_url;
|
||||
let request = self.http.get(format!("{base}{path}"));
|
||||
if self.token.is_empty() {
|
||||
request
|
||||
} else {
|
||||
request.bearer_auth(&self.token)
|
||||
}
|
||||
}
|
||||
|
||||
/// A POST builder for `path` with auth applied.
|
||||
fn post(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
let base = &self.base_url;
|
||||
let request = self.http.post(format!("{base}{path}"));
|
||||
if self.token.is_empty() {
|
||||
request
|
||||
} else {
|
||||
request.bearer_auth(&self.token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows `per_page`/`page` pagination until a short page.
|
||||
async fn paged<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(String, String)],
|
||||
) -> Result<Vec<T>, ForgeError> {
|
||||
let mut rows = Vec::new();
|
||||
for page in 1..=MAX_PAGES {
|
||||
let mut query = query.to_vec();
|
||||
query.push(("per_page".to_owned(), PER_PAGE.to_string()));
|
||||
query.push(("page".to_owned(), page.to_string()));
|
||||
let response = self
|
||||
.get(path)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let batch: Vec<T> = decode(response).await?;
|
||||
let full = batch.len() == PER_PAGE;
|
||||
rows.extend(batch);
|
||||
if !full {
|
||||
return Ok(rows);
|
||||
}
|
||||
}
|
||||
Err(ForgeError::Pagination(format!(
|
||||
"more than {MAX_PAGES} full pages from {path}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes a response: non-2xx to [`ForgeError::Api`] with the body
|
||||
/// snippet, undecodable bodies to [`ForgeError::Parse`].
|
||||
async fn decode<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, ForgeError> {
|
||||
let response = ensure_success(response).await?;
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| ForgeError::Parse(format!("malformed forge response: {err}")))
|
||||
}
|
||||
|
||||
// --- Wire rows (private): the subset each event lifts, plus the
|
||||
// --- untouched row under `raw`.
|
||||
|
||||
/// A GitHub user object.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireUser {
|
||||
login: Option<String>,
|
||||
}
|
||||
|
||||
/// A label object.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireLabel {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// The `pull_request` key: its presence is the only PR marker on issue
|
||||
/// rows; the fields it carries are not used.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WirePullMarker {}
|
||||
|
||||
/// One row of the issues list.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireIssue {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
number: Option<u64>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
user: Option<WireUser>,
|
||||
#[serde(default)]
|
||||
labels: Vec<WireLabel>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
closed_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
html_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pull_request: Option<WirePullMarker>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One `GET /pulls/{n}` row.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WirePull {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
number: Option<u64>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
user: Option<WireUser>,
|
||||
#[serde(default)]
|
||||
labels: Vec<WireLabel>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
closed_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
merged_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
html_url: Option<String>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One row of a PR's reviews.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireReview {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
user: Option<WireUser>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
submitted_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
html_url: Option<String>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One row of the repo-wide comments feed.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireComment {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
user: Option<WireUser>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
html_url: Option<String>,
|
||||
/// API URL of the parent thread; its trailing segment is the thread
|
||||
/// number, and `/pulls/` in it marks a PR comment.
|
||||
#[serde(default)]
|
||||
issue_url: Option<String>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One row of the account notifications feed.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WireNotification {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
#[serde(default)]
|
||||
subject: Option<WireNotificationSubject>,
|
||||
#[serde(default)]
|
||||
repository: Option<WireNotificationRepo>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Timestamp>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// The subject a notification is about.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WireNotificationSubject {
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
/// API URL of the thread; its trailing segment is the thread number.
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
/// `PullRequest`, `Issue`, `Release`, ...
|
||||
#[serde(rename = "type", default)]
|
||||
kind: Option<String>,
|
||||
}
|
||||
|
||||
/// The repository a notification's thread lives in.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WireNotificationRepo {
|
||||
#[serde(default)]
|
||||
full_name: Option<String>,
|
||||
}
|
||||
|
||||
// --- Mapping: wire rows -> normalized events. Pure, so the async
|
||||
// --- transport stays thin and the derivations are unit-testable.
|
||||
|
||||
/// The thread event an issues-list row carries, if any. State transitions
|
||||
/// are derivable from the row; other updates are not.
|
||||
fn issue_event(row: &WireIssue, repo: &RepoRef) -> Result<Option<ForgeEvent>, ForgeError> {
|
||||
let id = required(row.id, "issue id")?;
|
||||
let number = required(row.number, "issue number")?;
|
||||
let updated = required(row.updated_at.clone(), "issue updated_at")?;
|
||||
let created = required(row.created_at.clone(), "issue created_at")?;
|
||||
let kind = if row.state.as_deref() == Some("closed") {
|
||||
EventKind::Closed
|
||||
} else if row.closed_at.is_some() {
|
||||
EventKind::Reopened
|
||||
} else if created == updated {
|
||||
// Never touched since creation: opening is the only update.
|
||||
EventKind::Opened
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(ForgeEvent {
|
||||
id: format!("github:issue:{id}@{}", updated.raw()),
|
||||
kind,
|
||||
repo: repo.clone(),
|
||||
number,
|
||||
is_pr: false,
|
||||
actor: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
body: None,
|
||||
url: row.html_url.clone(),
|
||||
timestamp: updated,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The thread event a pulls row carries, if any. The merged flag wins over
|
||||
/// the closed state: a merge closes the PR.
|
||||
fn pull_event(row: &WirePull, repo: &RepoRef) -> Result<Option<ForgeEvent>, ForgeError> {
|
||||
let id = required(row.id, "pull id")?;
|
||||
let number = required(row.number, "pull number")?;
|
||||
let updated = required(row.updated_at.clone(), "pull updated_at")?;
|
||||
let created = required(row.created_at.clone(), "pull created_at")?;
|
||||
let kind = if row.merged_at.is_some() {
|
||||
EventKind::Merged
|
||||
} else if row.state.as_deref() == Some("closed") {
|
||||
EventKind::Closed
|
||||
} else if row.closed_at.is_some() {
|
||||
EventKind::Reopened
|
||||
} else if created == updated {
|
||||
EventKind::Opened
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(ForgeEvent {
|
||||
id: format!("github:pull:{id}@{}", updated.raw()),
|
||||
kind,
|
||||
repo: repo.clone(),
|
||||
number,
|
||||
is_pr: true,
|
||||
actor: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
body: None,
|
||||
url: row.html_url.clone(),
|
||||
timestamp: updated,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The review event a reviews row carries, if any: never-submitted
|
||||
/// (`PENDING`) rows and rows older than the cursor are skipped.
|
||||
fn review_event(
|
||||
row: &WireReview,
|
||||
repo: &RepoRef,
|
||||
number: u64,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<Option<ForgeEvent>, ForgeError> {
|
||||
if row.state.as_deref() == Some("PENDING") {
|
||||
return Ok(None);
|
||||
}
|
||||
// A review without a submission time was never submitted.
|
||||
let timestamp = required(row.submitted_at.clone(), "review submitted_at")?;
|
||||
if let Some(since) = since {
|
||||
if timestamp < *since {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let id = required(row.id, "review id")?;
|
||||
Ok(Some(ForgeEvent {
|
||||
id: format!("github:review:{id}"),
|
||||
kind: EventKind::Review,
|
||||
repo: repo.clone(),
|
||||
number,
|
||||
is_pr: true,
|
||||
actor: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
body: row.body.clone(),
|
||||
url: row.html_url.clone(),
|
||||
timestamp,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The comment event a repo-wide comments row carries.
|
||||
fn comment_event(row: &WireComment, repo: &RepoRef) -> Result<Option<ForgeEvent>, ForgeError> {
|
||||
let id = required(row.id, "comment id")?;
|
||||
let issue_url = required(row.issue_url.clone(), "comment issue_url")?;
|
||||
let number = parent_number(&issue_url).ok_or_else(|| {
|
||||
ForgeError::Parse(format!(
|
||||
"comment issue_url without a thread number: {issue_url}"
|
||||
))
|
||||
})?;
|
||||
let timestamp = required(
|
||||
row.updated_at.clone().or_else(|| row.created_at.clone()),
|
||||
"comment timestamp",
|
||||
)?;
|
||||
Ok(Some(ForgeEvent {
|
||||
id: format!("github:comment:{id}"),
|
||||
kind: EventKind::Comment,
|
||||
repo: repo.clone(),
|
||||
number,
|
||||
is_pr: issue_url.contains("/pulls/"),
|
||||
actor: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
body: row.body.clone(),
|
||||
url: row.html_url.clone(),
|
||||
timestamp,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The parent thread number: a forge thread URL's trailing segment.
|
||||
fn parent_number(url: &str) -> Option<u64> {
|
||||
url.trim_end_matches('/').rsplit('/').next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// The notification a notifications row carries. GitHub's `reason` field
|
||||
/// is the row's whole why; the subject's API URL yields the thread.
|
||||
fn notification_event(row: &WireNotification) -> Result<Option<Notification>, ForgeError> {
|
||||
let id = required(row.id, "notification id")?;
|
||||
let updated = required(row.updated_at.clone(), "notification updated_at")?;
|
||||
let subject = required(row.subject.clone(), "notification subject")?;
|
||||
let repo_row = required(row.repository.clone(), "notification repository")?;
|
||||
let full_name = required(repo_row.full_name, "notification repository full_name")?;
|
||||
let (owner, name) = full_name.split_once('/').ok_or_else(|| {
|
||||
ForgeError::Parse(format!(
|
||||
"notification repository full_name without a slash: {full_name}"
|
||||
))
|
||||
})?;
|
||||
let subject_url = subject.url.clone().unwrap_or_default();
|
||||
let number = parent_number(&subject_url).ok_or_else(|| {
|
||||
ForgeError::Parse(format!(
|
||||
"notification subject url without a thread number: {subject_url}"
|
||||
))
|
||||
})?;
|
||||
Ok(Some(Notification {
|
||||
id: format!("github:notification:{id}@{}", updated.raw()),
|
||||
reason: github_reason(row.reason.as_deref()),
|
||||
repo: RepoRef::new(owner, name),
|
||||
number,
|
||||
is_pr: subject.kind.as_deref() == Some("PullRequest"),
|
||||
actor: None,
|
||||
title: subject.title.clone(),
|
||||
url: None,
|
||||
timestamp: updated,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// GitHub's reason strings, reduced to the shared vocabulary. `comment`
|
||||
/// means "activity on a thread you participate in" — for the account
|
||||
/// this client authenticates as, that is overwhelmingly feedback on its
|
||||
/// own threads, so it maps to [`NotificationReason::ReviewFeedback`].
|
||||
fn github_reason(reason: Option<&str>) -> NotificationReason {
|
||||
match reason {
|
||||
Some("review_requested") => NotificationReason::ReviewRequested,
|
||||
Some("assign") => NotificationReason::Assigned,
|
||||
Some("mention") | Some("team_mention") => NotificationReason::Mention,
|
||||
Some("ci_activity") => NotificationReason::CiActivity,
|
||||
Some("author") => NotificationReason::Author,
|
||||
Some("comment") => NotificationReason::ReviewFeedback,
|
||||
Some(other) => NotificationReason::Other(other.to_owned()),
|
||||
None => NotificationReason::Other("unreasoned".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The snapshot an issues-list row yields.
|
||||
fn snapshot_from_issue(row: &WireIssue) -> Result<Snapshot, ForgeError> {
|
||||
Ok(Snapshot {
|
||||
title: required(row.title.clone(), "issue title")?,
|
||||
body: row.body.clone(),
|
||||
state: if row.state.as_deref() == Some("closed") {
|
||||
ThreadState::Closed
|
||||
} else {
|
||||
ThreadState::Open
|
||||
},
|
||||
is_pr: false,
|
||||
author: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
labels: row
|
||||
.labels
|
||||
.iter()
|
||||
.filter_map(|label| label.name.clone())
|
||||
.collect(),
|
||||
url: row.html_url.clone(),
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// The snapshot a pulls row yields; `merged_at` is the authoritative
|
||||
/// merged flag the issue view of a PR cannot prove.
|
||||
fn snapshot_from_pull(row: &WirePull) -> Result<Snapshot, ForgeError> {
|
||||
Ok(Snapshot {
|
||||
title: required(row.title.clone(), "pull title")?,
|
||||
body: row.body.clone(),
|
||||
state: if row.merged_at.is_some() {
|
||||
ThreadState::Merged
|
||||
} else if row.state.as_deref() == Some("closed") {
|
||||
ThreadState::Closed
|
||||
} else {
|
||||
ThreadState::Open
|
||||
},
|
||||
is_pr: true,
|
||||
author: row.user.as_ref().and_then(|user| user.login.clone()),
|
||||
labels: row
|
||||
.labels
|
||||
.iter()
|
||||
.filter_map(|label| label.name.clone())
|
||||
.collect(),
|
||||
url: row.html_url.clone(),
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Forge for GitHubForge {
|
||||
async fn poll(
|
||||
&self,
|
||||
repo: &RepoRef,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<ForgePage, ForgeError> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Thread feed: the issues list, the only feed with a server-side
|
||||
// time filter. PR rows ride behind the `pull_request` key.
|
||||
let threads_path = format!("/repos/{}/{}/issues", repo.owner, repo.name);
|
||||
let mut query = vec![
|
||||
("state".to_owned(), "all".to_owned()),
|
||||
("sort".to_owned(), "updated".to_owned()),
|
||||
("direction".to_owned(), "asc".to_owned()),
|
||||
];
|
||||
if let Some(since) = since {
|
||||
query.push(("since".to_owned(), since.raw().to_owned()));
|
||||
}
|
||||
let mut pull_numbers = Vec::new();
|
||||
for row in self.paged::<WireIssue>(&threads_path, &query).await? {
|
||||
if row.pull_request.is_some() {
|
||||
pull_numbers.push(required(row.number, "issue number")?);
|
||||
} else if let Some(event) = issue_event(&row, repo)? {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Per updated PR: the merged flag and the reviews are invisible
|
||||
// from the issues list.
|
||||
for number in pull_numbers {
|
||||
let detail_path = format!("/repos/{}/{}/pulls/{number}", repo.owner, repo.name);
|
||||
let response = self
|
||||
.get(&detail_path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let pull: WirePull = decode(response).await?;
|
||||
if let Some(event) = pull_event(&pull, repo)? {
|
||||
events.push(event);
|
||||
}
|
||||
let reviews = self
|
||||
.paged::<WireReview>(&format!("{detail_path}/reviews"), &[])
|
||||
.await?;
|
||||
for review in &reviews {
|
||||
if let Some(event) = review_event(review, repo, number, since)? {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comment feed, repo-wide: covers issue and PR conversation
|
||||
// comments in one shape.
|
||||
let comments_path = format!("/repos/{}/{}/issues/comments", repo.owner, repo.name);
|
||||
let mut query = vec![
|
||||
("sort".to_owned(), "created".to_owned()),
|
||||
("direction".to_owned(), "asc".to_owned()),
|
||||
];
|
||||
if let Some(since) = since {
|
||||
query.push(("since".to_owned(), since.raw().to_owned()));
|
||||
}
|
||||
for row in self.paged::<WireComment>(&comments_path, &query).await? {
|
||||
if let Some(event) = comment_event(&row, repo)? {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ForgePage::finish(events, since))
|
||||
}
|
||||
|
||||
async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError> {
|
||||
// The issues endpoint covers PRs too: every PR is an issue here.
|
||||
let path = format!(
|
||||
"/repos/{}/{}/issues/{number}/comments",
|
||||
repo.owner, repo.name
|
||||
);
|
||||
let response = self
|
||||
.post(&path)
|
||||
.json(&serde_json::json!({ "body": body }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
ensure_success(response).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn snapshot(&self, repo: &RepoRef, number: u64) -> Result<Snapshot, ForgeError> {
|
||||
let issue_path = format!("/repos/{}/{}/issues/{number}", repo.owner, repo.name);
|
||||
let response = self
|
||||
.get(&issue_path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let issue: WireIssue = decode(response).await?;
|
||||
if issue.pull_request.is_some() {
|
||||
// The issue view of a PR lacks the authoritative merged flag.
|
||||
let pull_path = format!("/repos/{}/{}/pulls/{number}", repo.owner, repo.name);
|
||||
let response = self
|
||||
.get(&pull_path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let pull: WirePull = decode(response).await?;
|
||||
Ok(snapshot_from_pull(&pull)?)
|
||||
} else {
|
||||
Ok(snapshot_from_issue(&issue)?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn notifications(
|
||||
&self,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<NotificationPage, ForgeError> {
|
||||
// The unread-notifications feed: everything addressed to the
|
||||
// account, every repository it can see. `since` filters on the
|
||||
// row's updated time; boundary rows repeat, ids dedup them.
|
||||
let mut query = Vec::new();
|
||||
if let Some(since) = since {
|
||||
query.push(("since".to_owned(), since.raw().to_owned()));
|
||||
}
|
||||
let rows = self
|
||||
.paged::<WireNotification>("/notifications", &query)
|
||||
.await?;
|
||||
let mut notifications = Vec::new();
|
||||
for row in &rows {
|
||||
if let Some(notification) = notification_event(row)? {
|
||||
notifications.push(notification);
|
||||
}
|
||||
}
|
||||
Ok(NotificationPage::finish(notifications, since))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn repo() -> RepoRef {
|
||||
RepoRef::new("acme", "widgets")
|
||||
}
|
||||
|
||||
fn row(json: &str) -> WireIssue {
|
||||
serde_json::from_str(json).expect("issue fixture parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_issue_row_maps_to_closed() {
|
||||
let event = issue_event(
|
||||
&row(r#"{"id": 1007, "number": 7, "state": "closed",
|
||||
"user": {"login": "alice"}, "labels": [],
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-02T10:00:00Z",
|
||||
"closed_at": "2026-10-02T10:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/issues/7"}"#),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("closed row carries an event");
|
||||
assert_eq!(event.kind, EventKind::Closed);
|
||||
assert_eq!(event.id, "github:issue:1007@2026-10-02T10:00:00Z");
|
||||
assert_eq!(event.number, 7);
|
||||
assert!(!event.is_pr);
|
||||
assert_eq!(event.actor.as_deref(), Some("alice"));
|
||||
assert_eq!(event.raw["title"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopen_is_derived_from_a_close_in_the_past() {
|
||||
let event = issue_event(
|
||||
&row(r#"{"id": 1004, "number": 4, "state": "open",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-07T09:00:00Z",
|
||||
"closed_at": "2026-10-03T00:00:00Z"}"#),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("reopened row carries an event");
|
||||
assert_eq!(event.kind, EventKind::Reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_updated_open_row_without_close_history_emits_nothing() {
|
||||
let event = issue_event(
|
||||
&row(r#"{"id": 1006, "number": 6, "state": "open",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-05T09:00:00Z",
|
||||
"closed_at": null}"#),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps");
|
||||
assert_eq!(event, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_missing_a_required_field_is_a_parse_error() {
|
||||
let err = issue_event(&row(r#"{"number": 7, "state": "closed"}"#), &repo())
|
||||
.expect_err("missing id is loud");
|
||||
assert!(matches!(err, ForgeError::Parse(_)), "got: {err:?}");
|
||||
}
|
||||
|
||||
fn pull(json: &str) -> WirePull {
|
||||
serde_json::from_str(json).expect("pull fixture parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_beats_closed_on_a_pull_row() {
|
||||
let merged = pull_event(
|
||||
&pull(
|
||||
r#"{"id": 9009, "number": 9, "state": "closed",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-06T12:00:00Z",
|
||||
"closed_at": "2026-10-06T12:00:00Z",
|
||||
"merged_at": "2026-10-06T12:00:00Z"}"#,
|
||||
),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("merged pull carries an event");
|
||||
assert_eq!(merged.kind, EventKind::Merged);
|
||||
assert!(merged.is_pr);
|
||||
assert_eq!(merged.id, "github:pull:9009@2026-10-06T12:00:00Z");
|
||||
|
||||
let closed = pull_event(
|
||||
&pull(
|
||||
r#"{"id": 9010, "number": 10, "state": "closed",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-06T12:00:00Z",
|
||||
"closed_at": "2026-10-06T12:00:00Z",
|
||||
"merged_at": null}"#,
|
||||
),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("closed pull carries an event");
|
||||
assert_eq!(closed.kind, EventKind::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_reviews_without_submission_are_skipped() {
|
||||
let review: WireReview =
|
||||
serde_json::from_str(r#"{"id": 82, "state": "PENDING", "submitted_at": null}"#)
|
||||
.expect("review fixture parses");
|
||||
let event = review_event(&review, &repo(), 8, None).expect("maps");
|
||||
assert_eq!(event, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pr_comment_is_detected_from_its_issue_url() {
|
||||
let comment: WireComment = serde_json::from_str(
|
||||
r#"{"id": 8001, "user": {"login": "dave"}, "body": "looks",
|
||||
"created_at": "2026-10-05T10:30:00Z",
|
||||
"updated_at": "2026-10-05T10:30:00Z",
|
||||
"issue_url": "https://api.github.com/repos/acme/widgets/pulls/8"}"#,
|
||||
)
|
||||
.expect("comment fixture parses");
|
||||
let event = comment_event(&comment, &repo())
|
||||
.expect("maps")
|
||||
.expect("comment carries an event");
|
||||
assert_eq!(event.kind, EventKind::Comment);
|
||||
assert!(event.is_pr);
|
||||
assert_eq!(event.number, 8);
|
||||
assert_eq!(event.id, "github:comment:8001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_url_without_a_number_is_a_parse_error() {
|
||||
let comment: WireComment = serde_json::from_str(
|
||||
r#"{"id": 8002, "issue_url": "https://api.github.com/repos/acme/widgets/issues/"}"#,
|
||||
)
|
||||
.expect("comment fixture parses");
|
||||
let err = comment_event(&comment, &repo()).expect_err("loud failure");
|
||||
assert!(matches!(err, ForgeError::Parse(_)), "got: {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_raw_payload_keeps_the_fields_the_event_does_not_lift() {
|
||||
let event = issue_event(
|
||||
&row(
|
||||
r#"{"id": 1007, "number": 7, "state": "closed", "title": "crash",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-02T10:00:00Z",
|
||||
"closed_at": "2026-10-02T10:00:00Z",
|
||||
"node_id": "MDU6SXNzdWUxMDA3"}"#,
|
||||
),
|
||||
&repo(),
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("closed row carries an event");
|
||||
// Undeclared keys survive; the lifted ones (id, number, state,
|
||||
// title, ...) do not.
|
||||
assert_eq!(event.raw["node_id"], "MDU6SXNzdWUxMDA3");
|
||||
assert!(event.raw.get("title").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
//! The GitLab adapter: gitlab.com or self-hosted, at
|
||||
//! [`DEFAULT_BASE_URL`] — every request path is prefixed with `/api/v4`,
|
||||
//! and the project is addressed by its URL-encoded full path. Auth is the
|
||||
//! token in the `PRIVATE-TOKEN` header.
|
||||
//!
|
||||
//! Endpoint choices and the constraints that differ:
|
||||
//! - Issues and merge requests come from
|
||||
//! `GET /projects/{encoded path}/{issues,merge_requests}` with
|
||||
//! `updated_after=<RFC 3339>&order_by=updated_at&sort=asc`: two separate
|
||||
//! feeds, since GitLab has no combined thread feed.
|
||||
//! - Issue iids and merge-request iids are separate sequences: a number
|
||||
//! can exist as both. Wherever a bare number must resolve, the issue
|
||||
//! feed is tried first and the merge-request feed is the 404 fallback;
|
||||
//! an iid that is neither surfaces the merge-request 404.
|
||||
//! - GitLab has no repo-wide notes feed and the per-thread notes endpoint
|
||||
//! has no updated filter, so every updated thread gets one paginated
|
||||
//! `GET .../{issues,merge_requests}/{iid}/notes?sort=asc&order_by=created_at`
|
||||
//! call and the `created_at` cutoff is client-side. Note edits do not
|
||||
//! re-fire (notes are keyed by creation time).
|
||||
//! - System notes (state changes, label flips, ...) are skipped: the
|
||||
//! thread-level transitions they record already surface from the thread
|
||||
//! rows.
|
||||
//! - `updated_after` matches rows at-or-after the instant, so a re-poll on
|
||||
//! the returned cursor repeats boundary rows; stable ids make the
|
||||
//! repetition dedupable.
|
||||
//! - Merge-request state `merged` maps to [`EventKind::Merged`], `closed`
|
||||
//! (unmerged) to [`EventKind::Closed`]; issue state `closed` to
|
||||
//! [`EventKind::Closed`].
|
||||
//! - Pagination is offset-based (`per_page`/`page`) with the next page in
|
||||
//! the `X-Next-Page` response header. Offset pages over an
|
||||
//! updated-ascending sort can shift while a poll runs; duplicates are
|
||||
//! absorbed by ids, misses only happen to rows that update twice inside
|
||||
//! one poll window.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::{ForgeError, ensure_success, required};
|
||||
use crate::event::{
|
||||
EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot,
|
||||
ThreadState, Timestamp,
|
||||
};
|
||||
use crate::forge::{Forge, ForgePage};
|
||||
|
||||
/// The production GitLab base.
|
||||
pub const DEFAULT_BASE_URL: &str = "https://gitlab.com";
|
||||
|
||||
/// Rows requested per page.
|
||||
const PER_PAGE: usize = 100;
|
||||
|
||||
/// Pages fetched before a server that keeps offering pages is declared
|
||||
/// misbehaving rather than polled forever.
|
||||
const MAX_PAGES: usize = 1000;
|
||||
|
||||
/// GitLab backend: a base URL, a token, one reqwest client.
|
||||
///
|
||||
/// An empty token is accepted but useless for writes; public reads work
|
||||
/// anonymously.
|
||||
pub struct GitLabForge {
|
||||
base_url: String,
|
||||
token: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl GitLabForge {
|
||||
/// A forge authenticating with `token` against [`DEFAULT_BASE_URL`].
|
||||
pub fn new(token: impl Into<String>) -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
// One connect attempt: a dead forge fails fast instead of
|
||||
// stalling the poll loop.
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
// Every forge call is a bounded request/response, unlike a
|
||||
// model stream, so one overall timeout per request is the
|
||||
// right guard.
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.expect("a client builder with only timeouts cannot fail");
|
||||
Self {
|
||||
base_url: DEFAULT_BASE_URL.to_owned(),
|
||||
token: token.into(),
|
||||
http,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the base URL (trailing slashes trimmed) — for
|
||||
/// self-hosted instances and for tests against a mock server.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into().trim_end_matches('/').to_owned();
|
||||
self
|
||||
}
|
||||
|
||||
/// A GET builder for `path` with auth applied: the token rides only in
|
||||
/// the `PRIVATE-TOKEN` header, never in a URL.
|
||||
fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
let base = &self.base_url;
|
||||
let request = self.http.get(format!("{base}/api/v4{path}"));
|
||||
self.auth(request)
|
||||
}
|
||||
|
||||
/// A POST builder for `path` with auth applied.
|
||||
fn post(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
let base = &self.base_url;
|
||||
let request = self.http.post(format!("{base}/api/v4{path}"));
|
||||
self.auth(request)
|
||||
}
|
||||
|
||||
/// GitLab's token scheme: the `PRIVATE-TOKEN` header.
|
||||
fn auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if self.token.is_empty() {
|
||||
return request;
|
||||
}
|
||||
request.header("PRIVATE-TOKEN", &self.token)
|
||||
}
|
||||
|
||||
/// Follows offset pagination via the `X-Next-Page` response header:
|
||||
/// absent or empty ends the feed.
|
||||
async fn paged<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(String, String)],
|
||||
) -> Result<Vec<T>, ForgeError> {
|
||||
let mut rows = Vec::new();
|
||||
let mut page: u32 = 1;
|
||||
loop {
|
||||
let mut query = query.to_vec();
|
||||
query.push(("per_page".to_owned(), PER_PAGE.to_string()));
|
||||
query.push(("page".to_owned(), page.to_string()));
|
||||
let response = self
|
||||
.get(path)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
// The next-page cursor is a header: read it before the body
|
||||
// moves.
|
||||
let next = response
|
||||
.headers()
|
||||
.get("x-next-page")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
rows.extend(decode::<Vec<T>>(response).await?);
|
||||
if next.is_empty() {
|
||||
return Ok(rows);
|
||||
}
|
||||
page = next
|
||||
.parse()
|
||||
.map_err(|_| ForgeError::Pagination(format!("non-numeric x-next-page: {next}")))?;
|
||||
if page > MAX_PAGES as u32 {
|
||||
return Err(ForgeError::Pagination(format!(
|
||||
"more than {MAX_PAGES} pages from {path}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes a response: non-2xx to [`ForgeError::Api`] with the body
|
||||
/// snippet, undecodable bodies to [`ForgeError::Parse`].
|
||||
async fn decode<T: DeserializeOwned>(response: reqwest::Response) -> Result<T, ForgeError> {
|
||||
let response = ensure_success(response).await?;
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| ForgeError::Parse(format!("malformed forge response: {err}")))
|
||||
}
|
||||
|
||||
/// The project path (`owner/name`, possibly with subgroups) as GitLab's
|
||||
/// API needs it: the whole path percent-encoded, slashes included.
|
||||
fn encode_project_path(path: &str) -> String {
|
||||
let mut encoded = String::with_capacity(path.len());
|
||||
for byte in path.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
encoded.push(byte as char)
|
||||
}
|
||||
other => encoded.push_str(&format!("%{other:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
// --- Wire rows (private): the subset each event lifts, plus the
|
||||
// --- untouched row under `raw`.
|
||||
|
||||
/// A GitLab author object; the login field is spelled `username` here.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WireAuthor {
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
/// One issue or merge-request row: the same subset lifts from both.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireThread {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
iid: Option<u64>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
author: Option<WireAuthor>,
|
||||
#[serde(default)]
|
||||
labels: Vec<String>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
/// The opening body: GitLab spells it `description`.
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
closed_at: Option<Timestamp>,
|
||||
#[serde(default)]
|
||||
web_url: Option<String>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One note (comment) row of a thread's notes feed.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WireNote {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
#[serde(default)]
|
||||
system: bool,
|
||||
#[serde(default)]
|
||||
author: Option<WireAuthor>,
|
||||
#[serde(default)]
|
||||
created_at: Option<Timestamp>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One row of the account's todo list — GitLab's notification feed.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WireTodo {
|
||||
#[serde(default)]
|
||||
id: Option<i64>,
|
||||
#[serde(default)]
|
||||
project: Option<WireTodoProject>,
|
||||
#[serde(default)]
|
||||
target_type: Option<String>,
|
||||
#[serde(default)]
|
||||
target: Option<WireTodoTarget>,
|
||||
#[serde(default)]
|
||||
action_name: Option<String>,
|
||||
/// Human-facing URL of the target thread.
|
||||
#[serde(default)]
|
||||
target_url: Option<String>,
|
||||
#[serde(default)]
|
||||
updated_at: Option<Timestamp>,
|
||||
#[serde(flatten)]
|
||||
raw: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// The project a todo's thread lives in.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WireTodoProject {
|
||||
#[serde(default)]
|
||||
path_with_namespace: Option<String>,
|
||||
}
|
||||
|
||||
/// The thread a todo points at.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WireTodoTarget {
|
||||
#[serde(default)]
|
||||
iid: Option<u64>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
author: Option<WireAuthor>,
|
||||
}
|
||||
|
||||
/// GitLab's `action_name` strings, reduced to the shared vocabulary.
|
||||
fn gitlab_reason(action: Option<&str>) -> NotificationReason {
|
||||
match action {
|
||||
Some("review_requested") | Some("approval_required") => NotificationReason::ReviewRequested,
|
||||
Some("assigned") => NotificationReason::Assigned,
|
||||
Some("mentioned") | Some("directly_addressed") => NotificationReason::Mention,
|
||||
Some("build_failed") | Some("build_success") => NotificationReason::CiActivity,
|
||||
Some(other) => NotificationReason::Other(other.to_owned()),
|
||||
None => NotificationReason::Other("unactioned".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The notification a todo row carries. The todo list has no server-side
|
||||
/// time filter, so the poll refetches every pending todo and the dedup
|
||||
/// ids absorb the repetition; the cursor is advisory only here.
|
||||
fn todo_notification(row: &WireTodo) -> Result<Option<Notification>, ForgeError> {
|
||||
let id = required(row.id, "todo id")?;
|
||||
let updated = required(row.updated_at.clone(), "todo updated_at")?;
|
||||
let project = required(row.project.clone(), "todo project")?;
|
||||
let path = required(project.path_with_namespace, "todo project path")?;
|
||||
let (owner, name) = path
|
||||
.rsplit_once('/')
|
||||
.ok_or_else(|| ForgeError::Parse(format!("todo project path without a slash: {path}")))?;
|
||||
let target = required(row.target.clone(), "todo target")?;
|
||||
let iid = required(target.iid, "todo target iid")?;
|
||||
let is_pr = row.target_type.as_deref() == Some("MergeRequest");
|
||||
Ok(Some(Notification {
|
||||
id: format!("gitlab:notification:{id}@{}", updated.raw()),
|
||||
reason: gitlab_reason(row.action_name.as_deref()),
|
||||
repo: RepoRef::new(owner, name),
|
||||
number: iid,
|
||||
is_pr,
|
||||
actor: target
|
||||
.author
|
||||
.as_ref()
|
||||
.and_then(|author| author.username.clone()),
|
||||
title: target.title.clone(),
|
||||
url: row.target_url.clone(),
|
||||
timestamp: updated,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Mapping: wire rows -> normalized events. Pure, so the async
|
||||
// --- transport stays thin and the derivations are unit-testable.
|
||||
|
||||
/// The thread event a thread row carries, if any. `merged` only exists on
|
||||
/// merge requests; a still-open row with a close in its past reopened.
|
||||
fn thread_event(
|
||||
row: &WireThread,
|
||||
repo: &RepoRef,
|
||||
is_pr: bool,
|
||||
) -> Result<Option<ForgeEvent>, ForgeError> {
|
||||
let id = required(row.id, "thread id")?;
|
||||
let iid = required(row.iid, "thread iid")?;
|
||||
let updated = required(row.updated_at.clone(), "thread updated_at")?;
|
||||
let created = required(row.created_at.clone(), "thread created_at")?;
|
||||
let state = row.state.as_deref().unwrap_or_default();
|
||||
let kind = if is_pr && state == "merged" {
|
||||
EventKind::Merged
|
||||
} else if state == "closed" {
|
||||
EventKind::Closed
|
||||
} else if row.closed_at.is_some() {
|
||||
EventKind::Reopened
|
||||
} else if created == updated {
|
||||
// Never touched since creation: opening is the only update.
|
||||
EventKind::Opened
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
let item = if is_pr { "mr" } else { "issue" };
|
||||
Ok(Some(ForgeEvent {
|
||||
id: format!("gitlab:{item}:{id}@{}", updated.raw()),
|
||||
kind,
|
||||
repo: repo.clone(),
|
||||
number: iid,
|
||||
is_pr,
|
||||
actor: row
|
||||
.author
|
||||
.as_ref()
|
||||
.and_then(|author| author.username.clone()),
|
||||
body: None,
|
||||
url: row.web_url.clone(),
|
||||
timestamp: updated,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The comment event a note row carries, if any: system notes record
|
||||
/// transitions the thread rows already surface, and rows older than the
|
||||
/// cursor are skipped.
|
||||
fn note_event(
|
||||
row: &WireNote,
|
||||
repo: &RepoRef,
|
||||
iid: u64,
|
||||
is_pr: bool,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<Option<ForgeEvent>, ForgeError> {
|
||||
if row.system {
|
||||
return Ok(None);
|
||||
}
|
||||
let timestamp = required(row.created_at.clone(), "note created_at")?;
|
||||
if let Some(since) = since {
|
||||
if timestamp < *since {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let id = required(row.id, "note id")?;
|
||||
Ok(Some(ForgeEvent {
|
||||
id: format!("gitlab:note:{id}"),
|
||||
kind: EventKind::Comment,
|
||||
repo: repo.clone(),
|
||||
number: iid,
|
||||
is_pr,
|
||||
actor: row
|
||||
.author
|
||||
.as_ref()
|
||||
.and_then(|author| author.username.clone()),
|
||||
body: row.body.clone(),
|
||||
// Note rows carry no URL of their own; the thread's `web_url` is
|
||||
// on the thread row.
|
||||
url: None,
|
||||
timestamp,
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The snapshot a thread row yields.
|
||||
fn snapshot_from_row(row: &WireThread, is_pr: bool) -> Result<Snapshot, ForgeError> {
|
||||
Ok(Snapshot {
|
||||
title: required(row.title.clone(), "thread title")?,
|
||||
body: row.description.clone(),
|
||||
state: match row.state.as_deref() {
|
||||
Some("merged") => ThreadState::Merged,
|
||||
Some("closed") => ThreadState::Closed,
|
||||
_ => ThreadState::Open,
|
||||
},
|
||||
is_pr,
|
||||
author: row
|
||||
.author
|
||||
.as_ref()
|
||||
.and_then(|author| author.username.clone()),
|
||||
labels: row.labels.clone(),
|
||||
url: row.web_url.clone(),
|
||||
raw: Value::Object(row.raw.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Forge for GitLabForge {
|
||||
async fn poll(
|
||||
&self,
|
||||
repo: &RepoRef,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<ForgePage, ForgeError> {
|
||||
let project = encode_project_path(&repo.path());
|
||||
let base = format!("/projects/{project}");
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Thread feeds: issues and merge requests, updated-ascending.
|
||||
let mut query = vec![
|
||||
("order_by".to_owned(), "updated_at".to_owned()),
|
||||
("sort".to_owned(), "asc".to_owned()),
|
||||
];
|
||||
if let Some(since) = since {
|
||||
query.push(("updated_after".to_owned(), since.raw().to_owned()));
|
||||
}
|
||||
let mut threads: Vec<(WireThread, bool)> = Vec::new();
|
||||
for row in self
|
||||
.paged::<WireThread>(&format!("{base}/issues"), &query)
|
||||
.await?
|
||||
{
|
||||
if let Some(event) = thread_event(&row, repo, false)? {
|
||||
events.push(event);
|
||||
}
|
||||
threads.push((row, false));
|
||||
}
|
||||
for row in self
|
||||
.paged::<WireThread>(&format!("{base}/merge_requests"), &query)
|
||||
.await?
|
||||
{
|
||||
if let Some(event) = thread_event(&row, repo, true)? {
|
||||
events.push(event);
|
||||
}
|
||||
threads.push((row, true));
|
||||
}
|
||||
|
||||
// Notes, per updated thread: no repo-wide feed, no updated filter
|
||||
// on the endpoint, so each thread's notes are paged and cut off
|
||||
// client-side.
|
||||
for (row, is_pr) in &threads {
|
||||
let iid = required(row.iid, "thread iid")?;
|
||||
let segment = if *is_pr { "merge_requests" } else { "issues" };
|
||||
let notes_query = vec![
|
||||
("order_by".to_owned(), "created_at".to_owned()),
|
||||
("sort".to_owned(), "asc".to_owned()),
|
||||
];
|
||||
let notes_path = format!("{base}/{segment}/{iid}/notes");
|
||||
for note in self.paged::<WireNote>(¬es_path, ¬es_query).await? {
|
||||
if let Some(event) = note_event(¬e, repo, iid, *is_pr, since)? {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ForgePage::finish(events, since))
|
||||
}
|
||||
|
||||
async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError> {
|
||||
let project = encode_project_path(&repo.path());
|
||||
// Issue iids and MR iids are separate sequences: try the issue
|
||||
// feed first, fall back on its 404. An iid that is neither
|
||||
// surfaces the merge-request 404.
|
||||
let notes_path = |segment: &str| format!("/projects/{project}/{segment}/{number}/notes");
|
||||
let response = self
|
||||
.post(¬es_path("issues"))
|
||||
.json(&serde_json::json!({ "body": body }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let response = if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
self.post(¬es_path("merge_requests"))
|
||||
.json(&serde_json::json!({ "body": body }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?
|
||||
} else {
|
||||
response
|
||||
};
|
||||
ensure_success(response).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn snapshot(&self, repo: &RepoRef, number: u64) -> Result<Snapshot, ForgeError> {
|
||||
let project = encode_project_path(&repo.path());
|
||||
let resource = |segment: &str| format!("/projects/{project}/{segment}/{number}");
|
||||
let response = self
|
||||
.get(&resource("issues"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
// Same iid sequences as `comment`: issues first, 404 fallback.
|
||||
let (row, is_pr) = match decode::<WireThread>(response).await {
|
||||
Ok(row) => (row, false),
|
||||
Err(ForgeError::Api { status: 404, .. }) => {
|
||||
let response = self
|
||||
.get(&resource("merge_requests"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let row: WireThread = decode(response).await?;
|
||||
(row, true)
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
};
|
||||
Ok(snapshot_from_row(&row, is_pr)?)
|
||||
}
|
||||
|
||||
async fn notifications(
|
||||
&self,
|
||||
since: Option<&Timestamp>,
|
||||
) -> Result<NotificationPage, ForgeError> {
|
||||
// The pending-todo feed: everything addressed to the account,
|
||||
// every project it can see. The endpoint has no time filter, so
|
||||
// `since` only shapes the returned cursor; the refetch is
|
||||
// absorbed by the dedup ids.
|
||||
let rows = self.paged::<WireTodo>("/todos", &[]).await?;
|
||||
let mut notifications = Vec::new();
|
||||
for row in &rows {
|
||||
if let Some(notification) = todo_notification(row)? {
|
||||
notifications.push(notification);
|
||||
}
|
||||
}
|
||||
Ok(NotificationPage::finish(notifications, since))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn repo() -> RepoRef {
|
||||
RepoRef::new("acme", "widgets")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_paths_encode_their_slashes() {
|
||||
assert_eq!(encode_project_path("acme/widgets"), "acme%2Fwidgets");
|
||||
assert_eq!(encode_project_path("group/sub/proj"), "group%2Fsub%2Fproj");
|
||||
// Unreserved characters pass through byte for byte.
|
||||
assert_eq!(encode_project_path("safe-name_1.0~x"), "safe-name_1.0~x");
|
||||
assert_eq!(encode_project_path("spa ce"), "spa%20ce");
|
||||
}
|
||||
|
||||
fn thread(json: &str) -> WireThread {
|
||||
serde_json::from_str(json).expect("thread fixture parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_issue_row_maps_to_closed() {
|
||||
let event = thread_event(
|
||||
&thread(
|
||||
r#"{"id": 407, "iid": 7, "state": "closed",
|
||||
"author": {"username": "alice"},
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-02T10:00:00.081Z",
|
||||
"closed_at": "2026-10-02T10:00:00Z",
|
||||
"web_url": "https://gitlab.com/acme/widgets/-/issues/7"}"#,
|
||||
),
|
||||
&repo(),
|
||||
false,
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("closed row carries an event");
|
||||
assert_eq!(event.kind, EventKind::Closed);
|
||||
// GitLab's fractional seconds survive verbatim in the id.
|
||||
assert_eq!(event.id, "gitlab:issue:407@2026-10-02T10:00:00.081Z");
|
||||
assert_eq!(event.number, 7);
|
||||
assert!(!event.is_pr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopened_and_opened_derive_from_the_close_history() {
|
||||
let reopened = thread_event(
|
||||
&thread(
|
||||
r#"{"id": 404, "iid": 4, "state": "opened",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-07T09:00:00Z",
|
||||
"closed_at": "2026-10-03T00:00:00Z"}"#,
|
||||
),
|
||||
&repo(),
|
||||
false,
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("reopened row carries an event");
|
||||
assert_eq!(reopened.kind, EventKind::Reopened);
|
||||
|
||||
let opened = thread_event(
|
||||
&thread(
|
||||
r#"{"id": 405, "iid": 5, "state": "opened",
|
||||
"created_at": "2026-10-03T08:00:00Z",
|
||||
"updated_at": "2026-10-03T08:00:00Z",
|
||||
"closed_at": null}"#,
|
||||
),
|
||||
&repo(),
|
||||
false,
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("fresh row carries an event");
|
||||
assert_eq!(opened.kind, EventKind::Opened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_merge_requests_map_to_merged() {
|
||||
let event = thread_event(
|
||||
&thread(
|
||||
r#"{"id": 503, "iid": 3, "state": "merged",
|
||||
"created_at": "2026-10-01T08:00:00Z",
|
||||
"updated_at": "2026-10-06T12:00:00Z",
|
||||
"closed_at": "2026-10-06T12:00:00Z"}"#,
|
||||
),
|
||||
&repo(),
|
||||
true,
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("merged row carries an event");
|
||||
assert_eq!(event.kind, EventKind::Merged);
|
||||
assert!(event.is_pr);
|
||||
assert_eq!(event.id, "gitlab:mr:503@2026-10-06T12:00:00Z");
|
||||
|
||||
// Unmerged close on an MR stays Closed.
|
||||
let closed = thread_event(
|
||||
&thread(
|
||||
r#"{"id": 504, "iid": 3, "state": "closed",
|
||||
"created_at": "2026-10-01T08:00:00Z",
|
||||
"updated_at": "2026-10-06T12:00:00Z",
|
||||
"closed_at": "2026-10-06T12:00:00Z"}"#,
|
||||
),
|
||||
&repo(),
|
||||
true,
|
||||
)
|
||||
.expect("maps")
|
||||
.expect("closed row carries an event");
|
||||
assert_eq!(closed.kind, EventKind::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_notes_and_stale_notes_are_skipped() {
|
||||
let system: WireNote = serde_json::from_str(
|
||||
r#"{"id": 301, "body": "changed the description", "system": true,
|
||||
"created_at": "2026-10-02T11:00:00Z"}"#,
|
||||
)
|
||||
.expect("note fixture parses");
|
||||
assert_eq!(
|
||||
note_event(&system, &repo(), 7, false, None).expect("maps"),
|
||||
None
|
||||
);
|
||||
|
||||
let stale: WireNote = serde_json::from_str(
|
||||
r#"{"id": 303, "body": "old", "system": false,
|
||||
"created_at": "2025-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.expect("note fixture parses");
|
||||
let since = Timestamp::parse("2026-10-01T00:00:00Z").expect("parses");
|
||||
assert_eq!(
|
||||
note_event(&stale, &repo(), 7, false, Some(&since)).expect("maps"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_states_map_from_the_row_state() {
|
||||
let snapshot = snapshot_from_row(
|
||||
&thread(
|
||||
r#"{"id": 503, "iid": 3, "state": "merged", "title": "fix crash",
|
||||
"description": "the fix", "author": {"username": "bob"},
|
||||
"labels": ["bug", "critical"],
|
||||
"web_url": "https://gitlab.com/acme/widgets/-/merge_requests/3"}"#,
|
||||
),
|
||||
true,
|
||||
)
|
||||
.expect("maps");
|
||||
assert_eq!(snapshot.state, ThreadState::Merged);
|
||||
assert!(snapshot.is_pr);
|
||||
assert_eq!(
|
||||
snapshot.labels,
|
||||
vec!["bug".to_owned(), "critical".to_owned()]
|
||||
);
|
||||
assert_eq!(snapshot.author.as_deref(), Some("bob"));
|
||||
assert_eq!(snapshot.body.as_deref(), Some("the fix"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! `rhema-forge-integration` — the forge integration: one normalized
|
||||
//! event vocabulary and one client contract for GitHub, Gitea, and
|
||||
//! GitLab, and the ingest path that turns forge activity into RSD
|
||||
//! dispatch.
|
||||
//!
|
||||
//! An integration, not an agent: no driver, no model, no loop lives
|
||||
//! here. A consumer composes the crate with its own agent tier — the
|
||||
//! [`crate::Watcher`] polls a [`crate::Forge`] for activity and feeds
|
||||
//! the events to a [`rhema_rsd::Dispatcher`](rhema_rsd) (the forge's
|
||||
//! stable dedup id becomes the event id, and the resource key is spec
|
||||
//! §8's `host:owner/repo#number` shape, so re-polling unchanged history
|
||||
//! dedups away and a changed row re-dispatches), and a dispatched
|
||||
//! thread is answered through the same [`crate::Forge`] capabilities,
|
||||
//! with [`crate::decode`] recovering the event behind an inbox item.
|
||||
//! Polling cadence, handler policy, and the loop itself are the
|
||||
//! consumer's — the claw daemon today, any other agent consumer of the
|
||||
//! framework tomorrow; the dispatch guarantees are the dispatcher's.
|
||||
//!
|
||||
//! ```
|
||||
//! use rhema_forge_integration::{Forge, GitHubForge, RepoRef};
|
||||
//!
|
||||
//! # async fn demo() -> Result<(), rhema_forge_integration::ForgeError> {
|
||||
//! let forge = GitHubForge::new(std::env::var("GITHUB_TOKEN").unwrap());
|
||||
//! // First poll: everything. Later polls: pass `page.cursor` back in.
|
||||
//! let page = forge.poll(&RepoRef::new("acme", "widgets"), None).await?;
|
||||
//! for event in &page.events {
|
||||
//! println!("{:?} on {}#{}", event.kind, event.repo.path(), event.number);
|
||||
//! }
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! Layout:
|
||||
//! - [`event`] — the normalized vocabulary: `ForgeEvent`, `EventKind`,
|
||||
//! `RepoRef`, `Snapshot`, `Timestamp`
|
||||
//! - [`error`] — the crate's single forge error type
|
||||
//! - [`forge`] — the `Forge` client contract and `ForgePage`
|
||||
//! - [`github`] — the GitHub adapter (api.github.com)
|
||||
//! - [`gitea`] — the Gitea adapter (self-hosted, `/api/v1`)
|
||||
//! - [`gitlab`] — the GitLab adapter (gitlab.com or self-hosted, `/api/v4`)
|
||||
//! - [`watcher`] — the dispatcher seam: polls a forge into an RSD
|
||||
//! `Dispatcher` (keys, dedup, cursors) and decodes delivered inbox
|
||||
//! items back into forge events
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod forge;
|
||||
pub mod gitea;
|
||||
pub mod github;
|
||||
pub mod gitlab;
|
||||
pub mod watcher;
|
||||
|
||||
// Re-exported so implementors of the async seam can apply #[async_trait]
|
||||
// without adding the crate themselves.
|
||||
pub use async_trait::async_trait;
|
||||
pub use error::ForgeError;
|
||||
pub use event::{
|
||||
EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot,
|
||||
ThreadState, Timestamp,
|
||||
};
|
||||
pub use forge::{Forge, ForgePage};
|
||||
pub use gitea::GiteaForge;
|
||||
pub use github::GitHubForge;
|
||||
pub use gitlab::GitLabForge;
|
||||
pub use watcher::{
|
||||
WatchError, Watcher, decode, forge_key_fn, parse_key, register_forge, resource_key,
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
//! The dispatcher seam: poll a forge feed into the RSD dispatcher, and
|
||||
//! decode a delivered inbox item back into the forge event it carried.
|
||||
//!
|
||||
//! The [`Watcher`] owns one cursor per repo (the newest event time fed to
|
||||
//! the dispatcher) and turns forge events into
|
||||
//! [`rhema_rsd::Event`]s: the forge's stable dedup id stays the event id,
|
||||
//! the serialized [`ForgeEvent`](crate::event::ForgeEvent) is the payload,
|
||||
//! and the resource key is the spec §8 shape —
|
||||
//! `{source}:{owner}/{name}#{number}` (see [`resource_key`]). Re-polling
|
||||
//! unchanged history therefore dedups away in the dispatcher, and a
|
||||
//! changed row re-dispatches.
|
||||
//!
|
||||
//! The watcher is loop-free: one `poll` per call. Cadence belongs to the
|
||||
//! embedder's loop.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rhema_rsd::{Clock, Dispatcher, Event, InboxItem, KeyFn, ResourceKey, SystemClock};
|
||||
|
||||
use crate::error::ForgeError;
|
||||
use crate::event::{ForgeEvent, RepoRef, Timestamp};
|
||||
use crate::forge::Forge;
|
||||
|
||||
/// Why a watch step failed: the forge call, or the dispatcher seam behind
|
||||
/// it.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WatchError {
|
||||
/// The poll itself failed.
|
||||
#[error(transparent)]
|
||||
Forge(#[from] ForgeError),
|
||||
/// The dispatcher rejected or could not persist an event.
|
||||
#[error(transparent)]
|
||||
Dispatch(#[from] rhema_rsd::DispatchError),
|
||||
}
|
||||
|
||||
/// The resource key of a thread on `source`: `{source}:{path}#{number}` —
|
||||
/// spec §8's `github:acme/widgets#142` shape.
|
||||
pub fn resource_key(source: &str, repo: &RepoRef, number: u64) -> ResourceKey {
|
||||
ResourceKey::from(format!("{source}:{}#{number}", repo.path()))
|
||||
}
|
||||
|
||||
/// Recovers the thread coordinates from [`resource_key`]'s shape. The
|
||||
/// inverse of the key builder, so a dispatch never needs the original
|
||||
/// event to reach the forge.
|
||||
pub fn parse_key(key: &ResourceKey) -> Option<(RepoRef, u64)> {
|
||||
let (head, number) = key.as_str().rsplit_once('#')?;
|
||||
let path = head.split_once(':')?.1;
|
||||
let (owner, name) = path.rsplit_once('/')?;
|
||||
Some((RepoRef::new(owner, name), number.parse().ok()?))
|
||||
}
|
||||
|
||||
/// The payload a watcher builds for every event: just the coordinates the
|
||||
/// canonical key function reads, before the full [`ForgeEvent`].
|
||||
#[derive(serde::Deserialize)]
|
||||
struct KeyParts {
|
||||
repo: RepoRef,
|
||||
number: u64,
|
||||
}
|
||||
|
||||
/// Registers `source` on `dispatcher` with the canonical key function —
|
||||
/// the one [`resource_key`] implements. Do this once before the first
|
||||
/// ingest.
|
||||
pub fn register_forge(dispatcher: &Dispatcher, source: &str) {
|
||||
let prefix = format!("{source}:");
|
||||
dispatcher.register_source(
|
||||
source.to_owned(),
|
||||
Arc::new(move |event: &Event| {
|
||||
match serde_json::from_value::<KeyParts>(event.payload.clone()) {
|
||||
Ok(parts) => {
|
||||
ResourceKey::from(format!("{prefix}{}#{}", parts.repo.path(), parts.number))
|
||||
}
|
||||
// Infallible seam: a payload the watcher itself did not
|
||||
// build still gets a unique key, so it can neither clobber
|
||||
// another thread nor wedge the dispatcher.
|
||||
Err(_) => ResourceKey::from(format!("{prefix}unparsable#{}", event.id)),
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// The `KeyFn` [`register_forge`] installs, for embedders wiring the
|
||||
/// source themselves.
|
||||
pub fn forge_key_fn(source: &str) -> Arc<KeyFn> {
|
||||
let prefix = format!("{source}:");
|
||||
Arc::new(
|
||||
move |event: &Event| match serde_json::from_value::<KeyParts>(event.payload.clone()) {
|
||||
Ok(parts) => {
|
||||
ResourceKey::from(format!("{prefix}{}#{}", parts.repo.path(), parts.number))
|
||||
}
|
||||
Err(_) => ResourceKey::from(format!("{prefix}unparsable#{}", event.id)),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Polls forge feeds into a [`Dispatcher`], one repo at a time, keeping
|
||||
/// each repo's cursor.
|
||||
pub struct Watcher {
|
||||
dispatcher: Arc<Dispatcher>,
|
||||
source: String,
|
||||
cursors: Mutex<HashMap<RepoRef, Timestamp>>,
|
||||
}
|
||||
|
||||
impl Watcher {
|
||||
/// A watcher feeding `dispatcher` as `source` (e.g. `"github"`).
|
||||
/// [`register_forge`] must have installed the source's key function.
|
||||
pub fn new(dispatcher: Arc<Dispatcher>, source: impl Into<String>) -> Self {
|
||||
Self {
|
||||
dispatcher,
|
||||
source: source.into(),
|
||||
cursors: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor `repo` polls from next: `None` before the first poll
|
||||
/// (everything the forge returns is fed), then the newest event time
|
||||
/// seen.
|
||||
pub fn cursor(&self, repo: &RepoRef) -> Option<Timestamp> {
|
||||
self.cursors
|
||||
.lock()
|
||||
.expect("cursor map lock")
|
||||
.get(repo)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// One poll of `repo`: the page's events are ingested in feed order
|
||||
/// and the cursor advances to the page's. Returns how many events were
|
||||
/// fed to the dispatcher — replayed ids included, they dedup there.
|
||||
pub async fn poll(&self, forge: &dyn Forge, repo: &RepoRef) -> Result<usize, WatchError> {
|
||||
let since = self.cursor(repo);
|
||||
let page = forge.poll(repo, since.as_ref()).await?;
|
||||
let now = SystemClock.now_millis();
|
||||
let mut fed = 0;
|
||||
for event in &page.events {
|
||||
let payload = serde_json::to_value(event)
|
||||
.map_err(|err| ForgeError::Parse(format!("event does not serialize: {err}")))?;
|
||||
self.dispatcher.ingest(Event {
|
||||
id: event.id.clone(),
|
||||
source: self.source.clone(),
|
||||
payload,
|
||||
received_at: now,
|
||||
})?;
|
||||
fed += 1;
|
||||
}
|
||||
if let Some(cursor) = page.cursor {
|
||||
self.cursors
|
||||
.lock()
|
||||
.expect("cursor map lock")
|
||||
.insert(repo.clone(), cursor);
|
||||
}
|
||||
Ok(fed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes a delivered inbox item back into the forge event the watcher
|
||||
/// fed. A payload this crate did not build is a loud parse error, never a
|
||||
/// silently skipped event.
|
||||
pub fn decode(item: &InboxItem) -> Result<ForgeEvent, ForgeError> {
|
||||
serde_json::from_value(item.event.payload.clone())
|
||||
.map_err(|err| ForgeError::Parse(format!("inbox payload is not a forge event: {err}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_key_shape_round_trips_through_parse() {
|
||||
let repo = RepoRef::new("acme", "widgets");
|
||||
let key = resource_key("github", &repo, 142);
|
||||
assert_eq!(key.as_str(), "github:acme/widgets#142");
|
||||
assert_eq!(parse_key(&key), Some((repo, 142)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gitlab_subgroup_path_survives_the_round_trip() {
|
||||
let repo = RepoRef::new("group/subgroup", "widgets");
|
||||
let key = resource_key("gitlab", &repo, 7);
|
||||
assert_eq!(parse_key(&key), Some((repo, 7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_without_a_number_does_not_parse() {
|
||||
assert_eq!(parse_key(&ResourceKey::from("github:acme/widgets")), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
//! Offline integration tests: the Gitea backend against an in-process
|
||||
//! wiremock server. Zero network.
|
||||
|
||||
use rhema_forge_integration::{
|
||||
EventKind, Forge, ForgeError, GiteaForge, NotificationReason, RepoRef, Timestamp,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const SINCE: &str = "2026-10-01T00:00:00Z";
|
||||
|
||||
fn forge(server: &MockServer) -> GiteaForge {
|
||||
GiteaForge::new(server.uri(), "tok")
|
||||
}
|
||||
|
||||
/// An issues-list row; `patch` overrides any field.
|
||||
fn issue_row(number: i64, patch: Value) -> Value {
|
||||
with(
|
||||
json!({
|
||||
"id": 1000 + number,
|
||||
"number": number,
|
||||
"state": "open",
|
||||
"title": format!("thread {number}"),
|
||||
"user": { "login": "alice" },
|
||||
"labels": [],
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"closed_at": Value::Null,
|
||||
"html_url": format!("https://forge.example/acme/widgets/issues/{number}"),
|
||||
"pull_request": Value::Null
|
||||
}),
|
||||
patch,
|
||||
)
|
||||
}
|
||||
|
||||
/// A pulls-list row; `patch` overrides any field.
|
||||
fn pull_row(number: i64, patch: Value) -> Value {
|
||||
with(
|
||||
json!({
|
||||
"id": 2000 + number,
|
||||
"number": number,
|
||||
"state": "open",
|
||||
"merged": false,
|
||||
"title": format!("pr {number}"),
|
||||
"user": { "login": "bob" },
|
||||
"labels": [],
|
||||
"created_at": "2026-10-01T08:00:00Z",
|
||||
"updated_at": "2026-10-01T08:00:00Z",
|
||||
"closed_at": Value::Null,
|
||||
"html_url": format!("https://forge.example/acme/widgets/pulls/{number}")
|
||||
}),
|
||||
patch,
|
||||
)
|
||||
}
|
||||
|
||||
/// Shallow-merges `patch` into `base`.
|
||||
fn with(base: Value, patch: Value) -> Value {
|
||||
match (base, patch) {
|
||||
(Value::Object(mut base), Value::Object(patch)) => {
|
||||
for (key, value) in patch {
|
||||
base.insert(key, value);
|
||||
}
|
||||
Value::Object(base)
|
||||
}
|
||||
(base, _) => base,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_pages_the_feeds_with_limit_and_filters_pulls_client_side() {
|
||||
let server = MockServer::start().await;
|
||||
// A full first page plus a short second one exercises pagination.
|
||||
let page1: Vec<Value> = (0..50)
|
||||
.map(|i| {
|
||||
issue_row(
|
||||
i,
|
||||
json!({
|
||||
"created_at": "2026-10-02T09:00:00Z",
|
||||
"updated_at": "2026-10-02T09:00:00Z"
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Mount order matters: the page-2 mock must match before the page-1 one.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues"))
|
||||
.and(query_param("page", "2"))
|
||||
.and(query_param("since", SINCE))
|
||||
.and(query_param("type", "issues"))
|
||||
.and(header("authorization", "token tok"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([issue_row(
|
||||
200,
|
||||
json!({
|
||||
"created_at": "2026-10-03T09:00:00Z",
|
||||
"updated_at": "2026-10-03T09:00:00Z"
|
||||
})
|
||||
)])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues"))
|
||||
.and(query_param("since", SINCE))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(Value::Array(page1)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// The pulls feed has no server-side time filter: it is always paged,
|
||||
// and stale rows must be cut off client-side (no reviews fetch for
|
||||
// them, no events either).
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/pulls"))
|
||||
.and(query_param("sort", "leastrecent"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([pull_row(
|
||||
55,
|
||||
json!({
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
})
|
||||
)])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues/comments"))
|
||||
.and(query_param("since", SINCE))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let since = Timestamp::parse(SINCE).expect("cursor parses");
|
||||
let page = forge(&server)
|
||||
.poll(&RepoRef::new("acme", "widgets"), Some(&since))
|
||||
.await
|
||||
.expect("poll succeeds");
|
||||
|
||||
assert_eq!(page.events.len(), 51);
|
||||
assert_eq!(
|
||||
page.cursor,
|
||||
Some(Timestamp::parse("2026-10-03T09:00:00Z").expect("parses"))
|
||||
);
|
||||
|
||||
let received = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording is on");
|
||||
// Issues pages 1+2, the pulls page, the comments page — and no
|
||||
// reviews fetch for the stale pull.
|
||||
assert_eq!(received.len(), 4);
|
||||
assert!(received[0].url.as_str().contains("limit=50"));
|
||||
assert!(received[0].url.as_str().contains("page=1"));
|
||||
assert!(received[1].url.as_str().contains("page=2"));
|
||||
assert!(received[2].url.path() == "/api/v1/repos/acme/widgets/pulls");
|
||||
assert!(received[3].url.path() == "/api/v1/repos/acme/widgets/issues/comments");
|
||||
assert!(
|
||||
received
|
||||
.iter()
|
||||
.all(|request| !request.url.path().contains("reviews"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_normalizes_state_kinds_pr_distinction_reviews_and_comments() {
|
||||
let server = MockServer::start().await;
|
||||
let issues = json!([
|
||||
issue_row(
|
||||
7,
|
||||
json!({
|
||||
"state": "closed",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-02T10:00:00Z",
|
||||
"closed_at": "2026-10-02T10:00:00Z"
|
||||
})
|
||||
),
|
||||
issue_row(
|
||||
5,
|
||||
json!({
|
||||
"created_at": "2026-10-03T08:00:00Z",
|
||||
"updated_at": "2026-10-03T08:00:00Z"
|
||||
})
|
||||
),
|
||||
issue_row(
|
||||
4,
|
||||
json!({
|
||||
"state": "open",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-07T09:00:00Z",
|
||||
"closed_at": "2026-10-03T00:00:00Z"
|
||||
})
|
||||
),
|
||||
]);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(issues))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// A merged PR (updated inside the window) and a stale one.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/pulls"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
pull_row(
|
||||
3,
|
||||
json!({
|
||||
"state": "closed",
|
||||
"merged": true,
|
||||
"created_at": "2026-10-01T08:00:00Z",
|
||||
"updated_at": "2026-10-06T12:00:00Z",
|
||||
"closed_at": "2026-10-06T12:00:00Z"
|
||||
})
|
||||
),
|
||||
pull_row(
|
||||
55,
|
||||
json!({
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z"
|
||||
})
|
||||
)
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// Reviews of the merged PR: one submitted, one never-submitted.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/pulls/3/reviews"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
{ "id": 31, "user": { "login": "carol" }, "body": "lgtm",
|
||||
"state": "APPROVED", "official": true, "stale": false,
|
||||
"commit_id": "abc123",
|
||||
"submitted": "2026-10-06T13:00:00Z",
|
||||
"created": "2026-10-06T13:00:00Z",
|
||||
"comments": [] },
|
||||
{ "id": 32, "user": { "login": "carol" }, "body": "",
|
||||
"state": "PENDING", "submitted": Value::Null,
|
||||
"created": Value::Null, "comments": [] }
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// The repo-wide comments feed: one issue comment, one PR comment.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues/comments"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
{ "id": 7001, "user": { "login": "dave" }, "body": "repro?",
|
||||
"created_at": "2026-10-02T11:00:00Z",
|
||||
"updated_at": "2026-10-02T11:00:00Z",
|
||||
"html_url": "https://forge.example/acme/widgets/issues/7#comment-7001",
|
||||
"issue_url": "https://forge.example/acme/widgets/issues/7",
|
||||
"pull_request_url": Value::Null },
|
||||
{ "id": 3001, "user": { "login": "erin" }, "body": "reviewed",
|
||||
"created_at": "2026-10-06T14:00:00Z",
|
||||
"updated_at": "2026-10-06T14:00:00Z",
|
||||
"html_url": "https://forge.example/acme/widgets/pulls/3#comment-3001",
|
||||
"issue_url": "https://forge.example/acme/widgets/issues/3",
|
||||
"pull_request_url": "https://forge.example/acme/widgets/pulls/3" }
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let since = Timestamp::parse(SINCE).expect("cursor parses");
|
||||
let page = forge(&server)
|
||||
.poll(&RepoRef::new("acme", "widgets"), Some(&since))
|
||||
.await
|
||||
.expect("poll succeeds");
|
||||
|
||||
// Oldest → newest; the stale pull and the PENDING review are gone.
|
||||
let summary: Vec<(EventKind, u64, bool)> = page
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| (event.kind, event.number, event.is_pr))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
vec![
|
||||
(EventKind::Closed, 7, false),
|
||||
(EventKind::Comment, 7, false),
|
||||
(EventKind::Opened, 5, false),
|
||||
(EventKind::Merged, 3, true),
|
||||
(EventKind::Review, 3, true),
|
||||
(EventKind::Comment, 3, true),
|
||||
(EventKind::Reopened, 4, false),
|
||||
]
|
||||
);
|
||||
assert_eq!(page.events[0].id, "gitea:issue:1007@2026-10-02T10:00:00Z");
|
||||
assert_eq!(page.events[1].id, "gitea:comment:7001");
|
||||
assert_eq!(page.events[3].id, "gitea:pull:2003@2026-10-06T12:00:00Z");
|
||||
assert_eq!(page.events[4].id, "gitea:review:31");
|
||||
assert_eq!(page.events[4].actor.as_deref(), Some("carol"));
|
||||
assert_eq!(
|
||||
page.cursor,
|
||||
Some(Timestamp::parse("2026-10-07T09:00:00Z").expect("parses"))
|
||||
);
|
||||
|
||||
// Gitea's token scheme rides on every request.
|
||||
let received = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording is on");
|
||||
assert!(received.iter().all(|request| {
|
||||
request
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
== Some("token tok")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn posting_a_comment_uses_the_token_header_and_the_issues_endpoint() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues/4/comments"))
|
||||
.and(header("authorization", "token tok"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 42 })))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
forge(&server)
|
||||
.comment(&RepoRef::new("acme", "widgets"), 4, "on it")
|
||||
.await
|
||||
.expect("comment posts");
|
||||
|
||||
let received = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording is on");
|
||||
assert_eq!(received.len(), 1);
|
||||
assert!(!received[0].url.as_str().contains("tok"));
|
||||
let body: Value = serde_json::from_slice(&received[0].body).expect("json body");
|
||||
assert_eq!(body, json!({ "body": "on it" }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_reads_prs_through_the_pulls_row() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues/7"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(issue_row(7, json!({ "labels": [{ "name": "bug" }] }))),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues/3"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(issue_row(3, json!({ "pull_request": { "merged": true } }))),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/pulls/3"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(pull_row(
|
||||
3,
|
||||
json!({
|
||||
"state": "closed",
|
||||
"merged": true,
|
||||
"title": "fix crash",
|
||||
"labels": [{ "name": "bug" }]
|
||||
}),
|
||||
)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let repo = RepoRef::new("acme", "widgets");
|
||||
let issue = forge(&server)
|
||||
.snapshot(&repo, 7)
|
||||
.await
|
||||
.expect("issue snapshot");
|
||||
assert_eq!(issue.state, rhema_forge_integration::ThreadState::Open);
|
||||
assert!(!issue.is_pr);
|
||||
assert_eq!(issue.labels, vec!["bug".to_owned()]);
|
||||
|
||||
let pull = forge(&server)
|
||||
.snapshot(&repo, 3)
|
||||
.await
|
||||
.expect("pull snapshot");
|
||||
assert_eq!(pull.state, rhema_forge_integration::ThreadState::Merged);
|
||||
assert!(pull.is_pr);
|
||||
assert_eq!(pull.title, "fix crash");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trailing_slashes_in_the_base_url_are_trimmed() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues/7"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(issue_row(7, json!({}))))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let forge = GiteaForge::new(format!("{}/", server.uri()), "tok");
|
||||
forge
|
||||
.snapshot(&RepoRef::new("acme", "widgets"), 7)
|
||||
.await
|
||||
.expect("the trimmed base reaches the same path");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_2xx_response_maps_to_an_api_error_with_the_body_message() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(401).set_body_string(
|
||||
json!({
|
||||
"message": "user does not exist [uid: 0, name: ]",
|
||||
"url": "https://forge.example/api/swagger"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = forge(&server)
|
||||
.poll(&RepoRef::new("acme", "widgets"), None)
|
||||
.await
|
||||
.expect_err("401 is an error");
|
||||
match err {
|
||||
ForgeError::Api { status, body } => {
|
||||
assert_eq!(status, 401);
|
||||
assert!(body.contains("user does not exist"), "got: {body}");
|
||||
}
|
||||
other => panic!("expected Api error, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_transport_failure_maps_to_network() {
|
||||
// An unroutable address: nothing is listening, so the connect fails.
|
||||
let forge = GiteaForge::new("http://127.0.0.1:1", "tok");
|
||||
let err = forge
|
||||
.poll(&RepoRef::new("acme", "widgets"), None)
|
||||
.await
|
||||
.expect_err("connection refused is an error");
|
||||
assert!(matches!(err, ForgeError::Network(_)), "got: {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notifications_poll_maps_the_subject_feed() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/notifications"))
|
||||
.and(query_param("status-types", "unread"))
|
||||
.and(query_param("since", SINCE))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
{
|
||||
"id": 91,
|
||||
"unread": true,
|
||||
"updated_at": "2026-10-05T12:00:00Z",
|
||||
"subject": {
|
||||
"title": "Fix the gate",
|
||||
"url": "https://forge.example/api/v1/repos/acme/widgets/pulls/9",
|
||||
"type": "Pull"
|
||||
},
|
||||
"repository": { "full_name": "acme/widgets" }
|
||||
}
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let since = Timestamp::parse(SINCE).expect("cursor parses");
|
||||
let page = forge(&server)
|
||||
.notifications(Some(&since))
|
||||
.await
|
||||
.expect("poll succeeds");
|
||||
|
||||
assert_eq!(page.notifications.len(), 1);
|
||||
let note = &page.notifications[0];
|
||||
assert_eq!(note.id, "gitea:notification:91@2026-10-05T12:00:00Z");
|
||||
// Gitea's feed names no per-row reason: the fixed fallback spelling.
|
||||
assert_eq!(note.reason, NotificationReason::Other("gitea".to_owned()));
|
||||
assert_eq!(note.number, 9);
|
||||
assert!(note.is_pr);
|
||||
assert_eq!(note.repo, RepoRef::new("acme", "widgets"));
|
||||
assert_eq!(note.title.as_deref(), Some("Fix the gate"));
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
//! Offline integration tests: the GitHub backend against an in-process
|
||||
//! wiremock server. Zero network.
|
||||
|
||||
use rhema_forge_integration::{
|
||||
EventKind, Forge, ForgeError, GitHubForge, NotificationReason, RepoRef, Timestamp,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const SINCE: &str = "2026-10-01T00:00:00Z";
|
||||
|
||||
fn forge(server: &MockServer) -> GitHubForge {
|
||||
GitHubForge::new("tok").with_base_url(server.uri())
|
||||
}
|
||||
|
||||
/// An issues-list row; `patch` overrides any field.
|
||||
fn issue_row(number: i64, patch: Value) -> Value {
|
||||
with(
|
||||
json!({
|
||||
"id": 1000 + number,
|
||||
"number": number,
|
||||
"state": "open",
|
||||
"title": format!("thread {number}"),
|
||||
"user": { "login": "alice" },
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"closed_at": Value::Null,
|
||||
"html_url": format!("https://github.com/acme/widgets/issues/{number}")
|
||||
}),
|
||||
patch,
|
||||
)
|
||||
}
|
||||
|
||||
/// Shallow-merges `patch` into `base`.
|
||||
fn with(base: Value, patch: Value) -> Value {
|
||||
match (base, patch) {
|
||||
(Value::Object(mut base), Value::Object(patch)) => {
|
||||
for (key, value) in patch {
|
||||
base.insert(key, value);
|
||||
}
|
||||
Value::Object(base)
|
||||
}
|
||||
(base, _) => base,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_pages_the_thread_feed_and_the_comment_feed() {
|
||||
let server = MockServer::start().await;
|
||||
let page1: Vec<Value> = (0..100)
|
||||
.map(|i| {
|
||||
issue_row(
|
||||
i,
|
||||
json!({
|
||||
"created_at": "2026-10-02T09:00:00Z",
|
||||
"updated_at": "2026-10-02T09:00:00Z"
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Mount order matters: the page-2 mock must match before the page-1 one.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues"))
|
||||
.and(query_param("page", "2"))
|
||||
.and(query_param("since", SINCE))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([issue_row(
|
||||
200,
|
||||
json!({
|
||||
"created_at": "2026-10-03T09:00:00Z",
|
||||
"updated_at": "2026-10-03T09:00:00Z"
|
||||
})
|
||||
)])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues"))
|
||||
.and(query_param("since", SINCE))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(Value::Array(page1)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues/comments"))
|
||||
.and(query_param("since", SINCE))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let since = Timestamp::parse(SINCE).expect("cursor parses");
|
||||
let page = forge(&server)
|
||||
.poll(&RepoRef::new("acme", "widgets"), Some(&since))
|
||||
.await
|
||||
.expect("poll succeeds");
|
||||
|
||||
assert_eq!(page.events.len(), 101);
|
||||
assert!(
|
||||
page.events
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].timestamp <= pair[1].timestamp)
|
||||
);
|
||||
assert_eq!(
|
||||
page.cursor,
|
||||
Some(Timestamp::parse("2026-10-03T09:00:00Z").expect("parses"))
|
||||
);
|
||||
|
||||
let received = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording is on");
|
||||
assert_eq!(received.len(), 3);
|
||||
assert!(received[0].url.as_str().contains("page=1"));
|
||||
assert!(received[0].url.as_str().contains("per_page=100"));
|
||||
assert!(received[0].url.as_str().contains("sort=updated"));
|
||||
assert!(received[0].url.as_str().contains("state=all"));
|
||||
assert!(received[1].url.as_str().contains("page=2"));
|
||||
assert!(received[2].url.path() == "/repos/acme/widgets/issues/comments");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_normalizes_state_kinds_pr_distinction_reviews_and_comments() {
|
||||
let server = MockServer::start().await;
|
||||
// A closed issue, a fresh issue, a reopened issue, an opened PR row,
|
||||
// and a closed PR row — the PRs ride the issues feed behind the
|
||||
// `pull_request` key.
|
||||
let issues = json!([
|
||||
issue_row(
|
||||
7,
|
||||
json!({
|
||||
"state": "closed",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-02T10:00:00Z",
|
||||
"closed_at": "2026-10-02T10:00:00Z",
|
||||
"node_id": "MDU6SXNzdWUxMDA3"
|
||||
})
|
||||
),
|
||||
issue_row(
|
||||
5,
|
||||
json!({
|
||||
"created_at": "2026-10-03T08:00:00Z",
|
||||
"updated_at": "2026-10-03T08:00:00Z"
|
||||
})
|
||||
),
|
||||
issue_row(
|
||||
4,
|
||||
json!({
|
||||
"state": "open",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-07T09:00:00Z",
|
||||
"closed_at": "2026-10-03T00:00:00Z"
|
||||
})
|
||||
),
|
||||
with(
|
||||
issue_row(
|
||||
8,
|
||||
json!({
|
||||
"created_at": "2026-10-04T08:00:00Z",
|
||||
"updated_at": "2026-10-04T08:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/pull/8"
|
||||
})
|
||||
),
|
||||
json!({ "pull_request": { "merged_at": Value::Null } })
|
||||
),
|
||||
with(
|
||||
issue_row(
|
||||
9,
|
||||
json!({
|
||||
"state": "closed",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-06T09:00:00Z",
|
||||
"closed_at": "2026-10-06T09:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/pull/9"
|
||||
})
|
||||
),
|
||||
json!({ "pull_request": { "merged_at": Value::Null } })
|
||||
),
|
||||
]);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(issues))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// PR #8: open and unmerged as a pulls row → Opened.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/pulls/8"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": 9008, "number": 8, "state": "open",
|
||||
"user": { "login": "alice" },
|
||||
"created_at": "2026-10-04T08:00:00Z",
|
||||
"updated_at": "2026-10-04T08:00:00Z",
|
||||
"closed_at": Value::Null,
|
||||
"merged_at": Value::Null,
|
||||
"html_url": "https://github.com/acme/widgets/pull/8"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/pulls/8/reviews"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
{ "id": 81, "user": { "login": "bob" }, "body": "ship it",
|
||||
"state": "APPROVED", "submitted_at": "2026-10-05T10:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/pull/8#pullrequestreview-81" },
|
||||
{ "id": 82, "user": { "login": "bob" }, "body": "",
|
||||
"state": "PENDING", "submitted_at": Value::Null,
|
||||
"html_url": "https://github.com/acme/widgets/pull/8#pullrequestreview-82" }
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// PR #9: closed and merged as a pulls row → Merged.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/pulls/9"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": 9009, "number": 9, "state": "closed",
|
||||
"user": { "login": "alice" },
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-06T12:00:00Z",
|
||||
"closed_at": "2026-10-06T09:00:00Z",
|
||||
"merged_at": "2026-10-06T12:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/pull/9"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/pulls/9/reviews"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
{ "id": 91, "user": { "login": "erin" }, "body": "one nit",
|
||||
"state": "CHANGES_REQUESTED", "submitted_at": "2026-10-06T13:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/pull/9#pullrequestreview-91" }
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// The repo-wide comments feed: one issue comment, one PR comment.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues/comments"))
|
||||
.and(query_param("sort", "created"))
|
||||
.and(query_param("direction", "asc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
{ "id": 7001, "user": { "login": "carol" }, "body": "repro?",
|
||||
"created_at": "2026-10-02T11:00:00Z",
|
||||
"updated_at": "2026-10-02T11:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/issues/7#issuecomment-7001",
|
||||
"issue_url": "https://api.github.com/repos/acme/widgets/issues/7" },
|
||||
{ "id": 8001, "user": { "login": "dave" }, "body": "left a review",
|
||||
"created_at": "2026-10-05T10:30:00Z",
|
||||
"updated_at": "2026-10-05T10:30:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/pull/8#issuecomment-8001",
|
||||
"issue_url": "https://api.github.com/repos/acme/widgets/pulls/8" }
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let since = Timestamp::parse(SINCE).expect("cursor parses");
|
||||
let page = forge(&server)
|
||||
.poll(&RepoRef::new("acme", "widgets"), Some(&since))
|
||||
.await
|
||||
.expect("poll succeeds");
|
||||
|
||||
// Oldest → newest; the PENDING review is gone.
|
||||
let summary: Vec<(EventKind, u64, bool)> = page
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| (event.kind, event.number, event.is_pr))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
vec![
|
||||
(EventKind::Closed, 7, false),
|
||||
(EventKind::Comment, 7, false),
|
||||
(EventKind::Opened, 5, false),
|
||||
(EventKind::Opened, 8, true),
|
||||
(EventKind::Review, 8, true),
|
||||
(EventKind::Comment, 8, true),
|
||||
(EventKind::Merged, 9, true),
|
||||
(EventKind::Review, 9, true),
|
||||
(EventKind::Reopened, 4, false),
|
||||
]
|
||||
);
|
||||
// Thread events id on the feed row's id + updated time; comment and
|
||||
// review events on their own ids.
|
||||
assert_eq!(page.events[0].id, "github:issue:1007@2026-10-02T10:00:00Z");
|
||||
assert_eq!(page.events[1].id, "github:comment:7001");
|
||||
assert_eq!(page.events[3].id, "github:pull:9008@2026-10-04T08:00:00Z");
|
||||
assert_eq!(page.events[4].id, "github:review:81");
|
||||
// Polls see row snapshots, not transitions: the actor of a thread
|
||||
// event is the thread author; reviews carry the reviewer.
|
||||
assert_eq!(page.events[0].actor.as_deref(), Some("alice"));
|
||||
assert_eq!(page.events[4].actor.as_deref(), Some("bob"));
|
||||
assert_eq!(page.events[4].body.as_deref(), Some("ship it"));
|
||||
// The untouched wire row rides along for handler context: fields the
|
||||
// event does not lift survive; lifted ones do not.
|
||||
assert_eq!(page.events[0].raw["node_id"], "MDU6SXNzdWUxMDA3");
|
||||
assert!(page.events[0].raw.get("title").is_none());
|
||||
assert_eq!(
|
||||
page.cursor,
|
||||
Some(Timestamp::parse("2026-10-07T09:00:00Z").expect("parses"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn posting_a_comment_uses_the_bearer_header_and_the_issues_endpoint() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/repos/acme/widgets/issues/7/comments"))
|
||||
.and(header("authorization", "Bearer tok"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 42 })))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
forge(&server)
|
||||
.comment(&RepoRef::new("acme", "widgets"), 7, "on it")
|
||||
.await
|
||||
.expect("comment posts");
|
||||
|
||||
let received = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording is on");
|
||||
assert_eq!(received.len(), 1);
|
||||
// The token is a header, never part of the URL.
|
||||
assert!(!received[0].url.as_str().contains("tok"));
|
||||
let body: Value = serde_json::from_slice(&received[0].body).expect("json body");
|
||||
assert_eq!(body, json!({ "body": "on it" }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_reads_prs_through_the_pulls_row() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues/7"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(issue_row(7, json!({ "labels": [{ "name": "bug" }] }))),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues/8"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(with(
|
||||
issue_row(8, json!({})),
|
||||
json!({ "pull_request": { "merged_at": Value::Null } }),
|
||||
)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/pulls/8"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": 9008, "number": 8, "state": "closed",
|
||||
"title": "fix crash",
|
||||
"user": { "login": "alice" },
|
||||
"labels": [{ "name": "bug" }],
|
||||
"created_at": "2026-10-04T08:00:00Z",
|
||||
"updated_at": "2026-10-06T12:00:00Z",
|
||||
"merged_at": "2026-10-06T12:00:00Z",
|
||||
"html_url": "https://github.com/acme/widgets/pull/8"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let repo = RepoRef::new("acme", "widgets");
|
||||
let issue = forge(&server)
|
||||
.snapshot(&repo, 7)
|
||||
.await
|
||||
.expect("issue snapshot");
|
||||
assert_eq!(issue.state, rhema_forge_integration::ThreadState::Open);
|
||||
assert!(!issue.is_pr);
|
||||
assert_eq!(issue.labels, vec!["bug".to_owned()]);
|
||||
assert_eq!(issue.author.as_deref(), Some("alice"));
|
||||
|
||||
let pull = forge(&server)
|
||||
.snapshot(&repo, 8)
|
||||
.await
|
||||
.expect("pull snapshot");
|
||||
assert_eq!(pull.state, rhema_forge_integration::ThreadState::Merged);
|
||||
assert!(pull.is_pr);
|
||||
assert_eq!(pull.title, "fix crash");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trailing_slashes_in_the_base_url_are_trimmed() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues/7"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(issue_row(7, json!({}))))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let forge = GitHubForge::new("tok").with_base_url(format!("{}/", server.uri()));
|
||||
forge
|
||||
.snapshot(&RepoRef::new("acme", "widgets"), 7)
|
||||
.await
|
||||
.expect("the trimmed base reaches the same path");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_2xx_response_maps_to_an_api_error_with_the_body_message() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/acme/widgets/issues"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(404).set_body_string(
|
||||
json!({
|
||||
"message": "Not Found",
|
||||
"documentation_url": "https://docs.github.com/rest"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = forge(&server)
|
||||
.poll(&RepoRef::new("acme", "widgets"), None)
|
||||
.await
|
||||
.expect_err("404 is an error");
|
||||
match err {
|
||||
ForgeError::Api { status, body } => {
|
||||
assert_eq!(status, 404);
|
||||
assert!(body.contains("Not Found"), "got: {body}");
|
||||
}
|
||||
other => panic!("expected Api error, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_transport_failure_maps_to_network() {
|
||||
// An unroutable address: nothing is listening, so the connect fails.
|
||||
let forge = GitHubForge::new("tok").with_base_url("http://127.0.0.1:1");
|
||||
let err = forge
|
||||
.poll(&RepoRef::new("acme", "widgets"), None)
|
||||
.await
|
||||
.expect_err("connection refused is an error");
|
||||
assert!(matches!(err, ForgeError::Network(_)), "got: {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notifications_poll_maps_reasons_and_subjects() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/notifications"))
|
||||
.and(query_param("since", SINCE))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
{
|
||||
"id": 42188,
|
||||
"unread": true,
|
||||
"reason": "review_requested",
|
||||
"updated_at": "2026-10-05T12:00:00Z",
|
||||
"subject": {
|
||||
"title": "Add the widget flange",
|
||||
"url": "https://api.github.com/repos/acme/widgets/pulls/142",
|
||||
"type": "PullRequest"
|
||||
},
|
||||
"repository": { "full_name": "acme/widgets" }
|
||||
},
|
||||
{
|
||||
"id": 42189,
|
||||
"unread": true,
|
||||
"reason": "assign",
|
||||
"updated_at": "2026-10-04T08:00:00Z",
|
||||
"subject": {
|
||||
"title": "Ship the barn door",
|
||||
"url": "https://api.github.com/repos/acme/widgets/issues/7",
|
||||
"type": "Issue"
|
||||
},
|
||||
"repository": { "full_name": "acme/widgets" }
|
||||
}
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let since = Timestamp::parse(SINCE).expect("cursor parses");
|
||||
let page = forge(&server)
|
||||
.notifications(Some(&since))
|
||||
.await
|
||||
.expect("poll succeeds");
|
||||
|
||||
assert_eq!(page.notifications.len(), 2);
|
||||
// Oldest first: the assignment predates the review request.
|
||||
let assignment = &page.notifications[0];
|
||||
assert_eq!(
|
||||
assignment.id,
|
||||
"github:notification:42189@2026-10-04T08:00:00Z"
|
||||
);
|
||||
assert_eq!(assignment.reason, NotificationReason::Assigned);
|
||||
assert_eq!(assignment.number, 7);
|
||||
assert!(!assignment.is_pr);
|
||||
assert_eq!(assignment.repo, RepoRef::new("acme", "widgets"));
|
||||
let review = &page.notifications[1];
|
||||
assert_eq!(review.id, "github:notification:42188@2026-10-05T12:00:00Z");
|
||||
assert_eq!(review.reason, NotificationReason::ReviewRequested);
|
||||
assert_eq!(review.number, 142);
|
||||
assert!(review.is_pr);
|
||||
assert_eq!(
|
||||
page.cursor,
|
||||
Some(Timestamp::parse("2026-10-05T12:00:00Z").expect("parses"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notifications_without_a_cursor_still_return_the_newest_seen() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/notifications"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
{
|
||||
"id": 7,
|
||||
"reason": "mention",
|
||||
"updated_at": "2026-10-02T00:00:00Z",
|
||||
"subject": {
|
||||
"title": "Hello",
|
||||
"url": "https://api.github.com/repos/acme/widgets/issues/1",
|
||||
"type": "Issue"
|
||||
},
|
||||
"repository": { "full_name": "acme/widgets" }
|
||||
}
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let page = forge(&server)
|
||||
.notifications(None)
|
||||
.await
|
||||
.expect("poll succeeds");
|
||||
assert_eq!(page.notifications.len(), 1);
|
||||
assert_eq!(page.notifications[0].reason, NotificationReason::Mention);
|
||||
assert_eq!(
|
||||
page.cursor,
|
||||
Some(Timestamp::parse("2026-10-02T00:00:00Z").expect("parses"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
//! Offline integration tests: the GitLab backend against an in-process
|
||||
//! wiremock server. Zero network.
|
||||
|
||||
use rhema_forge_integration::{
|
||||
EventKind, Forge, ForgeError, GitLabForge, NotificationReason, RepoRef, Timestamp,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const SINCE: &str = "2026-10-01T00:00:00Z";
|
||||
|
||||
fn forge(server: &MockServer) -> GitLabForge {
|
||||
GitLabForge::new("tok").with_base_url(server.uri())
|
||||
}
|
||||
|
||||
/// An issues-feed row; `patch` overrides any field.
|
||||
fn issue_row(iid: i64, patch: Value) -> Value {
|
||||
with(
|
||||
json!({
|
||||
"id": 4000 + iid,
|
||||
"iid": iid,
|
||||
"state": "opened",
|
||||
"title": format!("thread {iid}"),
|
||||
"author": { "username": "alice" },
|
||||
"labels": [],
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-01T09:00:00Z",
|
||||
"closed_at": Value::Null,
|
||||
"web_url": format!("https://gitlab.com/acme/widgets/-/issues/{iid}")
|
||||
}),
|
||||
patch,
|
||||
)
|
||||
}
|
||||
|
||||
/// A merge-requests-feed row; `patch` overrides any field.
|
||||
fn mr_row(iid: i64, patch: Value) -> Value {
|
||||
with(
|
||||
json!({
|
||||
"id": 5000 + iid,
|
||||
"iid": iid,
|
||||
"state": "opened",
|
||||
"title": format!("mr {iid}"),
|
||||
"author": { "username": "bob" },
|
||||
"labels": [],
|
||||
"created_at": "2026-10-01T08:00:00Z",
|
||||
"updated_at": "2026-10-01T08:00:00Z",
|
||||
"closed_at": Value::Null,
|
||||
"web_url": format!("https://gitlab.com/acme/widgets/-/merge_requests/{iid}")
|
||||
}),
|
||||
patch,
|
||||
)
|
||||
}
|
||||
|
||||
/// A notes row; `patch` overrides any field.
|
||||
fn note_row(id: i64, created_at: &str, patch: Value) -> Value {
|
||||
with(
|
||||
json!({
|
||||
"id": id,
|
||||
"body": "looks broken",
|
||||
"system": false,
|
||||
"author": { "username": "carol" },
|
||||
"created_at": created_at,
|
||||
"updated_at": created_at
|
||||
}),
|
||||
patch,
|
||||
)
|
||||
}
|
||||
|
||||
/// Shallow-merges `patch` into `base`.
|
||||
fn with(base: Value, patch: Value) -> Value {
|
||||
match (base, patch) {
|
||||
(Value::Object(mut base), Value::Object(patch)) => {
|
||||
for (key, value) in patch {
|
||||
base.insert(key, value);
|
||||
}
|
||||
Value::Object(base)
|
||||
}
|
||||
(base, _) => base,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_pages_via_x_next_page_and_fans_out_notes() {
|
||||
let server = MockServer::start().await;
|
||||
// The project path rides the URL percent-encoded.
|
||||
// Mount order matters: the page-2 mock must match before the page-1 one.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues"))
|
||||
.and(query_param("page", "2"))
|
||||
.and(query_param("updated_after", SINCE))
|
||||
.and(header("private-token", "tok"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([issue_row(
|
||||
4,
|
||||
json!({
|
||||
"created_at": "2026-10-03T09:00:00Z",
|
||||
"updated_at": "2026-10-07T09:00:00Z",
|
||||
"closed_at": "2026-10-03T00:00:00Z"
|
||||
})
|
||||
)])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues"))
|
||||
.and(query_param("updated_after", SINCE))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("x-next-page", "2")
|
||||
.set_body_json(json!([
|
||||
issue_row(
|
||||
7,
|
||||
json!({
|
||||
"state": "closed",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-02T10:00:00Z",
|
||||
"closed_at": "2026-10-02T10:00:00Z"
|
||||
})
|
||||
),
|
||||
issue_row(
|
||||
5,
|
||||
json!({
|
||||
"created_at": "2026-10-03T08:00:00Z",
|
||||
"updated_at": "2026-10-03T08:00:00Z"
|
||||
})
|
||||
)
|
||||
])),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/merge_requests"))
|
||||
.and(query_param("updated_after", SINCE))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// Notes fan out per updated thread, in feed order.
|
||||
for (iid, notes) in [
|
||||
(7, json!([note_row(701, "2026-10-02T11:00:00Z", json!({}))])),
|
||||
(5, json!([])),
|
||||
(4, json!([])),
|
||||
] {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"/api/v4/projects/acme%2Fwidgets/issues/{iid}/notes"
|
||||
)))
|
||||
.and(query_param("sort", "asc"))
|
||||
.and(query_param("order_by", "created_at"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(notes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
|
||||
let since = Timestamp::parse(SINCE).expect("cursor parses");
|
||||
let page = forge(&server)
|
||||
.poll(&RepoRef::new("acme", "widgets"), Some(&since))
|
||||
.await
|
||||
.expect("poll succeeds");
|
||||
|
||||
// Three thread events across the two pages (#7 closed, #5 opened,
|
||||
// #4 reopened), plus the note on #7.
|
||||
assert_eq!(page.events.len(), 4);
|
||||
assert_eq!(
|
||||
page.cursor,
|
||||
Some(Timestamp::parse("2026-10-07T09:00:00Z").expect("parses"))
|
||||
);
|
||||
|
||||
let received = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording is on");
|
||||
// Issues pages 1+2, merge requests page 1, then three notes feeds.
|
||||
assert_eq!(received.len(), 6);
|
||||
assert!(received[0].url.as_str().contains("per_page=100"));
|
||||
assert!(received[0].url.as_str().contains("order_by=updated_at"));
|
||||
assert!(received[0].url.as_str().contains("sort=asc"));
|
||||
assert!(received[1].url.as_str().contains("page=2"));
|
||||
assert!(received[2].url.path() == "/api/v4/projects/acme%2Fwidgets/merge_requests");
|
||||
assert!(received[3].url.path() == "/api/v4/projects/acme%2Fwidgets/issues/7/notes");
|
||||
assert!(received[4].url.path() == "/api/v4/projects/acme%2Fwidgets/issues/5/notes");
|
||||
assert!(received[5].url.path() == "/api/v4/projects/acme%2Fwidgets/issues/4/notes");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_normalizes_state_kinds_and_notes_on_both_feeds() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
// Closed, with GitLab's fractional-seconds updated stamp.
|
||||
issue_row(
|
||||
7,
|
||||
json!({
|
||||
"state": "closed",
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-02T10:00:00.081Z",
|
||||
"closed_at": "2026-10-02T10:00:00Z"
|
||||
})
|
||||
),
|
||||
issue_row(
|
||||
5,
|
||||
json!({
|
||||
"created_at": "2026-10-03T08:00:00Z",
|
||||
"updated_at": "2026-10-03T08:00:00Z"
|
||||
})
|
||||
),
|
||||
issue_row(
|
||||
4,
|
||||
json!({
|
||||
"created_at": "2026-10-01T09:00:00Z",
|
||||
"updated_at": "2026-10-07T09:00:00Z",
|
||||
"closed_at": "2026-10-03T00:00:00Z"
|
||||
})
|
||||
)
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// A merged MR and an MR updated without a transition (no thread event,
|
||||
// but its new note still surfaces).
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/merge_requests"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
mr_row(
|
||||
3,
|
||||
json!({
|
||||
"state": "merged",
|
||||
"created_at": "2026-10-01T08:00:00Z",
|
||||
"updated_at": "2026-10-06T12:00:00Z",
|
||||
"closed_at": "2026-10-06T12:00:00Z"
|
||||
})
|
||||
),
|
||||
mr_row(
|
||||
9,
|
||||
json!({
|
||||
"created_at": "2026-10-04T08:00:00Z",
|
||||
"updated_at": "2026-10-08T09:00:00Z"
|
||||
})
|
||||
)
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues/7/notes"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
// System notes and stale notes are filtered.
|
||||
note_row(
|
||||
700,
|
||||
"2026-10-02T10:00:00Z",
|
||||
json!({
|
||||
"body": "changed the description", "system": true
|
||||
})
|
||||
),
|
||||
note_row(701, "2026-10-02T11:00:00Z", json!({})),
|
||||
note_row(702, "2025-01-01T00:00:00Z", json!({ "body": "old" }))
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues/5/notes"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues/4/notes"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(
|
||||
"/api/v4/projects/acme%2Fwidgets/merge_requests/3/notes",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([note_row(
|
||||
301,
|
||||
"2026-10-06T14:00:00Z",
|
||||
json!({})
|
||||
)])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(
|
||||
"/api/v4/projects/acme%2Fwidgets/merge_requests/9/notes",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([note_row(
|
||||
901,
|
||||
"2026-10-08T09:00:00Z",
|
||||
json!({})
|
||||
)])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let since = Timestamp::parse(SINCE).expect("cursor parses");
|
||||
let page = forge(&server)
|
||||
.poll(&RepoRef::new("acme", "widgets"), Some(&since))
|
||||
.await
|
||||
.expect("poll succeeds");
|
||||
|
||||
// Oldest → newest; system and stale notes are gone, and the updated
|
||||
// MR without a transition still surfaces its note.
|
||||
let summary: Vec<(EventKind, u64, bool)> = page
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| (event.kind, event.number, event.is_pr))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
vec![
|
||||
(EventKind::Closed, 7, false),
|
||||
(EventKind::Comment, 7, false),
|
||||
(EventKind::Opened, 5, false),
|
||||
(EventKind::Merged, 3, true),
|
||||
(EventKind::Comment, 3, true),
|
||||
(EventKind::Reopened, 4, false),
|
||||
(EventKind::Comment, 9, true),
|
||||
]
|
||||
);
|
||||
// GitLab's fractional seconds survive verbatim in the dedup id.
|
||||
assert_eq!(
|
||||
page.events[0].id,
|
||||
"gitlab:issue:4007@2026-10-02T10:00:00.081Z"
|
||||
);
|
||||
assert_eq!(page.events[1].id, "gitlab:note:701");
|
||||
assert_eq!(page.events[3].id, "gitlab:mr:5003@2026-10-06T12:00:00Z");
|
||||
assert_eq!(page.events[5].actor.as_deref(), Some("alice"));
|
||||
assert_eq!(page.events[6].actor.as_deref(), Some("carol"));
|
||||
assert_eq!(
|
||||
page.cursor,
|
||||
Some(Timestamp::parse("2026-10-08T09:00:00Z").expect("parses"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn posting_a_comment_falls_back_from_issues_to_merge_requests() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues/4/notes"))
|
||||
.and(header("private-token", "tok"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 42 })))
|
||||
.mount(&server)
|
||||
.await;
|
||||
// An iid that is not an issue falls through to the MR feed.
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues/9/notes"))
|
||||
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
|
||||
"message": "404 Issue Not Found"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path(
|
||||
"/api/v4/projects/acme%2Fwidgets/merge_requests/9/notes",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 43 })))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let repo = RepoRef::new("acme", "widgets");
|
||||
forge(&server)
|
||||
.comment(&repo, 4, "on it")
|
||||
.await
|
||||
.expect("the issue comment posts");
|
||||
forge(&server)
|
||||
.comment(&repo, 9, "on the MR")
|
||||
.await
|
||||
.expect("the MR comment posts via the fallback");
|
||||
|
||||
let received = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording is on");
|
||||
assert_eq!(received.len(), 3);
|
||||
let first_body: Value = serde_json::from_slice(&received[0].body).expect("json body");
|
||||
assert_eq!(first_body, json!({ "body": "on it" }));
|
||||
let last_body: Value = serde_json::from_slice(&received[2].body).expect("json body");
|
||||
assert_eq!(last_body, json!({ "body": "on the MR" }));
|
||||
assert!(received[2].url.path() == "/api/v4/projects/acme%2Fwidgets/merge_requests/9/notes");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_prefers_issues_and_falls_back_to_merge_requests() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues/7"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(issue_row(7, json!({ "labels": ["bug", "critical"] }))),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues/3"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(404).set_body_json(json!({ "message": "404 Issue Not Found" })),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/merge_requests/3"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mr_row(
|
||||
3,
|
||||
json!({
|
||||
"state": "merged",
|
||||
"title": "fix crash",
|
||||
"labels": ["bug"]
|
||||
}),
|
||||
)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let repo = RepoRef::new("acme", "widgets");
|
||||
let issue = forge(&server)
|
||||
.snapshot(&repo, 7)
|
||||
.await
|
||||
.expect("issue snapshot");
|
||||
assert_eq!(issue.state, rhema_forge_integration::ThreadState::Open);
|
||||
assert!(!issue.is_pr);
|
||||
assert_eq!(issue.labels, vec!["bug".to_owned(), "critical".to_owned()]);
|
||||
|
||||
let mr = forge(&server)
|
||||
.snapshot(&repo, 3)
|
||||
.await
|
||||
.expect("mr snapshot");
|
||||
assert_eq!(mr.state, rhema_forge_integration::ThreadState::Merged);
|
||||
assert!(mr.is_pr);
|
||||
assert_eq!(mr.title, "fix crash");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_2xx_response_maps_to_an_api_error_with_the_body_message() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/projects/acme%2Fwidgets/issues"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(500)
|
||||
.set_body_string(json!({ "message": "500 Internal Server Error" }).to_string()),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = forge(&server)
|
||||
.poll(&RepoRef::new("acme", "widgets"), None)
|
||||
.await
|
||||
.expect_err("500 is an error");
|
||||
match err {
|
||||
ForgeError::Api { status, body } => {
|
||||
assert_eq!(status, 500);
|
||||
assert!(body.contains("Internal Server Error"), "got: {body}");
|
||||
}
|
||||
other => panic!("expected Api error, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_transport_failure_maps_to_network() {
|
||||
// An unroutable address: nothing is listening, so the connect fails.
|
||||
let forge = GitLabForge::new("tok").with_base_url("http://127.0.0.1:1");
|
||||
let err = forge
|
||||
.poll(&RepoRef::new("acme", "widgets"), None)
|
||||
.await
|
||||
.expect_err("connection refused is an error");
|
||||
assert!(matches!(err, ForgeError::Network(_)), "got: {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn todos_poll_maps_actions_targets_and_projects() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v4/todos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
|
||||
{
|
||||
"id": 501,
|
||||
"action_name": "review_requested",
|
||||
"target_type": "MergeRequest",
|
||||
"target_url": "https://gitlab.com/acme/widgets/-/merge_requests/12",
|
||||
"updated_at": "2026-10-05T12:00:00Z",
|
||||
"project": { "path_with_namespace": "group/acme/widgets" },
|
||||
"target": {
|
||||
"iid": 12,
|
||||
"title": "Add the flange",
|
||||
"author": { "username": "alice" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 502,
|
||||
"action_name": "assigned",
|
||||
"target_type": "Issue",
|
||||
"target_url": "https://gitlab.com/acme/widgets/-/issues/3",
|
||||
"updated_at": "2026-10-04T08:00:00Z",
|
||||
"project": { "path_with_namespace": "acme/widgets" },
|
||||
"target": {
|
||||
"iid": 3,
|
||||
"title": "Ship the barn door",
|
||||
"author": { "username": "bob" }
|
||||
}
|
||||
}
|
||||
])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let page = forge(&server)
|
||||
.notifications(None)
|
||||
.await
|
||||
.expect("poll succeeds");
|
||||
|
||||
assert_eq!(page.notifications.len(), 2);
|
||||
let assignment = &page.notifications[0];
|
||||
assert_eq!(
|
||||
assignment.id,
|
||||
"gitlab:notification:502@2026-10-04T08:00:00Z"
|
||||
);
|
||||
assert_eq!(assignment.reason, NotificationReason::Assigned);
|
||||
assert_eq!(assignment.repo, RepoRef::new("acme", "widgets"));
|
||||
assert!(!assignment.is_pr);
|
||||
assert_eq!(assignment.actor.as_deref(), Some("bob"));
|
||||
let review = &page.notifications[1];
|
||||
assert_eq!(review.reason, NotificationReason::ReviewRequested);
|
||||
// The namespace path is the owner, the project slug the name.
|
||||
assert_eq!(review.repo, RepoRef::new("group/acme", "widgets"));
|
||||
assert!(review.is_pr);
|
||||
assert_eq!(review.number, 12);
|
||||
assert_eq!(
|
||||
review.url.as_deref(),
|
||||
Some("https://gitlab.com/acme/widgets/-/merge_requests/12")
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user