feat: generate ticket browser contract from ServerApi

This commit is contained in:
2026-09-23 10:51:50 +09:00
parent 6f9dc7ab41
commit 515a19bbc3
27 changed files with 2121 additions and 462 deletions
@@ -0,0 +1,59 @@
use std::fs;
use std::path::Path;
use server_api::repository_openapi_typescript::{
INPUT_PATH, TICKET_BROWSER_OUTPUT_PATH, generate_ticket_browser_typescript,
};
fn main() {
if let Err(error) = run() {
eprintln!("{error}");
std::process::exit(1);
}
}
fn run() -> Result<(), String> {
let mut check = false;
for argument in std::env::args().skip(1) {
match argument.as_str() {
"--check" => check = true,
"--help" | "-h" => {
println!(
"Generate the bounded Ticket Browser API TypeScript artifact.\n\n\
Usage: cargo run -q -p server-api --example generate_ticket_api_types -- [--check]"
);
return Ok(());
}
other => return Err(format!("unknown argument `{other}`")),
}
}
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let input_path = root.join(INPUT_PATH);
let output_path = root.join(TICKET_BROWSER_OUTPUT_PATH);
let input = fs::read_to_string(&input_path)
.map_err(|error| format!("failed to read {}: {error}", input_path.display()))?;
let generated = generate_ticket_browser_typescript(&input)
.map_err(|error| format!("failed to generate Ticket Browser API types: {error}"))?;
if check {
let checked_in = fs::read_to_string(&output_path)
.map_err(|error| format!("failed to read {}: {error}", output_path.display()))?;
if checked_in != generated {
return Err(format!(
"{} is stale; regenerate it without --check",
output_path.display()
));
}
println!("{} is current", output_path.display());
return Ok(());
}
let temporary_path = output_path.with_extension("ts.tmp");
fs::write(&temporary_path, generated)
.map_err(|error| format!("failed to write {}: {error}", temporary_path.display()))?;
fs::rename(&temporary_path, &output_path)
.map_err(|error| format!("failed to replace {}: {error}", output_path.display()))?;
println!("generated {}", output_path.display());
Ok(())
}
@@ -28,6 +28,10 @@ pub const COMPANION_GENERATOR_VERSION: &str = "1";
pub const COMPANION_GENERATOR_OPTIONS: &str =
"companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output";
pub const COMPANION_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/companion-api.ts";
pub const TICKET_BROWSER_GENERATOR_NAME: &str = "yoi-ticket-browser-openapi-typescript";
pub const TICKET_BROWSER_GENERATOR_VERSION: &str = "1";
pub const TICKET_BROWSER_GENERATOR_OPTIONS: &str = "ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output";
pub const TICKET_BROWSER_OUTPUT_PATH: &str = "web/workspace/src/lib/generated/ticket-api.ts";
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
const MIN_SAFE_INTEGER: f64 = -9_007_199_254_740_991.0;
@@ -203,6 +207,19 @@ pub fn generate_companion_typescript(input: &str) -> Result<String, GenerationEr
)
}
pub fn generate_ticket_browser_typescript(input: &str) -> Result<String, GenerationError> {
generate_domain_typescript(
input,
"Ticket Browser",
validate_ticket_browser_operations,
TICKET_BROWSER_GENERATOR_NAME,
TICKET_BROWSER_GENERATOR_VERSION,
TICKET_BROWSER_GENERATOR_OPTIONS,
TICKET_BROWSER_OUTPUT_PATH,
"generate_ticket_api_types",
)
}
#[allow(clippy::too_many_arguments)]
fn generate_domain_typescript(
input: &str,
@@ -402,6 +419,112 @@ fn validate_companion_operations(
validate_operation_contracts(paths, &operations)
}
fn validate_ticket_browser_operations(
paths: &Map<String, Value>,
) -> Result<BTreeSet<String>, GenerationError> {
let operations = [
OperationContract {
path: "/api/w/{workspace_id}/tickets",
method: "get",
operation_id: "ticket_list",
request: None,
successes: &[("200", "TicketListResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}",
method: "get",
operation_id: "ticket_get",
request: None,
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}",
method: "patch",
operation_id: "ticket_edit",
request: Some("BrowserEditTicketRequest"),
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/assignments/{role}",
method: "put",
operation_id: "ticket_assignment_set",
request: Some("SetTicketRoleAssignmentRequest"),
successes: &[("200", "TicketRoleAssignmentMutationResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/implementation-cancellations",
method: "post",
operation_id: "ticket_implementation_cancel",
request: Some("CancelTicketImplementationRequest"),
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/state",
method: "post",
operation_id: "ticket_state_transition",
request: Some("BrowserTransitionTicketStateRequest"),
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/ready",
method: "post",
operation_id: "ticket_ready",
request: Some("TicketMarkReadyRequest"),
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/events",
method: "post",
operation_id: "ticket_event_append",
request: Some("BrowserAppendTicketEventRequest"),
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/queue",
method: "post",
operation_id: "ticket_queue",
request: Some("BrowserQueueTicketRequest"),
successes: &[("200", "TicketQueueOutcome")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/close",
method: "post",
operation_id: "ticket_close",
request: Some("BrowserCloseTicketRequest"),
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/objectives",
method: "get",
operation_id: "objective_list",
request: None,
successes: &[("200", "ObjectiveListResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/objectives/{objective_id}",
method: "get",
operation_id: "objective_get",
request: None,
successes: &[("200", "ObjectiveDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/merge-requests",
method: "get",
operation_id: "merge_request_list",
request: None,
successes: &[("200", "MergeRequestListResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/merge-requests/{merge_request_id}",
method: "get",
operation_id: "merge_request_show",
request: None,
successes: &[("200", "MergeRequestDetailResponse")],
},
];
validate_operation_contracts(paths, &operations)
}
fn validate_repository_operations(
paths: &Map<String, Value>,
) -> Result<BTreeSet<String>, GenerationError> {
@@ -1484,12 +1607,25 @@ fn render_typed_schema(
None => (MIN_SAFE_INTEGER, MAX_SAFE_INTEGER),
Some(_) => unreachable!("integer format validated above"),
};
if minimum != Some(expected_minimum) || maximum != Some(expected_maximum) {
let minimum = minimum.unwrap_or(expected_minimum);
let maximum = maximum.unwrap_or(expected_maximum);
if minimum.fract() != 0.0
|| maximum.fract() != 0.0
|| minimum < expected_minimum
|| maximum > expected_maximum
|| minimum > maximum
{
return Err(GenerationError::invalid(format!(
"{context} integer range does not match its JavaScript safe-number mapping"
)));
}
Ok("number".to_owned())
if minimum == expected_minimum && maximum == expected_maximum {
Ok("number".to_owned())
} else {
Ok(format!(
"number & {{ readonly \"__yoiIntegerRange\": \"{minimum:.0}..{maximum:.0}\"; }}"
))
}
}
"array" => {
ensure_schema_keywords(schema, &["items", "maxItems", "minItems", "type"], context)?;
@@ -1537,10 +1673,13 @@ fn render_typed_schema(
}
fn render_object(schema: &Map<String, Value>, context: &str) -> Result<String, GenerationError> {
let properties = schema
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| GenerationError::invalid(format!("{context} object has no properties")))?;
let empty_properties = Map::new();
let properties = match schema.get("properties") {
Some(value) => value.as_object().ok_or_else(|| {
GenerationError::invalid(format!("{context} object properties must be an object"))
})?,
None => &empty_properties,
};
let required = match schema.get("required") {
Some(Value::Array(required)) => required
.iter()
@@ -1564,13 +1703,23 @@ fn render_object(schema: &Map<String, Value>, context: &str) -> Result<String, G
"{context} requires missing property `{missing}`"
)));
}
if schema
.get("additionalProperties")
.is_some_and(|value| value != &Value::Bool(false))
{
return Err(GenerationError::invalid(format!(
"{context} contains unsupported additionalProperties"
)));
match schema.get("additionalProperties") {
Some(Value::Bool(false)) | None => {}
Some(additional) if properties.is_empty() && required.is_empty() => {
return Ok(format!(
"{{ [key: string]: {}; }}",
render_schema(additional, context)?
));
}
Some(_) => {
return Err(GenerationError::invalid(format!(
"{context} contains unsupported additionalProperties"
)));
}
}
if properties.is_empty() {
return Ok("{ [key: string]: never; }".to_owned());
}
let mut fields = Vec::with_capacity(properties.len());
@@ -1649,6 +1798,42 @@ mod tests {
include_str!("../../../web/workspace/src/lib/generated/repository-api.ts");
const RUNTIME_TYPESCRIPT: &str =
include_str!("../../../web/workspace/src/lib/generated/runtime-api.ts");
const TICKET_BROWSER_TYPESCRIPT: &str =
include_str!("../../../web/workspace/src/lib/generated/ticket-api.ts");
#[test]
fn checked_in_ticket_browser_typescript_is_current() {
assert_eq!(
generate_ticket_browser_typescript(OPENAPI).unwrap(),
TICKET_BROWSER_TYPESCRIPT,
"regenerate with `cargo run -q -p server-api --example generate_ticket_api_types`",
);
}
#[test]
fn ticket_browser_generator_validates_operation_ownership_and_schema_closure() {
let output = generate_ticket_browser_typescript(OPENAPI).unwrap();
assert!(output.contains(&format!(
"@generated by {TICKET_BROWSER_GENERATOR_NAME}@{TICKET_BROWSER_GENERATOR_VERSION}"
)));
assert!(output.contains("export type TicketListResponse ="));
assert!(
output.contains("limit: number & { readonly \"__yoiIntegerRange\": \"0..1000\"; }")
);
assert!(
output.contains("export type BrowserQueueTicketRequest = { [key: string]: never; };")
);
assert!(output.contains("attributes: { [key: string]: string; }"));
assert!(output.contains("export type ObjectiveDetail ="));
assert!(output.contains("export type MergeRequestDetailResponse ="));
assert!(!output.contains("export type RuntimeListResponse ="));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["paths"]["/api/w/{workspace_id}/tickets"]["get"]["operationId"] =
serde_json::json!("manual_ticket_list");
let error = generate_ticket_browser_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("ticket_list"));
}
#[test]
fn checked_in_runtime_typescript_is_current() {
@@ -1711,7 +1896,7 @@ mod tests {
}
#[test]
fn generator_rejects_lossy_safe_integer_and_union_changes() {
fn generator_rejects_lossy_safe_integers_and_preserves_bounded_ranges() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
serde_json::json!(9_007_199_254_740_992_u64);
@@ -1727,8 +1912,10 @@ mod tests {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
serde_json::json!(9_007_199_254_740_990_u64);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number"));
let output = generate_repository_typescript(&document.to_string()).unwrap();
assert!(output.contains(
"source_revision: number & { readonly \"__yoiIntegerRange\": \"0..9007199254740990\"; }"
));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] =
+1 -5
View File
@@ -10,10 +10,6 @@ autobins = false
name = "yoi-server"
path = "src/main.rs"
[features]
default = ["typescript"]
typescript = ["dep:ts-rs", "server-api/typescript"]
[dependencies]
async-trait.workspace = true
axum = { workspace = true, features = ["ws"] }
@@ -47,7 +43,7 @@ worker-runtime.workspace = true
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
ts-rs = { version = "12.0.1", optional = true }
ts-rs = "12.0.1"
url.workspace = true
uuid = { workspace = true, features = ["v7"] }
zeroize.workspace = true
@@ -1,3 +0,0 @@
fn main() {
print!("{}", yoi_workspace_server::ticket_api_typescript());
}
-2
View File
@@ -16,8 +16,6 @@ pub mod observation;
pub mod profile_settings;
pub mod prompt_settings;
pub mod records;
#[cfg(feature = "typescript")]
pub use records::ticket_api_typescript;
pub mod repositories;
pub mod repository_access;
pub mod repository_source;
-100
View File
@@ -22,14 +22,12 @@ pub struct TicketSummaryPage {
}
#[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 resource_key: String,
@@ -54,7 +52,6 @@ pub struct TicketListPageRequest {
}
#[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,
@@ -65,7 +62,6 @@ pub struct TicketListResponse {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketDetail {
pub id: String,
pub resource_key: String,
@@ -102,7 +98,6 @@ pub struct TicketDetail {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketEventDetail {
pub sequence: usize,
pub event_ref: String,
@@ -121,7 +116,6 @@ pub struct TicketEventDetail {
}
#[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,
@@ -134,7 +128,6 @@ pub struct TicketRelation {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct DerivedTicketRelation {
pub source_ticket: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -147,7 +140,6 @@ pub struct DerivedTicketRelation {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketRelationBlocker {
pub blocking_ticket: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -159,7 +151,6 @@ pub struct TicketRelationBlocker {
}
#[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,
@@ -167,7 +158,6 @@ pub struct TicketRelationNotice {
}
#[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>,
@@ -230,7 +220,6 @@ impl From<ticket::TicketRelationView> for TicketRelationView {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct ObjectiveLinkSummary {
pub id: String,
pub resource_key: String,
@@ -239,7 +228,6 @@ pub struct ObjectiveLinkSummary {
}
#[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,
@@ -250,7 +238,6 @@ pub struct TicketEvidenceEvent {
}
#[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,
@@ -260,7 +247,6 @@ pub struct TicketAssignmentSummary {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketRoleAssignmentSummary {
pub assignment_id: String,
pub role: String,
@@ -271,8 +257,6 @@ pub struct TicketRoleAssignmentSummary {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(tag = "kind", rename_all = "snake_case"))]
pub enum TicketAssignmentPrincipalSummary {
User {
account_id: String,
@@ -287,7 +271,6 @@ pub enum TicketAssignmentPrincipalSummary {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketActionEligibility {
pub can_assign_orchestrator: bool,
pub can_unassign_orchestrator: bool,
@@ -298,7 +281,6 @@ pub struct TicketActionEligibility {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketMergeRequestSummary {
pub merge_request_id: String,
pub repository_key: String,
@@ -315,14 +297,12 @@ pub struct TicketMergeRequestSummary {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct MergeRequestRefDiagnostic {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct MergeRequestListItem {
pub summary: TicketMergeRequestSummary,
pub ticket_ids: Vec<String>,
@@ -332,14 +312,12 @@ pub struct MergeRequestListItem {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct MergeRequestListResponse {
pub items: Vec<MergeRequestListItem>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketEvidenceSummary {
pub has_merge_request: bool,
pub has_current_subject_ref: bool,
@@ -354,7 +332,6 @@ pub struct TicketEvidenceSummary {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketQueryRequest {
pub query: Option<String>,
#[serde(default)]
@@ -377,7 +354,6 @@ pub struct TicketQueryRequest {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketQueryItem {
pub id: String,
pub resource_key: String,
@@ -393,7 +369,6 @@ pub struct TicketQueryItem {
pub snippet: Option<String>,
pub matching_event: Option<TicketEvidenceEvent>,
pub linked_objective_ids: Vec<String>,
#[ts(skip)]
pub linked_objective_keys: Vec<String>,
pub relation_count: usize,
pub blocker_count: usize,
@@ -401,12 +376,10 @@ pub struct TicketQueryItem {
pub unresolved_review_count: usize,
pub evidence: TicketEvidenceSummary,
pub merge_request: Option<TicketMergeRequestSummary>,
#[ts(skip)]
pub current_coder: Option<TicketAssignmentSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct TicketQueryResponse {
pub items: Vec<TicketQueryItem>,
pub page: QueryPage,
@@ -414,7 +387,6 @@ pub struct TicketQueryResponse {
}
#[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>,
@@ -461,78 +433,6 @@ pub struct ObjectiveShowRequest {
pub event_cursor: Option<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),
QueryPage::decl(&config),
TicketEventDetail::decl(&config),
ObjectiveLinkSummary::decl(&config),
TicketEvidenceEvent::decl(&config),
TicketAssignmentSummary::decl(&config),
TicketRoleAssignmentSummary::decl(&config),
TicketAssignmentPrincipalSummary::decl(&config),
TicketActionEligibility::decl(&config),
TicketMergeRequestSummary::decl(&config),
MergeRequestRefDiagnostic::decl(&config),
MergeRequestListItem::decl(&config),
MergeRequestListResponse::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),
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 summarize_body(body: &str) -> String {
let summary = body
.lines()
+4 -2
View File
@@ -6,12 +6,14 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview",
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check"
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check"
},
"imports": {
"$lib/": "./src/lib/",
+100 -28
View File
@@ -1,54 +1,126 @@
// Generated from yoi-workspace-server. Do not edit by hand.
// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:550383b600fe2a2e55630849c7de7a6a24463ad41d0c18dc5d254a3d14328f07)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
export type InvalidProjectRecord = { label: string, reason: string, };
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
export type TicketSummary = { id: string, resource_key: string, title: string, state: string, priority: string, updated_at: string | null, queued_by: string | null, queued_at: string | null, workspace_action_priority: string, record_source: string, };
export type BrowserCloseTicketRequest = { resolution: string; };
export type TicketListResponse = { workspace_id: string, limit: number, items: Array<TicketSummary>, page: QueryPage, invalid_records: Array<InvalidProjectRecord>, record_authority: string, };
export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; };
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 BrowserQueueTicketRequest = { [key: string]: never; };
export type TicketEventDetail = { sequence: number, event_ref: string, kind: string, author: string | null, at: string | null, status: string | null, from: string | null, to: string | null, reason: string | null, state_field: string | null, heading: string | null, body: string | null, attributes: { [key in string]: string }, references: Array<string>, };
export type BrowserTicketTargetEdit = { action: "set"; ref_selector?: string | null; repository_key: string; } | { action: "clear"; };
export type ObjectiveLinkSummary = { id: string, resource_key: string, title: string, state: string, };
export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report";
export type TicketEvidenceEvent = { event_ref: string, sequence: number, kind: string, at: string | null, author: string | null, excerpt: string, };
export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed";
export type TicketAssignmentSummary = { assignment_id: string, runtime_id: string, worker_id: string, worker_resource_key?: string | null, };
export type BrowserTransitionTicketStateRequest = { author?: string | null; body?: string | null; reason?: string | null; state: BrowserTicketWorkflowState; };
export type TicketRoleAssignmentSummary = { assignment_id: string, role: string, principal: TicketAssignmentPrincipalSummary, assigned_by: string, assigned_at: string, };
export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; };
export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, };
export type ConflictResolution = "none" | "clean" | "conflicts_resolved";
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, queue_tickets: Array<string>, blockers: Array<string>, };
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
export type DiagnosticSeverity = "info" | "warning" | "error";
export type MergeRequestRefDiagnostic = { code: string, message: string, };
export type FindingSeverity = "blocker" | "major" | "minor" | "note";
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, ref_diagnostics?: Array<MergeRequestRefDiagnostic>, };
export type InvalidProjectRecord = { label: string; reason: string; };
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>, next_cursor: string | null, };
export type MarkdownText = string;
export type TicketEvidenceSummary = { has_merge_request: boolean, has_current_subject_ref: boolean, has_review_request: boolean, has_commit: boolean, review_status: string | null, approved_current_subject: boolean, review_after_rescope: boolean, unresolved_request_changes: boolean, complete_for_integration: boolean, missing: Array<string>, };
export type MergeRequestDetailResponse = { created_at: string; linked_tickets: Array<MergeRequestLinkedTicketResponse>; merge_request_id: string; repository_key: string; selector_from?: string | null; selector_to: string; source: MergeRequestRefResponse; state: MergeRequestState; target: MergeRequestRefResponse; thread: Array<MergeRequestThreadEvent>; ticket_ids: Array<string>; updated_at: string; workspace_id: string; };
export type TicketQueryRequest = { query: 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 MergeRequestLinkedTicketResponse = { key?: string | null; ticket_id: string; };
export type TicketQueryItem = { id: string, resource_key: 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, matching_event: TicketEvidenceEvent | null, 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, };
export type MergeRequestListItem = { ref_diagnostics?: Array<MergeRequestRefDiagnostic>; summary: TicketMergeRequestSummary; thread_event_count: number; ticket_ids: Array<string>; };
export type TicketQueryResponse = { items: Array<TicketQueryItem>, page: QueryPage, record_authority: string, };
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>; next_cursor?: string | null; };
export type TicketShowRequest = { event_limit: number | null, event_cursor: string | null, };
export type MergeRequestRefDiagnostic = { code: string; message: string; };
export type TicketRelation = { ticket_id: string, kind: string, target: string, target_resource_key?: string | null, note: string | null, author: string, at: string, };
export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic | null; observed_at: string; ref?: string | null; status: string; };
export type DerivedTicketRelation = { source_ticket: string, source_resource_key?: string | null, inverse_kind: string, forward_kind: string, note: string | null, author: string, at: string, };
export type MergeRequestState = "open" | "merged" | "closed";
export type TicketRelationBlocker = { blocking_ticket: string, blocking_resource_key?: string | null, reason_kind: string, relation_kind: string, note: string | null, blocking_state: string, };
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
export type TicketRelationNotice = { related_ticket: string, kind: string, message: string, };
export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; };
export type TicketRelationView = { outgoing: Array<TicketRelation>, incoming: Array<DerivedTicketRelation>, blockers: Array<TicketRelationBlocker>, notices: Array<TicketRelationNotice>, };
export type MergeStrategy = "fast_forward" | "merge";
export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_key: string | null, ref_selector: string | null, risk_flags: Array<string>, body: string, body_truncated: boolean, event_count: number, events: Array<TicketEventDetail>, event_page: QueryPage, artifact_count: number, artifacts: Array<string>, relations: TicketRelationView, linked_objectives: Array<ObjectiveLinkSummary>, implementation_reports: Array<TicketEvidenceEvent>, assignments: Array<TicketRoleAssignmentSummary>, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array<string>, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, };
export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array<ObjectiveEventDetail>; id: string; linked_ticket_summaries: Array<ObjectiveLinkedTicketSummary>; linked_tickets: Array<string>; record_source: string; resource_key: string; resources: Array<ObjectiveResourceSummary>; revision: string; state: string; title: string; updated_at?: string | null; };
export type ObjectiveEventDetail = { body?: string | null; created_at: string; event_ref: string; kind: string; };
export type ObjectiveLinkSummary = { id: string; resource_key: string; state: string; title: string; };
export type ObjectiveLinkedTicketSummary = { id: string; resource_key: string; state: string; title: string; };
export type ObjectiveListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<ObjectiveSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; record_authority: string; workspace_id: string; };
export type ObjectiveResourceSummary = { bytes: number; media_type?: string | null; path: string; updated_at: string; };
export type ObjectiveSummary = { created_at?: string | null; id: string; linked_tickets: Array<string>; record_source: string; resource_key: string; state: string; summary: string; title: string; updated_at?: string | null; };
export type QueryPage = { has_more: boolean; limit: number; next_cursor?: string | null; returned: number; sort: string; source_limit?: number | null; source_truncated: boolean; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type ReviewDecision = "approve" | "request_changes";
export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; };
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; };
export type TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_coder: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; };
export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contributor";
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; ref_selector?: string | null; relations: TicketDetailRelationView; repository_key?: string | null; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; title: string; updated_at?: string | null; };
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
export type TicketDetailRelation = { at: string; author: string; kind: string; note?: string | null; target: string; target_resource_key?: string | null; ticket_id: string; };
export type TicketDetailRelationBlocker = { blocking_resource_key?: string | null; blocking_state: string; blocking_ticket: string; note?: string | null; reason_kind: string; relation_kind: string; };
export type TicketDetailRelationNotice = { kind: string; message: string; related_ticket: string; };
export type TicketDetailRelationView = { blockers: Array<TicketDetailRelationBlocker>; incoming: Array<TicketDetailDerivedRelation>; notices: Array<TicketDetailRelationNotice>; outgoing: Array<TicketDetailRelation>; };
export type TicketEventDetail = { at?: string | null; attributes: { [key: string]: string; }; author?: string | null; body?: string | null; event_ref: string; from?: string | null; heading?: string | null; kind: string; reason?: string | null; references: Array<string>; sequence: number; state_field?: string | null; status?: string | null; to?: string | null; };
export type TicketEvidenceEvent = { at?: string | null; author?: string | null; event_ref: string; excerpt: string; kind: string; sequence: number; };
export type TicketEvidenceSummary = { approved_current_subject: boolean; complete_for_integration: boolean; has_commit: boolean; has_current_subject_ref: boolean; has_merge_request: boolean; has_review_request: boolean; missing: Array<string>; review_after_rescope: boolean; review_status?: string | null; unresolved_request_changes: boolean; };
export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array<TicketReference>; };
export type TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; title: string; updated_at?: string | null; workspace_action_priority: string; };
export type TicketListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<TicketListItemSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; };
export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | null; operation_key: string; reason?: string | null; };
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; state: string; updated_at: string; };
export type TicketQueueOutcome = { queued_tickets: Array<string>; requested_ticket: string; };
export type TicketReference = { kind: string; target: string; };
export type TicketRoleAssignmentMutationResponse = { assignment?: TicketRoleAssignmentRecord | null; ticket_id: string; workspace_id: string; };
export type TicketRoleAssignmentRecord = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: TicketAssignmentRole; ticket_id: string; workspace_id: string; };
export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: string; };
+25 -5
View File
@@ -177,23 +177,41 @@ export async function readBoundedJson(
return JSON.parse(text) as unknown;
}
async function requireJson<T>(response: Response, path: string): Promise<T> {
async function requireJson<T>(
response: Response,
path: string,
parse: (value: unknown) => T,
maxResponseBytes?: number,
): Promise<T> {
if (!response.ok) {
if (maxResponseBytes !== undefined) {
await response.body?.cancel();
throw new Error(`${path} request failed (${response.status})`);
}
const text = await response.text();
throw new Error(text || `${path} request failed (${response.status})`);
}
return (await response.json()) as T;
const value: unknown = maxResponseBytes === undefined
? await response.json()
: await readBoundedJson(response, maxResponseBytes);
return parse(value);
}
export async function workspaceApiJson<T>(path: string): Promise<T> {
return requireJson<T>(await fetch(path), path);
export async function workspaceApiJson<T>(
path: string,
parse: (value: unknown) => T = (value) => value as T,
maxResponseBytes?: number,
): Promise<T> {
return requireJson(await fetch(path), path, parse, maxResponseBytes);
}
export async function workspaceApiJsonWithBody<T>(
path: string,
init: RequestInit,
parse: (value: unknown) => T = (value) => value as T,
maxResponseBytes?: number,
): Promise<T> {
return requireJson<T>(
return requireJson(
await fetch(path, {
headers: {
"content-type": "application/json",
@@ -202,6 +220,8 @@ export async function workspaceApiJsonWithBody<T>(
...init,
}),
path,
parse,
maxResponseBytes,
);
}
@@ -1,48 +1,13 @@
import type { MergeRequestListResponse } from "$lib/generated/ticket-api";
import type {
MergeRequestDetailResponse,
MergeRequestListResponse,
MergeRequestThreadEvent,
} from "$lib/generated/ticket-api";
import { workspaceApiPath } from "./http";
export type MergeRequestState = "open" | "merged" | "closed";
export type MergeRequestActor = {
runtime_id: string;
worker_id: string;
assignment_id: string;
};
export type MergeRequestThreadEvent = {
kind: string;
sequence: number;
at: string;
[key: string]: unknown;
};
export type MergeRequestRecord = {
merge_request_id: string;
workspace_id: string;
repository_key: string;
selector_from: string | null;
selector_to: string;
ticket_ids: string[];
state: MergeRequestState;
opened_by: MergeRequestActor;
created_at: string;
updated_at: string;
thread: MergeRequestThreadEvent[];
};
export type MergeRequestRefObservation = {
status: string;
ref: string | null;
observed_at: string;
};
export type MergeRequestDetail = MergeRequestRecord & {
source: MergeRequestRefObservation;
target: MergeRequestRefObservation;
linked_tickets: Array<{ ticket_id: string; key: string | null }>;
};
export type MergeRequestDetail = MergeRequestDetailResponse;
export type MergeRequestListPage = MergeRequestListResponse;
export type { MergeRequestThreadEvent };
export function mergeRequestCollectionPath(workspaceId: string): string {
return workspaceApiPath(workspaceId, "/merge-requests");
@@ -0,0 +1,143 @@
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
import {
parseMergeRequestDetailResponse,
parseObjectiveListResponse,
parseTicketListResponse,
} from "./ticket-browser.ts";
function assertEquals(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`Expected ${expectedJson}, received ${actualJson}`);
}
}
function assertThrows(operation: () => unknown, message: string): void {
try {
operation();
} catch (error) {
if (error instanceof Error && error.message.includes(message)) return;
throw error;
}
throw new Error(`Expected operation to throw: ${message}`);
}
const page = {
has_more: false,
limit: 30,
next_cursor: null,
returned: 1,
sort: "updated_desc",
source_limit: null,
source_truncated: false,
};
Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => {
const parsed = parseTicketListResponse({
workspace_id: "workspace-a",
limit: 30,
items: [{
id: "ticket-id",
resource_key: "T-1",
title: "Ticket",
state: "ready",
priority: "P2",
updated_at: null,
queued_by: null,
queued_at: null,
workspace_action_priority: "ready",
record_source: "sqlite",
}],
page,
invalid_records: [],
record_authority: "sqlite",
});
assertEquals(parsed.items[0]?.resource_key, "T-1");
});
Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits", () => {
const fixture = {
workspace_id: "workspace-a",
limit: 30,
items: [],
page: { ...page, returned: 0 },
invalid_records: [],
record_authority: "sqlite",
};
assertThrows(
() => parseTicketListResponse({ ...fixture, stale: true }),
"unknown field stale",
);
assertThrows(
() => parseTicketListResponse({ ...fixture, limit: 1001 }),
"0 through 1000",
);
});
Deno.test("Ticket Browser parser accepts the generated Objective list shape", () => {
const parsed = parseObjectiveListResponse({
workspace_id: "workspace-a",
limit: 100,
items: [{
id: "objective-id",
resource_key: "O-1",
title: "Objective",
state: "active",
created_at: null,
updated_at: null,
summary: "Summary",
linked_tickets: ["T-1"],
record_source: "sqlite",
}],
invalid_records: [],
record_authority: "sqlite",
});
assertEquals(parsed.items[0]?.resource_key, "O-1");
});
Deno.test("Ticket Browser parser validates the canonical Merge Request thread", () => {
const parsed = parseMergeRequestDetailResponse({
workspace_id: "workspace-a",
merge_request_id: "mr-1",
repository_key: "main",
state: "open",
selector_from: "work/T-1",
selector_to: "develop",
ticket_ids: ["ticket-id"],
created_at: "2026-09-22T00:00:00Z",
updated_at: "2026-09-22T00:00:00Z",
thread: [{
kind: "review_requested",
event_id: "event-1",
sequence: 1,
subject_ref: "abc123",
requested_by: { runtime_id: "runtime-a", worker_id: "worker-a" },
reviewer: { runtime_id: "runtime-a", worker_id: "reviewer-a" },
created_at: "2026-09-22T00:00:00Z",
}],
source: {
status: "resolved",
ref: "abc123",
observed_at: "2026-09-22T00:00:00Z",
},
target: {
status: "resolved",
ref: "def456",
observed_at: "2026-09-22T00:00:00Z",
},
linked_tickets: [{ ticket_id: "ticket-id", key: "T-1" }],
});
assertEquals(parsed.thread[0]?.kind, "review_requested");
assertThrows(
() =>
parseMergeRequestDetailResponse({
...parsed,
thread: [{ ...parsed.thread[0], unexpected: true }],
}),
"unknown field unexpected",
);
});
File diff suppressed because it is too large Load Diff
@@ -385,7 +385,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
"Tickets, Objectives, and Merge Requests should each be a single primary sidebar link",
);
assert(
ticketsLoad.includes("Object.entries(LANE_STATES)") &&
ticketsLoad.includes("loadLane(fetch, workspaceId") &&
ticketsLoad.includes('limit: "30"') &&
ticketsLoad.includes('states: states.join(",")') &&
ticketsLoad.includes("/tickets?${search}") &&
@@ -405,9 +405,12 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
"Ticket Kanban should combine related states into independent 30-item display windows",
);
assert(
generatedTicketApi.includes("Generated from yoi-workspace-server") &&
generatedTicketApi.includes(
"@generated by yoi-ticket-browser-openapi-typescript@1",
) &&
generatedTicketApi.includes("Input contract: 0.1.0 (sha256:") &&
generatedTicketApi.includes("export type TicketListResponse") &&
generatedTicketApi.includes("items: Array<TicketSummary>"),
generatedTicketApi.includes("items: Array<TicketListItemSummary>"),
"Ticket response types should come from the generated Server contract",
);
assert(
@@ -3,11 +3,17 @@ import type {
MergeRequestThreadEvent,
} from "./api/merge-requests.ts";
type ReviewEvent = Extract<MergeRequestThreadEvent, { kind: "review" }>;
type SourceEvidenceEvent = Extract<
MergeRequestThreadEvent,
{ kind: "review" | "review_requested" }
>;
function isCurrentSourceEvent(
event: MergeRequestThreadEvent,
source: string,
): boolean {
return event.subject_ref === source;
return "subject_ref" in event && event.subject_ref === source;
}
function requestHasTerminalOutcome(
@@ -26,16 +32,18 @@ export function sourceReviewFreshness(
const source = mergeRequest.source.ref;
if (!source) return "Source review unavailable: selector_from is unresolved.";
const effectiveReview = [...mergeRequest.thread].reverse().find((event) => {
if (event.kind !== "review" || !isCurrentSourceEvent(event, source)) {
return false;
}
return !mergeRequest.thread.some(
(candidate) =>
candidate.kind === "review_revoked" &&
candidate.review_event_id === event.event_id,
);
});
const effectiveReview = [...mergeRequest.thread].reverse().find(
(event): event is ReviewEvent => {
if (event.kind !== "review" || !isCurrentSourceEvent(event, source)) {
return false;
}
return !mergeRequest.thread.some(
(candidate) =>
candidate.kind === "review_revoked" &&
candidate.review_event_id === event.event_id,
);
},
);
if (effectiveReview) {
return effectiveReview.decision === "approve"
? `Current source approved at exact ref ${source}.`
@@ -43,9 +51,8 @@ export function sourceReviewFreshness(
}
const latestEvidence = [...mergeRequest.thread].reverse().find(
(event) =>
(event.kind === "review" || event.kind === "review_requested") &&
typeof event.subject_ref === "string",
(event): event is SourceEvidenceEvent =>
event.kind === "review" || event.kind === "review_requested",
);
if (latestEvidence?.subject_ref && latestEvidence.subject_ref !== source) {
return `Fresh source review required: selector_from moved from ${latestEvidence.subject_ref} to ${source}.`;
@@ -82,15 +82,17 @@ export type WorkerCapabilities = SharedWorkerCapabilitySummary;
export type WorkerWorkdirAttachment = SharedWorkerWorkdirAttachmentSummary;
export type Worker = Omit<
SharedWorkerSummary,
"display_name" | "tags" | "worker_state" | "diagnostics"
> & {
display_name: string;
tags: string[];
worker_state?: WorkerStateSnapshot | null;
diagnostics: Diagnostic[];
};
export type Worker =
& Omit<
SharedWorkerSummary,
"display_name" | "tags" | "worker_state" | "diagnostics"
>
& {
display_name: string;
tags: string[];
worker_state?: WorkerStateSnapshot | null;
diagnostics: Diagnostic[];
};
export type WorkerOperationState = "accepted" | "unsupported" | "rejected";
@@ -149,61 +151,22 @@ export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
export type RepositoryLogResponse = SharedRepositoryLogResponse;
export type {
DerivedTicketRelation,
ObjectiveDetail,
ObjectiveLinkedTicketSummary,
ObjectiveListResponse,
ObjectiveSummary,
TicketDetail,
TicketEventDetail,
TicketListResponse,
TicketRelation,
TicketRelationBlocker,
TicketRelationNotice,
TicketRelationView,
TicketSummary,
} from "$lib/generated/ticket-api";
export type ObjectiveSummary = {
id: string;
resource_key: string;
title: string;
state: string;
updated_at?: string | null;
summary: string;
linked_tickets?: string[];
record_source?: string;
};
export type ObjectiveLinkedTicketSummary = {
id: string;
resource_key: string;
title: string;
state: string;
};
export type ObjectiveDetail = {
id: string;
resource_key: string;
title: string;
state: string;
created_at?: string | null;
updated_at?: string | null;
linked_tickets: string[];
linked_ticket_summaries: ObjectiveLinkedTicketSummary[];
body: string;
body_truncated: boolean;
record_source: string;
};
export type InvalidProjectRecord = {
label: string;
reason: string;
};
export type ObjectiveListResponse = {
workspace_id: string;
limit: number;
items: ObjectiveSummary[];
invalid_records: InvalidProjectRecord[];
record_authority: string;
};
export type {
TicketDetailDerivedRelation as DerivedTicketRelation,
TicketDetailRelation as TicketRelation,
TicketDetailRelationBlocker as TicketRelationBlocker,
TicketDetailRelationNotice as TicketRelationNotice,
TicketDetailRelationView as TicketRelationView,
TicketListItemSummary as TicketSummary,
} from "$lib/generated/ticket-api";
export type {
CompanionCancelRequest,
@@ -7,7 +7,7 @@ import {
} from "./ticket-panel.ts";
import type {
TicketDetail,
TicketSummary,
TicketListItemSummary as TicketSummary,
} from "../../generated/ticket-api.ts";
declare const Deno: {
@@ -120,7 +120,10 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
assertEquals(source.includes("ticket.relations.blockers.length > 0"), true);
assertEquals(source.includes("ticket.action_eligibility.queue_tickets"), true);
assertEquals(
source.includes("ticket.action_eligibility.queue_tickets"),
true,
);
assertEquals(source.includes("This operation queues:"), true);
assertEquals(source.includes("outcome.queued_tickets.join"), true);
assertEquals(
@@ -1,5 +1,8 @@
import type { BrowserWorkspaceOrchestratorResponse } from "$lib/generated/worker-launch-api";
import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api";
import type {
TicketDetail,
TicketListItemSummary,
} from "$lib/generated/ticket-api";
export const TICKET_STATES = [
"planning",
@@ -38,7 +41,7 @@ export const TICKET_LANE_PAGE_SIZE = 30;
export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
export type TicketLaneId = TicketLaneDefinition["id"];
export type TicketCardSummary = Pick<
TicketSummary,
TicketListItemSummary,
"id" | "resource_key" | "title" | "state" | "priority" | "updated_at"
>;
@@ -1,33 +1,22 @@
import type { PageLoad } from "./$types";
import { loadJson } from "$lib/workspace/api/http";
import { mergeRequestCollectionPath } from "$lib/workspace/api/merge-requests";
import {
mergeRequestCollectionPath,
type MergeRequestListPage,
} from "$lib/workspace/api/merge-requests";
parseMergeRequestListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
export const load: PageLoad = async ({ params, fetch }) => {
try {
const response = await fetch(
`${mergeRequestCollectionPath(params.workspaceId)}?limit=100`,
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
return {
workspaceId: params.workspaceId,
mergeRequests: null,
error: body?.error ?? body?.message ??
`Request failed (${response.status})`,
};
}
return {
workspaceId: params.workspaceId,
mergeRequests: body as MergeRequestListPage,
error: null,
};
} catch (error) {
return {
workspaceId: params.workspaceId,
mergeRequests: null,
error: error instanceof Error ? error.message : String(error),
};
}
const result = await loadJson(
fetch,
`${mergeRequestCollectionPath(params.workspaceId)}?limit=100`,
undefined,
parseMergeRequestListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
return {
workspaceId: params.workspaceId,
mergeRequests: result.data,
error: result.error,
};
};
@@ -1,4 +1,5 @@
<script lang="ts">
import type { MergeRequestThreadEvent } from "$lib/generated/ticket-api";
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import {
sourceReviewFreshness,
@@ -13,8 +14,15 @@
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function textField(event: Record<string, unknown>, key: string): string | null {
const value = event[key];
function textField(
event: MergeRequestThreadEvent,
key: "subject_ref" | "decision" | "reason" | "body",
): string | null {
let value: unknown;
if (key === "subject_ref" && "subject_ref" in event) value = event.subject_ref;
if (key === "decision" && "decision" in event) value = event.decision;
if (key === "reason" && "reason" in event) value = event.reason;
if (key === "body" && "body" in event) value = event.body;
return typeof value === "string" && value.length > 0 ? value : null;
}
@@ -77,7 +85,7 @@
<div>
<header>
<strong>{event.kind}</strong>
<time>{prettyDate(event.at)}</time>
<time>{prettyDate(event.created_at)}</time>
</header>
{#if textField(event, "subject_ref")}
<p><code>{textField(event, "subject_ref")}</code></p>
@@ -1,38 +1,25 @@
import type { PageLoad } from "./$types";
import { loadJson } from "$lib/workspace/api/http";
import { mergeRequestDetailPath } from "$lib/workspace/api/merge-requests";
import {
type MergeRequestDetail,
mergeRequestDetailPath,
} from "$lib/workspace/api/merge-requests";
parseMergeRequestDetailResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
export const load: PageLoad = async ({ params, fetch }) => {
try {
const response = await fetch(
`${
mergeRequestDetailPath(params.workspaceId, params.mergeRequestId)
}?limit=100`,
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: null,
error: body?.error ?? body?.message ??
`Request failed (${response.status})`,
};
}
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: body as MergeRequestDetail,
error: null,
};
} catch (error) {
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: null,
error: error instanceof Error ? error.message : String(error),
};
}
const result = await loadJson(
fetch,
`${
mergeRequestDetailPath(params.workspaceId, params.mergeRequestId)
}?limit=100`,
undefined,
parseMergeRequestDetailResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: result.data,
error: result.error,
};
};
@@ -1,12 +1,18 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { ObjectiveListResponse } from "$lib/workspace/sidebar/types";
import {
parseObjectiveListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const objectives = await loadJson<ObjectiveListResponse>(
const objectives = await loadJson(
fetch,
apiPath("/objectives"),
undefined,
parseObjectiveListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
return {
@@ -1,18 +1,24 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseObjectiveDetail,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
import {
canonicalResourceReference,
resourceKey,
} from "$lib/workspace/resource-links";
import type { ObjectiveDetail } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const objectiveId = resourceKey(params.objectiveId);
const objective = await loadJson<ObjectiveDetail>(
const objective = await loadJson(
fetch,
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),
undefined,
parseObjectiveDetail,
TICKET_BROWSER_API_LOAD_POLICY,
);
if (objective.data) {
@@ -3,10 +3,13 @@
import type { ApiResult } from "$lib/workspace/api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
import {
TICKET_BROWSER_API_LOAD_POLICY,
parseTicketListResponse,
} from "$lib/workspace/api/ticket-browser";
import type {
QueryPage,
TicketListResponse,
TicketSummary,
TicketListItemSummary as TicketSummary,
} from "$lib/generated/ticket-api";
import { ticketHref } from "$lib/workspace/resource-links";
import {
@@ -70,15 +73,18 @@
states: lane.states.join(","),
cursor: lane.page.next_cursor,
});
const response = await fetch(
const result = await loadJson(
fetch,
`/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`,
undefined,
parseTicketListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
if (!response.ok) {
throw new Error(`追加読み込みに失敗しました (${response.status})`);
if (!result.data) {
throw new Error(result.error ?? "追加読み込みに失敗しました");
}
const page = (await response.json()) as TicketListResponse;
lane.tickets = mergeTickets(lane.tickets, page.items);
lane.page = page.page;
lane.tickets = mergeTickets(lane.tickets, result.data.items);
lane.page = result.data.page;
} catch (error) {
lane.error = error instanceof Error ? error.message : String(error);
} finally {
@@ -1,5 +1,9 @@
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseTicketListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
import type { TicketListResponse } from "$lib/generated/ticket-api";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { PageLoad } from "./$types";
@@ -17,43 +21,51 @@ export type TicketLanePage = {
response: TicketListResponse;
};
async function loadLane(
fetchFn: typeof fetch,
workspaceId: string,
laneId: TicketLaneId,
): Promise<TicketLanePage> {
const states = LANE_STATES[laneId];
const search = new URLSearchParams({
limit: "30",
states: states.join(","),
});
const result = await loadJson(
fetchFn,
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
undefined,
parseTicketListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
if (!result.data) {
throw new Error(result.error ?? `failed to load ${laneId} Ticket lane`);
}
return { states: [...states], response: result.data };
}
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const [entries, orchestrator] = await Promise.all([
Promise.all(
Object.entries(LANE_STATES).map(async ([laneId, states]) => {
const search = new URLSearchParams({
limit: "30",
states: states.join(","),
});
const response = await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
);
if (!response.ok) {
throw new Error(
`failed to load ${laneId} Ticket lane (${response.status})`,
);
}
return [
laneId,
{ states: [...states], response: await response.json() },
] as const;
}),
),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
const [readyPlanning, inprogressQueued, doneClosed, orchestrator] =
await Promise.all([
loadLane(fetch, workspaceId, "ready-planning"),
loadLane(fetch, workspaceId, "inprogress-queued"),
loadLane(fetch, workspaceId, "done-closed"),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
return {
workspaceId,
ticketLanes: Object.fromEntries(entries) as unknown as Record<
TicketLaneId,
TicketLanePage
>,
ticketLanes: {
"ready-planning": readyPlanning,
"inprogress-queued": inprogressQueued,
"done-closed": doneClosed,
},
orchestrator,
};
};
@@ -6,6 +6,12 @@
workspaceApiJsonWithBody,
workspaceApiPath,
} from "$lib/workspace/api/http";
import {
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketDetail,
parseTicketQueueOutcome,
parseTicketRoleAssignmentMutationResponse,
} from "$lib/workspace/api/ticket-browser";
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import {
relationLabel,
@@ -38,11 +44,6 @@
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = $derived(data.repositories.data);
type QueueOutcome = {
requested_ticket: string;
queued_tickets: string[];
};
let ticket = $state<TicketDetail>(loadedTicket);
const mergeRequest = $derived(ticket.merge_request);
let editing = $state(false);
@@ -146,10 +147,10 @@
busy = action;
errorMessage = null;
try {
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
const response = await workspaceApiJsonWithBody(path, {
method,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return false;
applyTicket(response);
return true;
@@ -171,12 +172,18 @@
errorMessage = null;
queueMessage = null;
try {
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
const outcome = await workspaceApiJsonWithBody(
`${path}/queue`,
{ method: "POST", body: JSON.stringify({}) },
parseTicketQueueOutcome,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
const updatedTicket = await workspaceApiJson(
path,
parseTicketDetail,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
applyTicket(updatedTicket);
@@ -210,9 +217,15 @@
expected_assignment_id: null,
}),
},
parseTicketRoleAssignmentMutationResponse,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
const updatedTicket = await workspaceApiJson(
path,
parseTicketDetail,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
applyTicket(updatedTicket);
} catch (error) {
@@ -317,7 +330,7 @@
event.preventDefault();
if (!threadBody.trim()) return;
if (
await mutate("thread", "/thread", {
await mutate("thread", "/events", {
role: threadRole,
body: threadBody.trim(),
})
@@ -1,12 +1,16 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
import {
parseTicketDetail,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
import {
canonicalResourceReference,
resourceKey,
} from "$lib/workspace/resource-links";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { TicketDetail } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
@@ -16,11 +20,19 @@ export const load = (async ({ fetch, params }) => {
`/tickets/${encodeURIComponent(reference)}`,
);
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath),
loadJson(
fetch,
ticketPath,
undefined,
parseTicketDetail,
TICKET_BROWSER_API_LOAD_POLICY,
),
loadWorkspaceRepositoryList(fetch, params.workspaceId),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
if (ticket.data) {
+95 -57
View File
@@ -14,11 +14,88 @@ function assertEquals(actual: unknown, expected: unknown): void {
}
}
function event(
kind: string,
fields: Record<string, unknown>,
): MergeRequestThreadEvent {
return { kind, sequence: 1, at: "2026-09-01T00:00:00Z", ...fields };
const worker = { runtime_id: "runtime", worker_id: "worker" };
const createdAt = "2026-09-01T00:00:00Z";
type ReviewRequested = Extract<
MergeRequestThreadEvent,
{ kind: "review_requested" }
>;
type Review = Extract<MergeRequestThreadEvent, { kind: "review" }>;
type ReviewRevoked = Extract<
MergeRequestThreadEvent,
{ kind: "review_revoked" }
>;
type ReviewCancelled = Extract<
MergeRequestThreadEvent,
{ kind: "review_cancelled" }
>;
function reviewRequested(
eventId: string,
subjectRef: string,
): ReviewRequested {
return {
kind: "review_requested",
event_id: eventId,
sequence: 1,
subject_ref: subjectRef,
requested_by: worker,
reviewer: worker,
created_at: createdAt,
};
}
function review(
eventId: string,
requestEventId: string,
subjectRef: string,
): Review {
return {
kind: "review",
event_id: eventId,
sequence: 1,
request_event_id: requestEventId,
subject_ref: subjectRef,
decision: "approve",
body: "approved",
findings: [],
reviewer: worker,
created_at: createdAt,
};
}
function reviewRevoked(
eventId: string,
reviewEventId: string,
subjectRef: string,
): ReviewRevoked {
return {
kind: "review_revoked",
event_id: eventId,
sequence: 1,
review_event_id: reviewEventId,
subject_ref: subjectRef,
reason: "superseded",
revoked_by: worker,
created_at: createdAt,
};
}
function reviewCancelled(
eventId: string,
requestEventId: string,
subjectRef: string,
): ReviewCancelled {
return {
kind: "review_cancelled",
event_id: eventId,
sequence: 1,
request_event_id: requestEventId,
subject_ref: subjectRef,
reason: "cancelled",
created_at: createdAt,
};
}
function detail(thread: MergeRequestThreadEvent[]): MergeRequestDetail {
@@ -30,22 +107,17 @@ function detail(thread: MergeRequestThreadEvent[]): MergeRequestDetail {
selector_from: "work/ticket",
selector_to: "develop",
state: "open",
opened_by: {
runtime_id: "runtime",
worker_id: "worker",
assignment_id: "assignment",
},
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
created_at: createdAt,
updated_at: createdAt,
source: {
status: "known",
ref: "source-2",
observed_at: "2026-09-01T00:00:00Z",
observed_at: createdAt,
},
target: {
status: "known",
ref: "target-2",
observed_at: "2026-09-01T00:00:00Z",
observed_at: createdAt,
},
linked_tickets: [{ ticket_id: "T-1", key: "T-1" }],
thread,
@@ -54,20 +126,9 @@ function detail(thread: MergeRequestThreadEvent[]): MergeRequestDetail {
Deno.test("revoked review requires a fresh review instead of appearing pending", () => {
const mergeRequest = detail([
event("review_requested", {
event_id: "request-1",
subject_ref: "source-2",
}),
event("review", {
event_id: "review-1",
request_event_id: "request-1",
subject_ref: "source-2",
decision: "approve",
}),
event("review_revoked", {
event_id: "revoke-1",
review_event_id: "review-1",
}),
reviewRequested("request-1", "source-2"),
review("review-1", "request-1", "source-2"),
reviewRevoked("revoke-1", "review-1", "source-2"),
]);
assertEquals(
@@ -78,10 +139,7 @@ Deno.test("revoked review requires a fresh review instead of appearing pending",
Deno.test("unresolved review request for the current source is pending", () => {
const mergeRequest = detail([
event("review_requested", {
event_id: "request-2",
subject_ref: "source-2",
}),
reviewRequested("request-2", "source-2"),
]);
assertEquals(
@@ -92,16 +150,8 @@ Deno.test("unresolved review request for the current source is pending", () => {
Deno.test("completed or cancelled request is not projected as pending", () => {
const approved = detail([
event("review_requested", {
event_id: "request-3",
subject_ref: "source-2",
}),
event("review", {
event_id: "review-3",
request_event_id: "request-3",
subject_ref: "source-2",
decision: "approve",
}),
reviewRequested("request-3", "source-2"),
review("review-3", "request-3", "source-2"),
]);
assertEquals(
sourceReviewFreshness(approved),
@@ -109,15 +159,8 @@ Deno.test("completed or cancelled request is not projected as pending", () => {
);
const cancelled = detail([
event("review_requested", {
event_id: "request-4",
subject_ref: "source-2",
}),
event("review_cancelled", {
event_id: "cancel-4",
request_event_id: "request-4",
subject_ref: "source-2",
}),
reviewRequested("request-4", "source-2"),
reviewCancelled("cancel-4", "request-4", "source-2"),
]);
assertEquals(
sourceReviewFreshness(cancelled),
@@ -127,12 +170,7 @@ Deno.test("completed or cancelled request is not projected as pending", () => {
Deno.test("source movement explains the exact stale and current refs", () => {
const mergeRequest = detail([
event("review", {
event_id: "review-old",
request_event_id: "request-old",
subject_ref: "source-1",
decision: "approve",
}),
review("review-old", "request-old", "source-1"),
]);
assertEquals(