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:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

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