fix(forge,claw): forge keys separate the issue from the pull

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).
This commit is contained in:
vhaudiquet committed 2026-11-05 19:03:52 +01:00
1 parent 10efe85f38
commit 00adf092ff
15 files changed
+478 -178

No files matched your search

+35 -18
View File
@@ -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");
}
}
+39 -19
View File
@@ -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<String> {
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.
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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);