workspace: add ticket panel workflow

This commit is contained in:
2026-07-31 01:12:10 +09:00
parent 1c2d284578
commit e720af38a6
25 changed files with 1570 additions and 985 deletions
+2
View File
@@ -274,6 +274,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
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,
@@ -287,6 +288,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
.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()),
+2
View File
@@ -15,6 +15,8 @@ pub mod memory_staging;
pub mod observation;
pub mod profile_settings;
pub mod records;
#[cfg(feature = "typescript")]
pub use records::ticket_api_typescript;
pub mod repositories;
pub mod resource_broker;
pub mod server;
+172
View File
@@ -13,12 +13,14 @@ pub struct ProjectRecordList<T> {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct InvalidProjectRecord {
pub label: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketSummary {
pub id: String,
pub title: String,
@@ -32,6 +34,17 @@ pub struct TicketSummary {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketListResponse {
pub workspace_id: String,
pub limit: usize,
pub items: Vec<TicketSummary>,
pub invalid_records: Vec<InvalidProjectRecord>,
pub record_authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketDetail {
pub id: String,
pub title: String,
@@ -41,6 +54,7 @@ pub struct TicketDetail {
pub updated_at: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub assignee: Option<String>,
pub repository_id: Option<String>,
pub ref_selector: Option<String>,
pub risk_flags: Vec<String>,
@@ -50,11 +64,13 @@ pub struct TicketDetail {
pub events: Vec<TicketEventDetail>,
pub artifact_count: usize,
pub artifacts: Vec<String>,
pub relations: TicketRelationView,
pub resolution: Option<String>,
pub record_source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketEventDetail {
pub sequence: usize,
pub kind: String,
@@ -69,6 +85,106 @@ pub struct TicketEventDetail {
pub body: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketRelation {
pub ticket_id: String,
pub kind: String,
pub target: String,
pub note: Option<String>,
pub author: String,
pub at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct DerivedTicketRelation {
pub source_ticket: String,
pub inverse_kind: String,
pub forward_kind: String,
pub note: Option<String>,
pub author: String,
pub at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketRelationBlocker {
pub blocking_ticket: String,
pub reason_kind: String,
pub relation_kind: String,
pub note: Option<String>,
pub blocking_state: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketRelationNotice {
pub related_ticket: String,
pub kind: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketRelationView {
pub outgoing: Vec<TicketRelation>,
pub incoming: Vec<DerivedTicketRelation>,
pub blockers: Vec<TicketRelationBlocker>,
pub notices: Vec<TicketRelationNotice>,
}
impl From<ticket::TicketRelationView> for TicketRelationView {
fn from(value: ticket::TicketRelationView) -> Self {
Self {
outgoing: value
.outgoing
.into_iter()
.map(|relation| TicketRelation {
ticket_id: relation.ticket_id,
kind: relation.kind.as_str().to_string(),
target: relation.target,
note: relation.note,
author: relation.author,
at: relation.at,
})
.collect(),
incoming: value
.incoming
.into_iter()
.map(|relation| DerivedTicketRelation {
source_ticket: relation.source_ticket,
inverse_kind: relation.inverse_kind,
forward_kind: relation.forward_kind.as_str().to_string(),
note: relation.note,
author: relation.author,
at: relation.at,
})
.collect(),
blockers: value
.blockers
.into_iter()
.map(|blocker| TicketRelationBlocker {
blocking_ticket: blocker.blocking_ticket,
reason_kind: blocker.reason_kind,
relation_kind: blocker.relation_kind.as_str().to_string(),
note: blocker.note,
blocking_state: blocker.blocking_state.as_str().to_string(),
})
.collect(),
notices: value
.notices
.into_iter()
.map(|notice| TicketRelationNotice {
related_ticket: notice.related_ticket,
kind: notice.kind.as_str().to_string(),
message: notice.message,
})
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveSummary {
pub id: String,
@@ -102,6 +218,62 @@ pub struct ObjectiveResourceSummary {
pub updated_at: String,
}
#[cfg(feature = "typescript")]
pub fn ticket_api_typescript() -> String {
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
InvalidProjectRecord::decl(&config),
TicketSummary::decl(&config),
TicketListResponse::decl(&config),
TicketEventDetail::decl(&config),
TicketRelation::decl(&config),
DerivedTicketRelation::decl(&config),
TicketRelationBlocker::decl(&config),
TicketRelationNotice::decl(&config),
TicketRelationView::decl(&config),
TicketDetail::decl(&config),
];
format!(
"// Generated from yoi-workspace-server. Do not edit by hand.\n// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts\n\n{}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
.collect::<Vec<_>>()
.join("\n\n")
)
}
#[cfg(all(test, feature = "typescript"))]
mod typescript_tests {
#[test]
fn generated_ticket_api_contract_is_current() {
let expected = super::ticket_api_typescript();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../web/workspace/src/lib/generated/ticket-api.ts");
let actual = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
assert_eq!(
normalize(&actual),
normalize(&expected),
"regenerate Ticket API TypeScript types with `cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts` and format the generated file",
);
}
fn normalize(value: &str) -> String {
value
.chars()
.filter_map(|character| match character {
character if character.is_whitespace() => None,
',' => Some(';'),
character => Some(character),
})
.collect::<String>()
.replace(";}", "}")
}
}
pub(crate) fn validate_project_id(id: &str) -> Result<()> {
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
}
+59 -133
View File
@@ -82,7 +82,7 @@ use crate::profile_settings::{
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
};
use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary};
use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail};
use crate::repositories::{
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
RepositoryRegistryReader, RepositorySummary,
@@ -577,14 +577,6 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/repositories/{repository_id}/log",
get(scoped_repository_log),
)
.route(
"/api/repositories/{repository_id}/tickets",
get(repository_tickets),
)
.route(
"/api/w/{workspace_id}/repositories/{repository_id}/tickets",
get(scoped_repository_tickets),
)
.route("/api/hosts", get(list_hosts))
.route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts))
.route(
@@ -1198,34 +1190,11 @@ pub struct RepositoryLogResponse {
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RepositoryTicketsResponse {
pub workspace_id: String,
pub repository_id: String,
pub limit: usize,
pub columns: Vec<TicketKanbanColumn>,
pub invalid_records: Vec<crate::records::InvalidProjectRecord>,
pub record_authority: String,
pub source: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TicketKanbanColumn {
pub state: String,
pub items: Vec<TicketSummary>,
}
#[derive(Debug, Deserialize)]
struct LogQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct TicketKanbanQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct MemoryStagingQuery {
limit: Option<usize>,
@@ -1256,6 +1225,11 @@ struct ObjectiveEditRequest {
replace_all: bool,
}
#[derive(Debug, Deserialize)]
struct TicketListQuery {
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct ObjectiveStateRequest {
state: String,
@@ -1540,8 +1514,8 @@ async fn scoped_delete_profile_source(
async fn scoped_list_tickets(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
Query(query): Query<TicketListQuery>,
) -> ApiResult<Json<crate::records::TicketListResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
list_tickets(State(api), Query(query)).await
}
@@ -2232,15 +2206,6 @@ async fn scoped_repository_log(
repository_log(State(api), AxumPath(path.repository_id), Query(query)).await
}
async fn scoped_repository_tickets(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRepositoryPath>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<RepositoryTicketsResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
repository_tickets(State(api), AxumPath(path.repository_id), Query(query)).await
}
async fn scoped_list_hosts(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -4111,8 +4076,8 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens
async fn list_tickets(
State(api): State<WorkspaceApi>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
Query(query): Query<TicketListQuery>,
) -> ApiResult<Json<crate::records::TicketListResponse>> {
let requested_limit = query.limit.unwrap_or(api.config.max_records);
let limit = requested_limit.min(1000);
let ProjectRecordList {
@@ -4120,7 +4085,7 @@ async fn list_tickets(
invalid_records,
record_authority,
} = api.authority.list_tickets(limit)?;
Ok(Json(ListResponse {
Ok(Json(crate::records::TicketListResponse {
workspace_id: api.config.workspace_id,
limit,
items,
@@ -4211,35 +4176,6 @@ async fn repository_log(
}))
}
async fn repository_tickets(
State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<RepositoryTicketsResponse>> {
repository_lookup(api.repository_reader().summary(&repository_id))?;
let canonical_repository_id = repository_id;
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
let ProjectRecordList {
items,
invalid_records,
record_authority,
} = api.authority.list_tickets(limit)?;
Ok(Json(RepositoryTicketsResponse {
workspace_id: api.config.workspace_id,
repository_id: canonical_repository_id,
limit,
columns: ticket_kanban_columns(items),
invalid_records,
record_authority,
source: "workspace_local_ticket_fallback".to_string(),
diagnostics: vec![RuntimeDiagnostic {
code: "repository_ticket_target_metadata_absent".to_string(),
severity: DiagnosticSeverity::Info,
message: "Ticket target Repository metadata is not available yet; Kanban groups all workspace-local Tickets by state as a read-only fallback.".to_string(),
}],
}))
}
async fn list_hosts(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<HostSummary>>> {
@@ -6811,52 +6747,6 @@ fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -
})
}
fn ticket_kanban_columns(items: Vec<TicketSummary>) -> Vec<TicketKanbanColumn> {
let mut columns = vec![
TicketKanbanColumn {
state: "planning".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "ready".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "queued".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "inprogress".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "done".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "closed".to_string(),
items: Vec::new(),
},
TicketKanbanColumn {
state: "other".to_string(),
items: Vec::new(),
},
];
for item in items {
let index = match item.state.as_str() {
"planning" => 0,
"ready" => 1,
"queued" => 2,
"inprogress" => 3,
"done" => 4,
"closed" => 5,
_ => 6,
};
columns[index].items.push(item);
}
columns
}
async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Response {
if uri.path().starts_with("/api/") || uri.path() == "/api" {
return (
@@ -7990,6 +7880,40 @@ mod tests {
workspace_id: TEST_WORKSPACE_ID.to_string(),
id: ticket_id.clone(),
};
let Json(related) = scoped_ticket_backend_operation(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Json(TicketBackendOperation::Create {
input: ticket::NewTicket::new("Related Browser Ticket"),
}),
)
.await
.unwrap();
let related_ticket_id = match related {
TicketBackendHttpResponse::Ok {
result: ticket::TicketBackendOperationResult::TicketRef(ticket_ref),
} => ticket_ref.id,
other => panic!("unexpected related create response: {other:?}"),
};
let _ = scoped_ticket_backend_operation(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Json(TicketBackendOperation::AddTicketRelation {
id: ticket_id.clone().into(),
relation: ticket::NewTicketRelation {
kind: ticket::TicketRelationKind::Related,
target: related_ticket_id.clone(),
note: Some("Browser relation".to_string()),
author: Some("browser-user".to_string()),
},
}),
)
.await
.unwrap();
let Json(edited) = scoped_edit_ticket_item(
State(api.clone()),
@@ -8013,6 +7937,10 @@ mod tests {
assert_eq!(edited.body, "Updated from the Browser API.");
assert_eq!(edited.repository_id.as_deref(), Some("main"));
assert_eq!(edited.ref_selector.as_deref(), Some("feature/api"));
assert_eq!(edited.assignee, None);
assert_eq!(edited.relations.outgoing.len(), 1);
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
assert_eq!(edited.relations.outgoing[0].kind, "related");
let Json(commented) = scoped_append_ticket_event(
State(api.clone()),
@@ -9408,19 +9336,17 @@ mod tests {
assert_eq!(repository_log["default_selector"], "HEAD");
assert_eq!(repository_log["limit"], 3);
let repository_tickets = get_json(app.clone(), "/api/repositories/main/tickets").await;
assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID);
let ready_column = repository_tickets["columns"]
.as_array()
.unwrap()
.iter()
.find(|column| column["state"] == "ready")
let removed_repository_tickets = app
.clone()
.oneshot(
Request::builder()
.uri("/api/repositories/main/tickets")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(ready_column["items"][0]["title"], "API Ticket");
assert_eq!(
repository_tickets["diagnostics"][0]["code"],
"repository_ticket_target_metadata_absent"
);
assert_eq!(removed_repository_tickets.status(), StatusCode::NOT_FOUND);
let unknown_repository_response = app
.clone()