feat(worker): project merge requests as native WIP objects
This commit is contained in:
@@ -1558,6 +1558,30 @@ where
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let engine = worker.engine_mut();
|
||||
|
||||
if feature_config.merge_request.any() && wip_mode {
|
||||
let feature_route = wip_mount_registry
|
||||
.allocate_feature_route("merge-request")
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("allocate Merge Request WIP route: {error}"),
|
||||
)
|
||||
})?;
|
||||
crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip(
|
||||
&mut wip_mount_registry,
|
||||
workspace_client.clone(),
|
||||
feature_config.merge_request,
|
||||
wip_permissions.clone(),
|
||||
&feature_route,
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount Merge Request WIP projection: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if feature_config.ticket.enabled && wip_mode {
|
||||
let ticket_access = crate::feature::builtin::ticket::TicketFeatureAccess {
|
||||
authoring: feature_config.ticket.authoring,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2907,6 +2907,394 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_request_native_runtime_covers_coder_reviewer_and_orchestrator_authority() {
|
||||
use crate::worker::{ReviewerChildWorkspaceClient, ReviewerContext};
|
||||
use manifest::MergeRequestFeatureConfig;
|
||||
|
||||
let coder_client = Arc::new(ScriptedWorkspaceClient::new([
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({
|
||||
"workspace_id": "workspace",
|
||||
"merge_request_id": "MR-1",
|
||||
"repository_key": "main",
|
||||
"state": "open",
|
||||
"selector_from": "work/T-685-native-mr",
|
||||
"selector_to": "develop",
|
||||
"ticket_ids": ["ticket-internal"],
|
||||
"created_at": "2026-10-03T00:00:00Z",
|
||||
"updated_at": "2026-10-03T00:00:00Z",
|
||||
"thread": []
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({
|
||||
"workspace_id": "workspace",
|
||||
"merge_request_id": "MR-1",
|
||||
"repository_key": "main",
|
||||
"state": "open",
|
||||
"selector_from": "work/T-685-native-mr",
|
||||
"selector_to": "develop",
|
||||
"ticket_ids": ["ticket-internal"],
|
||||
"created_at": "2026-10-03T00:00:00Z",
|
||||
"updated_at": "2026-10-03T00:00:00Z",
|
||||
"thread": [],
|
||||
"source": {"status": "known", "ref": "source-1", "observed_at": "2026-10-03T00:00:00Z"},
|
||||
"target": {"status": "known", "ref": "target-1", "observed_at": "2026-10-03T00:00:00Z"},
|
||||
"linked_tickets": [{"ticket_id": "ticket-internal", "key": "T-685"}]
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
]));
|
||||
let coder_config = MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
open: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut coder_engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
|
||||
for definition in crate::feature::builtin::merge_request::enabled_merge_request_definitions(
|
||||
coder_client.clone(),
|
||||
coder_config,
|
||||
) {
|
||||
coder_engine.register_tool(definition);
|
||||
}
|
||||
let mut coder_registry = WipMountRegistry::new();
|
||||
let route = coder_registry
|
||||
.allocate_feature_route("merge-request")
|
||||
.unwrap();
|
||||
crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip(
|
||||
&mut coder_registry,
|
||||
coder_client.clone(),
|
||||
coder_config,
|
||||
None,
|
||||
&route,
|
||||
)
|
||||
.unwrap();
|
||||
let coder = install_wip_mode_with_mounts(
|
||||
&mut coder_engine,
|
||||
None,
|
||||
"coder-worker".into(),
|
||||
coder_registry,
|
||||
)
|
||||
.unwrap();
|
||||
let discovered = coder.discover("/".into(), 3, false).await.unwrap();
|
||||
let discovered = discovered.content.unwrap();
|
||||
assert!(discovered.contains("/features/merge-request/merge-requests"));
|
||||
assert!(!discovered.contains("/tools/OpenMergeRequest"));
|
||||
assert!(!discovered.contains("/tools/ShowMergeRequest"));
|
||||
coder
|
||||
.inspect("yoi.merge-request/collection/v1".into(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let collection = "/features/merge-request/merge-requests";
|
||||
let opened = coder
|
||||
.call(
|
||||
collection.into(),
|
||||
"yoi.merge-request/collection/v1".into(),
|
||||
"open".into(),
|
||||
json!({
|
||||
"ticket": "T-685",
|
||||
"repository_key": "main",
|
||||
"selector_from": "work/T-685-native-mr",
|
||||
"selector_to": "develop"
|
||||
}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
opened
|
||||
.content
|
||||
.unwrap()
|
||||
.contains(&format!("{collection}/MR-1"))
|
||||
);
|
||||
let item = format!("{collection}/MR-1");
|
||||
coder.discover(item.clone(), 0, false).await.unwrap();
|
||||
coder
|
||||
.inspect("yoi.merge-request/item/v1".into(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
coder
|
||||
.call(
|
||||
item.clone(),
|
||||
"yoi.merge-request/item/v1".into(),
|
||||
"read".into(),
|
||||
json!({"after": 7, "limit": 25}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let coder_requests = coder_client.requests();
|
||||
assert_eq!(coder_requests.len(), 2);
|
||||
assert_eq!(
|
||||
coder_requests[0].path,
|
||||
"/api/w/workspace/tickets/T-685/merge-request"
|
||||
);
|
||||
let open_body: Json =
|
||||
serde_json::from_str(coder_requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(open_body["repository_key"], "main");
|
||||
assert_eq!(open_body["selector_from"], "work/T-685-native-mr");
|
||||
assert_eq!(open_body["selector_to"], "develop");
|
||||
assert_eq!(
|
||||
coder_requests[1].path,
|
||||
"/api/w/workspace/merge-requests/MR-1?after=7&limit=25"
|
||||
);
|
||||
|
||||
let reviewer_inner = Arc::new(ScriptedWorkspaceClient::new([
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({
|
||||
"event_id": "review-1",
|
||||
"sequence": 2,
|
||||
"request_event_id": "request-1",
|
||||
"subject_ref": "source-1",
|
||||
"ticket_item_revision": "ticket-rev-1",
|
||||
"ticket_merge_request_subjects": [{"merge_request_id": "MR-1", "subject_ref": "source-1"}],
|
||||
"decision": "approve",
|
||||
"body": "approved",
|
||||
"findings": [],
|
||||
"reviewer": {"runtime_id": "runtime", "worker_id": "reviewer"},
|
||||
"created_at": "2026-10-03T00:00:00Z"
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
]));
|
||||
let reviewer_client: Arc<dyn crate::worker::WorkspaceClient> =
|
||||
Arc::new(ReviewerChildWorkspaceClient::new(
|
||||
reviewer_inner.clone(),
|
||||
ReviewerContext {
|
||||
ticket_id: "T-685".into(),
|
||||
merge_request_id: "MR-1".into(),
|
||||
},
|
||||
"review-capability-secret".into(),
|
||||
));
|
||||
let reviewer_config = MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
review: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut reviewer_engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
|
||||
for definition in crate::feature::builtin::merge_request::enabled_merge_request_definitions(
|
||||
reviewer_client.clone(),
|
||||
reviewer_config,
|
||||
) {
|
||||
reviewer_engine.register_tool(definition);
|
||||
}
|
||||
let mut reviewer_registry = WipMountRegistry::new();
|
||||
let route = reviewer_registry
|
||||
.allocate_feature_route("merge-request")
|
||||
.unwrap();
|
||||
crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip(
|
||||
&mut reviewer_registry,
|
||||
reviewer_client,
|
||||
reviewer_config,
|
||||
None,
|
||||
&route,
|
||||
)
|
||||
.unwrap();
|
||||
let reviewer = install_wip_mode_with_mounts(
|
||||
&mut reviewer_engine,
|
||||
None,
|
||||
"reviewer-worker".into(),
|
||||
reviewer_registry,
|
||||
)
|
||||
.unwrap();
|
||||
reviewer.discover(item.clone(), 0, false).await.unwrap();
|
||||
reviewer
|
||||
.inspect("yoi.merge-request/item/v1".into(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
reviewer
|
||||
.call(
|
||||
item.clone(),
|
||||
"yoi.merge-request/item/v1".into(),
|
||||
"review".into(),
|
||||
json!({"decision": "approve", "body": "approved", "findings": []}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let reviewer_requests = reviewer_inner.requests();
|
||||
assert_eq!(reviewer_requests.len(), 1);
|
||||
assert_eq!(
|
||||
reviewer_requests[0].path,
|
||||
"/api/w/workspace/merge-requests/MR-1/reviews"
|
||||
);
|
||||
let review_body: Json =
|
||||
serde_json::from_str(reviewer_requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(review_body["capability_token"], "review-capability-secret");
|
||||
|
||||
let orchestrator_client = Arc::new(ScriptedWorkspaceClient::new([
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({
|
||||
"ready": true,
|
||||
"blockers": [],
|
||||
"subject_ref": "source-1",
|
||||
"review": {"event_id": "review-1", "decision": "approve"}
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({
|
||||
"event_id": "merge-1",
|
||||
"approved_source_ref": "source-1",
|
||||
"target_ref_before": "target-1",
|
||||
"target_ref_after": "result-1"
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({
|
||||
"operation_id": "complete-ticket-1",
|
||||
"ticket_id": "ticket-internal",
|
||||
"item_revision": "ticket-rev-1",
|
||||
"merge_request_ids": ["MR-1"],
|
||||
"requirement_approval_event_id": "review-1"
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
]));
|
||||
let orchestrator_config = MergeRequestFeatureConfig {
|
||||
readiness_check: true,
|
||||
complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut orchestrator_engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
|
||||
for definition in crate::feature::builtin::merge_request::enabled_merge_request_definitions(
|
||||
orchestrator_client.clone(),
|
||||
orchestrator_config,
|
||||
) {
|
||||
orchestrator_engine.register_tool(definition);
|
||||
}
|
||||
let mut orchestrator_registry = WipMountRegistry::new();
|
||||
let route = orchestrator_registry
|
||||
.allocate_feature_route("merge-request")
|
||||
.unwrap();
|
||||
crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip(
|
||||
&mut orchestrator_registry,
|
||||
orchestrator_client.clone(),
|
||||
orchestrator_config,
|
||||
None,
|
||||
&route,
|
||||
)
|
||||
.unwrap();
|
||||
let orchestrator = install_wip_mode_with_mounts(
|
||||
&mut orchestrator_engine,
|
||||
None,
|
||||
"orchestrator-worker".into(),
|
||||
orchestrator_registry,
|
||||
)
|
||||
.unwrap();
|
||||
orchestrator.discover(item.clone(), 0, false).await.unwrap();
|
||||
orchestrator
|
||||
.inspect("yoi.merge-request/item/v1".into(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
orchestrator
|
||||
.call(
|
||||
item.clone(),
|
||||
"yoi.merge-request/item/v1".into(),
|
||||
"check_readiness".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
orchestrator
|
||||
.call(
|
||||
item.clone(),
|
||||
"yoi.merge-request/item/v1".into(),
|
||||
"complete".into(),
|
||||
json!({
|
||||
"operation_id": "merge-op-1",
|
||||
"approval_event_id": "review-1",
|
||||
"target_ref_before": "target-1",
|
||||
"target_ref_after": "result-1",
|
||||
"strategy": "fast_forward",
|
||||
"resolution": "clean"
|
||||
}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
orchestrator
|
||||
.discover(collection.into(), 0, false)
|
||||
.await
|
||||
.unwrap();
|
||||
orchestrator
|
||||
.inspect("yoi.merge-request/collection/v1".into(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let before_ticket_completion = orchestrator
|
||||
.host
|
||||
.projection(&item)
|
||||
.unwrap()
|
||||
.object
|
||||
.validator;
|
||||
orchestrator
|
||||
.call(
|
||||
collection.into(),
|
||||
"yoi.merge-request/collection/v1".into(),
|
||||
"complete_ticket".into(),
|
||||
json!({
|
||||
"ticket": "T-685",
|
||||
"operation_id": "complete-ticket-1",
|
||||
"item_revision": "ticket-rev-1",
|
||||
"merge_request_ids": ["MR-1"],
|
||||
"requirement_approval_event_id": "review-1"
|
||||
}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
before_ticket_completion,
|
||||
orchestrator
|
||||
.host
|
||||
.projection(&item)
|
||||
.unwrap()
|
||||
.object
|
||||
.validator,
|
||||
"Ticket completion must stale every Merge Request in its exact result set"
|
||||
);
|
||||
let stale = orchestrator
|
||||
.call(
|
||||
item,
|
||||
"yoi.merge-request/item/v1".into(),
|
||||
"check_readiness".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.expect_err("affected Merge Request observation must be stale");
|
||||
assert!(stale.to_string().contains("ValidatorMismatch"));
|
||||
let orchestrator_requests = orchestrator_client.requests();
|
||||
assert_eq!(orchestrator_requests.len(), 3);
|
||||
assert_eq!(
|
||||
orchestrator_requests[0].path,
|
||||
"/api/w/workspace/merge-requests/MR-1/readiness"
|
||||
);
|
||||
assert_eq!(
|
||||
orchestrator_requests[1].path,
|
||||
"/api/w/workspace/merge-requests/MR-1/complete"
|
||||
);
|
||||
let completion_body: Json =
|
||||
serde_json::from_str(orchestrator_requests[1].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(completion_body["approval_event_id"], "review-1");
|
||||
assert_eq!(completion_body["target_ref_before"], "target-1");
|
||||
assert_eq!(completion_body["target_ref_after"], "result-1");
|
||||
assert_eq!(
|
||||
orchestrator_requests[2].path,
|
||||
"/api/w/workspace/tickets/T-685/complete"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn objective_native_runtime_covers_discovery_and_all_backend_operations() {
|
||||
let query_response = crate::worker::WorkspaceResponse {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Merge Request native WIP projection
|
||||
|
||||
## Purpose and authority
|
||||
|
||||
When a Worker explicitly runs in `wip` mode and its Merge Request Feature has at least one enabled operation, the Worker Host projects that exact operation set as native Worldspace objects. The projection is an adapter over the existing scoped Workspace Merge Request tools and Backend routes. It does not create a second Merge Request store, provider integration path, review grant, approval rule, or completion authority.
|
||||
|
||||
The Host allocates `/features/merge-request` and the Feature mounts:
|
||||
|
||||
```text
|
||||
/features/merge-request/merge-requests
|
||||
/features/merge-request/merge-requests/<merge-request-id>
|
||||
```
|
||||
|
||||
An item identity is a single canonical path segment. Nested paths and percent-encoded target substitution do not resolve. Item operations remove `merge_request_id` from their descriptor and reconstruct it only from the selected object route. Discovery does not mint authority: every call still uses the Worker's scoped `WorkspaceClient`, the enabled Feature flags, ordinary Tool permission rules, and the Backend's Workspace, repository, assignment, provider-ref, review, and integration checks.
|
||||
|
||||
## Current Feature inventory
|
||||
|
||||
The native surface is assembled from `MergeRequestFeatureConfig`, not from a broader Backend catalog. Only an ordinary Merge Request tool that would be registered for the Worker is projected and claimed from `/tools`. The current Feature has no list/query Tool, so the native adapter does not invent one: callers can resolve an existing known MR ID as a direct item route, while a successful `open` result includes the newly created item path.
|
||||
|
||||
Collection interface `yoi.merge-request/collection/v1`:
|
||||
|
||||
| Native operation | Existing Tool | Preserved authority inputs |
|
||||
| --- | --- | --- |
|
||||
| `open` | `OpenMergeRequest` | Ticket, repository key, source selector, target selector, summary |
|
||||
| `complete_ticket` | `CompleteTicket` | Ticket, operation ID, current item revision, exact linked-MR result set, requirement approval event |
|
||||
|
||||
Item interface `yoi.merge-request/item/v1`:
|
||||
|
||||
| Native operation | Existing Tool | Preserved non-identity inputs or authority |
|
||||
| --- | --- | --- |
|
||||
| `read` | `ShowMergeRequest` | bounded thread `after` and `limit`; Backend resolves current source and target refs |
|
||||
| `check_readiness` | `CheckMergeRequestReadiness` | Backend resolves the current source subject and derives current approval/blockers |
|
||||
| `review` | `ReviewMergeRequest` | decision/body/findings plus the injected Reviewer capability, captured request event, Ticket revision, linked result snapshot, and exact candidate |
|
||||
| `complete` | `CompleteMergeRequest` | operation ID, approval event, target-before/after refs, merge strategy and conflict resolution; Backend requires Orchestrator authority |
|
||||
|
||||
Typical role surfaces are therefore:
|
||||
|
||||
- Coder: collection `open`; item `read`.
|
||||
- Reviewer: item `read` and `review`.
|
||||
- Orchestrator: collection `complete_ticket`; item `read`, `check_readiness`, and `complete`.
|
||||
|
||||
`complete_ticket` is deliberately collection-scoped because its subject is a Ticket and an exact set of linked Merge Request results rather than one route-bound MR. It keeps those identities explicit. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run.
|
||||
|
||||
## Preserved review and integration gates
|
||||
|
||||
The native projection delegates to the same ordinary tools and REST endpoints. In particular, it does not replace or weaken:
|
||||
|
||||
- Workspace and configured-repository isolation;
|
||||
- current Coder assignment and assigned-Workdir source-selector proof for opening/review requests;
|
||||
- provider resolution of the exact current source candidate;
|
||||
- ReviewRequested binding to the Ticket item revision and complete linked-MR/source snapshot;
|
||||
- injected Reviewer capability consumption and exact MR/candidate matching;
|
||||
- source movement invalidating source approval while target-only movement preserves unchanged-source review;
|
||||
- unresolved request-change and revoked/stale approval rejection;
|
||||
- current target observation, target-before/after proof, strategy, and conflict-resolution checks;
|
||||
- Orchestrator-only Merge Request and Ticket completion;
|
||||
- immutable integration/result evidence and exact linked-result-set Ticket completion.
|
||||
|
||||
The Worldspace adapter performs no Git or provider integration. A visible `complete` operation remains unusable without the ordinary Backend's authenticated online Orchestrator source and integration evidence.
|
||||
|
||||
## Bounded reads, staleness, and outcomes
|
||||
|
||||
`read` exposes the existing bounded thread page with optional `after` and a `1..=200` limit. Responses keep linked Tickets as references/data, so Ticket or Objective native projections are not prerequisites. Descriptors never expand repository diffs, unbounded thread history, credentials, or capability material.
|
||||
|
||||
A successful item read records a response fingerprint. A successful item mutation advances a local mutation generation. Opening records the returned MR and adds its native path to the result. Ticket completion invalidates every MR route named in its exact result set because releasing the assignment can change later ref observations. The common WIP Host rejects calls made with stale object/interface validators; callers must rediscover before deciding whether to call again.
|
||||
|
||||
Optional fields preserve ordinary JSON-input semantics: omission remains omission and explicit WIP unit/null remains JSON `null` for schema and permission matching. The reconstructed object is validated against the original Tool JSON Schema before the Tool executes.
|
||||
|
||||
Backend 401/403 responses map to permission denial, 404 to missing objects, and deterministic 400/409/422 authority or workflow failures to invalid arguments. Cancellation and interruption remain distinct. Transport/server failures after dispatch, malformed successful mutation responses, and identity-mismatched mutation responses are reported as unknown outcomes and must not be retried automatically.
|
||||
|
||||
Normal Tool mode is unchanged. In WIP mode, only the enabled Merge Request compatibility tools represented by these native objects are suppressed, preventing duplicate native and `/tools` publication.
|
||||
Reference in New Issue
Block a user