feat(ticket): decouple audited lifecycle decisions from MR integration

T-718: unify state, completion, close and reopen with explicit CAS and operation receipts; keep MR integration proofs independent. Queue only the explicitly selected Ticket and fence target edits against active Worker grants. Align REST, Worker/WIP, evidence projection and generated contracts.

Validation: root cargo check; full ticket, merge-request, client, worker, yoi-workspace-server and server-api tests; server-api typescript feature; OpenAPI and six generated API checkers; Svelte check and 36 narrow Web tests; fmt and diff checks.
This commit is contained in:
2026-10-08 19:12:39 +09:00
parent e939cd4e4b
commit df390f02e9
25 changed files with 4596 additions and 2292 deletions
@@ -41,13 +41,12 @@ fn workflow_instruction() -> FeatureInstructionDeclaration {
.expect("static Merge Request workflow instruction declaration is valid")
}
const ALL_KINDS: [Kind; 6] = [
const ALL_KINDS: [Kind; 5] = [
Kind::Show,
Kind::Open,
Kind::Review,
Kind::Readiness,
Kind::Complete,
Kind::CompleteTicket,
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -56,7 +55,6 @@ enum Kind {
Readiness,
Open,
Complete,
CompleteTicket,
Review,
}
#[derive(Clone)]
@@ -98,14 +96,6 @@ struct CompleteMergeRequestInput {
resolution: MergeResolutionInput,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct CompleteTicketInput {
ticket: String,
operation_id: String,
item_revision: String,
merge_request_ids: Vec<String>,
requirement_approval_event_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum MergeStrategyInput {
FastForward,
@@ -150,7 +140,7 @@ impl Kind {
Self::Open => config.open,
Self::Review => config.review,
Self::Readiness => config.readiness_check,
Self::Complete | Self::CompleteTicket => config.complete,
Self::Complete => config.complete,
}
}
@@ -160,7 +150,6 @@ impl Kind {
Self::Readiness => "CheckMergeRequestReadiness",
Self::Open => "OpenMergeRequest",
Self::Complete => "CompleteMergeRequest",
Self::CompleteTicket => "CompleteTicket",
Self::Review => "ReviewMergeRequest",
}
}
@@ -170,16 +159,12 @@ impl Kind {
Self::Readiness => json!(schemars::schema_for!(MergeRequestInput)),
Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
Self::CompleteTicket => json!(schemars::schema_for!(CompleteTicketInput)),
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
}
}
fn mutating(self) -> bool {
matches!(
self,
Self::Open | Self::Review | Self::Complete | Self::CompleteTicket
)
matches!(self, Self::Open | Self::Review | Self::Complete)
}
}
#[async_trait]
@@ -240,23 +225,6 @@ impl Tool for MergeRequestTool {
),
)
}
Kind::CompleteTicket => {
let v: CompleteTicketInput = parse(input)?;
nonempty_named("ticket", &v.ticket)?;
(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{ws}/tickets/{}/complete",
encode_path_segment(&v.ticket)
),
Some(json!({
"operation_id": v.operation_id,
"item_revision": v.item_revision,
"merge_request_ids": v.merge_request_ids,
"requirement_approval_event_id": v.requirement_approval_event_id,
})),
)
}
Kind::Review => {
let v: ReviewMergeRequestInput = parse(input)?;
let ctx = self.client.reviewer_context().ok_or_else(|| {
@@ -499,11 +467,6 @@ fn native_merge_request_operation(kind: Kind) -> NativeMergeRequestOperation {
surface: NativeMergeRequestSurface::Item,
identity_field: Some("merge_request_id"),
},
Kind::CompleteTicket => NativeMergeRequestOperation {
operation: "complete_ticket",
surface: NativeMergeRequestSurface::Collection,
identity_field: None,
},
}
}
@@ -584,8 +547,8 @@ pub fn mount_workspace_http_merge_request_wip(
let collection_descriptor = merge_request_descriptor(
&collection_tools,
"Open Merge Requests and complete Tickets through existing scoped authority",
"Only collection-scoped Merge Request Feature operations enabled for this Worker are published. Opening retains repository and selector inputs; Ticket completion retains its full guarded result-set inputs.",
"Open Merge Requests through existing scoped authority",
"Only collection-scoped Merge Request Feature operations enabled for this Worker are published. Opening retains repository and selector inputs; Ticket completion is a separate Ticket operation.",
)?;
registry.mount(WipProjection {
route: collection_route.clone(),
@@ -617,7 +580,7 @@ pub fn mount_workspace_http_merge_request_wip(
let item_descriptor = merge_request_descriptor(
&item_tools,
"Inspect and operate on the Merge Request bound to this object route",
"The subject Merge Request identity comes exclusively from the target route. Repository selectors, exact review capability/candidate, Ticket result snapshot, approval, target refs, strategy, and resolution remain ordinary Backend-validated preconditions.",
"The subject Merge Request identity comes exclusively from the target route. Repository selectors, exact review capability/candidate, approval, target refs, strategy, and resolution remain ordinary Backend-validated preconditions.",
)?;
registry.mount_dynamic(WipDynamicMount {
collection_route: collection_route.clone(),
@@ -665,18 +628,6 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
.get(operation)
.ok_or_else(merge_request_operation_not_found)?;
let input = native_merge_request_input(arguments, None, tool.projection.identity_field)?;
let affected_merge_requests = if tool.kind == Kind::CompleteTicket {
input
.get("merge_request_ids")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
} else {
Vec::new()
};
let output =
execute_native_merge_request_tool(tool, &self.permissions, input, None, context)
.await?;
@@ -699,18 +650,6 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler {
);
}
}
Kind::CompleteTicket => {
for reference in affected_merge_requests {
if is_merge_request_route_reference(&reference) {
record_merge_request_observation(
&self.revisions,
&reference,
&response,
true,
);
}
}
}
_ => {}
}
Ok(WipOperationOutput::native(
@@ -1148,9 +1087,6 @@ pub fn description(n: &str) -> Option<&'static str> {
"CompleteMergeRequest" => Some(
"Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.",
),
"CompleteTicket" => Some(
"Complete the Ticket only after reviewing its current item revision and exact linked Merge Request result set; this ends unfinished work atomically while retaining responsibility and assignment history.",
),
"ReviewMergeRequest" => Some(
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
),
@@ -1220,7 +1156,6 @@ mod tests {
"CheckMergeRequestReadiness",
"OpenMergeRequest",
"CompleteMergeRequest",
"CompleteTicket",
"ReviewMergeRequest",
] {
assert!(description(name).is_some(), "missing operation {name}");
@@ -1369,8 +1304,7 @@ mod tests {
[
"ShowMergeRequest",
"CheckMergeRequestReadiness",
"CompleteMergeRequest",
"CompleteTicket"
"CompleteMergeRequest"
]
);
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
@@ -1427,7 +1361,6 @@ mod tests {
(NativeMergeRequestSurface::Item, "read"),
(NativeMergeRequestSurface::Item, "check_readiness"),
(NativeMergeRequestSurface::Item, "complete"),
(NativeMergeRequestSurface::Collection, "complete_ticket"),
]
);
@@ -176,6 +176,8 @@ struct ModelMergeRequest {
#[derive(Debug, Serialize)]
struct ModelTicketEvidence {
/// MR evidence is inapplicable without a linked MR, independent of Ticket decisions.
integration_applicable: bool,
has_merge_request: bool,
has_current_subject_ref: bool,
has_review_request: bool,
@@ -535,8 +537,10 @@ fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
let evidence = object(value, "Ticket evidence")?;
Ok(ModelTicketEvidence {
has_merge_request: bool_field(evidence, "has_merge_request")?,
let has_merge_request = bool_field(evidence, "has_merge_request")?;
let projected = ModelTicketEvidence {
integration_applicable: has_merge_request,
has_merge_request,
has_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?,
has_review_request: bool_field(evidence, "has_review_request")?,
has_commit: bool_field(evidence, "has_commit")?,
@@ -546,7 +550,20 @@ fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
missing: string_array(evidence, "missing")?,
})
};
if !has_merge_request {
// Empty MR sets are neither missing evidence nor vacuous approval.
return Ok(ModelTicketEvidence {
review_status: None,
approved_current_subject: false,
review_after_rescope: false,
unresolved_request_changes: false,
complete_for_integration: false,
missing: Vec::new(),
..projected
});
}
Ok(projected)
}
fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
@@ -677,6 +694,38 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
let projected = project_ticket_detail(json!({
"resource_key": "T-718", "item_revision": "rev-1", "title": "No Git required", "body": "Decision",
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null,
"evidence": {
"has_merge_request": false, "has_current_subject_ref": false, "has_review_request": false, "has_commit": false,
"review_status": "approved", "approved_current_subject": true, "review_after_rescope": true,
"unresolved_request_changes": false, "complete_for_integration": true, "missing": ["merge_request"]
}
})).unwrap();
let projected = serde_json::to_value(projected).unwrap();
assert_eq!(projected["state"], state);
assert_eq!(projected["thread"][0]["kind"], "completed");
assert_eq!(projected["evidence"]["integration_applicable"], false);
assert_eq!(projected["evidence"]["review_status"], Value::Null);
assert_eq!(projected["evidence"]["approved_current_subject"], false);
assert_eq!(projected["evidence"]["complete_for_integration"], false);
assert_eq!(projected["evidence"]["missing"], json!([]));
}
}
#[test]
fn objective_projection_exposes_only_resource_references() {
let projected = project_objective_detail(json!({
File diff suppressed because it is too large Load Diff
+7 -51
View File
@@ -6119,17 +6119,6 @@ mod tests {
})
.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,
@@ -6202,51 +6191,22 @@ mod tests {
.inspect("yoi.merge-request/collection/v1".into(), false)
.await
.unwrap();
let before_ticket_completion = orchestrator
.host
.projection(&item)
.unwrap()
.object
.validator;
orchestrator
let rejected = 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"));
.expect_err("Ticket completion is not a Merge Request operation");
assert!(
rejected.to_string().contains("operation is not declared"),
"{rejected}"
);
let orchestrator_requests = orchestrator_client.requests();
assert_eq!(orchestrator_requests.len(), 3);
assert_eq!(orchestrator_requests.len(), 2);
assert_eq!(
orchestrator_requests[0].path,
"/api/w/workspace/merge-requests/MR-1/readiness"
@@ -6260,10 +6220,6 @@ mod tests {
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]