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

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