feat: add bounded Ticket and Objective read APIs

This commit is contained in:
2026-08-17 05:18:27 +09:00
parent eb0dd67d16
commit 14aa1aabea
5 changed files with 1447 additions and 59 deletions
File diff suppressed because it is too large Load Diff
+185
View File
@@ -52,6 +52,7 @@ pub struct TicketDetail {
pub priority: String, pub priority: String,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub item_revision: String,
pub queued_by: Option<String>, pub queued_by: Option<String>,
pub queued_at: Option<String>, pub queued_at: Option<String>,
pub assignee: Option<String>, pub assignee: Option<String>,
@@ -62,9 +63,14 @@ pub struct TicketDetail {
pub body_truncated: bool, pub body_truncated: bool,
pub event_count: usize, pub event_count: usize,
pub events: Vec<TicketEventDetail>, pub events: Vec<TicketEventDetail>,
pub event_page: QueryPage,
pub artifact_count: usize, pub artifact_count: usize,
pub artifacts: Vec<String>, pub artifacts: Vec<String>,
pub relations: TicketRelationView, pub relations: TicketRelationView,
pub linked_objectives: Vec<ObjectiveLinkSummary>,
pub implementation_reports: Vec<TicketEvidenceEvent>,
pub merge_request: Option<TicketMergeRequestSummary>,
pub evidence: TicketEvidenceSummary,
pub resolution: Option<String>, pub resolution: Option<String>,
pub record_source: String, pub record_source: String,
} }
@@ -73,6 +79,7 @@ pub struct TicketDetail {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketEventDetail { pub struct TicketEventDetail {
pub sequence: usize, pub sequence: usize,
pub event_ref: String,
pub kind: String, pub kind: String,
pub author: Option<String>, pub author: Option<String>,
pub at: Option<String>, pub at: Option<String>,
@@ -83,6 +90,8 @@ pub struct TicketEventDetail {
pub state_field: Option<String>, pub state_field: Option<String>,
pub heading: Option<String>, pub heading: Option<String>,
pub body: Option<String>, pub body: Option<String>,
pub attributes: std::collections::BTreeMap<String, String>,
pub references: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -185,6 +194,170 @@ impl From<ticket::TicketRelationView> 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<String>,
pub sort: String,
pub source_limit: Option<usize>,
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<String>,
pub author: Option<String>,
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<String>,
pub updated_at: String,
pub review_submitted_at: Option<String>,
pub review_excerpt: Option<String>,
}
#[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<String>,
pub approved: bool,
pub unresolved_request_changes: bool,
pub complete_for_integration: bool,
pub missing: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketQueryRequest {
pub text: Option<String>,
#[serde(default)]
pub states: Vec<String>,
#[serde(default)]
pub event_kinds: Vec<String>,
#[serde(default)]
pub evidence: Vec<String>,
pub review_status: Option<String>,
#[serde(default)]
pub attention: Vec<String>,
pub related_ticket_id: Option<String>,
pub relation_kind: Option<String>,
pub linked_objective_id: Option<String>,
pub updated_after: Option<String>,
pub updated_before: Option<String>,
pub sort: Option<String>,
pub limit: Option<usize>,
pub cursor: Option<String>,
}
#[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<String>,
pub workspace_action_priority: String,
pub matched_fields: Vec<String>,
pub snippet: Option<String>,
pub matching_event: Option<TicketEvidenceEvent>,
pub linked_objective_ids: Vec<String>,
pub relation_count: usize,
pub blocker_count: usize,
pub evidence: TicketEvidenceSummary,
pub merge_request: Option<TicketMergeRequestSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketQueryResponse {
pub items: Vec<TicketQueryItem>,
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<usize>,
pub event_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ObjectiveQueryRequest {
pub text: Option<String>,
#[serde(default)]
pub states: Vec<String>,
pub linked_ticket_id: Option<String>,
pub updated_after: Option<String>,
pub updated_before: Option<String>,
pub sort: Option<String>,
pub limit: Option<usize>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveQueryItem {
pub id: String,
pub title: String,
pub state: String,
pub updated_at: Option<String>,
pub matched_fields: Vec<String>,
pub snippet: Option<String>,
pub linked_ticket_count: usize,
pub linked_tickets: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveQueryResponse {
pub items: Vec<ObjectiveQueryItem>,
pub page: QueryPage,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ObjectiveShowRequest {
pub event_limit: Option<usize>,
pub event_cursor: Option<String>,
}
#[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<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveSummary { pub struct ObjectiveSummary {
pub id: String, pub id: String,
@@ -201,12 +374,15 @@ pub struct ObjectiveDetail {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub state: String, pub state: String,
pub revision: String,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub linked_tickets: Vec<String>, pub linked_tickets: Vec<String>,
pub resources: Vec<ObjectiveResourceSummary>, pub resources: Vec<ObjectiveResourceSummary>,
pub body: String, pub body: String,
pub body_truncated: bool, pub body_truncated: bool,
pub events: Vec<ObjectiveEventDetail>,
pub event_page: QueryPage,
pub record_source: String, pub record_source: String,
} }
@@ -227,7 +403,16 @@ pub fn ticket_api_typescript() -> String {
InvalidProjectRecord::decl(&config), InvalidProjectRecord::decl(&config),
TicketSummary::decl(&config), TicketSummary::decl(&config),
TicketListResponse::decl(&config), TicketListResponse::decl(&config),
QueryPage::decl(&config),
TicketEventDetail::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), TicketRelation::decl(&config),
DerivedTicketRelation::decl(&config), DerivedTicketRelation::decl(&config),
TicketRelationBlocker::decl(&config), TicketRelationBlocker::decl(&config),
+112 -1
View File
@@ -92,7 +92,10 @@ use crate::observation::{
RuntimeObservationSource, RuntimeObservationSourceConfig, RuntimeObservationSource, RuntimeObservationSourceConfig,
}; };
use crate::profile_settings::UpdateWorkspaceMetadataRequest; 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::{ use crate::repositories::{
ConfiguredRepository, MergeTargetObservation, RepositoryListProjection, RepositoryLogRead, ConfiguredRepository, MergeTargetObservation, RepositoryListProjection, RepositoryLogRead,
RepositoryLookupError, RepositoryRegistryReader, RepositorySummary, RepositoryLookupError, RepositoryRegistryReader, RepositorySummary,
@@ -1232,6 +1235,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/tickets", "/api/w/{workspace_id}/tickets",
get(scoped_list_tickets).post(scoped_create_ticket_record), get(scoped_list_tickets).post(scoped_create_ticket_record),
) )
.route(
"/api/w/{workspace_id}/tickets/query",
post(scoped_query_tickets),
)
.route( .route(
"/api/w/{workspace_id}/memory", "/api/w/{workspace_id}/memory",
get(scoped_get_memory_document), get(scoped_get_memory_document),
@@ -1368,6 +1375,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/tickets/{id}", "/api/w/{workspace_id}/tickets/{id}",
get(scoped_get_ticket).patch(scoped_edit_ticket_item), get(scoped_get_ticket).patch(scoped_edit_ticket_item),
) )
.route(
"/api/w/{workspace_id}/tickets/{id}/show",
post(scoped_show_ticket),
)
.route( .route(
"/api/w/{workspace_id}/tickets/{id}/assignment", "/api/w/{workspace_id}/tickets/{id}/assignment",
get(scoped_get_ticket_worker_assignment) get(scoped_get_ticket_worker_assignment)
@@ -1399,11 +1410,19 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/objectives", "/api/w/{workspace_id}/objectives",
get(scoped_list_objectives).post(scoped_create_objective), 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/objectives/{id}", get(get_objective))
.route( .route(
"/api/w/{workspace_id}/objectives/{objective_id}", "/api/w/{workspace_id}/objectives/{objective_id}",
get(scoped_get_objective).patch(scoped_edit_objective), get(scoped_get_objective).patch(scoped_edit_objective),
) )
.route(
"/api/w/{workspace_id}/objectives/{objective_id}/show",
post(scoped_show_objective),
)
.route( .route(
"/api/w/{workspace_id}/objectives/{objective_id}/state", "/api/w/{workspace_id}/objectives/{objective_id}/state",
post(scoped_set_objective_state), post(scoped_set_objective_state),
@@ -2643,6 +2662,24 @@ async fn scoped_get_ticket(
get_ticket(State(api), AxumPath(path.id)).await get_ticket(State(api), AxumPath(path.id)).await
} }
async fn scoped_query_tickets(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(query): Json<TicketQueryRequest>,
) -> ApiResult<Json<TicketQueryResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.authority.query_tickets(query)?))
}
async fn scoped_show_ticket(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(query): Json<TicketShowRequest>,
) -> ApiResult<Json<TicketDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.authority.show_ticket(&path.id, query)?))
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct TicketWorkerAssignmentResponse { struct TicketWorkerAssignmentResponse {
workspace_id: String, workspace_id: String,
@@ -5581,6 +5618,26 @@ async fn scoped_get_objective(
get_objective(State(api), AxumPath(path.objective_id)).await get_objective(State(api), AxumPath(path.objective_id)).await
} }
async fn scoped_query_objectives(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(query): Json<ObjectiveQueryRequest>,
) -> ApiResult<Json<ObjectiveQueryResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.authority.query_objectives(query)?))
}
async fn scoped_show_objective(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedObjectivePath>,
Json(query): Json<ObjectiveShowRequest>,
) -> ApiResult<Json<ObjectiveDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(
api.authority.show_objective(&path.objective_id, query)?,
))
}
async fn scoped_create_objective( async fn scoped_create_objective(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -17703,10 +17760,64 @@ mod tests {
.await; .await;
assert_eq!(scoped_objective["id"], "00000000001J3"); assert_eq!(scoped_objective["id"], "00000000001J3");
assert_eq!(scoped_objective["record_source"], "workspace-sqlite"); assert_eq!(scoped_objective["record_source"], "workspace-sqlite");
assert_eq!(
scoped_objective["revision"].as_str().unwrap().is_empty(),
false
);
assert_eq!( assert_eq!(
scoped_objective["resources"][0]["path"], scoped_objective["resources"][0]["path"],
"memory-architecture-overview.md" "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 = let memory_document =
get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await; get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await;
+33
View File
@@ -633,6 +633,12 @@ pub trait ControlPlaneStore: Send + Sync {
fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>; fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>;
fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result<Vec<ObjectiveRecord>>; fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result<Vec<ObjectiveRecord>>;
fn list_objectives_for_ticket(
&self,
workspace_id: &str,
ticket_id: &str,
limit: usize,
) -> Result<Vec<ObjectiveRecord>>;
fn get_objective( fn get_objective(
&self, &self,
workspace_id: &str, 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<Vec<ObjectiveRecord>> {
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::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn get_objective( fn get_objective(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -23,8 +23,19 @@ export type TicketListResponse = {
record_authority: string; 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 = { export type TicketEventDetail = {
sequence: number; sequence: number;
event_ref: string;
kind: string; kind: string;
author: string | null; author: string | null;
at: string | null; at: string | null;
@@ -35,6 +46,89 @@ export type TicketEventDetail = {
state_field: string | null; state_field: string | null;
heading: string | null; heading: string | null;
body: string | null; body: string | null;
attributes: { [key in string]: string };
references: Array<string>;
};
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<string>;
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<string>;
};
export type TicketQueryRequest = {
text: string | null;
states: Array<string>;
event_kinds: Array<string>;
evidence: Array<string>;
review_status: string | null;
attention: Array<string>;
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<string>;
snippet: string | null;
matching_event: TicketEvidenceEvent | null;
linked_objective_ids: Array<string>;
relation_count: number;
blocker_count: number;
evidence: TicketEvidenceSummary;
merge_request: TicketMergeRequestSummary | null;
};
export type TicketQueryResponse = {
items: Array<TicketQueryItem>;
page: QueryPage;
record_authority: string;
};
export type TicketShowRequest = {
event_limit: number | null;
event_cursor: string | null;
}; };
export type TicketRelation = { export type TicketRelation = {
@@ -83,6 +177,7 @@ export type TicketDetail = {
priority: string; priority: string;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
item_revision: string;
queued_by: string | null; queued_by: string | null;
queued_at: string | null; queued_at: string | null;
assignee: string | null; assignee: string | null;
@@ -93,9 +188,14 @@ export type TicketDetail = {
body_truncated: boolean; body_truncated: boolean;
event_count: number; event_count: number;
events: Array<TicketEventDetail>; events: Array<TicketEventDetail>;
event_page: QueryPage;
artifact_count: number; artifact_count: number;
artifacts: Array<string>; artifacts: Array<string>;
relations: TicketRelationView; relations: TicketRelationView;
linked_objectives: Array<ObjectiveLinkSummary>;
implementation_reports: Array<TicketEvidenceEvent>;
merge_request: TicketMergeRequestSummary | null;
evidence: TicketEvidenceSummary;
resolution: string | null; resolution: string | null;
record_source: string; record_source: string;
}; };