From b31642e2842bf75f76412b6fa26ca80ba294a41f Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 27 Aug 2026 12:39:28 +0900 Subject: [PATCH] feat: project Ticket and Objective human references --- crates/ticket/src/tool.rs | 22 +- crates/worker/src/feature/builtin.rs | 1 + .../worker/src/feature/builtin/objective.rs | 62 +- .../feature/builtin/resource_projection.rs | 777 ++++++++++++++++++ crates/worker/src/feature/builtin/ticket.rs | 103 ++- crates/workspace-server/src/authority.rs | 57 +- crates/workspace-server/src/records.rs | 12 +- 7 files changed, 971 insertions(+), 63 deletions(-) create mode 100644 crates/worker/src/feature/builtin/resource_projection.rs diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index f6fb2842..14d4c4f8 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -410,7 +410,7 @@ struct TicketCreateParams { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketEditItemParams { - /// Ticket id. + /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. ticket: String, /// Optional replacement title. #[serde(default)] @@ -539,7 +539,7 @@ impl QueryTicketParams { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct ShowTicketParams { - /// Ticket id. Exactly one of `id` or `query` must be provided. + /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided. #[serde(default)] id: Option, /// Exact ticket id query. Exactly one of `id` or `query` must be provided. @@ -558,7 +558,7 @@ struct ShowTicketParams { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketThreadEventParams { - /// Ticket id. + /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. ticket: String, /// Markdown event body. body: String, @@ -566,7 +566,7 @@ struct TicketThreadEventParams { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketMarkReadyParams { - /// Ticket id. + /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. ticket: String, /// Optional reason attached to the state_changed event. #[serde(default)] @@ -575,7 +575,7 @@ struct TicketMarkReadyParams { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketIntakeReadyParams { - /// Ticket id. + /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. ticket: String, /// Concise bounded intake summary appended before the ready transition. intake_summary: String, @@ -586,13 +586,13 @@ struct TicketIntakeReadyParams { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketQueueParams { - /// Ticket id. + /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. ticket: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketWorkflowStateParams { - /// Ticket id. + /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. ticket: String, /// Expected current state. The backend rejects stale transitions. from: TicketWorkflowStateParam, @@ -606,7 +606,7 @@ struct TicketWorkflowStateParams { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketCloseParams { - /// Ticket id. + /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. ticket: String, /// Markdown resolution written to resolution.md and thread.md. resolution: String, @@ -614,7 +614,7 @@ struct TicketCloseParams { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketDependencyCheckParams { - /// Ticket id. + /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. ticket: String, } @@ -646,7 +646,7 @@ struct TicketRelationRecordParams { ticket: String, /// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of. kind: TicketRelationKindParam, - /// Target canonical Ticket id. Title/slug words are not accepted as relation authority. + /// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. target: String, /// Optional bounded rationale/note. #[serde(default)] @@ -659,7 +659,7 @@ struct TicketRelationRemoveParams { ticket: String, /// Forward relation kind to remove. kind: TicketRelationKindParam, - /// Target canonical Ticket id. + /// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. target: String, } diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index b9c5994f..010fcf96 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -12,6 +12,7 @@ pub mod memory_extract; pub mod merge_request; pub mod objective; pub mod orchestration; +mod resource_projection; pub mod session_explore; pub mod task; pub mod ticket; diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs index 460c929a..d15a36fe 100644 --- a/crates/worker/src/feature/builtin/objective.rs +++ b/crates/worker/src/feature/builtin/objective.rs @@ -14,6 +14,8 @@ use serde_json::json; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; +use super::resource_projection::{project_objective_detail, project_objective_query}; + #[derive(Clone, Debug)] pub struct WorkspaceHttpObjectiveBackend { client: Arc, @@ -37,6 +39,7 @@ impl WorkspaceHttpObjectiveBackend { ) .await .map_err(backend_error)?; + let response = project_objective_query(response).map_err(ToolError::ExecutionFailed)?; Ok(ToolOutput { summary: "Queried Objectives".to_string(), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), @@ -58,6 +61,7 @@ impl WorkspaceHttpObjectiveBackend { ) .await .map_err(backend_error)?; + let response = project_objective_detail(response).map_err(ToolError::ExecutionFailed)?; Ok(ToolOutput { summary: format!("Read objective {id}"), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), @@ -84,7 +88,7 @@ impl WorkspaceHttpObjectiveBackend { .await .map_err(backend_error)?; Ok(objective_output( - format!("Created objective {}", response.id), + format!("Created objective {}", &response.resource_key), response, )?) } @@ -112,7 +116,7 @@ impl WorkspaceHttpObjectiveBackend { .await .map_err(backend_error)?; Ok(objective_output( - format!("Edited objective {}", response.id), + format!("Edited objective {}", &response.resource_key), response, )?) } @@ -134,7 +138,7 @@ impl WorkspaceHttpObjectiveBackend { .await .map_err(backend_error)?; Ok(objective_output( - format!("Updated objective {} state", response.id), + format!("Updated objective {} state", &response.resource_key), response, )?) } @@ -154,7 +158,10 @@ impl WorkspaceHttpObjectiveBackend { .await .map_err(backend_error)?; Ok(objective_output( - format!("Linked ticket {ticket_id} to objective {}", response.id), + format!( + "Linked ticket {ticket_id} to objective {}", + &response.resource_key + ), response, )?) } @@ -170,7 +177,10 @@ impl WorkspaceHttpObjectiveBackend { .await .map_err(backend_error)?; Ok(objective_output( - format!("Unlinked ticket {ticket_id} from objective {}", response.id), + format!( + "Unlinked ticket {ticket_id} from objective {}", + &response.resource_key + ), response, )?) } @@ -185,7 +195,7 @@ impl WorkspaceHttpObjectiveBackend { pub enum WorkspaceObjectiveBackendError { #[error("workspace objective backend request failed: {0}")] Request(#[from] crate::worker::WorkspaceClientError), - #[error("workspace objective backend returned HTTP {status}: {body}")] + #[error("workspace objective backend returned HTTP {status}")] Http { status: reqwest::StatusCode, body: String, @@ -248,9 +258,19 @@ fn decode_response Deserialize<'de>>( } fn objective_output(summary: String, response: ObjectiveDetail) -> Result { + if !response.resource_key.starts_with("O-") { + return Err(ToolError::ExecutionFailed( + "required O- human key is unavailable".to_string(), + )); + } + let projected = serde_json::json!({ + "objective": &response.resource_key, + "title": response.title, + "state": response.state, + }); Ok(ToolOutput { summary, - content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), + content: Some(serde_json::to_string_pretty(&projected).map_err(decode_error)?), attachments: Vec::new(), }) @@ -260,7 +280,7 @@ fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> { let id = id.trim(); if id.is_empty() || id.contains('/') { return Err(ToolError::InvalidArgument(format!( - "{tool_name} requires non-empty canonical id without '/'" + "{tool_name} requires a non-empty Objective reference without '/'" ))); } Ok(id) @@ -411,9 +431,9 @@ const EDIT_DESCRIPTION: &str = const SET_STATE_DESCRIPTION: &str = "Set an Objective state through Backend Workspace API authority."; const LINK_TICKET_DESCRIPTION: &str = - "Link a Ticket id to an Objective through Backend Workspace API authority."; + "Link a Ticket reference to an Objective through Backend Workspace API authority."; const UNLINK_TICKET_DESCRIPTION: &str = - "Unlink a Ticket id from an Objective through Backend Workspace API authority."; + "Unlink a Ticket reference from an Objective through Backend Workspace API authority."; fn list_schema() -> serde_json::Value { json!({ @@ -422,7 +442,7 @@ fn list_schema() -> serde_json::Value { "properties":{ "query":{"type":["string","null"]}, "states":{"type":"array","items":{"type":"string"},"default":[]}, - "linked_ticket_id":{"type":["string","null"]}, + "linked_ticket_id":{"type":["string","null"],"description":"Linked Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."}, "updated_after":{"type":["string","null"]}, "updated_before":{"type":["string","null"]}, "sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]}, @@ -438,7 +458,7 @@ fn show_schema() -> serde_json::Value { "additionalProperties": false, "required":["id"], "properties":{ - "id":{"type":"string"}, + "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."}, "event_limit":{"type":["integer","null"],"minimum":1,"maximum":50}, "event_cursor":{"type":["string","null"]} } @@ -454,7 +474,7 @@ fn create_schema() -> serde_json::Value { "title":{"type":"string","minLength":1}, "body_md":{"type":"string"}, "state":{"type":"string","default":"active"}, - "linked_tickets":{"type":"array","items":{"type":"string"}} + "linked_tickets":{"type":"array","items":{"type":"string"},"description":"Linked Ticket references. Prefer T-*; canonical internal ids remain accepted for compatibility."} } }) } @@ -465,7 +485,7 @@ fn edit_schema() -> serde_json::Value { "additionalProperties": false, "required":["id"], "properties":{ - "id":{"type":"string"}, + "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."}, "title":{"type":["string","null"]}, "old_string":{"type":["string","null"]}, "new_string":{"type":["string","null"]}, @@ -480,7 +500,7 @@ fn set_state_schema() -> serde_json::Value { "additionalProperties": false, "required":["id","state"], "properties":{ - "id":{"type":"string"}, + "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."}, "state":{"type":"string","minLength":1} } }) @@ -500,8 +520,8 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value { "additionalProperties": false, "required": required, "properties":{ - "id":{"type":"string"}, - "ticket_id":{"type":"string"} + "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."}, + "ticket_id":{"type":"string","description":"Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."} } }) } @@ -595,15 +615,9 @@ fn default_state() -> String { #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct ObjectiveDetail { - id: String, + resource_key: String, title: String, state: String, - created_at: Option, - updated_at: Option, - linked_tickets: Vec, - body: String, - body_truncated: bool, - record_source: String, } #[cfg(test)] diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs new file mode 100644 index 00000000..96361787 --- /dev/null +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -0,0 +1,777 @@ +use serde::Serialize; +use serde_json::{Map, Value}; + +#[derive(Debug, Serialize)] +pub(super) struct ModelTicketQueryResponse { + tickets: Vec, + next_cursor: Option, + has_more: bool, +} + +#[derive(Debug, Serialize)] +struct ModelTicketQueryItem { + ticket: String, + title: String, + state: String, + readiness: Option, + priority: Option, + created_at: Option, + updated_at: Option, + workspace_action_priority: Option, + matched_fields: Vec, + snippet: Option, + current_coder: Option, + linked_objectives: Vec, + relation_count: usize, + blocker_count: usize, + unresolved_blocker_count: usize, + unresolved_review_count: usize, + evidence: Option, + merge_request: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct ModelTicketDetail { + ticket: String, + title: String, + body: String, + state: String, + readiness: Option, + priority: Option, + created_at: Option, + updated_at: Option, + thread: Vec, + relations: ModelTicketRelations, + linked_objectives: Vec, + assignments: Vec, + current_coder: Option, + implementation_reports: Vec, + merge_request: Option, + evidence: Option, + actions: Option, + event_page: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct ModelObjectiveQueryResponse { + objectives: Vec, + next_cursor: Option, + has_more: bool, +} + +#[derive(Debug, Serialize)] +struct ModelObjectiveQueryItem { + objective: String, + title: String, + summary: String, + state: String, + created_at: Option, + updated_at: Option, + linked_tickets: Vec, + linked_ticket_count: usize, +} + +#[derive(Debug, Serialize)] +pub(super) struct ModelObjectiveDetail { + objective: String, + title: String, + body: String, + state: String, + created_at: Option, + updated_at: Option, + linked_tickets: Vec, + events: Vec, + event_page: ModelObjectiveEventPage, +} + +#[derive(Debug, Serialize)] +struct ModelWorkerSummary { + worker: String, +} + +#[derive(Debug, Serialize)] +struct ModelTicketEvent { + sequence: usize, + kind: String, + body: Option, + created_at: Option, +} + +#[derive(Debug, Serialize, Default)] +struct ModelTicketRelations { + outgoing: Vec, + incoming: Vec, + blockers: Vec, + notices: Vec, +} + +#[derive(Debug, Serialize)] +struct ModelRelation { + ticket: String, + kind: String, + note: Option, + created_at: Option, +} + +#[derive(Debug, Serialize)] +struct ModelBlocker { + ticket: String, + kind: String, + state: Option, + resolved: bool, +} + +#[derive(Debug, Serialize)] +struct ModelNotice { + kind: String, +} + +#[derive(Debug, Serialize)] +struct ModelObjectiveSummary { + objective: String, + title: String, + state: String, +} + +#[derive(Debug, Serialize)] +struct ModelTicketSummary { + ticket: String, + title: String, + state: String, +} + +#[derive(Debug, Serialize)] +struct ModelAssignment { + role: String, + principal: String, + assigned_at: String, +} + +#[derive(Debug, Serialize)] +struct ModelEvidenceEvent { + sequence: usize, + kind: String, + created_at: Option, + excerpt: String, +} + +#[derive(Debug, Serialize)] +struct ModelMergeRequest { + state: String, + selector_from: Option, + selector_to: String, + review_status: String, + subject_ref: Option, + review_excerpt: Option, +} + +#[derive(Debug, Serialize)] +struct ModelTicketEvidence { + has_merge_request: bool, + has_current_subject_ref: bool, + has_review_request: bool, + has_commit: bool, + review_status: Option, + approved_current_subject: bool, + unresolved_request_changes: bool, + complete_for_integration: bool, + missing: Vec, +} + +#[derive(Debug, Serialize)] +struct ModelTicketActions { + can_assign_orchestrator: bool, + can_unassign_orchestrator: bool, + can_queue: bool, + can_start_manual_coder: bool, +} + +#[derive(Debug, Serialize)] +struct ModelEventPage { + next_cursor: Option, + has_more: bool, +} + +#[derive(Debug, Serialize)] +struct ModelObjectiveEvent { + kind: String, + created_at: String, + body: Option, +} + +#[derive(Debug, Serialize)] +struct ModelObjectiveEventPage { + next_cursor: Option, + has_more: bool, +} + +pub(super) fn project_ticket_query(value: Value) -> Result { + let root = object(&value, "Ticket query response")?; + let page = object_field(root, "page")?; + let tickets = array_field(root, "items")? + .iter() + .map(project_ticket_query_item) + .collect::, _>>()?; + Ok(ModelTicketQueryResponse { + tickets, + next_cursor: optional_string(page, "next_cursor")?, + has_more: bool_field(page, "has_more")?, + }) +} + +fn project_ticket_query_item(value: &Value) -> Result { + let item = object(value, "Ticket query item")?; + Ok(ModelTicketQueryItem { + ticket: human_ref(item, "resource_key", "T-")?, + title: string_field(item, "title")?, + state: string_field(item, "state")?, + readiness: optional_string(item, "readiness")?, + priority: optional_string(item, "priority")?, + created_at: optional_string(item, "created_at")?, + updated_at: optional_string(item, "updated_at")?, + workspace_action_priority: optional_string(item, "workspace_action_priority")?, + matched_fields: string_array(item, "matched_fields")?, + snippet: optional_string(item, "snippet")?, + current_coder: item + .get("current_coder") + .filter(|value| !value.is_null()) + .map(project_worker) + .transpose()?, + linked_objectives: string_array(item, "linked_objective_keys")? + .into_iter() + .map(|key| validate_human_ref(key, "O-")) + .collect::, _>>()?, + relation_count: usize_field(item, "relation_count")?, + blocker_count: usize_field(item, "blocker_count")?, + unresolved_blocker_count: usize_field(item, "unresolved_blocker_count")?, + unresolved_review_count: usize_field(item, "unresolved_review_count")?, + evidence: item.get("evidence").map(project_evidence).transpose()?, + merge_request: item + .get("merge_request") + .filter(|value| !value.is_null()) + .map(project_merge_request) + .transpose()?, + }) +} + +pub(super) fn project_ticket_detail(value: Value) -> Result { + let root = object(&value, "Ticket detail response")?; + let current_coder = root + .get("current_coder") + .filter(|value| !value.is_null()) + .map(project_worker) + .transpose()?; + let assignments = array_field(root, "assignments")? + .iter() + .map(|assignment| project_assignment(assignment, current_coder.as_ref())) + .collect::, _>>()?; + + Ok(ModelTicketDetail { + ticket: human_ref(root, "resource_key", "T-")?, + title: string_field(root, "title")?, + body: string_field(root, "body")?, + state: string_field(root, "state")?, + readiness: optional_string(root, "readiness")?, + priority: optional_string(root, "priority")?, + created_at: optional_string(root, "created_at")?, + updated_at: optional_string(root, "updated_at")?, + thread: array_field(root, "events")? + .iter() + .map(project_ticket_event) + .collect::, _>>()?, + relations: project_relations(root.get("relations"))?, + linked_objectives: array_field(root, "linked_objectives")? + .iter() + .map(project_objective_summary) + .collect::, _>>()?, + assignments, + current_coder, + implementation_reports: array_field(root, "implementation_reports")? + .iter() + .map(project_evidence_event) + .collect::, _>>()?, + merge_request: root + .get("merge_request") + .filter(|value| !value.is_null()) + .map(project_merge_request) + .transpose()?, + evidence: root.get("evidence").map(project_evidence).transpose()?, + actions: root + .get("action_eligibility") + .filter(|value| !value.is_null()) + .map(project_actions) + .transpose()?, + event_page: root + .get("event_page") + .filter(|value| !value.is_null()) + .map(project_event_page) + .transpose()?, + }) +} + +pub(super) fn project_objective_query(value: Value) -> Result { + let root = object(&value, "Objective query response")?; + let page = object_field(root, "page")?; + Ok(ModelObjectiveQueryResponse { + objectives: array_field(root, "items")? + .iter() + .map(project_objective_query_item) + .collect::, _>>()?, + next_cursor: optional_string(page, "next_cursor")?, + has_more: bool_field(page, "has_more")?, + }) +} + +fn project_objective_query_item(value: &Value) -> Result { + let item = object(value, "Objective query item")?; + let linked_tickets = string_array(item, "linked_ticket_keys")? + .into_iter() + .map(|key| validate_human_ref(key, "T-")) + .collect::, _>>()?; + Ok(ModelObjectiveQueryItem { + objective: human_ref(item, "resource_key", "O-")?, + title: string_field(item, "title")?, + summary: string_field(item, "snippet")?, + state: string_field(item, "state")?, + created_at: optional_string(item, "created_at")?, + updated_at: optional_string(item, "updated_at")?, + linked_ticket_count: linked_tickets.len(), + linked_tickets, + }) +} + +pub(super) fn project_objective_detail(value: Value) -> Result { + let root = object(&value, "Objective detail response")?; + Ok(ModelObjectiveDetail { + objective: human_ref(root, "resource_key", "O-")?, + title: string_field(root, "title")?, + body: string_field(root, "body")?, + state: string_field(root, "state")?, + created_at: optional_string(root, "created_at")?, + updated_at: optional_string(root, "updated_at")?, + linked_tickets: array_field(root, "linked_ticket_summaries")? + .iter() + .map(project_ticket_summary) + .collect::, _>>()?, + events: array_field(root, "events")? + .iter() + .map(project_objective_event) + .collect::, _>>()?, + event_page: project_objective_event_page( + root.get("event_page") + .ok_or_else(|| "Objective detail response is missing event_page".to_string())?, + )?, + }) +} + +fn project_worker(value: &Value) -> Result { + let worker = object(value, "Worker summary")?; + Ok(ModelWorkerSummary { + worker: human_ref(worker, "worker_resource_key", "W-")?, + }) +} + +fn project_ticket_event(value: &Value) -> Result { + let event = object(value, "Ticket event")?; + Ok(ModelTicketEvent { + sequence: usize_field(event, "sequence")?, + kind: string_field(event, "kind")?, + body: match event.get("body") { + None | Some(Value::Null) => None, + Some(Value::String(body)) => Some(body.clone()), + Some(_) => return Err("invalid Ticket event body".to_string()), + }, + created_at: optional_string(event, "at")?, + }) +} + +fn project_relations(value: Option<&Value>) -> Result { + let Some(value) = value else { + return Ok(ModelTicketRelations::default()); + }; + let relations = object(value, "Ticket relations")?; + Ok(ModelTicketRelations { + outgoing: array_field(relations, "outgoing")? + .iter() + .map(|value| project_relation(value, "target_resource_key", "kind")) + .collect::, _>>()?, + incoming: array_field(relations, "incoming")? + .iter() + .map(|value| project_relation(value, "source_resource_key", "forward_kind")) + .collect::, _>>()?, + blockers: array_field(relations, "blockers")? + .iter() + .map(project_blocker) + .collect::, _>>()?, + notices: array_field(relations, "notices")? + .iter() + .map(project_notice) + .collect::, _>>()?, + }) +} + +fn project_relation( + value: &Value, + ticket_key: &str, + kind_key: &str, +) -> Result { + let relation = object(value, "Ticket relation")?; + let relation_data = relation.get("relation").and_then(Value::as_object); + let kind = if kind_key == "kind" { + relation_data + .ok_or_else(|| "Ticket relation is missing relation data".to_string()) + .and_then(|data| string_field(data, "kind"))? + } else { + string_field(relation, kind_key)? + }; + let note = match relation_data { + Some(data) => optional_string(data, "note")?, + None => optional_string(relation, "note")?, + }; + let created_at = match relation_data { + Some(data) => optional_string(data, "at")?, + None => optional_string(relation, "at")?, + }; + Ok(ModelRelation { + ticket: human_ref(relation, ticket_key, "T-")?, + kind, + note, + created_at, + }) +} + +fn project_blocker(value: &Value) -> Result { + let blocker = object(value, "Ticket blocker")?; + Ok(ModelBlocker { + ticket: human_ref(blocker, "blocking_resource_key", "T-")?, + kind: string_field(blocker, "relation_kind")?, + state: optional_string(blocker, "blocking_state")?, + resolved: bool_field(blocker, "resolved")?, + }) +} + +fn project_notice(value: &Value) -> Result { + let notice = object(value, "Ticket notice")?; + Ok(ModelNotice { + kind: string_field(notice, "kind")?, + }) +} + +fn project_objective_summary(value: &Value) -> Result { + let summary = object(value, "Objective summary")?; + Ok(ModelObjectiveSummary { + objective: human_ref(summary, "resource_key", "O-")?, + title: string_field(summary, "title")?, + state: string_field(summary, "state")?, + }) +} + +fn project_ticket_summary(value: &Value) -> Result { + let summary = object(value, "Ticket summary")?; + Ok(ModelTicketSummary { + ticket: human_ref(summary, "resource_key", "T-")?, + title: string_field(summary, "title")?, + state: string_field(summary, "state")?, + }) +} + +fn project_assignment( + value: &Value, + current_coder: Option<&ModelWorkerSummary>, +) -> Result { + let assignment = object(value, "Ticket assignment")?; + let principal = object_field(assignment, "principal")?; + let kind = string_field(principal, "kind")?; + let principal = match kind.as_str() { + "worker" => current_coder + .map(|coder| coder.worker.clone()) + .ok_or_else(|| { + "Worker assignment is missing a Workspace human key projection".to_string() + })?, + "workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?), + "user" => "user".to_string(), + other => format!("source:{other}"), + }; + Ok(ModelAssignment { + role: string_field(assignment, "role")?, + principal, + assigned_at: string_field(assignment, "assigned_at")?, + }) +} + +fn project_evidence_event(value: &Value) -> Result { + let event = object(value, "Ticket evidence event")?; + Ok(ModelEvidenceEvent { + sequence: usize_field(event, "sequence")?, + kind: string_field(event, "kind")?, + created_at: optional_string(event, "at")?, + excerpt: string_field(event, "excerpt")?, + }) +} + +fn project_merge_request(value: &Value) -> Result { + let merge = object(value, "Merge Request summary")?; + Ok(ModelMergeRequest { + state: string_field(merge, "state")?, + selector_from: optional_string(merge, "selector_from")?, + selector_to: string_field(merge, "selector_to")?, + review_status: string_field(merge, "review_status")?, + subject_ref: optional_string(merge, "subject_ref")?, + review_excerpt: optional_string(merge, "review_excerpt")?, + }) +} + +fn project_evidence(value: &Value) -> Result { + let evidence = object(value, "Ticket evidence")?; + Ok(ModelTicketEvidence { + has_merge_request: bool_field(evidence, "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")?, + review_status: optional_string(evidence, "review_status")?, + approved_current_subject: bool_field(evidence, "approved_current_subject")?, + unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?, + complete_for_integration: bool_field(evidence, "complete_for_integration")?, + missing: string_array(evidence, "missing")?, + }) +} + +fn project_actions(value: &Value) -> Result { + let actions = object(value, "Ticket actions")?; + Ok(ModelTicketActions { + can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?, + can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?, + can_queue: bool_field(actions, "can_queue")?, + can_start_manual_coder: bool_field(actions, "can_start_manual_coder")?, + }) +} + +fn project_event_page(value: &Value) -> Result { + let page = object(value, "Ticket event page")?; + Ok(ModelEventPage { + next_cursor: optional_string(page, "next_cursor")?, + has_more: bool_field(page, "has_more")?, + }) +} + +fn project_objective_event(value: &Value) -> Result { + let event = object(value, "Objective event")?; + let body = optional_string(event, "body")?; + Ok(ModelObjectiveEvent { + kind: string_field(event, "kind")?, + created_at: string_field(event, "created_at")?, + body, + }) +} + +fn project_objective_event_page(value: &Value) -> Result { + let page = object(value, "Objective event page")?; + Ok(ModelObjectiveEventPage { + next_cursor: optional_string(page, "next_cursor")?, + has_more: bool_field(page, "has_more")?, + }) +} + +fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map, String> { + value + .as_object() + .ok_or_else(|| format!("{context} must be an object")) +} + +fn object_field<'a>( + object: &'a Map, + key: &str, +) -> Result<&'a Map, String> { + object + .get(key) + .and_then(Value::as_object) + .ok_or_else(|| format!("missing or invalid {key}")) +} + +fn array_field<'a>(object: &'a Map, key: &str) -> Result<&'a [Value], String> { + object + .get(key) + .and_then(Value::as_array) + .map(Vec::as_slice) + .ok_or_else(|| format!("missing or invalid {key}")) +} + +fn string_field(object: &Map, key: &str) -> Result { + object + .get(key) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| format!("missing or invalid {key}")) +} + +fn optional_string(object: &Map, key: &str) -> Result, String> { + match object.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(format!("invalid {key}")), + } +} + +fn bool_field(object: &Map, key: &str) -> Result { + object + .get(key) + .and_then(Value::as_bool) + .ok_or_else(|| format!("missing or invalid {key}")) +} + +fn usize_field(object: &Map, key: &str) -> Result { + object + .get(key) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| format!("missing or invalid {key}")) +} + +fn string_array(object: &Map, key: &str) -> Result, String> { + array_field(object, key)? + .iter() + .map(|value| { + value + .as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| format!("invalid {key}")) + }) + .collect() +} + +fn human_ref(object: &Map, key: &str, prefix: &str) -> Result { + let value = object + .get(key) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| format!("required {prefix} human key is unavailable"))?; + validate_human_ref(value, prefix) +} + +fn validate_human_ref(value: String, prefix: &str) -> Result { + if value.starts_with(prefix) && value.len() > prefix.len() { + Ok(value) + } else { + Err(format!("required {prefix} human key is unavailable")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn objective_projection_exposes_only_human_resource_references() { + let projected = project_objective_detail(json!({ + "id": "00001M10HW6BV", + "resource_key": "O-543", + "title": "Objective", + "body": "Body", + "state": "active", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + "linked_tickets": ["00001M0E82D1V"], + "linked_ticket_summaries": [{ + "id": "00001M0E82D1V", + "resource_key": "T-496", + "title": "Ticket", + "state": "done", + "updated_at": "2026-01-02T00:00:00Z" + }], + "events": [{ + "sequence": 3, + "event_ref": "objective-event-3", + "kind": "linked_ticket", + "created_at": "2026-01-02T00:00:00Z", + "body": "linked" + }], + "event_page": {"next_cursor": null, "has_more": false, "window_start_sequence": 3, "window_end_sequence": 3} + })).expect("projection"); + let json = serde_json::to_value(projected).expect("serialize"); + let text = json.to_string(); + assert!(text.contains("O-543")); + assert!(text.contains("T-496")); + assert!(!text.contains("00001M10HW6BV")); + assert!(!text.contains("00001M0E82D1V")); + assert!(!text.contains("event_ref")); + } + + #[test] + fn query_projections_accept_workspace_api_shapes_and_scrub_internal_ids() { + let ticket = project_ticket_query(json!({ + "page": {"next_cursor": null, "has_more": false}, + "record_authority": "workspace_sqlite", + "items": [{ + "id": "00001TICKETINTERNAL", + "resource_key": "T-543", + "title": "Ticket", + "state": "inprogress", + "readiness": null, + "priority": "high", + "created_at": null, + "updated_at": "2026-01-01T00:00:00Z", + "workspace_action_priority": "active_work", + "matched_fields": ["title"], + "snippet": "Ticket", + "current_coder": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"}, + "linked_objective_ids": ["00001OBJECTIVEINTERNAL"], + "linked_objective_keys": ["O-6"], + "relation_count": 0, + "blocker_count": 0, + "unresolved_blocker_count": 0, + "unresolved_review_count": 0, + "evidence": { + "has_merge_request": false, + "has_current_subject_ref": false, + "has_review_request": false, + "has_commit": false, + "review_status": null, + "approved_current_subject": false, + "unresolved_request_changes": false, + "complete_for_integration": false, + "missing": ["merge_request"] + }, + "merge_request": null + }] + })).expect("Ticket query projection"); + let ticket_json = serde_json::to_string(&ticket).expect("serialize Ticket query"); + assert!(ticket_json.contains("T-543")); + assert!(ticket_json.contains("O-6")); + assert!(ticket_json.contains("W-12")); + assert!(!ticket_json.contains("00001TICKETINTERNAL")); + assert!(!ticket_json.contains("runtime-internal")); + assert!(!ticket_json.contains("worker-internal")); + + let objective = project_objective_query(json!({ + "page": {"next_cursor": null, "has_more": false}, + "record_authority": "workspace_sqlite", + "items": [{ + "id": "00001OBJECTIVEINTERNAL", + "resource_key": "O-6", + "title": "Objective", + "state": "active", + "created_at": null, + "updated_at": null, + "matched_fields": [], + "snippet": "Objective summary", + "linked_ticket_count": 1, + "linked_tickets": ["00001TICKETINTERNAL"], + "linked_ticket_keys": ["T-543"] + }] + })) + .expect("Objective query projection"); + let objective_json = serde_json::to_string(&objective).expect("serialize Objective query"); + assert!(objective_json.contains("O-6")); + assert!(objective_json.contains("T-543")); + assert!(!objective_json.contains("00001OBJECTIVEINTERNAL")); + assert!(!objective_json.contains("00001TICKETINTERNAL")); + } + + #[test] + fn ticket_projection_fails_closed_without_worker_resource_key() { + let error = project_worker(&json!({"worker_resource_key": null})) + .expect_err("missing W-key must fail"); + assert!(error.contains("W-")); + } +} diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 9001d6bb..5208df3f 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -33,6 +33,8 @@ use crate::feature::{ use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; +use super::resource_projection::{project_ticket_detail, project_ticket_query}; + #[derive(Clone, Copy)] enum WorkspaceTicketReadKind { Query, @@ -153,8 +155,10 @@ struct WorkspaceQueryTicketInput { /// stale_after_rescope, and missing_evidence. #[serde(default)] attention: Vec, + /// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. related_ticket_id: Option, relation_kind: Option, + /// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility. linked_objective_id: Option, updated_after: Option, updated_before: Option, @@ -169,6 +173,7 @@ struct WorkspaceQueryTicketInput { #[derive(Debug, Deserialize, Serialize, JsonSchema)] struct WorkspaceShowTicketInput { + /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. id: String, /// Most-recent thread entries to return, bounded by the Backend to 1..=50. event_limit: Option, @@ -229,13 +234,27 @@ impl Tool for WorkspaceTicketReadTool { .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; if !response.is_success() { return Err(ToolError::ExecutionFailed(format!( - "Workspace Ticket API returned HTTP {}: {}", - response.status, response.body + "Workspace Ticket API request failed with HTTP status {}", + response.status ))); } + let response_value: Value = serde_json::from_str(&response.body).map_err(|error| { + ToolError::ExecutionFailed(format!( + "Workspace Ticket API returned invalid JSON: {error}" + )) + })?; + let content = match self.kind { + WorkspaceTicketReadKind::Query => serde_json::to_string( + &project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?, + ), + WorkspaceTicketReadKind::Show => serde_json::to_string( + &project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?, + ), + } + .map_err(|error| ToolError::Internal(error.to_string()))?; Ok(ToolOutput { summary: self.kind.name().to_string(), - content: Some(response.body), + content: Some(content), attachments: Vec::new(), }) } @@ -712,14 +731,43 @@ impl WorkspaceHttpTicketBackend { })?; if !response.is_success() { return Err(TicketError::Conflict(format!( - "ticket REST API returned HTTP {}: {}", - response.status, response.body + "ticket REST API request failed with HTTP status {}", + response.status ))); } - serde_json::from_str(&response.body) + let mut value: Value = serde_json::from_str(&response.body).map_err(|error| { + TicketError::Conflict(format!("decode ticket REST response: {error}")) + })?; + Self::canonicalize_ticket_references(&mut value); + serde_json::from_value(value) .map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}"))) } + fn canonicalize_ticket_references(value: &mut Value) { + match value { + Value::Array(values) => { + for value in values { + Self::canonicalize_ticket_references(value); + } + } + Value::Object(object) => { + for value in object.values_mut() { + Self::canonicalize_ticket_references(value); + } + if let Some(resource_key) = object + .get("resource_key") + .and_then(Value::as_str) + .filter(|key| key.starts_with("T-")) + .map(ToOwned::to_owned) + && object.contains_key("id") + { + object.insert("id".to_string(), Value::String(resource_key)); + } + } + _ => {} + } + } + fn request_unit( client: Arc, method: WorkspaceRequestMethod, @@ -739,8 +787,8 @@ impl WorkspaceHttpTicketBackend { })?; if !response.is_success() { return Err(TicketError::Conflict(format!( - "ticket REST API returned HTTP {}: {}", - response.status, response.body + "ticket REST API request failed with HTTP status {}", + response.status ))); } Ok(TicketBackendOperationResult::Unit) @@ -889,7 +937,13 @@ impl WorkspaceHttpTicketBackend { })?), ), TicketBackendOperation::AddTicketRelation { id, relation } => { - let relation = Self::request( + let source_reference = match &id { + TicketIdOrSlug::Id(value) + | TicketIdOrSlug::Slug(value) + | TicketIdOrSlug::Query(value) => value.clone(), + }; + let target_reference = relation.target.clone(); + let mut relation: TicketRelation = Self::request( client, WorkspaceRequestMethod::Post, format!("{base}/{}/relations", Self::ticket_path(&id)), @@ -897,20 +951,32 @@ impl WorkspaceHttpTicketBackend { TicketError::Conflict(format!("serialize Ticket relation: {error}")) })?), )?; + relation.ticket_id = source_reference; + relation.target = target_reference; + relation.author = "workspace".to_string(); Ok(TicketBackendOperationResult::Relation(relation)) } TicketBackendOperation::RemoveTicketRelation { id, kind, target } => { + let source_reference = match &id { + TicketIdOrSlug::Id(value) + | TicketIdOrSlug::Slug(value) + | TicketIdOrSlug::Query(value) => value.clone(), + }; let target = match target { TicketIdOrSlug::Id(value) | TicketIdOrSlug::Slug(value) | TicketIdOrSlug::Query(value) => value, }; - let relation = Self::request( + let target_reference = target.clone(); + let mut relation: TicketRelation = Self::request( client, WorkspaceRequestMethod::Delete, format!("{base}/{}/relations", Self::ticket_path(&id)), Some(serde_json::json!({ "kind": kind, "target": target })), )?; + relation.ticket_id = source_reference; + relation.target = target_reference; + relation.author = "workspace".to_string(); Ok(TicketBackendOperationResult::Relation(relation)) } TicketBackendOperation::QueryTicketRelations { ticket, kind } => { @@ -1245,6 +1311,23 @@ mod tests { .expect("tool exists") } + #[test] + fn workspace_ticket_backend_canonicalizes_model_facing_ticket_ids() { + let mut value = serde_json::json!({ + "id": "00001INTERNAL", + "resource_key": "T-42", + "nested": { + "id": "00002INTERNAL", + "resource_key": "T-43" + }, + "body": "user-authored 00003BODY stays unchanged" + }); + WorkspaceHttpTicketBackend::canonicalize_ticket_references(&mut value); + assert_eq!(value["id"], "T-42"); + assert_eq!(value["nested"]["id"], "T-43"); + assert_eq!(value["body"], "user-authored 00003BODY stays unchanged"); + } + #[test] fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() { let client: Arc = Arc::new( diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 1d01aad7..dd87052c 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -22,7 +22,7 @@ use crate::records::{ TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView, TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage, - summarize_body, truncate_body, validate_project_id, + summarize_body, truncate_body, }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, @@ -633,7 +633,15 @@ impl SqliteWorkspaceAuthority { predicates.push(format!("o.updated_at<{value}")); } if let Some(value) = &query.linked_ticket_id { - let value = bind(SqlValue::Text(value.clone())); + let resolved = self + .store + .resolve_resource_reference( + &self.workspace_id, + WorkspaceResourceKind::Ticket, + value, + )? + .ok_or_else(|| invalid_objective_error("linked Ticket was not found"))?; + let value = bind(SqlValue::Text(resolved)); predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=o.workspace_id AND link.objective_id=o.objective_id AND link.ticket_id={value})")); } let relevance_rank = if let Some(text) = @@ -1237,6 +1245,10 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { .into_iter() .map(|link| link.ticket_id) .collect::>(); + let linked_ticket_keys = linked_tickets + .iter() + .map(|ticket_id| self.resource_key(WorkspaceResourceKind::Ticket, ticket_id)) + .collect::>>()?; let body_md = record.body_md.clone(); let objective = ObjectiveSummary { resource_key: self @@ -1253,6 +1265,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { items.push(objective_query_item( objective, linked_tickets, + linked_ticket_keys, query.query.as_deref(), &body_md, )); @@ -1339,9 +1352,19 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { fn create_objective(&self, input: ObjectiveCreateInput) -> Result { validate_objective_title(&input.title)?; validate_objective_state(&input.state)?; - for ticket_id in &input.linked_tickets { - validate_project_id(ticket_id)?; - } + let linked_tickets = input + .linked_tickets + .iter() + .map(|ticket_reference| { + self.store + .resolve_resource_reference( + &self.workspace_id, + WorkspaceResourceKind::Ticket, + ticket_reference, + )? + .ok_or_else(|| invalid_objective_error("linked Ticket was not found")) + }) + .collect::>>()?; let now = now_rfc3339(); let objective_id = allocate_record_id( unix_epoch_millis_now().map_err(|err| { @@ -1367,8 +1390,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { updated_at: now.clone(), }; self.store.upsert_objective(&record)?; - let links = input - .linked_tickets + let links = linked_tickets .into_iter() .map(|ticket_id| ObjectiveTicketLinkRecord { workspace_id: self.workspace_id.clone(), @@ -2283,12 +2305,18 @@ fn ticket_query_item( .iter() .map(|objective| objective.id.clone()) .collect(), + linked_objective_keys: detail + .linked_objectives + .iter() + .map(|objective| objective.resource_key.clone()) + .collect(), relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(), blocker_count: detail.relations.blockers.len(), unresolved_blocker_count: detail.relations.blockers.len(), unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes), evidence: detail.evidence.clone(), merge_request: detail.merge_request.clone(), + current_coder: detail.current_coder.clone(), } } @@ -2399,6 +2427,7 @@ fn ticket_item_after_cursor( fn objective_query_item( objective: ObjectiveSummary, linked_tickets: Vec, + linked_ticket_keys: Vec, text: Option<&str>, body_md: &str, ) -> ObjectiveQueryItem { @@ -2426,6 +2455,7 @@ fn objective_query_item( snippet, linked_ticket_count: linked_tickets.len(), linked_tickets, + linked_ticket_keys, } } @@ -3308,16 +3338,21 @@ VALUES ('workspace-test', 'ticket', 4); assert!(!objective.revision.is_empty()); assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2"); assert_eq!(objective.linked_ticket_summaries[0].state, "ready"); + let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone(); let objective_query = authority .query_objectives(ObjectiveQueryRequest { query: Some("Control plane".to_string()), - linked_ticket_id: Some("00000000001J2".to_string()), + linked_ticket_id: Some(linked_ticket_key.clone()), limit: Some(1), ..ObjectiveQueryRequest::default() }) .unwrap(); assert_eq!(objective_query.items.len(), 1); assert_eq!(objective_query.items[0].linked_ticket_count, 1); + assert_eq!( + objective_query.items[0].linked_ticket_keys, + vec![linked_ticket_key] + ); assert_eq!(objective_query.page.limit, 1); let body_query = authority .query_objectives(ObjectiveQueryRequest { @@ -3410,7 +3445,7 @@ VALUES ('workspace-test', 'ticket', 3); title: "Create Objective".to_string(), body_md: "Alpha body".to_string(), state: "active".to_string(), - linked_tickets: vec!["00000000001J2".to_string()], + linked_tickets: vec!["T-1".to_string()], }) .unwrap(); assert_eq!(created.title, "Create Objective"); @@ -3436,14 +3471,14 @@ VALUES ('workspace-test', 'ticket', 3); assert_eq!(state.state, "paused"); assert_eq!( authority - .link_objective_ticket(&created.id, "00000000001J3") + .link_objective_ticket(&created.id, "T-2") .unwrap() .linked_tickets, vec!["00000000001J2", "00000000001J3"] ); assert_eq!( authority - .unlink_objective_ticket(&created.id, "00000000001J2") + .unlink_objective_ticket(&created.id, "T-1") .unwrap() .linked_tickets, vec!["00000000001J3"] diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 91b85100..c5312fa3 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -1,12 +1,9 @@ -use project_record::validate_record_id; use serde::{Deserialize, Serialize}; pub use workspace_api::{ ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary, ObjectiveSummary, QueryPage, }; -use crate::{Error, Result}; - const SUMMARY_BODY_LIMIT: usize = 240; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -387,12 +384,16 @@ pub struct TicketQueryItem { pub snippet: Option, pub matching_event: Option, pub linked_objective_ids: Vec, + #[ts(skip)] + pub linked_objective_keys: Vec, pub relation_count: usize, pub blocker_count: usize, pub unresolved_blocker_count: usize, pub unresolved_review_count: usize, pub evidence: TicketEvidenceSummary, pub merge_request: Option, + #[ts(skip)] + pub current_coder: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -435,6 +436,7 @@ pub struct ObjectiveQueryItem { pub snippet: Option, pub linked_ticket_count: usize, pub linked_tickets: Vec, + pub linked_ticket_keys: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -521,10 +523,6 @@ mod typescript_tests { } } -pub(crate) fn validate_project_id(id: &str) -> Result<()> { - validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string())) -} - pub(crate) fn summarize_body(body: &str) -> String { let summary = body .lines()