From 00adf092ff220b05d8b9de999b8d86a12194e4df Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Thu, 5 Nov 2026 19:03:52 +0100 Subject: [PATCH] fix(forge,claw): forge keys separate the issue from the pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watch key encoded source, repo, and number but not the thread kind, and on GitLab an issue and a merge request share one number space: iid 7 twice produced one key, one snapshot, and comments on the merge request landing in the issue (or a 404 fallback that misrouted the other way). The key carries the kind now — {source}:{kind}:{owner}/{name}#{number}, with GitLab spelling the pull variant mr — comment and snapshot route outright by kind, and a kind-less payload parses to the unparsable key instead of guessing issue. The claw's callers thread the kind from the payload and strip the segment where the bare repo is wanted; pre-kind stored keys parse as None rather than misroute. Four route-pinning wiremock tests cover the GitLab split, and the GitHub/Gitea clients take the kind without using it (their issues and PRs share the same REST space). --- crates/rhema-claw/src/forge.rs | 53 ++++-- crates/rhema-claw/src/handler.rs | 58 ++++-- crates/rhema-claw/src/interactive.rs | 4 +- crates/rhema-claw/tests/continuity.rs | 2 +- crates/rhema-claw/tests/e2e.rs | 2 +- .../rhema-forge-integration/src/event.rs | 22 ++- .../rhema-forge-integration/src/forge.rs | 31 +++- .../rhema-forge-integration/src/gitea.rs | 25 ++- .../rhema-forge-integration/src/github.rs | 25 ++- .../rhema-forge-integration/src/gitlab.rs | 78 ++++---- .../rhema-forge-integration/src/lib.rs | 8 +- .../rhema-forge-integration/src/watcher.rs | 171 +++++++++++++++--- .../rhema-forge-integration/tests/gitea.rs | 16 +- .../rhema-forge-integration/tests/github.rs | 16 +- .../rhema-forge-integration/tests/gitlab.rs | 145 ++++++++++----- 15 files changed, 478 insertions(+), 178 deletions(-) diff --git a/crates/rhema-claw/src/forge.rs b/crates/rhema-claw/src/forge.rs index 6117553..dc085d9 100644 --- a/crates/rhema-claw/src/forge.rs +++ b/crates/rhema-claw/src/forge.rs @@ -345,14 +345,17 @@ impl ForgeSource { } Triage::Agent(role) => { // The key function reads `repo` (as the - // `{owner, name}` row RepoRef serializes to) and - // `number` off the payload's top level, so the - // thread key is the same `forge:owner/repo#n` shape - // the repo watches use — one session per thread, - // whatever fed it. + // `{owner, name}` row RepoRef serializes to), + // `number`, and `is_pr` (the thread kind, since + // GitLab numbers the two sequences separately) off + // the payload's top level, so the thread key is the + // same `forge:kind:owner/repo#n` shape the repo + // watches use — one session per thread, whatever fed + // it. let payload = serde_json::json!({ "repo": notification.repo, "number": notification.number, + "is_pr": notification.is_pr, "role": role.as_str(), "notification": notification, }); @@ -582,31 +585,45 @@ mod tests { // The key function must fold the triaged payload onto the thread // key: build a payload the way `poll_account` does and run it // through the registered `forge_key_fn`. - let notification = Notification { - id: "github:notification:7@2026-10-08T00:00:00Z".to_owned(), + let notification = |is_pr: bool, number: u64| Notification { + id: format!("github:notification:{number}@2026-10-08T00:00:00Z"), reason: NotificationReason::ReviewRequested, repo: RepoRef::new("acme", "widgets"), - number: 142, - is_pr: true, + number, + is_pr, actor: None, title: Some("Add the flange".to_owned()), url: None, timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"), raw: serde_json::json!({}), }; - let payload = serde_json::json!({ - "repo": notification.repo, - "number": notification.number, - "role": "reviewer", - "notification": notification, - }); + let payload = |notification: &Notification| { + serde_json::json!({ + "repo": notification.repo, + "number": notification.number, + "is_pr": notification.is_pr, + "role": "reviewer", + "notification": notification, + }) + }; let key_fn = forge_key_fn("github"); + // The pull request and the same-numbered issue land on distinct + // keys — GitLab numbers the sequences separately. + let pull = notification(true, 142); let key = key_fn(&Event { - id: notification.id.clone(), + id: pull.id.clone(), source: "github".to_owned(), - payload, + payload: payload(&pull), received_at: 0, }); - assert_eq!(key.as_str(), "github:acme/widgets#142"); + assert_eq!(key.as_str(), "github:pull:acme/widgets#142"); + let issue = notification(false, 142); + let key = key_fn(&Event { + id: issue.id.clone(), + source: "github".to_owned(), + payload: payload(&issue), + received_at: 0, + }); + assert_eq!(key.as_str(), "github:issue:acme/widgets#142"); } } diff --git a/crates/rhema-claw/src/handler.rs b/crates/rhema-claw/src/handler.rs index 12ec4a4..7b6a646 100644 --- a/crates/rhema-claw/src/handler.rs +++ b/crates/rhema-claw/src/handler.rs @@ -95,8 +95,9 @@ impl ClawHandler { } } - /// The forge client a dispatch key answers to: `"github:acme/widgets#7"` - /// resolves its watch record from the table at dispatch time — the + /// The forge client a dispatch key answers to: + /// `"github:issue:acme/widgets#7"` resolves its watch record from the + /// table at dispatch time — the /// table is the source of truth, not a boot-time snapshot — and the /// client is cached per `(forge, host, token)` so one poll pool serves /// every thread of a watch. @@ -141,12 +142,12 @@ impl ClawHandler { // Owned: the key names the run through every step, while `ctx` // itself goes mutable for the inbox drain. let key = ctx.key.as_str().to_owned(); - let (repo, number) = parse_key(&ctx.key) + let (repo, thread_kind, number) = parse_key(&ctx.key) .ok_or_else(|| format!("resource key {key} is not a forge thread key"))?; let forge = self.watch_forge(&ctx.key)?; let snapshot = forge - .snapshot(&repo, number) + .snapshot(&repo, thread_kind, number) .await .map_err(|err| format!("snapshot of {key} failed: {err}"))?; let news = self.drain_news(ctx, &key).await?; @@ -183,7 +184,7 @@ impl ClawHandler { let reply = final_text(&output.message) .ok_or_else(|| format!("the model returned no text to post for {key}"))?; forge - .comment(&repo, number, &reply) + .comment(&repo, thread_kind, number, &reply) .await .map_err(|err| format!("comment on {key} failed: {err}"))?; Ok(Outcome::Handled) @@ -273,15 +274,18 @@ impl Handler for ClawHandler { } } -/// The watch-table key of a resource key: `"github:acme/widgets#7"` builds -/// `"github:acme/widgets"`. `None` for a key that is not a forge thread -/// key at all. +/// The watch-table key of a resource key: +/// `"github:issue:acme/widgets#7"` builds `"github:acme/widgets"` — the +/// thread kind between source and path is key identity, not watch +/// identity. `None` for a key that is not a forge thread key at all. pub fn watch_key(key: &ResourceKey) -> Option { let (head, _) = key.as_str().rsplit_once('#')?; - head.contains(':').then(|| head.to_owned()) + let (source, rest) = head.split_once(':')?; + let (_, path) = rest.split_once(':')?; + Some(format!("{source}:{path}")) } -/// The watch-table entry for a repo: `{kind}:{owner}/{name}` — the value +/// The watch-table entry for a repo: `{forge}:{owner}/{name}` — the value /// [`watch_key`] parses back out of a dispatch key. The pair is the /// key-parse-to-watch-lookup seam. pub fn watch_entry(kind: &str, repo: &RepoRef) -> String { @@ -547,7 +551,7 @@ fn first_line(content: &str) -> String { mod tests { use super::*; use rhema_core::ModelProvider; - use rhema_forge_integration::NotificationReason; + use rhema_forge_integration::{ForgeThreadKind, NotificationReason, resource_key}; fn snapshot() -> Snapshot { serde_json::from_value(serde_json::json!({ @@ -582,7 +586,7 @@ mod tests { #[test] fn the_prompt_carries_the_thread_the_events_and_the_contract() { let prompt = build_prompt( - "github:acme/widgets#7", + "github:issue:acme/widgets#7", &snapshot(), &[InboxNews::Event(event( EventKind::Comment, @@ -592,7 +596,10 @@ mod tests { Path::new("/tmp/workspaces/github-acme-widgets-7"), None, ); - assert!(prompt.contains("github:acme/widgets#7"), "key: {prompt}"); + assert!( + prompt.contains("github:issue:acme/widgets#7"), + "key: {prompt}" + ); assert!(prompt.contains("crash on save"), "title: {prompt}"); assert!(prompt.contains("state: open"), "state: {prompt}"); assert!(prompt.contains("Labels: bug, cli"), "labels: {prompt}"); @@ -609,7 +616,7 @@ mod tests { #[test] fn thread_level_events_carry_no_body_and_the_prompt_says_so() { let prompt = build_prompt( - "github:acme/widgets#7", + "github:issue:acme/widgets#7", &snapshot(), &[InboxNews::Event(event(EventKind::Opened, "alice", None))], Path::new("/tmp/w"), @@ -725,7 +732,7 @@ mod tests { #[test] fn a_reviewer_dispatch_frames_the_prompt_as_a_review() { let prompt = build_prompt( - "github:acme/widgets#142", + "github:issue:acme/widgets#142", &snapshot(), &[InboxNews::Notified { role: Role::Reviewer, @@ -793,12 +800,16 @@ mod tests { // A watch added to the table AFTER the handler was built resolves: // the table is the source of truth, not a boot-time snapshot. - let key = ResourceKey::from("github:acme/widgets#7"); + let key = ResourceKey::from("github:issue:acme/widgets#7"); assert_eq!(watch_key(&key).as_deref(), Some("github:acme/widgets")); + // The kind segment is key identity, not watch identity: an MR + // dispatch resolves the same watch row. + let mr_key = ResourceKey::from("github:pull:acme/widgets#7"); + assert_eq!(watch_key(&mr_key).as_deref(), Some("github:acme/widgets")); assert!(handler.watch_forge(&key).is_ok(), "the watch resolves"); assert!( handler - .watch_forge(&ResourceKey::from("gitea:acme/widgets#7")) + .watch_forge(&ResourceKey::from("gitea:issue:acme/widgets#7")) .is_err(), "another forge's key does not" ); @@ -855,7 +866,11 @@ mod tests { #[test] fn the_workspace_name_flattens_to_filesystem_safe_characters() { assert_eq!( - workspace_name("github:acme/widgets#7", &RepoRef::new("acme", "widgets"), 7), + workspace_name( + "github:issue:acme/widgets#7", + &RepoRef::new("acme", "widgets"), + 7 + ), "github-acme-widgets-7" ); assert_eq!( @@ -872,8 +887,13 @@ mod tests { fn the_table_entry_round_trips_through_the_dispatch_key() { let repo = RepoRef::new("acme", "widgets"); let entry = watch_entry("github", &repo); - let key = ResourceKey::from(format!("{entry}#7")); + // Built the way dispatches are: the canonical key builder, with + // the thread kind in place — which must not reach the lookup. + let key = resource_key("github", &repo, ForgeThreadKind::PullRequest, 7); + assert_eq!(key.as_str(), "github:pull:acme/widgets#7"); assert_eq!(watch_key(&key).as_deref(), Some(entry.as_str())); + // A pre-kind key is not a thread key at all anymore. + assert_eq!(watch_key(&ResourceKey::from("github:acme/widgets#7")), None); } /// Stands in for the provider in pure-lookup tests; nothing calls it. diff --git a/crates/rhema-claw/src/interactive.rs b/crates/rhema-claw/src/interactive.rs index 27cc9d3..437a006 100644 --- a/crates/rhema-claw/src/interactive.rs +++ b/crates/rhema-claw/src/interactive.rs @@ -1116,8 +1116,8 @@ mod tests { assert!(is_resident_key(key), "{key} is resident"); } for key in [ - "github:acme/widgets#7", - "gitea:acme/barn#1", + "github:issue:acme/widgets#7", + "gitea:pull:acme/barn#1", "interactive", "tasks", "task", diff --git a/crates/rhema-claw/tests/continuity.rs b/crates/rhema-claw/tests/continuity.rs index 43090e9..c3fbfd9 100644 --- a/crates/rhema-claw/tests/continuity.rs +++ b/crates/rhema-claw/tests/continuity.rs @@ -137,7 +137,7 @@ async fn the_second_dispatch_resumes_the_first_conversation() { let dir = tempfile::tempdir().expect("tempdir"); let config = config(&dir); seed_watch(&dir, &server.uri()); - let key = ResourceKey::from("github:acme/widgets#7"); + let key = ResourceKey::from("github:issue:acme/widgets#7"); // Pass one: the opened event alone. The cron shape — a fresh process, // a fresh watcher, the same db. diff --git a/crates/rhema-claw/tests/e2e.rs b/crates/rhema-claw/tests/e2e.rs index 627433c..833dbdb 100644 --- a/crates/rhema-claw/tests/e2e.rs +++ b/crates/rhema-claw/tests/e2e.rs @@ -125,7 +125,7 @@ async fn one_pass_polls_reasons_and_posts() { Arc::new(SystemClock), Arc::new(store), ); - let key = ResourceKey::from("github:acme/widgets#7"); + let key = ResourceKey::from("github:issue:acme/widgets#7"); let status = dispatcher.status(&key).expect("status reads"); assert_eq!(status.state, KeyState::Idle); assert_eq!(status.inbox_depth, 0); diff --git a/integrations/rhema-forge-integration/src/event.rs b/integrations/rhema-forge-integration/src/event.rs index 105ba57..43c023a 100644 --- a/integrations/rhema-forge-integration/src/event.rs +++ b/integrations/rhema-forge-integration/src/event.rs @@ -55,8 +55,26 @@ pub enum ThreadState { Merged, } -/// Repository coordinates. `owner` + `name` + a thread [`ForgeEvent::number`] -/// is the resource key the RSD dispatcher forms (`host:owner/repo#number`). +/// Which of a forge's thread sequences a number names. GitLab numbers +/// issues and merge requests from separate sequences, so the kind is part +/// of the thread's identity there; GitHub and Gitea share one sequence, +/// where the kind rides along for a uniform vocabulary. +/// +/// `PullRequest` is the crate's umbrella term for the issue's +/// counterpart: GitHub's and Gitea's pull request, GitLab's merge +/// request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ForgeThreadKind { + /// A thread in the forge's issue sequence. + Issue, + /// A thread in the forge's pull/merge-request sequence. + PullRequest, +} + +/// Repository coordinates. `owner` + `name` + a thread's [`ForgeEvent::number`] +/// and [`ForgeThreadKind`] are the resource key the RSD dispatcher forms +/// (`host:issue: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. diff --git a/integrations/rhema-forge-integration/src/forge.rs b/integrations/rhema-forge-integration/src/forge.rs index bc07faa..3f8368b 100644 --- a/integrations/rhema-forge-integration/src/forge.rs +++ b/integrations/rhema-forge-integration/src/forge.rs @@ -16,7 +16,7 @@ use async_trait::async_trait; use crate::error::ForgeError; -use crate::event::{ForgeEvent, NotificationPage, RepoRef, Snapshot, Timestamp}; +use crate::event::{ForgeEvent, ForgeThreadKind, NotificationPage, RepoRef, Snapshot, Timestamp}; /// One poll's worth of activity, with the cursor to continue from. #[derive(Debug, Clone, PartialEq)] @@ -61,12 +61,33 @@ pub trait Forge: Send + Sync { since: Option<&Timestamp>, ) -> Result; - /// Posts `body` as a new comment on thread `number`. - async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError>; + /// Posts `body` as a new comment on the thread `kind` + `number` + /// name. + /// + /// On GitLab the kind picks the sequence the iid is read against: + /// issues and merge requests are numbered independently, and only + /// the named one is addressed — a miss surfaces as a 404, never a + /// silent post to the other sequence. GitHub and Gitea share one + /// number space across issues and pull requests (every pull request + /// is an issue there), so they accept the kind for signature + /// uniformity and either value addresses the same thread. + async fn comment( + &self, + repo: &RepoRef, + kind: ForgeThreadKind, + 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; + /// second API round-trip. `kind` selects the sequence `number` is + /// read against, under the same rules as [`Forge::comment`]. + async fn snapshot( + &self, + repo: &RepoRef, + kind: ForgeThreadKind, + number: u64, + ) -> Result; /// The account's notification feed: everything addressed to the /// account this client authenticates as, across every repository it diff --git a/integrations/rhema-forge-integration/src/gitea.rs b/integrations/rhema-forge-integration/src/gitea.rs index 82fa063..2563a76 100644 --- a/integrations/rhema-forge-integration/src/gitea.rs +++ b/integrations/rhema-forge-integration/src/gitea.rs @@ -40,8 +40,8 @@ use serde_json::Value; use crate::error::{ForgeError, ensure_success, required}; use crate::event::{ - EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot, - ThreadState, Timestamp, + EventKind, ForgeEvent, ForgeThreadKind, Notification, NotificationPage, NotificationReason, + RepoRef, Snapshot, ThreadState, Timestamp, }; use crate::forge::{Forge, ForgePage}; @@ -612,8 +612,18 @@ impl Forge for GiteaForge { Ok(ForgePage::finish(events, since)) } - async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError> { + async fn comment( + &self, + repo: &RepoRef, + _kind: ForgeThreadKind, + number: u64, + body: &str, + ) -> Result<(), ForgeError> { // The issues endpoint covers PRs too: every PR is an issue here. + // Issues and pull requests share one number space, so `_kind` + // cannot change the route — either value addresses the same + // thread, and the parameter exists only for the trait's uniform + // signature (GitLab is the forge whose sequences differ). let path = format!( "/repos/{}/{}/issues/{number}/comments", repo.owner, repo.name @@ -628,7 +638,14 @@ impl Forge for GiteaForge { Ok(()) } - async fn snapshot(&self, repo: &RepoRef, number: u64) -> Result { + async fn snapshot( + &self, + repo: &RepoRef, + _kind: ForgeThreadKind, + number: u64, + ) -> Result { + // One number space across issues and pulls: `_kind` cannot + // change the route (see `comment`). let issue_path = format!("/repos/{}/{}/issues/{number}", repo.owner, repo.name); let response = self .get(&issue_path) diff --git a/integrations/rhema-forge-integration/src/github.rs b/integrations/rhema-forge-integration/src/github.rs index baf5c86..c5199bb 100644 --- a/integrations/rhema-forge-integration/src/github.rs +++ b/integrations/rhema-forge-integration/src/github.rs @@ -41,8 +41,8 @@ use serde_json::Value; use crate::error::{ForgeError, ensure_success, required}; use crate::event::{ - EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot, - ThreadState, Timestamp, + EventKind, ForgeEvent, ForgeThreadKind, Notification, NotificationPage, NotificationReason, + RepoRef, Snapshot, ThreadState, Timestamp, }; use crate::forge::{Forge, ForgePage}; @@ -620,8 +620,18 @@ impl Forge for GitHubForge { Ok(ForgePage::finish(events, since)) } - async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError> { + async fn comment( + &self, + repo: &RepoRef, + _kind: ForgeThreadKind, + number: u64, + body: &str, + ) -> Result<(), ForgeError> { // The issues endpoint covers PRs too: every PR is an issue here. + // Issues and pull requests share one number space, so `_kind` + // cannot change the route — either value addresses the same + // thread, and the parameter exists only for the trait's uniform + // signature (GitLab is the forge whose sequences differ). let path = format!( "/repos/{}/{}/issues/{number}/comments", repo.owner, repo.name @@ -636,7 +646,14 @@ impl Forge for GitHubForge { Ok(()) } - async fn snapshot(&self, repo: &RepoRef, number: u64) -> Result { + async fn snapshot( + &self, + repo: &RepoRef, + _kind: ForgeThreadKind, + number: u64, + ) -> Result { + // One number space across issues and pulls: `_kind` cannot + // change the route (see `comment`). let issue_path = format!("/repos/{}/{}/issues/{number}", repo.owner, repo.name); let response = self .get(&issue_path) diff --git a/integrations/rhema-forge-integration/src/gitlab.rs b/integrations/rhema-forge-integration/src/gitlab.rs index 6af4adf..a67b255 100644 --- a/integrations/rhema-forge-integration/src/gitlab.rs +++ b/integrations/rhema-forge-integration/src/gitlab.rs @@ -9,9 +9,12 @@ //! `updated_after=&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. +//! can exist as both. Every bare number arrives with its +//! [`ForgeThreadKind`] (the resource key carries it, and the +//! [`Forge`] write methods take it), and the kind picks the +//! `{issues,merge_requests}` path outright — there is no +//! try-issues-then-fallback guessing, so a reply meant for one +//! sequence can never land on the other. //! - 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` @@ -41,8 +44,8 @@ use serde_json::Value; use crate::error::{ForgeError, ensure_success, required}; use crate::event::{ - EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot, - ThreadState, Timestamp, + EventKind, ForgeEvent, ForgeThreadKind, Notification, NotificationPage, NotificationReason, + RepoRef, Snapshot, ThreadState, Timestamp, }; use crate::forge::{Forge, ForgePage}; @@ -169,6 +172,15 @@ async fn decode(response: reqwest::Response) -> Result &'static str { + match kind { + ForgeThreadKind::Issue => "issues", + ForgeThreadKind::PullRequest => "merge_requests", + } +} + /// 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 { @@ -488,53 +500,45 @@ impl Forge for GitLabForge { Ok(ForgePage::finish(events, since)) } - async fn comment(&self, repo: &RepoRef, number: u64, body: &str) -> Result<(), ForgeError> { + async fn comment( + &self, + repo: &RepoRef, + kind: ForgeThreadKind, + 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"); + // The kind names the sequence outright: iid sequences are + // independent, so guessing (issues first, 404 fallback) could + // land a merge-request reply on the same-numbered issue. + let segment = thread_segment(kind); + let notes_path = format!("/projects/{project}/{segment}/{number}/notes"); let response = self - .post(¬es_path("issues")) + .post(¬es_path) .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 { + async fn snapshot( + &self, + repo: &RepoRef, + kind: ForgeThreadKind, + number: u64, + ) -> Result { let project = encode_project_path(&repo.path()); - let resource = |segment: &str| format!("/projects/{project}/{segment}/{number}"); + // Same sequences as `comment`: the kind picks the path. + let is_pr = kind == ForgeThreadKind::PullRequest; + let resource = format!("/projects/{project}/{}/{number}", thread_segment(kind)); let response = self - .get(&resource("issues")) + .get(&resource) .send() .await .map_err(ForgeError::network)?; - // Same iid sequences as `comment`: issues first, 404 fallback. - let (row, is_pr) = match decode::(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), - }; + let row: WireThread = decode(response).await?; Ok(snapshot_from_row(&row, is_pr)?) } diff --git a/integrations/rhema-forge-integration/src/lib.rs b/integrations/rhema-forge-integration/src/lib.rs index 56dc190..1011adb 100644 --- a/integrations/rhema-forge-integration/src/lib.rs +++ b/integrations/rhema-forge-integration/src/lib.rs @@ -8,8 +8,8 @@ //! [`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 +//! §8's `host:kind: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 @@ -63,8 +63,8 @@ pub mod watcher; pub use async_trait::async_trait; pub use error::ForgeError; pub use event::{ - EventKind, ForgeEvent, Notification, NotificationPage, NotificationReason, RepoRef, Snapshot, - ThreadState, Timestamp, + EventKind, ForgeEvent, ForgeThreadKind, Notification, NotificationPage, NotificationReason, + RepoRef, Snapshot, ThreadState, Timestamp, }; pub use forge::{Forge, ForgePage}; pub use gitea::GiteaForge; diff --git a/integrations/rhema-forge-integration/src/watcher.rs b/integrations/rhema-forge-integration/src/watcher.rs index aa1e9fa..596d163 100644 --- a/integrations/rhema-forge-integration/src/watcher.rs +++ b/integrations/rhema-forge-integration/src/watcher.rs @@ -6,7 +6,7 @@ //! [`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 +//! `{source}:{kind}:{owner}/{name}#{number}` (see [`resource_key`]). Re-polling //! unchanged history therefore dedups away in the dispatcher, and a //! changed row re-dispatches. //! @@ -19,7 +19,7 @@ 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::event::{ForgeEvent, ForgeThreadKind, RepoRef, Timestamp}; use crate::forge::Forge; /// Why a watch step failed: the forge call, or the dispatcher seam behind @@ -34,20 +34,71 @@ pub enum WatchError { 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())) +/// The resource key of a thread on `source`: +/// `{source}:{kind}:{owner}/{name}#{number}` — spec §8's shape with the +/// thread kind spelled between source and path, as `github:issue:acme/ +/// widgets#142` or `gitlab:mr:acme/widgets#7`. The kind is part of the +/// key because GitLab numbers issues and merge requests from separate +/// sequences: without it, issue #7 and merge request #7 would share one +/// key, one dedup lane, and one agent session. +pub fn resource_key( + source: &str, + repo: &RepoRef, + kind: ForgeThreadKind, + number: u64, +) -> ResourceKey { + ResourceKey::from(format!( + "{source}:{}:{}#{number}", + key_segment(source, kind), + repo.path() + )) +} + +/// The key segment `source` spells `kind` with: GitLab says `mr` where +/// the rest say `pull`, so a key reads like the forge's own dedup ids +/// (`github:pull:…`, `gitlab:mr:…`) it rides beside. +fn key_segment(source: &str, kind: ForgeThreadKind) -> &'static str { + match (kind, source == "gitlab") { + (ForgeThreadKind::Issue, _) => "issue", + (ForgeThreadKind::PullRequest, true) => "mr", + (ForgeThreadKind::PullRequest, false) => "pull", + } +} + +/// The kind a key segment names: `issue`, or the pull variant in either +/// spelling (`pull` on GitHub and Gitea, `mr` on GitLab). `None` for +/// anything else — an old-format key has no segment and parses as none. +fn segment_kind(segment: &str) -> Option { + match segment { + "issue" => Some(ForgeThreadKind::Issue), + "pull" | "mr" => Some(ForgeThreadKind::PullRequest), + _ => None, + } } /// 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)> { +/// event to reach the forge. Pre-kind keys (`gitlab:acme/w#7`) parse as +/// `None` rather than guess a sequence. +pub fn parse_key(key: &ResourceKey) -> Option<(RepoRef, ForgeThreadKind, u64)> { let (head, number) = key.as_str().rsplit_once('#')?; - let path = head.split_once(':')?.1; + let (_, rest) = head.split_once(':')?; + let (segment, path) = rest.split_once(':')?; + let kind = segment_kind(segment)?; let (owner, name) = path.rsplit_once('/')?; - Some((RepoRef::new(owner, name), number.parse().ok()?)) + Some((RepoRef::new(owner, name), kind, number.parse().ok()?)) +} + +/// The thread kind a payload's `is_pr` field names. Both payload builders +/// this crate ships (the watcher's serialized [`ForgeEvent`] and the claw's +/// triaged notification) carry `is_pr` at the top level, so the key +/// function reads the wire field instead of redefining the payload. +fn thread_kind(is_pr: bool) -> ForgeThreadKind { + if is_pr { + ForgeThreadKind::PullRequest + } else { + ForgeThreadKind::Issue + } } /// The payload a watcher builds for every event: just the coordinates the @@ -56,24 +107,29 @@ pub fn parse_key(key: &ResourceKey) -> Option<(RepoRef, u64)> { struct KeyParts { repo: RepoRef, number: u64, + /// The thread kind, in its wire spelling. No default: a payload + /// without it cannot name a GitLab sequence, and guessing `issue` + /// would rebuild the ambiguity this key shape exists to prevent — + /// such a payload lands on the unparsable key instead. + is_pr: bool, } /// 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}:"); + let source = source.to_owned(); dispatcher.register_source( - source.to_owned(), + source.clone(), Arc::new(move |event: &Event| { match serde_json::from_value::(event.payload.clone()) { Ok(parts) => { - ResourceKey::from(format!("{prefix}{}#{}", parts.repo.path(), parts.number)) + resource_key(&source, &parts.repo, thread_kind(parts.is_pr), 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)), + Err(_) => ResourceKey::from(format!("{source}:unparsable#{}", event.id)), } }), ); @@ -82,13 +138,11 @@ pub fn register_forge(dispatcher: &Dispatcher, source: &str) { /// The `KeyFn` [`register_forge`] installs, for embedders wiring the /// source themselves. pub fn forge_key_fn(source: &str) -> Arc { - let prefix = format!("{source}:"); + let source = source.to_owned(); Arc::new( move |event: &Event| match serde_json::from_value::(event.payload.clone()) { - Ok(parts) => { - ResourceKey::from(format!("{prefix}{}#{}", parts.repo.path(), parts.number)) - } - Err(_) => ResourceKey::from(format!("{prefix}unparsable#{}", event.id)), + Ok(parts) => resource_key(&source, &parts.repo, thread_kind(parts.is_pr), parts.number), + Err(_) => ResourceKey::from(format!("{source}:unparsable#{}", event.id)), }, ) } @@ -167,16 +221,85 @@ mod tests { #[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))); + let key = resource_key("github", &repo, ForgeThreadKind::Issue, 142); + assert_eq!(key.as_str(), "github:issue:acme/widgets#142"); + assert_eq!(parse_key(&key), Some((repo, ForgeThreadKind::Issue, 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))); + let key = resource_key("gitlab", &repo, ForgeThreadKind::Issue, 7); + assert_eq!(parse_key(&key), Some((repo, ForgeThreadKind::Issue, 7))); + } + + #[test] + fn a_gitlab_issue_and_merge_request_produce_distinct_keys() { + // GitLab numbers the two sequences independently: iid 7 exists + // twice, and the keys must not collapse them onto one session. + let repo = RepoRef::new("acme", "widgets"); + let issue = resource_key("gitlab", &repo, ForgeThreadKind::Issue, 7); + let mr = resource_key("gitlab", &repo, ForgeThreadKind::PullRequest, 7); + assert_eq!(issue.as_str(), "gitlab:issue:acme/widgets#7"); + assert_eq!(mr.as_str(), "gitlab:mr:acme/widgets#7"); + assert_ne!(issue, mr); + assert_eq!( + parse_key(&mr), + Some((repo, ForgeThreadKind::PullRequest, 7)) + ); + } + + #[test] + fn a_github_pull_request_key_spells_the_pull_segment() { + let repo = RepoRef::new("acme", "widgets"); + let pull = resource_key("github", &repo, ForgeThreadKind::PullRequest, 142); + assert_eq!(pull.as_str(), "github:pull:acme/widgets#142"); + assert_eq!( + parse_key(&pull), + Some((repo, ForgeThreadKind::PullRequest, 142)) + ); + } + + #[test] + fn a_pre_kind_key_parses_as_none_instead_of_guessing() { + assert_eq!(parse_key(&ResourceKey::from("gitlab:acme/widgets#7")), None); + } + + #[test] + fn the_key_fn_separates_issue_and_pull_payloads() { + let event = |is_pr: bool, number: u64| Event { + id: format!("gitlab:thread:{number}"), + source: "gitlab".to_owned(), + payload: serde_json::to_value(ForgeEvent { + id: format!("gitlab:thread:{number}@2026-10-08T00:00:00Z"), + kind: crate::EventKind::Comment, + repo: RepoRef::new("acme", "widgets"), + number, + is_pr, + actor: None, + body: None, + url: None, + timestamp: Timestamp::parse("2026-10-08T00:00:00Z").expect("parses"), + raw: serde_json::json!({}), + }) + .expect("a forge event serializes"), + received_at: 0, + }; + let key_fn = forge_key_fn("gitlab"); + assert_eq!( + key_fn(&event(false, 7)).as_str(), + "gitlab:issue:acme/widgets#7" + ); + assert_eq!(key_fn(&event(true, 7)).as_str(), "gitlab:mr:acme/widgets#7"); + // A payload without the kind cannot name a sequence: the + // unparsable key keeps it off every thread's lane. + let key = key_fn(&Event { + id: "foreign".to_owned(), + source: "gitlab".to_owned(), + payload: serde_json::json!({ "repo": {"owner": "acme", "name": "widgets"}, "number": 7 }), + received_at: 0, + }); + assert_eq!(key.as_str(), "gitlab:unparsable#foreign"); } #[test] diff --git a/integrations/rhema-forge-integration/tests/gitea.rs b/integrations/rhema-forge-integration/tests/gitea.rs index d7e7849..24b93b9 100644 --- a/integrations/rhema-forge-integration/tests/gitea.rs +++ b/integrations/rhema-forge-integration/tests/gitea.rs @@ -2,7 +2,8 @@ //! wiremock server. Zero network. use rhema_forge_integration::{ - EventKind, Forge, ForgeError, GiteaForge, NotificationReason, RepoRef, Timestamp, + EventKind, Forge, ForgeError, ForgeThreadKind, GiteaForge, NotificationReason, RepoRef, + Timestamp, }; use serde_json::{Value, json}; use wiremock::matchers::{header, method, path, query_param}; @@ -311,7 +312,12 @@ async fn posting_a_comment_uses_the_token_header_and_the_issues_endpoint() { .await; forge(&server) - .comment(&RepoRef::new("acme", "widgets"), 4, "on it") + .comment( + &RepoRef::new("acme", "widgets"), + ForgeThreadKind::Issue, + 4, + "on it", + ) .await .expect("comment posts"); @@ -360,7 +366,7 @@ async fn snapshot_reads_prs_through_the_pulls_row() { let repo = RepoRef::new("acme", "widgets"); let issue = forge(&server) - .snapshot(&repo, 7) + .snapshot(&repo, ForgeThreadKind::Issue, 7) .await .expect("issue snapshot"); assert_eq!(issue.state, rhema_forge_integration::ThreadState::Open); @@ -368,7 +374,7 @@ async fn snapshot_reads_prs_through_the_pulls_row() { assert_eq!(issue.labels, vec!["bug".to_owned()]); let pull = forge(&server) - .snapshot(&repo, 3) + .snapshot(&repo, ForgeThreadKind::PullRequest, 3) .await .expect("pull snapshot"); assert_eq!(pull.state, rhema_forge_integration::ThreadState::Merged); @@ -387,7 +393,7 @@ async fn trailing_slashes_in_the_base_url_are_trimmed() { let forge = GiteaForge::new(format!("{}/", server.uri()), "tok"); forge - .snapshot(&RepoRef::new("acme", "widgets"), 7) + .snapshot(&RepoRef::new("acme", "widgets"), ForgeThreadKind::Issue, 7) .await .expect("the trimmed base reaches the same path"); } diff --git a/integrations/rhema-forge-integration/tests/github.rs b/integrations/rhema-forge-integration/tests/github.rs index 6129257..b678e03 100644 --- a/integrations/rhema-forge-integration/tests/github.rs +++ b/integrations/rhema-forge-integration/tests/github.rs @@ -2,7 +2,8 @@ //! wiremock server. Zero network. use rhema_forge_integration::{ - EventKind, Forge, ForgeError, GitHubForge, NotificationReason, RepoRef, Timestamp, + EventKind, Forge, ForgeError, ForgeThreadKind, GitHubForge, NotificationReason, RepoRef, + Timestamp, }; use serde_json::{Value, json}; use wiremock::matchers::{header, method, path, query_param}; @@ -306,7 +307,12 @@ async fn posting_a_comment_uses_the_bearer_header_and_the_issues_endpoint() { .await; forge(&server) - .comment(&RepoRef::new("acme", "widgets"), 7, "on it") + .comment( + &RepoRef::new("acme", "widgets"), + ForgeThreadKind::Issue, + 7, + "on it", + ) .await .expect("comment posts"); @@ -357,7 +363,7 @@ async fn snapshot_reads_prs_through_the_pulls_row() { let repo = RepoRef::new("acme", "widgets"); let issue = forge(&server) - .snapshot(&repo, 7) + .snapshot(&repo, ForgeThreadKind::Issue, 7) .await .expect("issue snapshot"); assert_eq!(issue.state, rhema_forge_integration::ThreadState::Open); @@ -366,7 +372,7 @@ async fn snapshot_reads_prs_through_the_pulls_row() { assert_eq!(issue.author.as_deref(), Some("alice")); let pull = forge(&server) - .snapshot(&repo, 8) + .snapshot(&repo, ForgeThreadKind::PullRequest, 8) .await .expect("pull snapshot"); assert_eq!(pull.state, rhema_forge_integration::ThreadState::Merged); @@ -385,7 +391,7 @@ async fn trailing_slashes_in_the_base_url_are_trimmed() { let forge = GitHubForge::new("tok").with_base_url(format!("{}/", server.uri())); forge - .snapshot(&RepoRef::new("acme", "widgets"), 7) + .snapshot(&RepoRef::new("acme", "widgets"), ForgeThreadKind::Issue, 7) .await .expect("the trimmed base reaches the same path"); } diff --git a/integrations/rhema-forge-integration/tests/gitlab.rs b/integrations/rhema-forge-integration/tests/gitlab.rs index 4b16ae7..66bc0df 100644 --- a/integrations/rhema-forge-integration/tests/gitlab.rs +++ b/integrations/rhema-forge-integration/tests/gitlab.rs @@ -2,7 +2,8 @@ //! wiremock server. Zero network. use rhema_forge_integration::{ - EventKind, Forge, ForgeError, GitLabForge, NotificationReason, RepoRef, Timestamp, + EventKind, Forge, ForgeError, ForgeThreadKind, GitLabForge, NotificationReason, RepoRef, + Timestamp, }; use serde_json::{Value, json}; use wiremock::matchers::{header, method, path, query_param}; @@ -328,7 +329,7 @@ async fn poll_normalizes_state_kinds_and_notes_on_both_feeds() { } #[tokio::test] -async fn posting_a_comment_falls_back_from_issues_to_merge_requests() { +async fn an_issue_comment_posts_to_the_issues_path() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/v4/projects/acme%2Fwidgets/issues/4/notes")) @@ -336,46 +337,72 @@ async fn posting_a_comment_falls_back_from_issues_to_merge_requests() { .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") + .comment( + &RepoRef::new("acme", "widgets"), + ForgeThreadKind::Issue, + 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"); + // One request, to the named sequence only: the merge-requests path + // is never probed for an issue iid. + assert_eq!(received.len(), 1); + assert_eq!( + received[0].url.path(), + "/api/v4/projects/acme%2Fwidgets/issues/4/notes" + ); + 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_prefers_issues_and_falls_back_to_merge_requests() { +async fn a_merge_request_comment_posts_to_the_merge_requests_path() { + let server = MockServer::start().await; + // Only the merge-requests path is mounted: an issues-first probe + // would 404 here and fail the test, which is the point — the kind + // must route outright, with no fallback guessing. + Mock::given(method("POST")) + .and(path( + "/api/v4/projects/acme%2Fwidgets/merge_requests/9/notes", + )) + .and(header("private-token", "tok")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "id": 43 }))) + .mount(&server) + .await; + + forge(&server) + .comment( + &RepoRef::new("acme", "widgets"), + ForgeThreadKind::PullRequest, + 9, + "on the MR", + ) + .await + .expect("the MR comment posts"); + + let received = server + .received_requests() + .await + .expect("request recording is on"); + assert_eq!(received.len(), 1); + assert_eq!( + received[0].url.path(), + "/api/v4/projects/acme%2Fwidgets/merge_requests/9/notes" + ); + let body: Value = serde_json::from_slice(&received[0].body).expect("json body"); + assert_eq!(body, json!({ "body": "on the MR" })); +} + +#[tokio::test] +async fn an_issue_snapshot_reads_the_issues_path() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/api/v4/projects/acme%2Fwidgets/issues/7")) @@ -385,13 +412,32 @@ async fn snapshot_prefers_issues_and_falls_back_to_merge_requests() { ) .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; + + let issue = forge(&server) + .snapshot(&RepoRef::new("acme", "widgets"), ForgeThreadKind::Issue, 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 received = server + .received_requests() + .await + .expect("request recording is on"); + assert_eq!(received.len(), 1); + assert_eq!( + received[0].url.path(), + "/api/v4/projects/acme%2Fwidgets/issues/7" + ); +} + +#[tokio::test] +async fn a_merge_request_snapshot_reads_the_merge_requests_path() { + let server = MockServer::start().await; + // Only the merge-requests path is mounted: the issues path would 404 + // and a fallback would still pass — so the request count pins the + // no-fallback behavior. Mock::given(method("GET")) .and(path("/api/v4/projects/acme%2Fwidgets/merge_requests/3")) .respond_with(ResponseTemplate::new(200).set_body_json(mr_row( @@ -405,22 +451,27 @@ async fn snapshot_prefers_issues_and_falls_back_to_merge_requests() { .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) + .snapshot( + &RepoRef::new("acme", "widgets"), + ForgeThreadKind::PullRequest, + 3, + ) .await .expect("mr snapshot"); assert_eq!(mr.state, rhema_forge_integration::ThreadState::Merged); assert!(mr.is_pr); assert_eq!(mr.title, "fix crash"); + + let received = server + .received_requests() + .await + .expect("request recording is on"); + assert_eq!(received.len(), 1); + assert_eq!( + received[0].url.path(), + "/api/v4/projects/acme%2Fwidgets/merge_requests/3" + ); } #[tokio::test]