feat: project Ticket and Objective human references

This commit is contained in:
2026-08-27 12:39:28 +09:00
parent 060f280fdf
commit b31642e284
7 changed files with 971 additions and 63 deletions
+11 -11
View File
@@ -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<String>,
/// 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,
}
+1
View File
@@ -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;
+38 -24
View File
@@ -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<dyn WorkspaceClient>,
@@ -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<T: for<'de> Deserialize<'de>>(
}
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
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<String>,
updated_at: Option<String>,
linked_tickets: Vec<String>,
body: String,
body_truncated: bool,
record_source: String,
}
#[cfg(test)]
@@ -0,0 +1,777 @@
use serde::Serialize;
use serde_json::{Map, Value};
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketQueryResponse {
tickets: Vec<ModelTicketQueryItem>,
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelTicketQueryItem {
ticket: String,
title: String,
state: String,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
workspace_action_priority: Option<String>,
matched_fields: Vec<String>,
snippet: Option<String>,
current_coder: Option<ModelWorkerSummary>,
linked_objectives: Vec<String>,
relation_count: usize,
blocker_count: usize,
unresolved_blocker_count: usize,
unresolved_review_count: usize,
evidence: Option<ModelTicketEvidence>,
merge_request: Option<ModelMergeRequest>,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail {
ticket: String,
title: String,
body: String,
state: String,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
thread: Vec<ModelTicketEvent>,
relations: ModelTicketRelations,
linked_objectives: Vec<ModelObjectiveSummary>,
assignments: Vec<ModelAssignment>,
current_coder: Option<ModelWorkerSummary>,
implementation_reports: Vec<ModelEvidenceEvent>,
merge_request: Option<ModelMergeRequest>,
evidence: Option<ModelTicketEvidence>,
actions: Option<ModelTicketActions>,
event_page: Option<ModelEventPage>,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelObjectiveQueryResponse {
objectives: Vec<ModelObjectiveQueryItem>,
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveQueryItem {
objective: String,
title: String,
summary: String,
state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<String>,
linked_ticket_count: usize,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelObjectiveDetail {
objective: String,
title: String,
body: String,
state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<ModelTicketSummary>,
events: Vec<ModelObjectiveEvent>,
event_page: ModelObjectiveEventPage,
}
#[derive(Debug, Serialize)]
struct ModelWorkerSummary {
worker: String,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvent {
sequence: usize,
kind: String,
body: Option<String>,
created_at: Option<String>,
}
#[derive(Debug, Serialize, Default)]
struct ModelTicketRelations {
outgoing: Vec<ModelRelation>,
incoming: Vec<ModelRelation>,
blockers: Vec<ModelBlocker>,
notices: Vec<ModelNotice>,
}
#[derive(Debug, Serialize)]
struct ModelRelation {
ticket: String,
kind: String,
note: Option<String>,
created_at: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelBlocker {
ticket: String,
kind: String,
state: Option<String>,
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<String>,
excerpt: String,
}
#[derive(Debug, Serialize)]
struct ModelMergeRequest {
state: String,
selector_from: Option<String>,
selector_to: String,
review_status: String,
subject_ref: Option<String>,
review_excerpt: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvidence {
has_merge_request: bool,
has_current_subject_ref: bool,
has_review_request: bool,
has_commit: bool,
review_status: Option<String>,
approved_current_subject: bool,
unresolved_request_changes: bool,
complete_for_integration: bool,
missing: Vec<String>,
}
#[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<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveEvent {
kind: String,
created_at: String,
body: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveEventPage {
next_cursor: Option<String>,
has_more: bool,
}
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
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::<Result<Vec<_>, _>>()?;
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<ModelTicketQueryItem, String> {
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::<Result<Vec<_>, _>>()?,
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<ModelTicketDetail, String> {
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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?,
relations: project_relations(root.get("relations"))?,
linked_objectives: array_field(root, "linked_objectives")?
.iter()
.map(project_objective_summary)
.collect::<Result<Vec<_>, _>>()?,
assignments,
current_coder,
implementation_reports: array_field(root, "implementation_reports")?
.iter()
.map(project_evidence_event)
.collect::<Result<Vec<_>, _>>()?,
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<ModelObjectiveQueryResponse, String> {
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::<Result<Vec<_>, _>>()?,
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem, String> {
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::<Result<Vec<_>, _>>()?;
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<ModelObjectiveDetail, String> {
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::<Result<Vec<_>, _>>()?,
events: array_field(root, "events")?
.iter()
.map(project_objective_event)
.collect::<Result<Vec<_>, _>>()?,
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<ModelWorkerSummary, String> {
let worker = object(value, "Worker summary")?;
Ok(ModelWorkerSummary {
worker: human_ref(worker, "worker_resource_key", "W-")?,
})
}
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
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<ModelTicketRelations, String> {
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::<Result<Vec<_>, _>>()?,
incoming: array_field(relations, "incoming")?
.iter()
.map(|value| project_relation(value, "source_resource_key", "forward_kind"))
.collect::<Result<Vec<_>, _>>()?,
blockers: array_field(relations, "blockers")?
.iter()
.map(project_blocker)
.collect::<Result<Vec<_>, _>>()?,
notices: array_field(relations, "notices")?
.iter()
.map(project_notice)
.collect::<Result<Vec<_>, _>>()?,
})
}
fn project_relation(
value: &Value,
ticket_key: &str,
kind_key: &str,
) -> Result<ModelRelation, String> {
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<ModelBlocker, String> {
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<ModelNotice, String> {
let notice = object(value, "Ticket notice")?;
Ok(ModelNotice {
kind: string_field(notice, "kind")?,
})
}
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
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<ModelTicketSummary, String> {
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<ModelAssignment, String> {
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<ModelEvidenceEvent, String> {
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<ModelMergeRequest, String> {
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<ModelTicketEvidence, String> {
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<ModelTicketActions, String> {
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<ModelEventPage, String> {
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<ModelObjectiveEvent, String> {
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<ModelObjectiveEventPage, String> {
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>, String> {
value
.as_object()
.ok_or_else(|| format!("{context} must be an object"))
}
fn object_field<'a>(
object: &'a Map<String, Value>,
key: &str,
) -> Result<&'a Map<String, Value>, String> {
object
.get(key)
.and_then(Value::as_object)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn array_field<'a>(object: &'a Map<String, Value>, 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<String, Value>, key: &str) -> Result<String, String> {
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<String, Value>, key: &str) -> Result<Option<String>, 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<String, Value>, key: &str) -> Result<bool, String> {
object
.get(key)
.and_then(Value::as_bool)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn usize_field(object: &Map<String, Value>, key: &str) -> Result<usize, String> {
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<String, Value>, key: &str) -> Result<Vec<String>, 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<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
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<String, String> {
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-"));
}
}
+93 -10
View File
@@ -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<WorkspaceTicketAttentionFilter>,
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
related_ticket_id: Option<String>,
relation_kind: Option<WorkspaceTicketRelationFilter>,
/// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility.
linked_objective_id: Option<String>,
updated_after: Option<String>,
updated_before: Option<String>,
@@ -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<usize>,
@@ -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<dyn WorkspaceClient>,
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<dyn WorkspaceClient> = Arc::new(
+46 -11
View File
@@ -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::<Vec<_>>();
let linked_ticket_keys = linked_tickets
.iter()
.map(|ticket_id| self.resource_key(WorkspaceResourceKind::Ticket, ticket_id))
.collect::<Result<Vec<_>>>()?;
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<ObjectiveDetail> {
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::<Result<Vec<_>>>()?;
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<String>,
linked_ticket_keys: Vec<String>,
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"]
+5 -7
View File
@@ -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<String>,
pub matching_event: Option<TicketEvidenceEvent>,
pub linked_objective_ids: Vec<String>,
#[ts(skip)]
pub linked_objective_keys: Vec<String>,
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<TicketMergeRequestSummary>,
#[ts(skip)]
pub current_coder: Option<TicketAssignmentSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -435,6 +436,7 @@ pub struct ObjectiveQueryItem {
pub snippet: Option<String>,
pub linked_ticket_count: usize,
pub linked_tickets: Vec<String>,
pub linked_ticket_keys: Vec<String>,
}
#[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()