From 14aa1aabea0fdac81c6717f46b33baf2b51bb6b9 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 17 Aug 2026 05:18:27 +0900 Subject: [PATCH] feat: add bounded Ticket and Objective read APIs --- crates/workspace-server/src/authority.rs | 1075 ++++++++++++++++- crates/workspace-server/src/records.rs | 185 +++ crates/workspace-server/src/server.rs | 113 +- crates/workspace-server/src/store.rs | 33 + web/workspace/src/lib/generated/ticket-api.ts | 100 ++ 5 files changed, 1447 insertions(+), 59 deletions(-) diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index b4224666..9814848b 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -1,16 +1,21 @@ use std::path::PathBuf; use chrono::Utc; +use merge_request::{MergeRequest, ReviewStatus, SqliteMergeRequestStore}; use project_record::{allocate_record_id, unix_epoch_millis_now}; use ticket::{ - SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkspaceActionPriority, + SqliteTicketBackend, TicketBackend, TicketEvent, TicketIdOrSlug, TicketWorkspaceActionPriority, project_ticket_workspace_item, }; use crate::records::{ - ObjectiveDetail, ObjectiveResourceSummary, ObjectiveSummary, ProjectRecordList, TicketDetail, - TicketEventDetail, TicketSummary, summarize_body, truncate_body, validate_project_id, + 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, }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, @@ -35,12 +40,16 @@ impl WorkspaceAuthority for T where T: ObjectiveAuthority + TicketAuthority + pub trait TicketAuthority { fn list_tickets(&self, limit: usize) -> Result>; + fn query_tickets(&self, query: TicketQueryRequest) -> Result; fn ticket(&self, id: &str) -> Result; + fn show_ticket(&self, id: &str, query: TicketShowRequest) -> Result; } pub trait ObjectiveAuthority { fn list_objectives(&self, limit: usize) -> Result>; + fn query_objectives(&self, query: ObjectiveQueryRequest) -> Result; fn objective(&self, id: &str) -> Result; + fn show_objective(&self, id: &str, query: ObjectiveShowRequest) -> Result; fn create_objective(&self, input: ObjectiveCreateInput) -> Result; fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result; fn set_objective_state(&self, id: &str, state: &str) -> Result; @@ -122,6 +131,7 @@ pub struct SqliteWorkspaceAuthority { workspace_id: String, store: SqliteWorkspaceStore, ticket_backend: SqliteTicketBackend, + merge_request_store: SqliteMergeRequestStore, } impl SqliteWorkspaceAuthority { @@ -131,7 +141,12 @@ impl SqliteWorkspaceAuthority { Ok(Self { workspace_id: workspace_id.clone(), store: SqliteWorkspaceStore::open(&database_path)?, - ticket_backend: SqliteTicketBackend::open_verified(database_path, workspace_id)?, + ticket_backend: SqliteTicketBackend::open_verified( + database_path.clone(), + workspace_id.clone(), + )?, + merge_request_store: SqliteMergeRequestStore::open(database_path, workspace_id) + .map_err(|error| Error::Store(error.to_string()))?, }) } @@ -160,16 +175,51 @@ impl SqliteWorkspaceAuthority { }) .collect(); let (body, body_truncated) = truncate_body(&record.body_md, DETAIL_BODY_LIMIT); + let all_events = self + .store + .list_objective_events(&self.workspace_id, &record.objective_id)?; + let event_start = all_events.len().saturating_sub(TICKET_EVENT_LIMIT); + let events = all_events[event_start..] + .iter() + .map(|event| ObjectiveEventDetail { + event_ref: event.event_id.clone(), + kind: event.kind.clone(), + body: event + .body_md + .as_deref() + .map(|body| truncate_body(body, TICKET_EVENT_BODY_LIMIT).0), + created_at: event.created_at.clone(), + }) + .collect::>(); + let revision = format!( + "{}:{}", + record.updated_at, + all_events + .last() + .map(|event| event.event_id.as_str()) + .unwrap_or("none") + ); Ok(ObjectiveDetail { id: record.objective_id, title: record.title, state: record.state, + revision, created_at: Some(record.created_at), updated_at: Some(record.updated_at), linked_tickets, resources, body, body_truncated, + events, + event_page: QueryPage { + limit: TICKET_EVENT_LIMIT, + returned: all_events.len() - event_start, + has_more: event_start > 0, + next_cursor: (event_start > 0).then(|| event_start.to_string()), + sort: "sequence_desc".to_string(), + source_limit: None, + source_truncated: false, + }, record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), }) } @@ -203,6 +253,106 @@ impl SqliteWorkspaceAuthority { created_at: now_rfc3339(), }) } + + fn read_ticket_detail(&self, id: &str, request: TicketShowRequest) -> Result { + validate_project_id(id)?; + let ticket = self + .ticket_backend + .show(TicketIdOrSlug::Id(id.to_string()))?; + let (body, body_truncated) = + truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); + let event_limit = request + .event_limit + .unwrap_or(TICKET_EVENT_LIMIT) + .clamp(1, TICKET_EVENT_LIMIT); + let event_end = request + .event_cursor + .as_deref() + .map(|cursor| parse_offset_cursor(cursor, "event_cursor")) + .transpose()? + .unwrap_or(ticket.events.len()) + .min(ticket.events.len()); + let event_start = event_end.saturating_sub(event_limit); + let events = ticket.events[event_start..event_end] + .iter() + .enumerate() + .map(|(index, event)| ticket_event_detail(event_start + index, event)) + .collect::>(); + let linked_objectives = self + .store + .list_objectives_for_ticket(&self.workspace_id, id, 1_000)? + .into_iter() + .map(|objective| ObjectiveLinkSummary { + id: objective.objective_id, + title: objective.title, + state: objective.state, + }) + .collect::>(); + let implementation_reports = ticket + .events + .iter() + .enumerate() + .filter(|(_, event)| event.kind.as_str() == "implementation_report") + .map(|(sequence, event)| ticket_evidence_event(sequence, event)) + .collect::>(); + let merge_request = self + .merge_request_store + .show_for_ticket(id) + .map_err(|error| Error::Store(error.to_string()))? + .map(merge_request_summary); + let evidence = ticket_evidence_summary(&ticket.events, merge_request.as_ref()); + let item_revision = ticket + .events + .iter() + .rev() + .find(|event| matches!(event.kind.as_str(), "create" | "item_edit")) + .and_then(|event| event.attributes.get("event_id").cloned()) + .or_else(|| ticket.meta.updated_at.clone()) + .unwrap_or_else(|| format!("{}:0", ticket.meta.id)); + Ok(TicketDetail { + id: ticket.meta.id, + title: ticket.meta.title, + state: ticket.meta.workflow_state.as_str().to_string(), + priority: ticket.meta.priority, + created_at: ticket.meta.created_at, + updated_at: ticket.meta.updated_at, + item_revision, + queued_by: ticket.meta.queued_by, + queued_at: ticket.meta.queued_at, + assignee: ticket.meta.assignee, + repository_id: ticket.meta.repository_id, + ref_selector: ticket.meta.ref_selector, + risk_flags: ticket.meta.risk_flags, + body, + body_truncated, + event_count: ticket.events.len(), + events, + event_page: QueryPage { + limit: event_limit, + returned: event_end - event_start, + has_more: event_start > 0, + next_cursor: (event_start > 0).then(|| event_start.to_string()), + sort: "sequence_desc".to_string(), + source_limit: None, + source_truncated: false, + }, + artifact_count: ticket.artifacts.len(), + artifacts: ticket + .artifacts + .into_iter() + .map(|artifact| artifact.relative_path.display().to_string()) + .collect(), + relations: ticket.relations.into(), + linked_objectives, + implementation_reports, + merge_request, + evidence, + resolution: ticket + .resolution + .map(|resolution| resolution.as_str().to_string()), + record_source: "sqlite_yoi_ticket".to_string(), + }) + } } impl TicketAuthority for SqliteWorkspaceAuthority { @@ -235,62 +385,62 @@ impl TicketAuthority for SqliteWorkspaceAuthority { }) } - fn ticket(&self, id: &str) -> Result { - validate_project_id(id)?; - let ticket = self - .ticket_backend - .show(TicketIdOrSlug::Id(id.to_string()))?; - let (body, body_truncated) = - truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); - let event_start = ticket.events.len().saturating_sub(TICKET_EVENT_LIMIT); - let events = ticket.events[event_start..] - .iter() - .enumerate() - .map(|(index, event)| TicketEventDetail { - sequence: event_start + index, - kind: event.kind.as_str().to_owned(), - author: event.author.clone(), - at: event.at.clone(), - status: event.status.clone(), - from: event.from.clone(), - to: event.to.clone(), - reason: event.reason.clone(), - state_field: event.state_field.clone(), - heading: event.heading.clone(), - body: (!event.body.as_str().is_empty()) - .then(|| truncate_body(event.body.as_str(), TICKET_EVENT_BODY_LIMIT).0), - }) - .collect(); - Ok(TicketDetail { - id: ticket.meta.id, - title: ticket.meta.title, - state: ticket.meta.workflow_state.as_str().to_string(), - priority: ticket.meta.priority, - created_at: ticket.meta.created_at, - updated_at: ticket.meta.updated_at, - queued_by: ticket.meta.queued_by, - queued_at: ticket.meta.queued_at, - assignee: ticket.meta.assignee, - repository_id: ticket.meta.repository_id, - ref_selector: ticket.meta.ref_selector, - risk_flags: ticket.meta.risk_flags, - body, - body_truncated, - event_count: ticket.events.len(), - events, - artifact_count: ticket.artifacts.len(), - artifacts: ticket - .artifacts - .into_iter() - .map(|artifact| artifact.relative_path.display().to_string()) - .collect(), - relations: ticket.relations.into(), - resolution: ticket - .resolution - .map(|resolution| resolution.as_str().to_string()), - record_source: "sqlite_yoi_ticket".to_string(), + fn query_tickets(&self, query: TicketQueryRequest) -> Result { + validate_ticket_query(&query)?; + let limit = query.limit.unwrap_or(50).clamp(1, 100); + let sort = normalize_ticket_sort(query.sort.as_deref())?; + let cursor = query + .cursor + .as_deref() + .map(parse_query_cursor) + .transpose()?; + let mut summaries = self.list_tickets(1_001)?.items; + let source_truncated = summaries.len() > 1_000; + summaries.truncate(1_000); + let mut items = Vec::new(); + for summary in summaries { + let detail = self.read_ticket_detail( + &summary.id, + TicketShowRequest { + event_limit: Some(TICKET_EVENT_LIMIT), + event_cursor: None, + }, + )?; + if ticket_matches_query(&summary, &detail, &query) { + items.push(ticket_query_item(summary, &detail, &query)); + } + } + sort_ticket_query_items(&mut items, sort); + if let Some(cursor) = cursor { + items.retain(|item| ticket_item_after_cursor(item, sort, &cursor)); + } + let has_more = items.len() > limit; + items.truncate(limit); + let next_cursor = has_more + .then(|| items.last().map(|item| make_ticket_cursor(item, sort))) + .flatten(); + Ok(TicketQueryResponse { + page: QueryPage { + limit, + returned: items.len(), + has_more, + next_cursor, + sort: sort.to_string(), + source_limit: Some(1_000), + source_truncated, + }, + items, + record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), }) } + + fn ticket(&self, id: &str) -> Result { + self.read_ticket_detail(id, TicketShowRequest::default()) + } + + fn show_ticket(&self, id: &str, query: TicketShowRequest) -> Result { + self.read_ticket_detail(id, query) + } } impl ObjectiveAuthority for SqliteWorkspaceAuthority { @@ -320,12 +470,114 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { }) } + fn query_objectives(&self, query: ObjectiveQueryRequest) -> Result { + validate_time_bounds( + query.updated_after.as_deref(), + 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 cursor = query + .cursor + .as_deref() + .map(parse_query_cursor) + .transpose()?; + let mut objectives = self.list_objectives(1_001)?.items; + let source_truncated = objectives.len() > 1_000; + objectives.truncate(1_000); + let mut items = Vec::new(); + for objective in objectives { + if !objective_matches_query(&objective, &query) { + continue; + } + let linked_tickets = self + .store + .list_objective_ticket_links(&self.workspace_id, &objective.id)? + .into_iter() + .map(|link| link.ticket_id) + .collect::>(); + if query + .linked_ticket_id + .as_ref() + .is_some_and(|id| !linked_tickets.iter().any(|ticket_id| ticket_id == id)) + { + continue; + } + items.push(objective_query_item( + objective, + linked_tickets, + query.text.as_deref(), + )); + } + sort_objective_query_items(&mut items, sort); + if let Some(cursor) = cursor { + items.retain(|item| objective_item_after_cursor(item, sort, &cursor)); + } + let has_more = items.len() > limit; + items.truncate(limit); + let next_cursor = has_more + .then(|| items.last().map(|item| make_objective_cursor(item, sort))) + .flatten(); + Ok(ObjectiveQueryResponse { + page: QueryPage { + limit, + returned: items.len(), + has_more, + next_cursor, + sort: sort.to_string(), + source_limit: Some(1_000), + source_truncated, + }, + items, + record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), + }) + } + fn objective(&self, id: &str) -> Result { validate_project_id(id)?; let record = self.objective_record(id)?; self.objective_detail_from_record(record) } + fn show_objective(&self, id: &str, query: ObjectiveShowRequest) -> Result { + let mut detail = self.objective(id)?; + let all_events = self.store.list_objective_events(&self.workspace_id, id)?; + let event_limit = query + .event_limit + .unwrap_or(TICKET_EVENT_LIMIT) + .clamp(1, TICKET_EVENT_LIMIT); + let event_end = query + .event_cursor + .as_deref() + .map(|cursor| parse_offset_cursor(cursor, "event_cursor")) + .transpose()? + .unwrap_or(all_events.len()) + .min(all_events.len()); + let event_start = event_end.saturating_sub(event_limit); + detail.events = all_events[event_start..event_end] + .iter() + .map(|event| ObjectiveEventDetail { + event_ref: event.event_id.clone(), + kind: event.kind.clone(), + body: event + .body_md + .as_deref() + .map(|body| truncate_body(body, TICKET_EVENT_BODY_LIMIT).0), + created_at: event.created_at.clone(), + }) + .collect(); + detail.event_page = QueryPage { + limit: event_limit, + returned: event_end - event_start, + has_more: event_start > 0, + next_cursor: (event_start > 0).then(|| event_start.to_string()), + sort: "sequence_desc".to_string(), + source_limit: None, + source_truncated: false, + }; + Ok(detail) + } + fn create_objective(&self, input: ObjectiveCreateInput) -> Result { validate_objective_title(&input.title)?; validate_objective_state(&input.state)?; @@ -597,6 +849,608 @@ impl MemoryAuthority for SqliteWorkspaceAuthority { } } +#[derive(Clone, Copy)] +enum TicketQuerySort { + UpdatedDesc, + Priority, + Title, +} + +impl std::fmt::Display for TicketQuerySort { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::UpdatedDesc => "updated_desc", + Self::Priority => "priority", + Self::Title => "title", + }) + } +} + +#[derive(Clone, Copy)] +enum ObjectiveQuerySort { + UpdatedDesc, + Title, +} + +impl std::fmt::Display for ObjectiveQuerySort { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::UpdatedDesc => "updated_desc", + Self::Title => "title", + }) + } +} + +fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String { + event + .attributes + .get("event_id") + .cloned() + .unwrap_or_else(|| format!("sequence:{sequence}")) +} + +fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetail { + TicketEventDetail { + sequence, + event_ref: ticket_event_ref(sequence, event), + kind: event.kind.as_str().to_owned(), + author: event.author.clone(), + at: event.at.clone(), + status: event.status.clone(), + from: event.from.clone(), + to: event.to.clone(), + reason: event.reason.clone(), + state_field: event.state_field.clone(), + heading: event.heading.clone(), + body: (!event.body.as_str().is_empty()) + .then(|| truncate_body(event.body.as_str(), TICKET_EVENT_BODY_LIMIT).0), + attributes: event.attributes.clone(), + references: event + .references + .iter() + .map(|reference| format!("{}:{}", reference.kind.as_str(), reference.target)) + .collect(), + } +} + +fn ticket_evidence_event(sequence: usize, event: &TicketEvent) -> TicketEvidenceEvent { + TicketEvidenceEvent { + event_ref: ticket_event_ref(sequence, event), + sequence, + kind: event.kind.as_str().to_owned(), + at: event.at.clone(), + author: event.author.clone(), + excerpt: truncate_body(event.body.as_str(), 512).0, + } +} + +fn merge_request_summary(request: MergeRequest) -> TicketMergeRequestSummary { + let review_status = match request.review_status { + ReviewStatus::Pending => "pending", + ReviewStatus::Approved => "approved", + ReviewStatus::ChangesRequested => "changes_requested", + }; + TicketMergeRequestSummary { + merge_request_id: request.merge_request_id, + state: serde_json::to_value(request.state) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or_else(|| "open".to_string()), + review_status: review_status.to_string(), + revision_id: request.current_revision.revision_id, + base_commit: request.current_revision.base_commit, + head_commit: request.current_revision.head_commit, + changed_paths: request.current_revision.changed_paths, + updated_at: request.updated_at, + review_submitted_at: request + .current_review + .as_ref() + .map(|review| review.submitted_at.clone()), + review_excerpt: request + .current_review + .as_ref() + .map(|review| truncate_body(&review.body, 512).0), + } +} + +fn ticket_evidence_summary( + events: &[TicketEvent], + merge_request: Option<&TicketMergeRequestSummary>, +) -> TicketEvidenceSummary { + let latest_report = events + .iter() + .enumerate() + .rev() + .find(|(_, event)| event.kind.as_str() == "implementation_report") + .map(|(sequence, _)| sequence); + let latest_rescope = events + .iter() + .enumerate() + .rev() + .find(|(_, event)| event.kind.as_str() == "item_edit") + .map(|(sequence, _)| sequence); + let report_after_rescope = + latest_report.is_some_and(|report| latest_rescope.is_none_or(|rescope| report > rescope)); + let has_commit = merge_request.is_some_and(|request| !request.head_commit.is_empty()) + || events.iter().any(|event| { + event.attributes.contains_key("commit") || event.attributes.contains_key("head_commit") + }); + let review_status = merge_request.map(|request| request.review_status.clone()); + let approved = review_status.as_deref() == Some("approved"); + let unresolved_request_changes = review_status.as_deref() == Some("changes_requested"); + let mut missing = Vec::new(); + if latest_report.is_none() { + missing.push("implementation_report".to_string()); + } else if !report_after_rescope { + missing.push("implementation_report_after_rescope".to_string()); + } + if !has_commit { + missing.push("commit".to_string()); + } + if merge_request.is_none() { + missing.push("merge_request".to_string()); + } + if !approved { + missing.push("approved_review".to_string()); + } + TicketEvidenceSummary { + has_implementation_report: latest_report.is_some(), + implementation_report_after_rescope: report_after_rescope, + has_merge_request: merge_request.is_some(), + has_commit, + review_status, + approved, + unresolved_request_changes, + complete_for_integration: missing.is_empty(), + missing, + } +} + +fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { + for state in &query.states { + if ticket::TicketWorkflowState::parse(state).is_none() { + return Err(Error::InvalidRecordId(format!( + "unsupported Ticket workflow state `{state}`" + ))); + } + } + for evidence in &query.evidence { + if !matches!( + evidence.as_str(), + "implementation_report" + | "implementation_report_after_rescope" + | "merge_request" + | "commit" + | "approved_review" + ) { + return Err(Error::InvalidRecordId(format!( + "unsupported Ticket evidence filter `{evidence}`" + ))); + } + } + for attention in &query.attention { + if !matches!( + attention.as_str(), + "blocked" + | "ready" + | "awaiting_review" + | "unresolved_changes" + | "stale_after_rescope" + | "missing_evidence" + ) { + return Err(Error::InvalidRecordId(format!( + "unsupported Ticket attention filter `{attention}`" + ))); + } + } + if let Some(status) = query.review_status.as_deref() + && !matches!(status, "pending" | "approved" | "changes_requested") + { + return Err(Error::InvalidRecordId(format!( + "unsupported review status `{status}`" + ))); + } + if let Some(kind) = query.relation_kind.as_deref() + && !matches!( + kind, + "depends_on" | "blocks" | "related" | "supersedes" | "duplicate_of" + ) + { + return Err(Error::InvalidRecordId(format!( + "unsupported Ticket relation kind `{kind}`" + ))); + } + validate_time_bounds( + query.updated_after.as_deref(), + query.updated_before.as_deref(), + ) +} + +fn validate_time_bounds(after: Option<&str>, before: Option<&str>) -> Result<()> { + let parse = |field: &str, value: &str| { + chrono::DateTime::parse_from_rfc3339(value) + .map_err(|_| Error::InvalidRecordId(format!("{field} must be an RFC3339 timestamp"))) + }; + if let Some(after) = after { + parse("updated_after", after)?; + } + if let Some(before) = before { + parse("updated_before", before)?; + } + Ok(()) +} + +fn normalize_ticket_sort(sort: Option<&str>) -> Result { + match sort.unwrap_or("updated_desc") { + "updated_desc" => Ok(TicketQuerySort::UpdatedDesc), + "priority" => Ok(TicketQuerySort::Priority), + "title" => Ok(TicketQuerySort::Title), + other => Err(Error::InvalidRecordId(format!( + "unsupported Ticket query sort `{other}`" + ))), + } +} + +fn normalize_objective_sort(sort: Option<&str>) -> Result { + match sort.unwrap_or("updated_desc") { + "updated_desc" => Ok(ObjectiveQuerySort::UpdatedDesc), + "title" => Ok(ObjectiveQuerySort::Title), + other => Err(Error::InvalidRecordId(format!( + "unsupported Objective query sort `{other}`" + ))), + } +} + +fn parse_offset_cursor(cursor: &str, field: &str) -> Result { + cursor + .parse::() + .map_err(|_| Error::InvalidRecordId(format!("invalid {field}"))) +} + +fn make_query_cursor(key: &str, id: &str) -> String { + format!("v1:{}:{key}{id}", key.len()) +} + +fn parse_query_cursor(cursor: &str) -> Result<(String, String)> { + let rest = cursor + .strip_prefix("v1:") + .ok_or_else(|| Error::InvalidRecordId("invalid query cursor".to_string()))?; + let (length, value) = rest + .split_once(':') + .ok_or_else(|| Error::InvalidRecordId("invalid query cursor".to_string()))?; + let length = length + .parse::() + .map_err(|_| Error::InvalidRecordId("invalid query cursor".to_string()))?; + if length > value.len() || !value.is_char_boundary(length) { + return Err(Error::InvalidRecordId("invalid query cursor".to_string())); + } + Ok((value[..length].to_string(), value[length..].to_string())) +} + +fn ticket_matches_query( + summary: &TicketSummary, + detail: &TicketDetail, + query: &TicketQueryRequest, +) -> bool { + if !query.states.is_empty() && !query.states.iter().any(|state| state == &summary.state) { + return false; + } + if query + .updated_after + .as_ref() + .is_some_and(|after| summary.updated_at.as_deref().unwrap_or("") <= after.as_str()) + || query + .updated_before + .as_ref() + .is_some_and(|before| summary.updated_at.as_deref().unwrap_or("") >= before.as_str()) + { + return false; + } + if !query.event_kinds.is_empty() + && !detail + .events + .iter() + .any(|event| query.event_kinds.iter().any(|kind| kind == &event.kind)) + { + return false; + } + if let Some(review_status) = &query.review_status + && detail.evidence.review_status.as_ref() != Some(review_status) + { + return false; + } + if !query + .evidence + .iter() + .all(|evidence| match evidence.as_str() { + "implementation_report" => detail.evidence.has_implementation_report, + "implementation_report_after_rescope" => { + detail.evidence.implementation_report_after_rescope + } + "merge_request" => detail.evidence.has_merge_request, + "commit" => detail.evidence.has_commit, + "approved_review" => detail.evidence.approved, + _ => false, + }) + { + return false; + } + if !query + .attention + .iter() + .all(|attention| match attention.as_str() { + "blocked" => !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, + "stale_after_rescope" => { + detail.evidence.has_implementation_report + && !detail.evidence.implementation_report_after_rescope + } + "missing_evidence" => !detail.evidence.complete_for_integration, + _ => false, + }) + { + return false; + } + if query.linked_objective_id.as_ref().is_some_and(|id| { + !detail + .linked_objectives + .iter() + .any(|objective| &objective.id == id) + }) { + return false; + } + if let Some(ticket_id) = &query.related_ticket_id { + let relation_json = serde_json::to_string(&detail.relations).unwrap_or_default(); + if !relation_json.contains(ticket_id) { + return false; + } + } + if let Some(kind) = &query.relation_kind { + let relation_json = serde_json::to_string(&detail.relations).unwrap_or_default(); + if !relation_json.contains(kind) { + return false; + } + } + query.text.as_ref().is_none_or(|text| { + let needle = text.to_lowercase(); + summary.title.to_lowercase().contains(&needle) + || detail.body.to_lowercase().contains(&needle) + || detail.events.iter().any(|event| { + event + .body + .as_deref() + .is_some_and(|body| body.to_lowercase().contains(&needle)) + }) + }) +} + +fn ticket_query_item( + summary: TicketSummary, + detail: &TicketDetail, + query: &TicketQueryRequest, +) -> TicketQueryItem { + let mut matched_fields = Vec::new(); + let mut snippet = None; + let mut matching_event = None; + if let Some(text) = query.text.as_ref() { + let needle = text.to_lowercase(); + if summary.title.to_lowercase().contains(&needle) { + matched_fields.push("title".to_string()); + snippet = Some(summary.title.clone()); + } + if detail.body.to_lowercase().contains(&needle) { + matched_fields.push("body".to_string()); + snippet.get_or_insert_with(|| matching_snippet(&detail.body, text)); + } + if let Some(event) = detail.events.iter().find(|event| { + event + .body + .as_deref() + .is_some_and(|body| body.to_lowercase().contains(&needle)) + }) { + matched_fields.push("event".to_string()); + matching_event = Some(TicketEvidenceEvent { + event_ref: event.event_ref.clone(), + sequence: event.sequence, + kind: event.kind.clone(), + at: event.at.clone(), + author: event.author.clone(), + excerpt: event.body.clone().unwrap_or_default(), + }); + snippet.get_or_insert_with(|| event.body.clone().unwrap_or_default()); + } + } + TicketQueryItem { + id: summary.id, + title: summary.title, + state: summary.state, + priority: summary.priority, + updated_at: summary.updated_at, + workspace_action_priority: summary.workspace_action_priority, + matched_fields, + snippet: snippet.map(|value| truncate_body(&value, 512).0), + matching_event, + linked_objective_ids: detail + .linked_objectives + .iter() + .map(|objective| objective.id.clone()) + .collect(), + relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(), + blocker_count: detail.relations.blockers.len(), + evidence: detail.evidence.clone(), + merge_request: detail.merge_request.clone(), + } +} + +fn matching_snippet(body: &str, text: &str) -> String { + let lower = body.to_lowercase(); + let start = lower + .find(&text.to_lowercase()) + .unwrap_or(0) + .saturating_sub(96); + let start = body.floor_char_boundary(start); + let end = body.ceil_char_boundary((start + 320).min(body.len())); + body[start..end].to_string() +} + +fn ticket_sort_key(item: &TicketQueryItem, sort: TicketQuerySort) -> String { + match sort { + TicketQuerySort::UpdatedDesc => item.updated_at.clone().unwrap_or_default(), + TicketQuerySort::Priority => format!( + "{}|{}", + match item.workspace_action_priority.as_str() { + "ready_for_queue" => 0, + "active_work" => 1, + _ => 2, + }, + item.updated_at.as_deref().unwrap_or("") + ), + TicketQuerySort::Title => item.title.to_lowercase(), + } +} + +fn sort_ticket_query_items(items: &mut [TicketQueryItem], sort: TicketQuerySort) { + items.sort_by(|left, right| match sort { + TicketQuerySort::UpdatedDesc => right + .updated_at + .cmp(&left.updated_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, + "active_work" => 1, + _ => 2, + }; + rank(left) + .cmp(&rank(right)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.id.cmp(&right.id)) + } + TicketQuerySort::Title => left + .title + .to_lowercase() + .cmp(&right.title.to_lowercase()) + .then_with(|| left.id.cmp(&right.id)), + }); +} + +fn make_ticket_cursor(item: &TicketQueryItem, sort: TicketQuerySort) -> String { + make_query_cursor(&ticket_sort_key(item, sort), &item.id) +} + +fn ticket_item_after_cursor( + item: &TicketQueryItem, + sort: TicketQuerySort, + cursor: &(String, String), +) -> bool { + let key = ticket_sort_key(item, sort); + match sort { + TicketQuerySort::UpdatedDesc => key < cursor.0 || (key == cursor.0 && item.id > cursor.1), + TicketQuerySort::Title => key > cursor.0 || (key == cursor.0 && item.id > cursor.1), + 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 + || (rank == cursor_rank + && (updated < cursor_updated + || (updated == cursor_updated && item.id > cursor.1))) + } + } +} + +fn objective_matches_query(objective: &ObjectiveSummary, query: &ObjectiveQueryRequest) -> bool { + if !query.states.is_empty() && !query.states.iter().any(|state| state == &objective.state) { + return false; + } + if query + .updated_after + .as_ref() + .is_some_and(|after| objective.updated_at.as_deref().unwrap_or("") <= after.as_str()) + || query + .updated_before + .as_ref() + .is_some_and(|before| objective.updated_at.as_deref().unwrap_or("") >= before.as_str()) + { + return false; + } + query.text.as_ref().is_none_or(|text| { + let needle = text.to_lowercase(); + objective.title.to_lowercase().contains(&needle) + || objective.summary.to_lowercase().contains(&needle) + }) +} + +fn objective_query_item( + objective: ObjectiveSummary, + linked_tickets: Vec, + text: Option<&str>, +) -> ObjectiveQueryItem { + let mut matched_fields = Vec::new(); + let mut snippet = None; + if let Some(text) = text { + let needle = text.to_lowercase(); + if objective.title.to_lowercase().contains(&needle) { + matched_fields.push("title".to_string()); + snippet = Some(objective.title.clone()); + } + if objective.summary.to_lowercase().contains(&needle) { + matched_fields.push("body".to_string()); + snippet.get_or_insert_with(|| matching_snippet(&objective.summary, text)); + } + } + ObjectiveQueryItem { + id: objective.id, + title: objective.title, + state: objective.state, + updated_at: objective.updated_at, + matched_fields, + snippet, + linked_ticket_count: linked_tickets.len(), + linked_tickets, + } +} + +fn objective_sort_key(item: &ObjectiveQueryItem, sort: ObjectiveQuerySort) -> String { + match sort { + ObjectiveQuerySort::UpdatedDesc => item.updated_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::UpdatedDesc => right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.id.cmp(&right.id)), + ObjectiveQuerySort::Title => left + .title + .to_lowercase() + .cmp(&right.title.to_lowercase()) + .then_with(|| left.id.cmp(&right.id)), + }); +} + +fn make_objective_cursor(item: &ObjectiveQueryItem, sort: ObjectiveQuerySort) -> String { + make_query_cursor(&objective_sort_key(item, sort), &item.id) +} + +fn objective_item_after_cursor( + item: &ObjectiveQueryItem, + sort: ObjectiveQuerySort, + cursor: &(String, String), +) -> bool { + let key = objective_sort_key(item, sort); + match sort { + ObjectiveQuerySort::UpdatedDesc => { + key < cursor.0 || (key == cursor.0 && item.id > cursor.1) + } + ObjectiveQuerySort::Title => key > cursor.0 || (key == cursor.0 && item.id > cursor.1), + } +} + fn now_rfc3339() -> String { Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } @@ -716,10 +1570,64 @@ mod tests { use super::*; use crate::store::{ObjectiveRecord, ObjectiveTicketLinkRecord, WorkspaceRecord}; + #[test] + fn ticket_evidence_summary_requires_report_after_latest_rescope_and_approved_revision() { + let event = |kind: &str| TicketEvent { + kind: ticket::TicketEventKind::Other(kind.to_string()), + author: Some("coder".to_string()), + at: Some("2026-01-01T00:00:00Z".to_string()), + status: None, + from: None, + to: None, + reason: None, + state_field: None, + heading: None, + body: ticket::MarkdownText::new(kind), + attributes: Default::default(), + references: Vec::new(), + }; + let request = TicketMergeRequestSummary { + merge_request_id: "mr-1".to_string(), + state: "open".to_string(), + review_status: "approved".to_string(), + revision_id: "revision-1".to_string(), + base_commit: "base".to_string(), + head_commit: "head".to_string(), + changed_paths: vec!["src/lib.rs".to_string()], + updated_at: "2026-01-01T00:00:00Z".to_string(), + review_submitted_at: Some("2026-01-01T00:00:00Z".to_string()), + review_excerpt: Some("approved".to_string()), + }; + let stale = ticket_evidence_summary( + &[event("implementation_report"), event("item_edit")], + Some(&request), + ); + assert!(!stale.implementation_report_after_rescope); + assert!(!stale.complete_for_integration); + assert!( + stale + .missing + .contains(&"implementation_report_after_rescope".to_string()) + ); + let current = ticket_evidence_summary( + &[ + event("implementation_report"), + event("item_edit"), + event("implementation_report"), + ], + Some(&request), + ); + assert!(current.implementation_report_after_rescope); + assert!(current.has_commit); + assert!(current.approved); + assert!(current.complete_for_integration); + } + #[tokio::test] async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() { let dir = tempfile::tempdir().unwrap(); write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready"); + write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning"); let db_path = dir.path().join("workspace.db"); SqliteTicketBackend::open(&db_path, "workspace-test") .unwrap() @@ -783,6 +1691,45 @@ mod tests { let ticket = authority.ticket("00000000001J2").unwrap(); assert!(ticket.body.contains("Ticket body")); + assert!(!ticket.item_revision.is_empty()); + assert_eq!(ticket.linked_objectives[0].id, "00000000001J3"); + assert_eq!(ticket.event_page.returned, ticket.events.len()); + let ticket_query = authority + .query_tickets(TicketQueryRequest { + text: Some("Ticket body".to_string()), + states: vec!["ready".to_string()], + linked_objective_id: Some("00000000001J3".to_string()), + limit: Some(1), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert_eq!(ticket_query.items.len(), 1); + assert_eq!(ticket_query.items[0].id, "00000000001J2"); + assert!( + ticket_query.items[0] + .matched_fields + .contains(&"body".to_string()) + ); + assert_eq!(ticket_query.page.limit, 1); + let first_page = authority + .query_tickets(TicketQueryRequest { + sort: Some("title".to_string()), + limit: Some(1), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert!(first_page.page.has_more); + let second_page = authority + .query_tickets(TicketQueryRequest { + sort: Some("title".to_string()), + limit: Some(1), + cursor: first_page.page.next_cursor.clone(), + ..TicketQueryRequest::default() + }) + .unwrap(); + assert_eq!(second_page.items.len(), 1); + assert_ne!(first_page.items[0].id, second_page.items[0].id); + assert!(!second_page.page.has_more); let objectives = authority.list_objectives(20).unwrap(); assert_eq!(objectives.record_authority, "workspace-sqlite"); @@ -792,6 +1739,18 @@ mod tests { let objective = authority.objective("00000000001J3").unwrap(); assert!(objective.body.contains("Objective body")); + assert!(!objective.revision.is_empty()); + let objective_query = authority + .query_objectives(ObjectiveQueryRequest { + text: Some("Control plane".to_string()), + linked_ticket_id: Some("00000000001J2".to_string()), + 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.page.limit, 1); let memory = authority.ensure_memory_document().unwrap(); assert_eq!(memory.body_md, DEFAULT_MEMORY_DOCUMENT_BODY); let updated = authority diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index c95aa816..ec7b2249 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -52,6 +52,7 @@ pub struct TicketDetail { pub priority: String, pub created_at: Option, pub updated_at: Option, + pub item_revision: String, pub queued_by: Option, pub queued_at: Option, pub assignee: Option, @@ -62,9 +63,14 @@ pub struct TicketDetail { pub body_truncated: bool, pub event_count: usize, pub events: Vec, + pub event_page: QueryPage, pub artifact_count: usize, pub artifacts: Vec, pub relations: TicketRelationView, + pub linked_objectives: Vec, + pub implementation_reports: Vec, + pub merge_request: Option, + pub evidence: TicketEvidenceSummary, pub resolution: Option, pub record_source: String, } @@ -73,6 +79,7 @@ pub struct TicketDetail { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketEventDetail { pub sequence: usize, + pub event_ref: String, pub kind: String, pub author: Option, pub at: Option, @@ -83,6 +90,8 @@ pub struct TicketEventDetail { pub state_field: Option, pub heading: Option, pub body: Option, + pub attributes: std::collections::BTreeMap, + pub references: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -185,6 +194,170 @@ impl From for TicketRelationView { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct QueryPage { + pub limit: usize, + pub returned: usize, + pub has_more: bool, + pub next_cursor: Option, + pub sort: String, + pub source_limit: Option, + pub source_truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct ObjectiveLinkSummary { + pub id: String, + pub title: String, + pub state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketEvidenceEvent { + pub event_ref: String, + pub sequence: usize, + pub kind: String, + pub at: Option, + pub author: Option, + pub excerpt: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketMergeRequestSummary { + pub merge_request_id: String, + pub state: String, + pub review_status: String, + pub revision_id: String, + pub base_commit: String, + pub head_commit: String, + pub changed_paths: Vec, + pub updated_at: String, + pub review_submitted_at: Option, + pub review_excerpt: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketEvidenceSummary { + pub has_implementation_report: bool, + pub implementation_report_after_rescope: bool, + pub has_merge_request: bool, + pub has_commit: bool, + pub review_status: Option, + pub approved: bool, + pub unresolved_request_changes: bool, + pub complete_for_integration: bool, + pub missing: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketQueryRequest { + pub text: Option, + #[serde(default)] + pub states: Vec, + #[serde(default)] + pub event_kinds: Vec, + #[serde(default)] + pub evidence: Vec, + pub review_status: Option, + #[serde(default)] + pub attention: Vec, + pub related_ticket_id: Option, + pub relation_kind: Option, + pub linked_objective_id: Option, + pub updated_after: Option, + pub updated_before: Option, + pub sort: Option, + pub limit: Option, + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketQueryItem { + pub id: String, + pub title: String, + pub state: String, + pub priority: String, + pub updated_at: Option, + pub workspace_action_priority: String, + pub matched_fields: Vec, + pub snippet: Option, + pub matching_event: Option, + pub linked_objective_ids: Vec, + pub relation_count: usize, + pub blocker_count: usize, + pub evidence: TicketEvidenceSummary, + pub merge_request: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketQueryResponse { + pub items: Vec, + pub page: QueryPage, + pub record_authority: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketShowRequest { + pub event_limit: Option, + pub event_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ObjectiveQueryRequest { + pub text: Option, + #[serde(default)] + pub states: Vec, + pub linked_ticket_id: Option, + pub updated_after: Option, + pub updated_before: Option, + pub sort: Option, + pub limit: Option, + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveQueryItem { + pub id: String, + pub title: String, + pub state: String, + pub updated_at: Option, + pub matched_fields: Vec, + pub snippet: Option, + pub linked_ticket_count: usize, + pub linked_tickets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveQueryResponse { + pub items: Vec, + pub page: QueryPage, + pub record_authority: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ObjectiveShowRequest { + pub event_limit: Option, + pub event_cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct ObjectiveEventDetail { + pub event_ref: String, + pub kind: String, + pub body: Option, + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, @@ -201,12 +374,15 @@ pub struct ObjectiveDetail { pub id: String, pub title: String, pub state: String, + pub revision: String, pub created_at: Option, pub updated_at: Option, pub linked_tickets: Vec, pub resources: Vec, pub body: String, pub body_truncated: bool, + pub events: Vec, + pub event_page: QueryPage, pub record_source: String, } @@ -227,7 +403,16 @@ pub fn ticket_api_typescript() -> String { InvalidProjectRecord::decl(&config), TicketSummary::decl(&config), TicketListResponse::decl(&config), + QueryPage::decl(&config), TicketEventDetail::decl(&config), + ObjectiveLinkSummary::decl(&config), + TicketEvidenceEvent::decl(&config), + TicketMergeRequestSummary::decl(&config), + TicketEvidenceSummary::decl(&config), + TicketQueryRequest::decl(&config), + TicketQueryItem::decl(&config), + TicketQueryResponse::decl(&config), + TicketShowRequest::decl(&config), TicketRelation::decl(&config), DerivedTicketRelation::decl(&config), TicketRelationBlocker::decl(&config), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index efe0b2ae..3e316485 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -92,7 +92,10 @@ use crate::observation::{ RuntimeObservationSource, RuntimeObservationSourceConfig, }; use crate::profile_settings::UpdateWorkspaceMetadataRequest; -use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail}; +use crate::records::{ + ObjectiveDetail, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveShowRequest, + ProjectRecordList, TicketDetail, TicketQueryRequest, TicketQueryResponse, TicketShowRequest, +}; use crate::repositories::{ ConfiguredRepository, MergeTargetObservation, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, RepositoryRegistryReader, RepositorySummary, @@ -1232,6 +1235,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/tickets", get(scoped_list_tickets).post(scoped_create_ticket_record), ) + .route( + "/api/w/{workspace_id}/tickets/query", + post(scoped_query_tickets), + ) .route( "/api/w/{workspace_id}/memory", get(scoped_get_memory_document), @@ -1368,6 +1375,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket).patch(scoped_edit_ticket_item), ) + .route( + "/api/w/{workspace_id}/tickets/{id}/show", + post(scoped_show_ticket), + ) .route( "/api/w/{workspace_id}/tickets/{id}/assignment", get(scoped_get_ticket_worker_assignment) @@ -1399,11 +1410,19 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/objectives", get(scoped_list_objectives).post(scoped_create_objective), ) + .route( + "/api/w/{workspace_id}/objectives/query", + post(scoped_query_objectives), + ) .route("/api/objectives/{id}", get(get_objective)) .route( "/api/w/{workspace_id}/objectives/{objective_id}", get(scoped_get_objective).patch(scoped_edit_objective), ) + .route( + "/api/w/{workspace_id}/objectives/{objective_id}/show", + post(scoped_show_objective), + ) .route( "/api/w/{workspace_id}/objectives/{objective_id}/state", post(scoped_set_objective_state), @@ -2643,6 +2662,24 @@ async fn scoped_get_ticket( get_ticket(State(api), AxumPath(path.id)).await } +async fn scoped_query_tickets( + State(api): State, + AxumPath(path): AxumPath, + Json(query): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.query_tickets(query)?)) +} + +async fn scoped_show_ticket( + State(api): State, + AxumPath(path): AxumPath, + Json(query): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.show_ticket(&path.id, query)?)) +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct TicketWorkerAssignmentResponse { workspace_id: String, @@ -5581,6 +5618,26 @@ async fn scoped_get_objective( get_objective(State(api), AxumPath(path.objective_id)).await } +async fn scoped_query_objectives( + State(api): State, + AxumPath(path): AxumPath, + Json(query): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.query_objectives(query)?)) +} + +async fn scoped_show_objective( + State(api): State, + AxumPath(path): AxumPath, + Json(query): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json( + api.authority.show_objective(&path.objective_id, query)?, + )) +} + async fn scoped_create_objective( State(api): State, AxumPath(path): AxumPath, @@ -17703,10 +17760,64 @@ mod tests { .await; assert_eq!(scoped_objective["id"], "00000000001J3"); assert_eq!(scoped_objective["record_source"], "workspace-sqlite"); + assert_eq!( + scoped_objective["revision"].as_str().unwrap().is_empty(), + false + ); assert_eq!( scoped_objective["resources"][0]["path"], "memory-architecture-overview.md" ); + let queried_tickets = request_json( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"), + Some(json!({ + "limit": 1 + })), + StatusCode::OK, + ) + .await; + assert_eq!(queried_tickets["items"][0]["title"], "API Ticket"); + let queried_ticket_id = queried_tickets["items"][0]["id"] + .as_str() + .expect("query Ticket id") + .to_string(); + assert_eq!(queried_tickets["page"]["limit"], 1); + let shown_ticket = request_json( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{queried_ticket_id}/show"), + Some(json!({"event_limit": 10})), + StatusCode::OK, + ) + .await; + assert!(shown_ticket["evidence"]["missing"].is_array()); + assert!(shown_ticket["item_revision"].as_str().is_some()); + let queried_objectives = request_json( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/objectives/query"), + Some(json!({ + "text": "Objective body", + "linked_ticket_id": "00000000001J2", + "limit": 1 + })), + StatusCode::OK, + ) + .await; + assert_eq!(queried_objectives["items"][0]["id"], "00000000001J3"); + assert_eq!(queried_objectives["page"]["limit"], 1); + let shown_objective = request_json( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/objectives/00000000001J3/show"), + Some(json!({"event_limit": 10})), + StatusCode::OK, + ) + .await; + assert_eq!(shown_objective["linked_tickets"][0], "00000000001J2"); + assert!(shown_objective["event_page"]["returned"].is_number()); let memory_document = get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await; diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index eb89c8f8..704ccd77 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -633,6 +633,12 @@ pub trait ControlPlaneStore: Send + Sync { fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>; fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result>; + fn list_objectives_for_ticket( + &self, + workspace_id: &str, + ticket_id: &str, + limit: usize, + ) -> Result>; fn get_objective( &self, workspace_id: &str, @@ -1552,6 +1558,33 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn list_objectives_for_ticket( + &self, + workspace_id: &str, + ticket_id: &str, + limit: usize, + ) -> Result> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + r#"SELECT o.workspace_id, o.objective_id, o.title, o.state, o.body_md, + o.created_at, o.updated_at + FROM objectives AS o + INNER JOIN objective_ticket_links AS l + ON l.workspace_id = o.workspace_id + AND l.objective_id = o.objective_id + WHERE o.workspace_id = ?1 AND l.ticket_id = ?2 + ORDER BY o.updated_at DESC, o.objective_id ASC + LIMIT ?3"#, + )?; + let rows = stmt.query_map( + params![workspace_id, ticket_id, limit as i64], + read_objective_record, + )?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } + fn get_objective( &self, workspace_id: &str, diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index df60854d..7beb97f9 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -23,8 +23,19 @@ export type TicketListResponse = { record_authority: string; }; +export type QueryPage = { + limit: number; + returned: number; + has_more: boolean; + next_cursor: string | null; + sort: string; + source_limit: number | null; + source_truncated: boolean; +}; + export type TicketEventDetail = { sequence: number; + event_ref: string; kind: string; author: string | null; at: string | null; @@ -35,6 +46,89 @@ export type TicketEventDetail = { state_field: string | null; heading: string | null; body: string | null; + attributes: { [key in string]: string }; + references: Array; +}; + +export type ObjectiveLinkSummary = { id: string; title: string; state: string }; + +export type TicketEvidenceEvent = { + event_ref: string; + sequence: number; + kind: string; + at: string | null; + author: string | null; + excerpt: string; +}; + +export type TicketMergeRequestSummary = { + merge_request_id: string; + state: string; + review_status: string; + revision_id: string; + base_commit: string; + head_commit: string; + changed_paths: Array; + updated_at: string; + review_submitted_at: string | null; + review_excerpt: string | null; +}; + +export type TicketEvidenceSummary = { + has_implementation_report: boolean; + implementation_report_after_rescope: boolean; + has_merge_request: boolean; + has_commit: boolean; + review_status: string | null; + approved: boolean; + unresolved_request_changes: boolean; + complete_for_integration: boolean; + missing: Array; +}; + +export type TicketQueryRequest = { + text: string | null; + states: Array; + event_kinds: Array; + evidence: Array; + review_status: string | null; + attention: Array; + related_ticket_id: string | null; + relation_kind: string | null; + linked_objective_id: string | null; + updated_after: string | null; + updated_before: string | null; + sort: string | null; + limit: number | null; + cursor: string | null; +}; + +export type TicketQueryItem = { + id: string; + title: string; + state: string; + priority: string; + updated_at: string | null; + workspace_action_priority: string; + matched_fields: Array; + snippet: string | null; + matching_event: TicketEvidenceEvent | null; + linked_objective_ids: Array; + relation_count: number; + blocker_count: number; + evidence: TicketEvidenceSummary; + merge_request: TicketMergeRequestSummary | null; +}; + +export type TicketQueryResponse = { + items: Array; + page: QueryPage; + record_authority: string; +}; + +export type TicketShowRequest = { + event_limit: number | null; + event_cursor: string | null; }; export type TicketRelation = { @@ -83,6 +177,7 @@ export type TicketDetail = { priority: string; created_at: string | null; updated_at: string | null; + item_revision: string; queued_by: string | null; queued_at: string | null; assignee: string | null; @@ -93,9 +188,14 @@ export type TicketDetail = { body_truncated: boolean; event_count: number; events: Array; + event_page: QueryPage; artifact_count: number; artifacts: Array; relations: TicketRelationView; + linked_objectives: Array; + implementation_reports: Array; + merge_request: TicketMergeRequestSummary | null; + evidence: TicketEvidenceSummary; resolution: string | null; record_source: string; };