feat: unify Ticket posts and simplify detail layout
Checkpoint requested before the targets read-tool correction. Includes canonical initial posts, creator/edit metadata, migration and UI work, and removal of delivery diagnostics from Ticket details.\n\nIntegration remains incomplete: the Client post-edit trait implementation and server post routes/dispatch are not yet connected; cargo check currently fails. This commit does not assert validation or delivery completion.
This commit is contained in:
@@ -43,6 +43,9 @@ pub(super) struct ModelTicketDetail {
|
||||
content_digest: String,
|
||||
title: String,
|
||||
body: String,
|
||||
body_truncated: bool,
|
||||
initial_post: ModelTicketEvent,
|
||||
created_by: Option<String>,
|
||||
state: ticket::TicketState,
|
||||
closure_operation_key: Option<String>,
|
||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
||||
@@ -131,9 +134,17 @@ struct ModelTicketEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tag_changes: Option<ModelTicketTagChange>,
|
||||
sequence: usize,
|
||||
event_ref: String,
|
||||
is_initial: bool,
|
||||
kind: String,
|
||||
body: Option<String>,
|
||||
body_truncated: bool,
|
||||
body_digest: String,
|
||||
editable: bool,
|
||||
author: Option<String>,
|
||||
created_at: Option<String>,
|
||||
edited_at: Option<String>,
|
||||
edited_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
@@ -335,6 +346,12 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
content_digest: string_field(root, "content_digest")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
body_truncated: bool_field(root, "body_truncated")?,
|
||||
initial_post: project_ticket_event(
|
||||
root.get("initial_post")
|
||||
.ok_or("Ticket detail response is missing initial_post")?,
|
||||
)?,
|
||||
created_by: optional_string(root, "created_by")?,
|
||||
state: typed_field(root, "state")?,
|
||||
closure_operation_key: typed_field(root, "closure_operation_key")?,
|
||||
current_closure: typed_field(root, "current_closure")?,
|
||||
@@ -471,6 +488,14 @@ fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
|
||||
Ok(ModelTicketEvent {
|
||||
tag_changes,
|
||||
sequence: usize_field(event, "sequence")?,
|
||||
event_ref: string_field(event, "event_ref")?,
|
||||
is_initial: bool_field(event, "is_initial")?,
|
||||
body_truncated: bool_field(event, "body_truncated")?,
|
||||
body_digest: string_field(event, "body_digest")?,
|
||||
editable: bool_field(event, "editable")?,
|
||||
author: optional_string(event, "author")?,
|
||||
edited_at: optional_string(event, "edited_at")?,
|
||||
edited_by: optional_string(event, "edited_by")?,
|
||||
kind: string_field(event, "kind")?,
|
||||
body: match event.get("body") {
|
||||
None | Some(Value::Null) => None,
|
||||
@@ -815,13 +840,24 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn post_fixture(initial: bool, body: &str) -> Value {
|
||||
json!({
|
||||
"sequence": if initial { 0 } else { 1 },
|
||||
"event_ref": if initial { "post-initial" } else { "post-discussion" },
|
||||
"is_initial": initial, "kind": "comment", "body": body,
|
||||
"body_truncated": false, "body_digest": "a".repeat(64), "editable": true,
|
||||
"author": "workspace-user:original", "at": "2026-01-01T00:00:00Z",
|
||||
"edited_at": "2026-01-02T00:00:00Z", "edited_by": "workspace-user:editor"
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
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",
|
||||
"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"),
|
||||
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
|
||||
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
|
||||
"state": state, "events": [post_fixture(false, "No Git needed")],
|
||||
"linked_objectives": [], "assignments": [], "implementation_reports": [],
|
||||
"merge_requests": [], "merge_request": null,
|
||||
"evidence": {
|
||||
@@ -832,7 +868,7 @@ mod tests {
|
||||
})).unwrap();
|
||||
let projected = serde_json::to_value(projected).unwrap();
|
||||
assert_eq!(projected["state"], state);
|
||||
assert_eq!(projected["thread"][0]["kind"], "completed");
|
||||
assert_eq!(projected["thread"][0]["kind"], "comment");
|
||||
assert_eq!(projected["evidence"]["integration_applicable"], false);
|
||||
assert_eq!(projected["evidence"]["review_status"], Value::Null);
|
||||
assert_eq!(projected["evidence"]["approved_current_subject"], false);
|
||||
@@ -862,7 +898,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",
|
||||
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "body_truncated": false, "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": [],
|
||||
@@ -1013,7 +1049,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": "Body", "body_truncated": false, "initial_post": post_fixture(true, "Body"),
|
||||
"state": "open",
|
||||
"readiness": null,
|
||||
"priority": "P2",
|
||||
@@ -1117,7 +1153,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": "Body", "body_truncated": false, "initial_post": post_fixture(true, "Body"),
|
||||
"state": "open",
|
||||
"events": [],
|
||||
"linked_objectives": [],
|
||||
@@ -1301,7 +1337,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", "content_digest":"content-1",
|
||||
"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",
|
||||
"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":[]
|
||||
});
|
||||
@@ -1348,6 +1384,41 @@ mod tests {
|
||||
}
|
||||
assert_eq!(projected_detail["closure_history"], history);
|
||||
assert_eq!(projected_detail["content_digest"], "content-1");
|
||||
assert_eq!(projected_detail["created_by"], "workspace-user:creator");
|
||||
assert_eq!(projected_detail["body_truncated"], false);
|
||||
for initial in [true, false] {
|
||||
let input = post_fixture(initial, "The same post contract");
|
||||
let output = serde_json::to_value(project_ticket_event(&input).unwrap()).unwrap();
|
||||
for field in [
|
||||
"event_ref",
|
||||
"is_initial",
|
||||
"author",
|
||||
"edited_at",
|
||||
"edited_by",
|
||||
"body",
|
||||
"body_digest",
|
||||
"body_truncated",
|
||||
"editable",
|
||||
] {
|
||||
assert_eq!(output[field], input[field], "post metadata {field}");
|
||||
}
|
||||
assert_eq!(output["created_at"], input["at"]);
|
||||
}
|
||||
assert_eq!(
|
||||
projected_detail["initial_post"]["author"],
|
||||
"workspace-user:original"
|
||||
);
|
||||
assert_eq!(
|
||||
projected_detail["initial_post"]["edited_by"],
|
||||
"workspace-user:editor"
|
||||
);
|
||||
let mut missing_post = detail.clone();
|
||||
missing_post.as_object_mut().unwrap().remove("initial_post");
|
||||
assert!(
|
||||
project_ticket_detail(missing_post)
|
||||
.unwrap_err()
|
||||
.contains("initial_post")
|
||||
);
|
||||
// Unknown observations must not be replaced with empty/null values.
|
||||
for field in [
|
||||
"tags",
|
||||
@@ -1410,11 +1481,14 @@ mod tests {
|
||||
#[test]
|
||||
fn ticket_tag_history_projection_preserves_observed_names_and_identities() {
|
||||
let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false});
|
||||
let event = serde_json::json!({"sequence":3,"kind":"tags_changed","body":"Ticket tags changed","at":"then","attributes":{"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"}});
|
||||
let mut event = post_fixture(false, "Ticket tags changed");
|
||||
event["kind"] = json!("tags_changed");
|
||||
event["editable"] = json!(false);
|
||||
event["attributes"] = serde_json::json!({"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"});
|
||||
let projected = serde_json::to_value(project_ticket_event(&event).unwrap()).unwrap();
|
||||
assert_eq!(projected["tag_changes"]["before"], serde_json::json!([tag]));
|
||||
assert_eq!(projected["tag_changes"]["after"], serde_json::json!([]));
|
||||
let ordinary = serde_json::json!({"sequence":0,"kind":"comment","body":"hello","at":null});
|
||||
let ordinary = post_fixture(false, "hello");
|
||||
assert!(
|
||||
serde_json::to_value(project_ticket_event(&ordinary).unwrap())
|
||||
.unwrap()
|
||||
|
||||
@@ -670,6 +670,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[
|
||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketEditPost",
|
||||
"TicketCaseUpdate",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
@@ -681,6 +682,7 @@ const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
|
||||
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketEditPost",
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketComment",
|
||||
@@ -1109,6 +1111,17 @@ impl WorkspaceHttpTicketBackend {
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||
}
|
||||
TicketBackendOperation::EditPost { id, edit } => {
|
||||
let (previous, current): (Ticket, Ticket) = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Patch,
|
||||
format!("{base}/{}/posts", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(edit).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket post edit: {error}"))
|
||||
})?),
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::TicketSnapshots { previous, current })
|
||||
}
|
||||
TicketBackendOperation::DependencyCheck { id } => {
|
||||
let check = Self::request(
|
||||
client,
|
||||
@@ -1289,6 +1302,21 @@ macro_rules! expect_ticket_result {
|
||||
}
|
||||
|
||||
impl TicketBackend for WorkspaceHttpTicketBackend {
|
||||
fn edit_post_with_snapshots(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
edit: ticket::TicketPostEdit,
|
||||
) -> TicketResult<(Ticket, Ticket)> {
|
||||
match self.invoke(TicketBackendOperation::EditPost { id, edit })? {
|
||||
TicketBackendOperationResult::TicketSnapshots { previous, current } => {
|
||||
Ok((previous, current))
|
||||
}
|
||||
other => Err(TicketError::Conflict(format!(
|
||||
"unexpected ticket post response: {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_workspace_tags(
|
||||
&self,
|
||||
request: ticket::TagListRequest,
|
||||
@@ -1573,6 +1601,12 @@ fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
|
||||
identity_field: Some("ticket"),
|
||||
mutating: true,
|
||||
},
|
||||
"TicketEditPost" => NativeTicketOperation {
|
||||
operation: "edit_post",
|
||||
surface: NativeTicketSurface::Item,
|
||||
identity_field: Some("ticket"),
|
||||
mutating: true,
|
||||
},
|
||||
"TicketComment" => NativeTicketOperation {
|
||||
operation: "comment",
|
||||
surface: NativeTicketSurface::Item,
|
||||
@@ -2470,6 +2504,47 @@ mod tests {
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_edit_backend_transports_atomic_snapshots_and_propagates_conflicts() {
|
||||
let record: Ticket =
|
||||
serde_json::from_str(&decision_client(200, TicketState::Open).response.body).unwrap();
|
||||
let input = json!({
|
||||
"event_ref": "post-reference",
|
||||
"expected_body": "Observed post",
|
||||
"body": "Updated post",
|
||||
"author": "standalone"
|
||||
});
|
||||
for status in [200, 409] {
|
||||
let client = Arc::new(DecisionClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
response: crate::worker::WorkspaceResponse {
|
||||
status,
|
||||
body: serde_json::to_string(&(record.clone(), record.clone())).unwrap(),
|
||||
},
|
||||
});
|
||||
let backend = WorkspaceHttpTicketBackend::new(client.clone());
|
||||
let result = backend.edit_post_with_snapshots(
|
||||
TicketIdOrSlug::from("T-739"),
|
||||
serde_json::from_value(input.clone()).unwrap(),
|
||||
);
|
||||
if status == 200 {
|
||||
let (previous, current) = result.unwrap();
|
||||
assert_eq!(previous.meta.id, record.meta.id);
|
||||
assert_eq!(current.meta.id, record.meta.id);
|
||||
} else {
|
||||
assert!(result.unwrap_err().to_string().contains("409"));
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Patch);
|
||||
assert_eq!(requests[0].path, "/api/w/workspace/tickets/T-739/posts");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(requests[0].body.as_deref().unwrap()).unwrap(),
|
||||
input,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_schema_allows_only_open_and_closed_case_states() {
|
||||
let client = decision_client(200, TicketState::Open);
|
||||
@@ -3242,6 +3317,7 @@ mod tests {
|
||||
.collect::<Vec<_>>();
|
||||
assert!(workspace_tools.contains(&"TicketCreate"));
|
||||
assert!(workspace_tools.contains(&"TicketEditItem"));
|
||||
assert!(workspace_tools.contains(&"TicketEditPost"));
|
||||
assert!(!workspace_tools.contains(&"TicketQueue"));
|
||||
assert!(workspace_tools.contains(&"TicketCaseUpdate"));
|
||||
|
||||
@@ -3257,6 +3333,7 @@ mod tests {
|
||||
assert!(orchestration_tools.contains(&"TicketRelationRecord"));
|
||||
assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord"));
|
||||
assert!(!orchestration_tools.contains(&"TicketEditItem"));
|
||||
assert!(!orchestration_tools.contains(&"TicketEditPost"));
|
||||
assert!(!orchestration_tools.contains(&"TicketQueue"));
|
||||
|
||||
let work_report = workspace_feature(TicketFeatureAccess::work_report());
|
||||
@@ -3315,6 +3392,18 @@ mod tests {
|
||||
assert!(native.iter().all(|tool| tool.name != name), "{name}");
|
||||
assert!(native_ticket_operation(name).is_none(), "{name}");
|
||||
}
|
||||
assert_eq!(
|
||||
normal
|
||||
.iter()
|
||||
.any(|definition| definition().0.name == "TicketEditPost"),
|
||||
access.authoring,
|
||||
);
|
||||
assert_eq!(
|
||||
native
|
||||
.iter()
|
||||
.any(|tool| tool.projection.operation == "edit_post"),
|
||||
access.authoring,
|
||||
);
|
||||
if access.authoring || access.thread || access.workflow {
|
||||
assert!(
|
||||
normal
|
||||
|
||||
Reference in New Issue
Block a user