feat: generate ticket browser contract from ServerApi
This commit is contained in:
@@ -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"] =
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+11
-3
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user