fix: align read projections with query contracts
This commit is contained in:
@@ -422,12 +422,12 @@ fn list_schema() -> serde_json::Value {
|
||||
"type":"object",
|
||||
"additionalProperties": false,
|
||||
"properties":{
|
||||
"text":{"type":["string","null"]},
|
||||
"query":{"type":["string","null"]},
|
||||
"states":{"type":"array","items":{"type":"string"},"default":[]},
|
||||
"linked_ticket_id":{"type":["string","null"]},
|
||||
"updated_after":{"type":["string","null"]},
|
||||
"updated_before":{"type":["string","null"]},
|
||||
"sort":{"type":["string","null"],"enum":["updated_desc","title",null]},
|
||||
"sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]},
|
||||
"limit":{"type":["integer","null"],"minimum":1,"maximum":100},
|
||||
"cursor":{"type":["string","null"]}
|
||||
}
|
||||
@@ -510,7 +510,7 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct QueryObjectiveInput {
|
||||
text: Option<String>,
|
||||
query: Option<String>,
|
||||
#[serde(default)]
|
||||
states: Vec<String>,
|
||||
linked_ticket_id: Option<String>,
|
||||
|
||||
@@ -69,33 +69,103 @@ impl WorkspaceTicketReadKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketStateFilter {
|
||||
Planning,
|
||||
Ready,
|
||||
Queued,
|
||||
Inprogress,
|
||||
Done,
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketEvidenceFilter {
|
||||
ImplementationReport,
|
||||
ImplementationReportAfterRescope,
|
||||
MergeRequest,
|
||||
Commit,
|
||||
ApprovedReview,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketReviewFilter {
|
||||
None,
|
||||
Pending,
|
||||
Approved,
|
||||
RequestChanges,
|
||||
UnresolvedChanges,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketAttentionFilter {
|
||||
DoneNotClosed,
|
||||
ImplementationReportNotClosed,
|
||||
ReportAfterRescope,
|
||||
UnresolvedReview,
|
||||
MissingCommit,
|
||||
Blocked,
|
||||
Unblocked,
|
||||
Ready,
|
||||
AwaitingReview,
|
||||
UnresolvedChanges,
|
||||
StaleAfterRescope,
|
||||
MissingEvidence,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketRelationFilter {
|
||||
DependsOn,
|
||||
Blocks,
|
||||
Related,
|
||||
Supersedes,
|
||||
DuplicateOf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketSort {
|
||||
Relevance,
|
||||
UpdatedDesc,
|
||||
CreatedDesc,
|
||||
Priority,
|
||||
Title,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
struct WorkspaceQueryTicketInput {
|
||||
/// Full-text match over Ticket title, item body, and bounded thread excerpts.
|
||||
text: Option<String>,
|
||||
query: Option<String>,
|
||||
/// Exact workflow states. Empty means every state.
|
||||
#[serde(default)]
|
||||
states: Vec<String>,
|
||||
states: Vec<WorkspaceTicketStateFilter>,
|
||||
/// Exact typed event kinds that must occur in the bounded thread window.
|
||||
#[serde(default)]
|
||||
event_kinds: Vec<String>,
|
||||
/// Required evidence kinds: implementation_report, implementation_report_after_rescope,
|
||||
/// merge_request, commit, or approved_review.
|
||||
#[serde(default)]
|
||||
evidence: Vec<String>,
|
||||
/// Current authoritative Merge Request review status.
|
||||
review_status: Option<String>,
|
||||
/// Attention filters: blocked, ready, awaiting_review, unresolved_changes,
|
||||
/// stale_after_rescope, or missing_evidence.
|
||||
evidence: Vec<WorkspaceTicketEvidenceFilter>,
|
||||
/// Current authoritative Merge Request review status: none, pending, approved,
|
||||
/// request_changes, or unresolved_changes.
|
||||
review_status: Option<WorkspaceTicketReviewFilter>,
|
||||
/// Attention filters include done_not_closed, implementation_report_not_closed,
|
||||
/// report_after_rescope, unresolved_review, missing_commit, blocked, and unblocked.
|
||||
#[serde(default)]
|
||||
attention: Vec<String>,
|
||||
attention: Vec<WorkspaceTicketAttentionFilter>,
|
||||
related_ticket_id: Option<String>,
|
||||
relation_kind: Option<String>,
|
||||
relation_kind: Option<WorkspaceTicketRelationFilter>,
|
||||
linked_objective_id: Option<String>,
|
||||
updated_after: Option<String>,
|
||||
updated_before: Option<String>,
|
||||
/// updated_desc (default), priority, or title.
|
||||
sort: Option<String>,
|
||||
/// relevance (default when query is present), updated_desc, created_desc,
|
||||
/// priority, or title.
|
||||
sort: Option<WorkspaceTicketSort>,
|
||||
/// Page size; bounded by the Backend to 1..=100.
|
||||
limit: Option<usize>,
|
||||
/// Opaque cursor returned by a prior QueryTicket page.
|
||||
@@ -306,14 +376,7 @@ impl TicketFeatureAccess {
|
||||
}
|
||||
}
|
||||
|
||||
const READ_ONLY_TOOL_NAMES: &[&str] = &[
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
|
||||
|
||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
@@ -337,12 +400,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketComment",
|
||||
"TicketQueue",
|
||||
"TicketClose",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -353,10 +412,8 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketDependencyCheck",
|
||||
"TicketDoctor",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
"TicketRelationQuery",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
@@ -364,9 +421,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
||||
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
"TicketDependencyCheck",
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -1232,10 +1291,30 @@ mod tests {
|
||||
assert!(query.input_schema["properties"]["evidence"].is_object());
|
||||
assert!(query.input_schema["properties"]["attention"].is_object());
|
||||
assert!(query.input_schema["properties"]["cursor"].is_object());
|
||||
let query_schema = serde_json::to_string(&query.input_schema).unwrap();
|
||||
assert!(query_schema.contains("done_not_closed"));
|
||||
assert!(query_schema.contains("request_changes"));
|
||||
assert!(query_schema.contains("created_desc"));
|
||||
assert!(
|
||||
query_schema.len() < 8_000,
|
||||
"QueryTicket schema grew unexpectedly"
|
||||
);
|
||||
let (show, _) = workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Show)();
|
||||
assert_eq!(show.name, "ShowTicket");
|
||||
assert!(show.input_schema["properties"]["event_limit"].is_object());
|
||||
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
|
||||
assert_eq!(tool_names.len(), 9);
|
||||
assert!(
|
||||
tool_names.len() < 13,
|
||||
"authoring catalog must stay below the prior broad catalog"
|
||||
);
|
||||
let workflow_names = TicketFeatureAccess::workflow().tool_names();
|
||||
assert_eq!(workflow_names.len(), 10);
|
||||
assert!(
|
||||
workflow_names.len() < 12,
|
||||
"workflow catalog must stay below the prior broad catalog"
|
||||
);
|
||||
assert_eq!(TicketFeatureAccess::review().tool_names().len(), 2);
|
||||
assert!(tool_names.contains(&"QueryTicket"));
|
||||
assert!(tool_names.contains(&"ShowTicket"));
|
||||
assert!(!tool_names.contains(&"TicketList"));
|
||||
|
||||
@@ -10,12 +10,12 @@ use ticket::{
|
||||
};
|
||||
|
||||
use crate::records::{
|
||||
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveQueryItem,
|
||||
ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary, ObjectiveShowRequest,
|
||||
ObjectiveSummary, ProjectRecordList, QueryPage, TicketDetail, TicketEventDetail,
|
||||
TicketEvidenceEvent, TicketEvidenceSummary, TicketMergeRequestSummary, TicketQueryItem,
|
||||
TicketQueryRequest, TicketQueryResponse, TicketShowRequest, TicketSummary, summarize_body,
|
||||
truncate_body, validate_project_id,
|
||||
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveLinkedTicketSummary,
|
||||
ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary,
|
||||
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary,
|
||||
TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary,
|
||||
TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, TicketQueryResponse,
|
||||
TicketShowRequest, TicketSummary, summarize_body, truncate_body, validate_project_id,
|
||||
};
|
||||
use crate::store::{
|
||||
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
||||
@@ -163,6 +163,17 @@ impl SqliteWorkspaceAuthority {
|
||||
.into_iter()
|
||||
.map(|link| link.ticket_id)
|
||||
.collect::<Vec<_>>();
|
||||
let linked_ticket_summaries = self
|
||||
.list_tickets(1_000)?
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|ticket| linked_tickets.iter().any(|id| id == &ticket.id))
|
||||
.map(|ticket| ObjectiveLinkedTicketSummary {
|
||||
id: ticket.id,
|
||||
title: ticket.title,
|
||||
state: ticket.state,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let resources = self
|
||||
.store
|
||||
.list_objective_resources(&self.workspace_id, &record.objective_id)?
|
||||
@@ -207,6 +218,7 @@ impl SqliteWorkspaceAuthority {
|
||||
created_at: Some(record.created_at),
|
||||
updated_at: Some(record.updated_at),
|
||||
linked_tickets,
|
||||
linked_ticket_summaries,
|
||||
resources,
|
||||
body,
|
||||
body_truncated,
|
||||
@@ -295,6 +307,14 @@ impl SqliteWorkspaceAuthority {
|
||||
.filter(|(_, event)| event.kind.as_str() == "implementation_report")
|
||||
.map(|(sequence, event)| ticket_evidence_event(sequence, event))
|
||||
.collect::<Vec<_>>();
|
||||
let current_assignment = self
|
||||
.store
|
||||
.get_current_ticket_worker_assignment(&self.workspace_id, id)?
|
||||
.map(|assignment| TicketAssignmentSummary {
|
||||
assignment_id: assignment.assignment_id,
|
||||
runtime_id: assignment.worker.runtime_id,
|
||||
worker_id: assignment.worker.worker_id,
|
||||
});
|
||||
let merge_request = self
|
||||
.merge_request_store
|
||||
.show_for_ticket(id)
|
||||
@@ -313,6 +333,7 @@ impl SqliteWorkspaceAuthority {
|
||||
id: ticket.meta.id,
|
||||
title: ticket.meta.title,
|
||||
state: ticket.meta.workflow_state.as_str().to_string(),
|
||||
readiness: ticket.meta.readiness,
|
||||
priority: ticket.meta.priority,
|
||||
created_at: ticket.meta.created_at,
|
||||
updated_at: ticket.meta.updated_at,
|
||||
@@ -345,6 +366,7 @@ impl SqliteWorkspaceAuthority {
|
||||
relations: ticket.relations.into(),
|
||||
linked_objectives,
|
||||
implementation_reports,
|
||||
current_assignment,
|
||||
merge_request,
|
||||
evidence,
|
||||
resolution: ticket
|
||||
@@ -388,7 +410,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||
fn query_tickets(&self, query: TicketQueryRequest) -> Result<TicketQueryResponse> {
|
||||
validate_ticket_query(&query)?;
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 100);
|
||||
let sort = normalize_ticket_sort(query.sort.as_deref())?;
|
||||
let sort = normalize_ticket_sort(query.sort.as_deref(), query.query.is_some())?;
|
||||
let cursor = query
|
||||
.cursor
|
||||
.as_deref()
|
||||
@@ -457,6 +479,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
id: record.objective_id,
|
||||
title: record.title,
|
||||
state: record.state,
|
||||
created_at: Some(record.created_at),
|
||||
updated_at: Some(record.updated_at),
|
||||
summary: summarize_body(&record.body_md),
|
||||
linked_tickets,
|
||||
@@ -476,7 +499,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
query.updated_before.as_deref(),
|
||||
)?;
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 100);
|
||||
let sort = normalize_objective_sort(query.sort.as_deref())?;
|
||||
let sort = normalize_objective_sort(query.sort.as_deref(), query.query.is_some())?;
|
||||
let cursor = query
|
||||
.cursor
|
||||
.as_deref()
|
||||
@@ -506,7 +529,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||
items.push(objective_query_item(
|
||||
objective,
|
||||
linked_tickets,
|
||||
query.text.as_deref(),
|
||||
query.query.as_deref(),
|
||||
));
|
||||
}
|
||||
sort_objective_query_items(&mut items, sort);
|
||||
@@ -851,7 +874,9 @@ impl MemoryAuthority for SqliteWorkspaceAuthority {
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TicketQuerySort {
|
||||
Relevance,
|
||||
UpdatedDesc,
|
||||
CreatedDesc,
|
||||
Priority,
|
||||
Title,
|
||||
}
|
||||
@@ -859,7 +884,9 @@ enum TicketQuerySort {
|
||||
impl std::fmt::Display for TicketQuerySort {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::Relevance => "relevance",
|
||||
Self::UpdatedDesc => "updated_desc",
|
||||
Self::CreatedDesc => "created_desc",
|
||||
Self::Priority => "priority",
|
||||
Self::Title => "title",
|
||||
})
|
||||
@@ -868,14 +895,18 @@ impl std::fmt::Display for TicketQuerySort {
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ObjectiveQuerySort {
|
||||
Relevance,
|
||||
UpdatedDesc,
|
||||
CreatedDesc,
|
||||
Title,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ObjectiveQuerySort {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::Relevance => "relevance",
|
||||
Self::UpdatedDesc => "updated_desc",
|
||||
Self::CreatedDesc => "created_desc",
|
||||
Self::Title => "title",
|
||||
})
|
||||
}
|
||||
@@ -1031,7 +1062,13 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
|
||||
for attention in &query.attention {
|
||||
if !matches!(
|
||||
attention.as_str(),
|
||||
"blocked"
|
||||
"done_not_closed"
|
||||
| "implementation_report_not_closed"
|
||||
| "report_after_rescope"
|
||||
| "unresolved_review"
|
||||
| "missing_commit"
|
||||
| "blocked"
|
||||
| "unblocked"
|
||||
| "ready"
|
||||
| "awaiting_review"
|
||||
| "unresolved_changes"
|
||||
@@ -1044,7 +1081,15 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
|
||||
}
|
||||
}
|
||||
if let Some(status) = query.review_status.as_deref()
|
||||
&& !matches!(status, "pending" | "approved" | "changes_requested")
|
||||
&& !matches!(
|
||||
status,
|
||||
"none"
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "request_changes"
|
||||
| "unresolved_changes"
|
||||
| "changes_requested"
|
||||
)
|
||||
{
|
||||
return Err(Error::InvalidRecordId(format!(
|
||||
"unsupported review status `{status}`"
|
||||
@@ -1080,9 +1125,15 @@ fn validate_time_bounds(after: Option<&str>, before: Option<&str>) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_ticket_sort(sort: Option<&str>) -> Result<TicketQuerySort> {
|
||||
match sort.unwrap_or("updated_desc") {
|
||||
fn normalize_ticket_sort(sort: Option<&str>, has_query: bool) -> Result<TicketQuerySort> {
|
||||
match sort.unwrap_or(if has_query {
|
||||
"relevance"
|
||||
} else {
|
||||
"updated_desc"
|
||||
}) {
|
||||
"relevance" => Ok(TicketQuerySort::Relevance),
|
||||
"updated_desc" => Ok(TicketQuerySort::UpdatedDesc),
|
||||
"created_desc" => Ok(TicketQuerySort::CreatedDesc),
|
||||
"priority" => Ok(TicketQuerySort::Priority),
|
||||
"title" => Ok(TicketQuerySort::Title),
|
||||
other => Err(Error::InvalidRecordId(format!(
|
||||
@@ -1091,9 +1142,15 @@ fn normalize_ticket_sort(sort: Option<&str>) -> Result<TicketQuerySort> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_objective_sort(sort: Option<&str>) -> Result<ObjectiveQuerySort> {
|
||||
match sort.unwrap_or("updated_desc") {
|
||||
fn normalize_objective_sort(sort: Option<&str>, has_query: bool) -> Result<ObjectiveQuerySort> {
|
||||
match sort.unwrap_or(if has_query {
|
||||
"relevance"
|
||||
} else {
|
||||
"updated_desc"
|
||||
}) {
|
||||
"relevance" => Ok(ObjectiveQuerySort::Relevance),
|
||||
"updated_desc" => Ok(ObjectiveQuerySort::UpdatedDesc),
|
||||
"created_desc" => Ok(ObjectiveQuerySort::CreatedDesc),
|
||||
"title" => Ok(ObjectiveQuerySort::Title),
|
||||
other => Err(Error::InvalidRecordId(format!(
|
||||
"unsupported Objective query sort `{other}`"
|
||||
@@ -1154,11 +1211,18 @@ fn ticket_matches_query(
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(review_status) = &query.review_status
|
||||
&& detail.evidence.review_status.as_ref() != Some(review_status)
|
||||
{
|
||||
if let Some(review_status) = &query.review_status {
|
||||
let matches = match review_status.as_str() {
|
||||
"none" => detail.evidence.review_status.is_none(),
|
||||
"request_changes" | "unresolved_changes" => {
|
||||
detail.evidence.review_status.as_deref() == Some("changes_requested")
|
||||
}
|
||||
status => detail.evidence.review_status.as_deref() == Some(status),
|
||||
};
|
||||
if !matches {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if !query
|
||||
.evidence
|
||||
.iter()
|
||||
@@ -1179,7 +1243,15 @@ fn ticket_matches_query(
|
||||
.attention
|
||||
.iter()
|
||||
.all(|attention| match attention.as_str() {
|
||||
"done_not_closed" => summary.state == "done",
|
||||
"implementation_report_not_closed" => {
|
||||
detail.evidence.has_implementation_report && summary.state != "closed"
|
||||
}
|
||||
"report_after_rescope" => detail.evidence.implementation_report_after_rescope,
|
||||
"unresolved_review" => detail.evidence.unresolved_request_changes,
|
||||
"missing_commit" => !detail.evidence.has_commit,
|
||||
"blocked" => !detail.relations.blockers.is_empty(),
|
||||
"unblocked" => detail.relations.blockers.is_empty(),
|
||||
"ready" => summary.state == "ready" && detail.relations.blockers.is_empty(),
|
||||
"awaiting_review" => detail.evidence.review_status.as_deref() == Some("pending"),
|
||||
"unresolved_changes" => detail.evidence.unresolved_request_changes,
|
||||
@@ -1213,7 +1285,7 @@ fn ticket_matches_query(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
query.text.as_ref().is_none_or(|text| {
|
||||
query.query.as_ref().is_none_or(|text| {
|
||||
let needle = text.to_lowercase();
|
||||
summary.title.to_lowercase().contains(&needle)
|
||||
|| detail.body.to_lowercase().contains(&needle)
|
||||
@@ -1234,7 +1306,7 @@ fn ticket_query_item(
|
||||
let mut matched_fields = Vec::new();
|
||||
let mut snippet = None;
|
||||
let mut matching_event = None;
|
||||
if let Some(text) = query.text.as_ref() {
|
||||
if let Some(text) = query.query.as_ref() {
|
||||
let needle = text.to_lowercase();
|
||||
if summary.title.to_lowercase().contains(&needle) {
|
||||
matched_fields.push("title".to_string());
|
||||
@@ -1266,8 +1338,11 @@ fn ticket_query_item(
|
||||
id: summary.id,
|
||||
title: summary.title,
|
||||
state: summary.state,
|
||||
readiness: detail.readiness.clone(),
|
||||
priority: summary.priority,
|
||||
created_at: detail.created_at.clone(),
|
||||
updated_at: summary.updated_at,
|
||||
item_revision: detail.item_revision.clone(),
|
||||
workspace_action_priority: summary.workspace_action_priority,
|
||||
matched_fields,
|
||||
snippet: snippet.map(|value| truncate_body(&value, 512).0),
|
||||
@@ -1279,6 +1354,8 @@ fn ticket_query_item(
|
||||
.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(),
|
||||
}
|
||||
@@ -1295,9 +1372,27 @@ fn matching_snippet(body: &str, text: &str) -> String {
|
||||
body[start..end].to_string()
|
||||
}
|
||||
|
||||
fn ticket_match_rank(item: &TicketQueryItem) -> usize {
|
||||
if item.matched_fields.iter().any(|field| field == "title") {
|
||||
0
|
||||
} else if item.matched_fields.iter().any(|field| field == "body") {
|
||||
1
|
||||
} else if item.matched_fields.iter().any(|field| field == "event") {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_sort_key(item: &TicketQueryItem, sort: TicketQuerySort) -> String {
|
||||
match sort {
|
||||
TicketQuerySort::Relevance => format!(
|
||||
"{}|{}",
|
||||
ticket_match_rank(item),
|
||||
item.updated_at.as_deref().unwrap_or("")
|
||||
),
|
||||
TicketQuerySort::UpdatedDesc => item.updated_at.clone().unwrap_or_default(),
|
||||
TicketQuerySort::CreatedDesc => item.created_at.clone().unwrap_or_default(),
|
||||
TicketQuerySort::Priority => format!(
|
||||
"{}|{}",
|
||||
match item.workspace_action_priority.as_str() {
|
||||
@@ -1313,10 +1408,18 @@ fn ticket_sort_key(item: &TicketQueryItem, sort: TicketQuerySort) -> String {
|
||||
|
||||
fn sort_ticket_query_items(items: &mut [TicketQueryItem], sort: TicketQuerySort) {
|
||||
items.sort_by(|left, right| match sort {
|
||||
TicketQuerySort::Relevance => ticket_match_rank(left)
|
||||
.cmp(&ticket_match_rank(right))
|
||||
.then_with(|| right.updated_at.cmp(&left.updated_at))
|
||||
.then_with(|| left.id.cmp(&right.id)),
|
||||
TicketQuerySort::UpdatedDesc => right
|
||||
.updated_at
|
||||
.cmp(&left.updated_at)
|
||||
.then_with(|| left.id.cmp(&right.id)),
|
||||
TicketQuerySort::CreatedDesc => right
|
||||
.created_at
|
||||
.cmp(&left.created_at)
|
||||
.then_with(|| left.id.cmp(&right.id)),
|
||||
TicketQuerySort::Priority => {
|
||||
let rank = |item: &TicketQueryItem| match item.workspace_action_priority.as_str() {
|
||||
"ready_for_queue" => 0,
|
||||
@@ -1347,9 +1450,11 @@ fn ticket_item_after_cursor(
|
||||
) -> bool {
|
||||
let key = ticket_sort_key(item, sort);
|
||||
match sort {
|
||||
TicketQuerySort::UpdatedDesc => key < cursor.0 || (key == cursor.0 && item.id > cursor.1),
|
||||
TicketQuerySort::UpdatedDesc | TicketQuerySort::CreatedDesc => {
|
||||
key < cursor.0 || (key == cursor.0 && item.id > cursor.1)
|
||||
}
|
||||
TicketQuerySort::Title => key > cursor.0 || (key == cursor.0 && item.id > cursor.1),
|
||||
TicketQuerySort::Priority => {
|
||||
TicketQuerySort::Relevance | TicketQuerySort::Priority => {
|
||||
let (rank, updated) = key.split_once('|').unwrap_or(("9", ""));
|
||||
let (cursor_rank, cursor_updated) = cursor.0.split_once('|').unwrap_or(("9", ""));
|
||||
rank > cursor_rank
|
||||
@@ -1375,7 +1480,7 @@ fn objective_matches_query(objective: &ObjectiveSummary, query: &ObjectiveQueryR
|
||||
{
|
||||
return false;
|
||||
}
|
||||
query.text.as_ref().is_none_or(|text| {
|
||||
query.query.as_ref().is_none_or(|text| {
|
||||
let needle = text.to_lowercase();
|
||||
objective.title.to_lowercase().contains(&needle)
|
||||
|| objective.summary.to_lowercase().contains(&needle)
|
||||
@@ -1404,6 +1509,7 @@ fn objective_query_item(
|
||||
id: objective.id,
|
||||
title: objective.title,
|
||||
state: objective.state,
|
||||
created_at: objective.created_at,
|
||||
updated_at: objective.updated_at,
|
||||
matched_fields,
|
||||
snippet,
|
||||
@@ -1412,19 +1518,43 @@ fn objective_query_item(
|
||||
}
|
||||
}
|
||||
|
||||
fn objective_match_rank(item: &ObjectiveQueryItem) -> usize {
|
||||
if item.matched_fields.iter().any(|field| field == "title") {
|
||||
0
|
||||
} else if item.matched_fields.iter().any(|field| field == "body") {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn objective_sort_key(item: &ObjectiveQueryItem, sort: ObjectiveQuerySort) -> String {
|
||||
match sort {
|
||||
ObjectiveQuerySort::Relevance => format!(
|
||||
"{}|{}",
|
||||
objective_match_rank(item),
|
||||
item.updated_at.as_deref().unwrap_or("")
|
||||
),
|
||||
ObjectiveQuerySort::UpdatedDesc => item.updated_at.clone().unwrap_or_default(),
|
||||
ObjectiveQuerySort::CreatedDesc => item.created_at.clone().unwrap_or_default(),
|
||||
ObjectiveQuerySort::Title => item.title.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_objective_query_items(items: &mut [ObjectiveQueryItem], sort: ObjectiveQuerySort) {
|
||||
items.sort_by(|left, right| match sort {
|
||||
ObjectiveQuerySort::Relevance => objective_match_rank(left)
|
||||
.cmp(&objective_match_rank(right))
|
||||
.then_with(|| right.updated_at.cmp(&left.updated_at))
|
||||
.then_with(|| left.id.cmp(&right.id)),
|
||||
ObjectiveQuerySort::UpdatedDesc => right
|
||||
.updated_at
|
||||
.cmp(&left.updated_at)
|
||||
.then_with(|| left.id.cmp(&right.id)),
|
||||
ObjectiveQuerySort::CreatedDesc => right
|
||||
.created_at
|
||||
.cmp(&left.created_at)
|
||||
.then_with(|| left.id.cmp(&right.id)),
|
||||
ObjectiveQuerySort::Title => left
|
||||
.title
|
||||
.to_lowercase()
|
||||
@@ -1444,9 +1574,17 @@ fn objective_item_after_cursor(
|
||||
) -> bool {
|
||||
let key = objective_sort_key(item, sort);
|
||||
match sort {
|
||||
ObjectiveQuerySort::UpdatedDesc => {
|
||||
ObjectiveQuerySort::UpdatedDesc | ObjectiveQuerySort::CreatedDesc => {
|
||||
key < cursor.0 || (key == cursor.0 && item.id > cursor.1)
|
||||
}
|
||||
ObjectiveQuerySort::Relevance => {
|
||||
let (rank, updated) = key.split_once('|').unwrap_or(("9", ""));
|
||||
let (cursor_rank, cursor_updated) = cursor.0.split_once('|').unwrap_or(("9", ""));
|
||||
rank > cursor_rank
|
||||
|| (rank == cursor_rank
|
||||
&& (updated < cursor_updated
|
||||
|| (updated == cursor_updated && item.id > cursor.1)))
|
||||
}
|
||||
ObjectiveQuerySort::Title => key > cursor.0 || (key == cursor.0 && item.id > cursor.1),
|
||||
}
|
||||
}
|
||||
@@ -1696,9 +1834,10 @@ mod tests {
|
||||
assert_eq!(ticket.event_page.returned, ticket.events.len());
|
||||
let ticket_query = authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
text: Some("Ticket body".to_string()),
|
||||
query: Some("Ticket body".to_string()),
|
||||
states: vec!["ready".to_string()],
|
||||
linked_objective_id: Some("00000000001J3".to_string()),
|
||||
attention: vec!["unblocked".to_string(), "missing_commit".to_string()],
|
||||
limit: Some(1),
|
||||
..TicketQueryRequest::default()
|
||||
})
|
||||
@@ -1740,9 +1879,11 @@ mod tests {
|
||||
let objective = authority.objective("00000000001J3").unwrap();
|
||||
assert!(objective.body.contains("Objective body"));
|
||||
assert!(!objective.revision.is_empty());
|
||||
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
|
||||
assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
|
||||
let objective_query = authority
|
||||
.query_objectives(ObjectiveQueryRequest {
|
||||
text: Some("Control plane".to_string()),
|
||||
query: Some("Control plane".to_string()),
|
||||
linked_ticket_id: Some("00000000001J2".to_string()),
|
||||
limit: Some(1),
|
||||
..ObjectiveQueryRequest::default()
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct TicketDetail {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub readiness: Option<String>,
|
||||
pub priority: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
@@ -69,6 +70,7 @@ pub struct TicketDetail {
|
||||
pub relations: TicketRelationView,
|
||||
pub linked_objectives: Vec<ObjectiveLinkSummary>,
|
||||
pub implementation_reports: Vec<TicketEvidenceEvent>,
|
||||
pub current_assignment: Option<TicketAssignmentSummary>,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub resolution: Option<String>,
|
||||
@@ -225,6 +227,14 @@ pub struct TicketEvidenceEvent {
|
||||
pub excerpt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketAssignmentSummary {
|
||||
pub assignment_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketMergeRequestSummary {
|
||||
@@ -257,7 +267,7 @@ pub struct TicketEvidenceSummary {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketQueryRequest {
|
||||
pub text: Option<String>,
|
||||
pub query: Option<String>,
|
||||
#[serde(default)]
|
||||
pub states: Vec<String>,
|
||||
#[serde(default)]
|
||||
@@ -283,8 +293,11 @@ pub struct TicketQueryItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub readiness: Option<String>,
|
||||
pub priority: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub item_revision: String,
|
||||
pub workspace_action_priority: String,
|
||||
pub matched_fields: Vec<String>,
|
||||
pub snippet: Option<String>,
|
||||
@@ -292,6 +305,8 @@ pub struct TicketQueryItem {
|
||||
pub linked_objective_ids: 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>,
|
||||
}
|
||||
@@ -313,7 +328,7 @@ pub struct TicketShowRequest {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct ObjectiveQueryRequest {
|
||||
pub text: Option<String>,
|
||||
pub query: Option<String>,
|
||||
#[serde(default)]
|
||||
pub states: Vec<String>,
|
||||
pub linked_ticket_id: Option<String>,
|
||||
@@ -329,6 +344,7 @@ pub struct ObjectiveQueryItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub matched_fields: Vec<String>,
|
||||
pub snippet: Option<String>,
|
||||
@@ -358,11 +374,19 @@ pub struct ObjectiveEventDetail {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveLinkedTicketSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ObjectiveSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub summary: String,
|
||||
pub linked_tickets: Vec<String>,
|
||||
@@ -378,6 +402,7 @@ pub struct ObjectiveDetail {
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub linked_tickets: Vec<String>,
|
||||
pub linked_ticket_summaries: Vec<ObjectiveLinkedTicketSummary>,
|
||||
pub resources: Vec<ObjectiveResourceSummary>,
|
||||
pub body: String,
|
||||
pub body_truncated: bool,
|
||||
@@ -407,6 +432,7 @@ pub fn ticket_api_typescript() -> String {
|
||||
TicketEventDetail::decl(&config),
|
||||
ObjectiveLinkSummary::decl(&config),
|
||||
TicketEvidenceEvent::decl(&config),
|
||||
TicketAssignmentSummary::decl(&config),
|
||||
TicketMergeRequestSummary::decl(&config),
|
||||
TicketEvidenceSummary::decl(&config),
|
||||
TicketQueryRequest::decl(&config),
|
||||
|
||||
@@ -17799,7 +17799,7 @@ mod tests {
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/objectives/query"),
|
||||
Some(json!({
|
||||
"text": "Objective body",
|
||||
"query": "Objective body",
|
||||
"linked_ticket_id": "00000000001J2",
|
||||
"limit": 1
|
||||
})),
|
||||
|
||||
@@ -41,8 +41,8 @@ Workers with the Ticket built-in feature can use typed Ticket tools:
|
||||
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
|
||||
- `TicketClose`
|
||||
- `TicketRelationRecord`
|
||||
- `TicketRelationQuery`
|
||||
- `TicketDoctor`
|
||||
|
||||
Profile-visible Ticket catalogs are intentionally smaller than the former broad read catalog: Workspace authoring exposes 9 tools instead of 13, workflow exposes 10 instead of 12, and review exposes only `QueryTicket` plus `ShowTicket` (2 instead of 6). The `QueryTicket` schema is regression-guarded below 8 KiB while consolidating relation/evidence/attention discovery; diagnostics are not projected into normal profiles, while specialized orchestration-plan commands remain visible only to workflow roles that need their distinct semantics.
|
||||
|
||||
These tools operate through the typed Ticket backend. They are not arbitrary filesystem write permission to `.yoi/tickets/`.
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ export type TicketEvidenceEvent = {
|
||||
excerpt: string;
|
||||
};
|
||||
|
||||
export type TicketAssignmentSummary = {
|
||||
assignment_id: string;
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
};
|
||||
|
||||
export type TicketMergeRequestSummary = {
|
||||
merge_request_id: string;
|
||||
state: string;
|
||||
@@ -87,7 +93,7 @@ export type TicketEvidenceSummary = {
|
||||
};
|
||||
|
||||
export type TicketQueryRequest = {
|
||||
text: string | null;
|
||||
query: string | null;
|
||||
states: Array<string>;
|
||||
event_kinds: Array<string>;
|
||||
evidence: Array<string>;
|
||||
@@ -107,8 +113,11 @@ export type TicketQueryItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
state: string;
|
||||
readiness: string | null;
|
||||
priority: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
item_revision: string;
|
||||
workspace_action_priority: string;
|
||||
matched_fields: Array<string>;
|
||||
snippet: string | null;
|
||||
@@ -116,6 +125,8 @@ export type TicketQueryItem = {
|
||||
linked_objective_ids: Array<string>;
|
||||
relation_count: number;
|
||||
blocker_count: number;
|
||||
unresolved_blocker_count: number;
|
||||
unresolved_review_count: number;
|
||||
evidence: TicketEvidenceSummary;
|
||||
merge_request: TicketMergeRequestSummary | null;
|
||||
};
|
||||
@@ -174,6 +185,7 @@ export type TicketDetail = {
|
||||
id: string;
|
||||
title: string;
|
||||
state: string;
|
||||
readiness: string | null;
|
||||
priority: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
@@ -194,6 +206,7 @@ export type TicketDetail = {
|
||||
relations: TicketRelationView;
|
||||
linked_objectives: Array<ObjectiveLinkSummary>;
|
||||
implementation_reports: Array<TicketEvidenceEvent>;
|
||||
current_assignment: TicketAssignmentSummary | null;
|
||||
merge_request: TicketMergeRequestSummary | null;
|
||||
evidence: TicketEvidenceSummary;
|
||||
resolution: string | null;
|
||||
|
||||
Reference in New Issue
Block a user