fix: complete Ticket post integration and expose targets

Preserve ordered targets in Workspace ShowTicket and native reads. Complete authenticated post-edit transport, shared dispatch, generated contracts and regression fixtures. Keep canonical content-edit delivery inputs transactional and separate from discussion posts.
This commit is contained in:
2026-10-14 14:49:20 +09:00
parent 8611078058
commit da378fc902
22 changed files with 1311 additions and 178 deletions
@@ -37,6 +37,14 @@ struct ModelTicketQueryItem {
merge_request: Option<ModelMergeRequest>,
}
#[derive(Debug, serde::Deserialize, Serialize)]
struct ModelTicketTarget {
repository_key: String,
// Keep an unspecified selector explicit in the model-facing read contract.
ref_selector: Option<String>,
access: ticket::TicketTargetAccess,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail {
ticket: String,
@@ -45,6 +53,7 @@ pub(super) struct ModelTicketDetail {
body: String,
body_truncated: bool,
initial_post: ModelTicketEvent,
targets: Vec<ModelTicketTarget>,
created_by: Option<String>,
state: ticket::TicketState,
closure_operation_key: Option<String>,
@@ -351,6 +360,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
root.get("initial_post")
.ok_or("Ticket detail response is missing initial_post")?,
)?,
targets: typed_field(root, "targets")?,
created_by: optional_string(root, "created_by")?,
state: typed_field(root, "state")?,
closure_operation_key: typed_field(root, "closure_operation_key")?,
@@ -855,7 +865,7 @@ mod tests {
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
for state in ["open", "closed"] {
let projected = project_ticket_detail(json!({
"resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", "body_truncated": false, "initial_post": post_fixture(true, "Decision"),
"resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Decision"),
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"state": state, "events": [post_fixture(false, "No Git needed")],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
@@ -898,7 +908,7 @@ mod tests {
});
let response = json!({
"assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}],
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "body_truncated": false, "initial_post": post_fixture(true, "Requirements"),
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Requirements"),
"state": "open", "closure_operation_key": "close-1", "current_closure": null,
"closure_history": history, "legacy_handoff": legacy,
"events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [],
@@ -1049,7 +1059,7 @@ mod tests {
"content_digest": "ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Queued Submit",
"body": "Body", "body_truncated": false, "initial_post": post_fixture(true, "Body"),
"body": "Body", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Body"),
"state": "open",
"readiness": null,
"priority": "P2",
@@ -1153,7 +1163,7 @@ mod tests {
"content_digest": "current-ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Source evidence",
"body": "Body", "body_truncated": false, "initial_post": post_fixture(true, "Body"),
"body": "Body", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Body"),
"state": "open",
"events": [],
"linked_objectives": [],
@@ -1337,7 +1347,7 @@ mod tests {
let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"});
let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]);
let detail = json!({
"resource_key":"T-739", "title":"Case", "body":"Requirements", "body_truncated":false, "initial_post":post_fixture(true, "Requirements"), "created_by":"workspace-user:creator", "content_digest":"content-1",
"resource_key":"T-739", "title":"Case", "body":"Requirements", "body_truncated":false, "targets":[], "initial_post":post_fixture(true, "Requirements"), "created_by":"workspace-user:creator", "content_digest":"content-1",
"state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure,
"legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]
});
+118 -3
View File
@@ -101,7 +101,7 @@ impl WorkspaceTicketReadKind {
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
}
Self::Show => {
"Show one authoritative Workspace Ticket with its content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence."
"Show one authoritative Workspace Ticket with its ordered implementation targets (repository_key, ref_selector, access), content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence. Read targets here before choosing a work repository; a null selector remains unspecified, not an inferred default."
}
}
}
@@ -2529,8 +2529,8 @@ mod tests {
);
if status == 200 {
let (previous, current) = result.unwrap();
assert_eq!(previous.meta.id, record.meta.id);
assert_eq!(current.meta.id, record.meta.id);
assert_eq!(previous.meta.id, "T-718");
assert_eq!(current.meta.id, "T-718");
} else {
assert!(result.unwrap_err().to_string().contains("409"));
}
@@ -2545,6 +2545,121 @@ mod tests {
}
}
fn target_detail_fixture(targets: Value) -> Value {
json!({
"resource_key": "T-739", "title": "Work location", "body": "Requirements",
"body_truncated": false, "content_digest": "observed-content", "targets": targets,
"initial_post": {
"sequence": 0, "event_ref": "initial", "is_initial": true,
"kind": "comment", "body": "Requirements", "body_truncated": false,
"body_digest": "a".repeat(64), "editable": true,
"author": null, "at": null, "edited_at": null, "edited_by": null
},
"state": "open", "closure_operation_key": null, "current_closure": null,
"legacy_handoff": null, "closure_history": [], "assignment_tracking": [],
"tags": [], "events": [], "linked_objectives": [], "assignments": [],
"implementation_reports": [], "merge_requests": []
})
}
#[tokio::test]
async fn orchestrator_show_and_native_read_preserve_ordered_targets() {
for targets in [
json!([]),
json!([
{"repository_key": "main", "ref_selector": "develop", "access": "read_write"},
{"repository_key": "docs", "ref_selector": null, "access": "read_only"},
{"repository_key": "archive", "ref_selector": "refs/tags/v1", "access": "read_only"}
]),
] {
let client = Arc::new(DecisionClient {
requests: Mutex::new(Vec::new()),
response: crate::worker::WorkspaceResponse {
status: 200,
body: target_detail_fixture(targets.clone()).to_string(),
},
});
let definitions =
enabled_ticket_definitions(client.clone(), TicketFeatureAccess::workflow());
let definition = definitions
.into_iter()
.find(|definition| definition().0.name == "ShowTicket")
.unwrap();
let (meta, tool) = definition();
assert!(meta.description.contains("targets"));
let output = tool
.execute(
r#"{"id":"T-739","event_limit":1}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(
ticket_tool_output_json(output)
.unwrap_or_else(|_| panic!("Ticket read returned invalid JSON"))["targets"],
targets
);
let native =
native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let read = native
.into_iter()
.find(|tool| tool.projection.operation == "read")
.unwrap();
let output = read
.tool
.execute(
r#"{"id":"T-739","event_cursor":"10"}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(
ticket_tool_output_json(output)
.unwrap_or_else(|_| panic!("Ticket read returned invalid JSON"))["targets"],
targets
);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert!(
requests
.iter()
.all(|request| request.path == "/api/w/workspace/tickets/T-739/show")
);
}
}
#[tokio::test]
async fn show_rejects_missing_or_malformed_targets_instead_of_reporting_unassigned() {
for malformed in [
None,
Some(Value::Null),
Some(json!({})),
Some(json!([{"repository_key":"main","access":"write"}])),
] {
let mut detail = target_detail_fixture(json!([]));
if let Some(value) = malformed {
detail["targets"] = value;
} else {
detail.as_object_mut().unwrap().remove("targets");
}
let client = Arc::new(DecisionClient {
requests: Mutex::new(Vec::new()),
response: crate::worker::WorkspaceResponse {
status: 200,
body: detail.to_string(),
},
});
let (_, tool) =
workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Show)();
let error = tool
.execute(r#"{"id":"T-739"}"#, ToolExecutionContext::direct())
.await
.unwrap_err();
assert!(error.to_string().contains("targets"), "{error}");
}
}
#[test]
fn query_schema_allows_only_open_and_closed_case_states() {
let client = decision_client(200, TicketState::Open);
@@ -139,7 +139,7 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations()
assert_eq!(body[field], input[field]);
}
}
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "body_truncated":false, "targets":[], "initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Body","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null}, "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
let client = recording(detail);
let (_, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();