workspace: add ticket panel workflow
This commit is contained in:
parent
1c2d284578
commit
e720af38a6
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -6109,6 +6109,7 @@ dependencies = [
|
||||||
"toml",
|
"toml",
|
||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"ts-rs",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webauthn-rs",
|
"webauthn-rs",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ autobins = false
|
||||||
name = "yoi-server"
|
name = "yoi-server"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["typescript"]
|
||||||
|
typescript = ["dep:ts-rs"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
axum = { workspace = true, features = ["ws"] }
|
axum = { workspace = true, features = ["ws"] }
|
||||||
|
|
@ -33,6 +37,7 @@ worker.workspace = true
|
||||||
worker-runtime.workspace = true
|
worker-runtime.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
ts-rs = { version = "12.0.1", optional = true }
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
uuid = { workspace = true, features = ["v7"] }
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
webauthn-rs = { workspace = true }
|
webauthn-rs = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
print!("{}", yoi_workspace_server::ticket_api_typescript());
|
||||||
|
}
|
||||||
|
|
@ -274,6 +274,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||||
updated_at: ticket.meta.updated_at,
|
updated_at: ticket.meta.updated_at,
|
||||||
queued_by: ticket.meta.queued_by,
|
queued_by: ticket.meta.queued_by,
|
||||||
queued_at: ticket.meta.queued_at,
|
queued_at: ticket.meta.queued_at,
|
||||||
|
assignee: ticket.meta.assignee,
|
||||||
repository_id: ticket.meta.repository_id,
|
repository_id: ticket.meta.repository_id,
|
||||||
ref_selector: ticket.meta.ref_selector,
|
ref_selector: ticket.meta.ref_selector,
|
||||||
risk_flags: ticket.meta.risk_flags,
|
risk_flags: ticket.meta.risk_flags,
|
||||||
|
|
@ -287,6 +288,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|artifact| artifact.relative_path.display().to_string())
|
.map(|artifact| artifact.relative_path.display().to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
|
relations: ticket.relations.into(),
|
||||||
resolution: ticket
|
resolution: ticket
|
||||||
.resolution
|
.resolution
|
||||||
.map(|resolution| resolution.as_str().to_string()),
|
.map(|resolution| resolution.as_str().to_string()),
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ pub mod memory_staging;
|
||||||
pub mod observation;
|
pub mod observation;
|
||||||
pub mod profile_settings;
|
pub mod profile_settings;
|
||||||
pub mod records;
|
pub mod records;
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
pub use records::ticket_api_typescript;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod resource_broker;
|
pub mod resource_broker;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@ pub struct ProjectRecordList<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct InvalidProjectRecord {
|
pub struct InvalidProjectRecord {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketSummary {
|
pub struct TicketSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|
@ -32,6 +34,17 @@ pub struct TicketSummary {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct TicketListResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub limit: usize,
|
||||||
|
pub items: Vec<TicketSummary>,
|
||||||
|
pub invalid_records: Vec<InvalidProjectRecord>,
|
||||||
|
pub record_authority: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketDetail {
|
pub struct TicketDetail {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|
@ -41,6 +54,7 @@ pub struct TicketDetail {
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
pub queued_by: Option<String>,
|
pub queued_by: Option<String>,
|
||||||
pub queued_at: Option<String>,
|
pub queued_at: Option<String>,
|
||||||
|
pub assignee: Option<String>,
|
||||||
pub repository_id: Option<String>,
|
pub repository_id: Option<String>,
|
||||||
pub ref_selector: Option<String>,
|
pub ref_selector: Option<String>,
|
||||||
pub risk_flags: Vec<String>,
|
pub risk_flags: Vec<String>,
|
||||||
|
|
@ -50,11 +64,13 @@ pub struct TicketDetail {
|
||||||
pub events: Vec<TicketEventDetail>,
|
pub events: Vec<TicketEventDetail>,
|
||||||
pub artifact_count: usize,
|
pub artifact_count: usize,
|
||||||
pub artifacts: Vec<String>,
|
pub artifacts: Vec<String>,
|
||||||
|
pub relations: TicketRelationView,
|
||||||
pub resolution: Option<String>,
|
pub resolution: Option<String>,
|
||||||
pub record_source: String,
|
pub record_source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketEventDetail {
|
pub struct TicketEventDetail {
|
||||||
pub sequence: usize,
|
pub sequence: usize,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
|
@ -69,6 +85,106 @@ pub struct TicketEventDetail {
|
||||||
pub body: Option<String>,
|
pub body: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct TicketRelation {
|
||||||
|
pub ticket_id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub target: String,
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub author: String,
|
||||||
|
pub at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct DerivedTicketRelation {
|
||||||
|
pub source_ticket: String,
|
||||||
|
pub inverse_kind: String,
|
||||||
|
pub forward_kind: String,
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub author: String,
|
||||||
|
pub at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct TicketRelationBlocker {
|
||||||
|
pub blocking_ticket: String,
|
||||||
|
pub reason_kind: String,
|
||||||
|
pub relation_kind: String,
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub blocking_state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct TicketRelationNotice {
|
||||||
|
pub related_ticket: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct TicketRelationView {
|
||||||
|
pub outgoing: Vec<TicketRelation>,
|
||||||
|
pub incoming: Vec<DerivedTicketRelation>,
|
||||||
|
pub blockers: Vec<TicketRelationBlocker>,
|
||||||
|
pub notices: Vec<TicketRelationNotice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ticket::TicketRelationView> for TicketRelationView {
|
||||||
|
fn from(value: ticket::TicketRelationView) -> Self {
|
||||||
|
Self {
|
||||||
|
outgoing: value
|
||||||
|
.outgoing
|
||||||
|
.into_iter()
|
||||||
|
.map(|relation| TicketRelation {
|
||||||
|
ticket_id: relation.ticket_id,
|
||||||
|
kind: relation.kind.as_str().to_string(),
|
||||||
|
target: relation.target,
|
||||||
|
note: relation.note,
|
||||||
|
author: relation.author,
|
||||||
|
at: relation.at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
incoming: value
|
||||||
|
.incoming
|
||||||
|
.into_iter()
|
||||||
|
.map(|relation| DerivedTicketRelation {
|
||||||
|
source_ticket: relation.source_ticket,
|
||||||
|
inverse_kind: relation.inverse_kind,
|
||||||
|
forward_kind: relation.forward_kind.as_str().to_string(),
|
||||||
|
note: relation.note,
|
||||||
|
author: relation.author,
|
||||||
|
at: relation.at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
blockers: value
|
||||||
|
.blockers
|
||||||
|
.into_iter()
|
||||||
|
.map(|blocker| TicketRelationBlocker {
|
||||||
|
blocking_ticket: blocker.blocking_ticket,
|
||||||
|
reason_kind: blocker.reason_kind,
|
||||||
|
relation_kind: blocker.relation_kind.as_str().to_string(),
|
||||||
|
note: blocker.note,
|
||||||
|
blocking_state: blocker.blocking_state.as_str().to_string(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
notices: value
|
||||||
|
.notices
|
||||||
|
.into_iter()
|
||||||
|
.map(|notice| TicketRelationNotice {
|
||||||
|
related_ticket: notice.related_ticket,
|
||||||
|
kind: notice.kind.as_str().to_string(),
|
||||||
|
message: notice.message,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ObjectiveSummary {
|
pub struct ObjectiveSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -102,6 +218,62 @@ pub struct ObjectiveResourceSummary {
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
pub fn ticket_api_typescript() -> String {
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
let declarations = [
|
||||||
|
InvalidProjectRecord::decl(&config),
|
||||||
|
TicketSummary::decl(&config),
|
||||||
|
TicketListResponse::decl(&config),
|
||||||
|
TicketEventDetail::decl(&config),
|
||||||
|
TicketRelation::decl(&config),
|
||||||
|
DerivedTicketRelation::decl(&config),
|
||||||
|
TicketRelationBlocker::decl(&config),
|
||||||
|
TicketRelationNotice::decl(&config),
|
||||||
|
TicketRelationView::decl(&config),
|
||||||
|
TicketDetail::decl(&config),
|
||||||
|
];
|
||||||
|
format!(
|
||||||
|
"// Generated from yoi-workspace-server. Do not edit by hand.\n// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts\n\n{}\n",
|
||||||
|
declarations
|
||||||
|
.into_iter()
|
||||||
|
.map(|declaration| format!("export {declaration}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "typescript"))]
|
||||||
|
mod typescript_tests {
|
||||||
|
#[test]
|
||||||
|
fn generated_ticket_api_contract_is_current() {
|
||||||
|
let expected = super::ticket_api_typescript();
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../web/workspace/src/lib/generated/ticket-api.ts");
|
||||||
|
let actual = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
|
||||||
|
assert_eq!(
|
||||||
|
normalize(&actual),
|
||||||
|
normalize(&expected),
|
||||||
|
"regenerate Ticket API TypeScript types with `cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts` and format the generated file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter_map(|character| match character {
|
||||||
|
character if character.is_whitespace() => None,
|
||||||
|
',' => Some(';'),
|
||||||
|
character => Some(character),
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.replace(";}", "}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_project_id(id: &str) -> Result<()> {
|
pub(crate) fn validate_project_id(id: &str) -> Result<()> {
|
||||||
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
|
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ use crate::profile_settings::{
|
||||||
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
|
||||||
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
|
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
|
||||||
};
|
};
|
||||||
use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary};
|
use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail};
|
||||||
use crate::repositories::{
|
use crate::repositories::{
|
||||||
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
||||||
RepositoryRegistryReader, RepositorySummary,
|
RepositoryRegistryReader, RepositorySummary,
|
||||||
|
|
@ -577,14 +577,6 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||||
"/api/w/{workspace_id}/repositories/{repository_id}/log",
|
"/api/w/{workspace_id}/repositories/{repository_id}/log",
|
||||||
get(scoped_repository_log),
|
get(scoped_repository_log),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/api/repositories/{repository_id}/tickets",
|
|
||||||
get(repository_tickets),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/w/{workspace_id}/repositories/{repository_id}/tickets",
|
|
||||||
get(scoped_repository_tickets),
|
|
||||||
)
|
|
||||||
.route("/api/hosts", get(list_hosts))
|
.route("/api/hosts", get(list_hosts))
|
||||||
.route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts))
|
.route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts))
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -1198,34 +1190,11 @@ pub struct RepositoryLogResponse {
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct RepositoryTicketsResponse {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub repository_id: String,
|
|
||||||
pub limit: usize,
|
|
||||||
pub columns: Vec<TicketKanbanColumn>,
|
|
||||||
pub invalid_records: Vec<crate::records::InvalidProjectRecord>,
|
|
||||||
pub record_authority: String,
|
|
||||||
pub source: String,
|
|
||||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct TicketKanbanColumn {
|
|
||||||
pub state: String,
|
|
||||||
pub items: Vec<TicketSummary>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct LogQuery {
|
struct LogQuery {
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct TicketKanbanQuery {
|
|
||||||
limit: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct MemoryStagingQuery {
|
struct MemoryStagingQuery {
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
|
|
@ -1256,6 +1225,11 @@ struct ObjectiveEditRequest {
|
||||||
replace_all: bool,
|
replace_all: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TicketListQuery {
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ObjectiveStateRequest {
|
struct ObjectiveStateRequest {
|
||||||
state: String,
|
state: String,
|
||||||
|
|
@ -1540,8 +1514,8 @@ async fn scoped_delete_profile_source(
|
||||||
async fn scoped_list_tickets(
|
async fn scoped_list_tickets(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
Query(query): Query<TicketKanbanQuery>,
|
Query(query): Query<TicketListQuery>,
|
||||||
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
|
) -> ApiResult<Json<crate::records::TicketListResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
list_tickets(State(api), Query(query)).await
|
list_tickets(State(api), Query(query)).await
|
||||||
}
|
}
|
||||||
|
|
@ -2232,15 +2206,6 @@ async fn scoped_repository_log(
|
||||||
repository_log(State(api), AxumPath(path.repository_id), Query(query)).await
|
repository_log(State(api), AxumPath(path.repository_id), Query(query)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_repository_tickets(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
AxumPath(path): AxumPath<ScopedRepositoryPath>,
|
|
||||||
Query(query): Query<TicketKanbanQuery>,
|
|
||||||
) -> ApiResult<Json<RepositoryTicketsResponse>> {
|
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
|
||||||
repository_tickets(State(api), AxumPath(path.repository_id), Query(query)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn scoped_list_hosts(
|
async fn scoped_list_hosts(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
|
@ -4111,8 +4076,8 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens
|
||||||
|
|
||||||
async fn list_tickets(
|
async fn list_tickets(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
Query(query): Query<TicketKanbanQuery>,
|
Query(query): Query<TicketListQuery>,
|
||||||
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
|
) -> ApiResult<Json<crate::records::TicketListResponse>> {
|
||||||
let requested_limit = query.limit.unwrap_or(api.config.max_records);
|
let requested_limit = query.limit.unwrap_or(api.config.max_records);
|
||||||
let limit = requested_limit.min(1000);
|
let limit = requested_limit.min(1000);
|
||||||
let ProjectRecordList {
|
let ProjectRecordList {
|
||||||
|
|
@ -4120,7 +4085,7 @@ async fn list_tickets(
|
||||||
invalid_records,
|
invalid_records,
|
||||||
record_authority,
|
record_authority,
|
||||||
} = api.authority.list_tickets(limit)?;
|
} = api.authority.list_tickets(limit)?;
|
||||||
Ok(Json(ListResponse {
|
Ok(Json(crate::records::TicketListResponse {
|
||||||
workspace_id: api.config.workspace_id,
|
workspace_id: api.config.workspace_id,
|
||||||
limit,
|
limit,
|
||||||
items,
|
items,
|
||||||
|
|
@ -4211,35 +4176,6 @@ async fn repository_log(
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn repository_tickets(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
AxumPath(repository_id): AxumPath<String>,
|
|
||||||
Query(query): Query<TicketKanbanQuery>,
|
|
||||||
) -> ApiResult<Json<RepositoryTicketsResponse>> {
|
|
||||||
repository_lookup(api.repository_reader().summary(&repository_id))?;
|
|
||||||
let canonical_repository_id = repository_id;
|
|
||||||
let limit = query.limit.unwrap_or(api.config.max_records).min(200);
|
|
||||||
let ProjectRecordList {
|
|
||||||
items,
|
|
||||||
invalid_records,
|
|
||||||
record_authority,
|
|
||||||
} = api.authority.list_tickets(limit)?;
|
|
||||||
Ok(Json(RepositoryTicketsResponse {
|
|
||||||
workspace_id: api.config.workspace_id,
|
|
||||||
repository_id: canonical_repository_id,
|
|
||||||
limit,
|
|
||||||
columns: ticket_kanban_columns(items),
|
|
||||||
invalid_records,
|
|
||||||
record_authority,
|
|
||||||
source: "workspace_local_ticket_fallback".to_string(),
|
|
||||||
diagnostics: vec![RuntimeDiagnostic {
|
|
||||||
code: "repository_ticket_target_metadata_absent".to_string(),
|
|
||||||
severity: DiagnosticSeverity::Info,
|
|
||||||
message: "Ticket target Repository metadata is not available yet; Kanban groups all workspace-local Tickets by state as a read-only fallback.".to_string(),
|
|
||||||
}],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_hosts(
|
async fn list_hosts(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
) -> ApiResult<Json<RuntimeListResponse<HostSummary>>> {
|
) -> ApiResult<Json<RuntimeListResponse<HostSummary>>> {
|
||||||
|
|
@ -6811,52 +6747,6 @@ fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_kanban_columns(items: Vec<TicketSummary>) -> Vec<TicketKanbanColumn> {
|
|
||||||
let mut columns = vec![
|
|
||||||
TicketKanbanColumn {
|
|
||||||
state: "planning".to_string(),
|
|
||||||
items: Vec::new(),
|
|
||||||
},
|
|
||||||
TicketKanbanColumn {
|
|
||||||
state: "ready".to_string(),
|
|
||||||
items: Vec::new(),
|
|
||||||
},
|
|
||||||
TicketKanbanColumn {
|
|
||||||
state: "queued".to_string(),
|
|
||||||
items: Vec::new(),
|
|
||||||
},
|
|
||||||
TicketKanbanColumn {
|
|
||||||
state: "inprogress".to_string(),
|
|
||||||
items: Vec::new(),
|
|
||||||
},
|
|
||||||
TicketKanbanColumn {
|
|
||||||
state: "done".to_string(),
|
|
||||||
items: Vec::new(),
|
|
||||||
},
|
|
||||||
TicketKanbanColumn {
|
|
||||||
state: "closed".to_string(),
|
|
||||||
items: Vec::new(),
|
|
||||||
},
|
|
||||||
TicketKanbanColumn {
|
|
||||||
state: "other".to_string(),
|
|
||||||
items: Vec::new(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
for item in items {
|
|
||||||
let index = match item.state.as_str() {
|
|
||||||
"planning" => 0,
|
|
||||||
"ready" => 1,
|
|
||||||
"queued" => 2,
|
|
||||||
"inprogress" => 3,
|
|
||||||
"done" => 4,
|
|
||||||
"closed" => 5,
|
|
||||||
_ => 6,
|
|
||||||
};
|
|
||||||
columns[index].items.push(item);
|
|
||||||
}
|
|
||||||
columns
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Response {
|
async fn static_or_spa_fallback(State(api): State<WorkspaceApi>, uri: Uri) -> Response {
|
||||||
if uri.path().starts_with("/api/") || uri.path() == "/api" {
|
if uri.path().starts_with("/api/") || uri.path() == "/api" {
|
||||||
return (
|
return (
|
||||||
|
|
@ -7990,6 +7880,40 @@ mod tests {
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
id: ticket_id.clone(),
|
id: ticket_id.clone(),
|
||||||
};
|
};
|
||||||
|
let Json(related) = scoped_ticket_backend_operation(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedWorkspacePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
}),
|
||||||
|
Json(TicketBackendOperation::Create {
|
||||||
|
input: ticket::NewTicket::new("Related Browser Ticket"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let related_ticket_id = match related {
|
||||||
|
TicketBackendHttpResponse::Ok {
|
||||||
|
result: ticket::TicketBackendOperationResult::TicketRef(ticket_ref),
|
||||||
|
} => ticket_ref.id,
|
||||||
|
other => panic!("unexpected related create response: {other:?}"),
|
||||||
|
};
|
||||||
|
let _ = scoped_ticket_backend_operation(
|
||||||
|
State(api.clone()),
|
||||||
|
AxumPath(ScopedWorkspacePath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
}),
|
||||||
|
Json(TicketBackendOperation::AddTicketRelation {
|
||||||
|
id: ticket_id.clone().into(),
|
||||||
|
relation: ticket::NewTicketRelation {
|
||||||
|
kind: ticket::TicketRelationKind::Related,
|
||||||
|
target: related_ticket_id.clone(),
|
||||||
|
note: Some("Browser relation".to_string()),
|
||||||
|
author: Some("browser-user".to_string()),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let Json(edited) = scoped_edit_ticket_item(
|
let Json(edited) = scoped_edit_ticket_item(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
|
|
@ -8013,6 +7937,10 @@ mod tests {
|
||||||
assert_eq!(edited.body, "Updated from the Browser API.");
|
assert_eq!(edited.body, "Updated from the Browser API.");
|
||||||
assert_eq!(edited.repository_id.as_deref(), Some("main"));
|
assert_eq!(edited.repository_id.as_deref(), Some("main"));
|
||||||
assert_eq!(edited.ref_selector.as_deref(), Some("feature/api"));
|
assert_eq!(edited.ref_selector.as_deref(), Some("feature/api"));
|
||||||
|
assert_eq!(edited.assignee, None);
|
||||||
|
assert_eq!(edited.relations.outgoing.len(), 1);
|
||||||
|
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
|
||||||
|
assert_eq!(edited.relations.outgoing[0].kind, "related");
|
||||||
|
|
||||||
let Json(commented) = scoped_append_ticket_event(
|
let Json(commented) = scoped_append_ticket_event(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
|
|
@ -9408,19 +9336,17 @@ mod tests {
|
||||||
assert_eq!(repository_log["default_selector"], "HEAD");
|
assert_eq!(repository_log["default_selector"], "HEAD");
|
||||||
assert_eq!(repository_log["limit"], 3);
|
assert_eq!(repository_log["limit"], 3);
|
||||||
|
|
||||||
let repository_tickets = get_json(app.clone(), "/api/repositories/main/tickets").await;
|
let removed_repository_tickets = app
|
||||||
assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID);
|
.clone()
|
||||||
let ready_column = repository_tickets["columns"]
|
.oneshot(
|
||||||
.as_array()
|
Request::builder()
|
||||||
.unwrap()
|
.uri("/api/repositories/main/tickets")
|
||||||
.iter()
|
.body(Body::empty())
|
||||||
.find(|column| column["state"] == "ready")
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(ready_column["items"][0]["title"], "API Ticket");
|
assert_eq!(removed_repository_tickets.status(), StatusCode::NOT_FOUND);
|
||||||
assert_eq!(
|
|
||||||
repository_tickets["diagnostics"][0]["code"],
|
|
||||||
"repository_ticket_target_metadata_absent"
|
|
||||||
);
|
|
||||||
|
|
||||||
let unknown_repository_response = app
|
let unknown_repository_response = app
|
||||||
.clone()
|
.clone()
|
||||||
|
|
|
||||||
101
web/workspace/src/lib/generated/ticket-api.ts
Normal file
101
web/workspace/src/lib/generated/ticket-api.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// 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
|
||||||
|
|
||||||
|
export type InvalidProjectRecord = { label: string; reason: string };
|
||||||
|
|
||||||
|
export type TicketSummary = {
|
||||||
|
id: 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 TicketListResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
limit: number;
|
||||||
|
items: Array<TicketSummary>;
|
||||||
|
invalid_records: Array<InvalidProjectRecord>;
|
||||||
|
record_authority: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketEventDetail = {
|
||||||
|
sequence: number;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketRelation = {
|
||||||
|
ticket_id: string;
|
||||||
|
kind: string;
|
||||||
|
target: string;
|
||||||
|
note: string | null;
|
||||||
|
author: string;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DerivedTicketRelation = {
|
||||||
|
source_ticket: string;
|
||||||
|
inverse_kind: string;
|
||||||
|
forward_kind: string;
|
||||||
|
note: string | null;
|
||||||
|
author: string;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketRelationBlocker = {
|
||||||
|
blocking_ticket: string;
|
||||||
|
reason_kind: string;
|
||||||
|
relation_kind: string;
|
||||||
|
note: string | null;
|
||||||
|
blocking_state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketRelationNotice = {
|
||||||
|
related_ticket: string;
|
||||||
|
kind: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketRelationView = {
|
||||||
|
outgoing: Array<TicketRelation>;
|
||||||
|
incoming: Array<DerivedTicketRelation>;
|
||||||
|
blockers: Array<TicketRelationBlocker>;
|
||||||
|
notices: Array<TicketRelationNotice>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketDetail = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
priority: string;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
queued_by: string | null;
|
||||||
|
queued_at: string | null;
|
||||||
|
assignee: string | null;
|
||||||
|
repository_id: string | null;
|
||||||
|
ref_selector: string | null;
|
||||||
|
risk_flags: Array<string>;
|
||||||
|
body: string;
|
||||||
|
body_truncated: boolean;
|
||||||
|
event_count: number;
|
||||||
|
events: Array<TicketEventDetail>;
|
||||||
|
artifact_count: number;
|
||||||
|
artifacts: Array<string>;
|
||||||
|
relations: TicketRelationView;
|
||||||
|
resolution: string | null;
|
||||||
|
record_source: string;
|
||||||
|
};
|
||||||
|
|
@ -114,7 +114,9 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
assert(
|
assert(
|
||||||
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
|
workersPage.includes("workerConsoleHref(worker, data.workspaceId)") &&
|
||||||
workersPage.includes('<table class="workers-table">') &&
|
workersPage.includes('<table class="workers-table">') &&
|
||||||
workersPage.includes("workerDisplayName = worker.display_name || worker.label") &&
|
workersPage.includes(
|
||||||
|
"workerDisplayName = worker.display_name || worker.label",
|
||||||
|
) &&
|
||||||
workersPage.includes("worker <code>{worker.worker_id}</code>") &&
|
workersPage.includes("worker <code>{worker.worker_id}</code>") &&
|
||||||
workersPage.includes("Delete ${workerDisplayName}"),
|
workersPage.includes("Delete ${workerDisplayName}"),
|
||||||
"dedicated Workers page should expose a table, console link target, and icon actions per Worker",
|
"dedicated Workers page should expose a table, console link target, and icon actions per Worker",
|
||||||
|
|
@ -136,7 +138,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async () => {
|
Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", async () => {
|
||||||
const ticketsNav = await Deno.readTextFile(
|
const ticketsNav = await Deno.readTextFile(
|
||||||
new URL("../sidebar/TicketsNavSection.svelte", import.meta.url),
|
new URL("../sidebar/TicketsNavSection.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
|
|
@ -164,6 +166,24 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const ticketPanelModel = await Deno.readTextFile(
|
||||||
|
new URL("../tickets/ticket-panel.ts", import.meta.url),
|
||||||
|
);
|
||||||
|
const generatedTicketApi = await Deno.readTextFile(
|
||||||
|
new URL("../../generated/ticket-api.ts", import.meta.url),
|
||||||
|
);
|
||||||
|
const repositoryLoad = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const repositoryPage = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
|
ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
|
||||||
|
|
@ -171,26 +191,41 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async
|
||||||
"Tickets sidebar section should link to the workspace Tickets surface",
|
"Tickets sidebar section should link to the workspace Tickets surface",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ticketsLoad.includes(
|
ticketsLoad.includes("?limit=1000") &&
|
||||||
'`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`',
|
ticketsPage.includes("data.tickets.data?.items") &&
|
||||||
) &&
|
ticketsPage.includes("ticketLanes(tickets)") &&
|
||||||
ticketsPage.includes("Notion-style filtering and sorting") &&
|
ticketsPage.includes('class="ticket-kanban"') &&
|
||||||
ticketsPage.includes("toggleSort('updated_at')") &&
|
ticketsPage.includes("lane.tickets"),
|
||||||
ticketsPage.includes("bind:value={visibilityFilter}") &&
|
"Tickets list should load every workflow state and render a workspace Kanban board",
|
||||||
ticketsPage.includes("sortKey = $state<SortKey>('panel')") &&
|
|
||||||
ticketsPage.includes("workspace_action_priority") &&
|
|
||||||
ticketsPage.includes("bind:value={stateFilter}") &&
|
|
||||||
ticketsPage.includes(
|
|
||||||
"workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)",
|
|
||||||
),
|
|
||||||
"Tickets list should read the workspace-scoped Ticket API and expose sortable/filterable table links",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") &&
|
ticketPanelModel.includes('label: "Ready + Planning"') &&
|
||||||
ticketDetailPage.includes("event_count") &&
|
ticketPanelModel.includes('label: "In progress + Queued"') &&
|
||||||
ticketDetailPage.includes("artifact_count") &&
|
ticketPanelModel.includes('label: "Done + Closed"') &&
|
||||||
ticketDetailPage.includes("<pre>{data.ticket.data.body"),
|
ticketPanelModel.includes("updatedAt(right) - updatedAt(left)"),
|
||||||
"Ticket detail should read one Ticket record and expose body plus metadata without mutation controls",
|
"Ticket Kanban should combine related states and sort state priority before recency",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
generatedTicketApi.includes("Generated from yoi-workspace-server") &&
|
||||||
|
generatedTicketApi.includes("export type TicketListResponse") &&
|
||||||
|
generatedTicketApi.includes("items: Array<TicketSummary>"),
|
||||||
|
"Ticket response types should come from the generated Server contract",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!repositoryLoad.includes("/tickets") &&
|
||||||
|
!repositoryPage.includes("Repository Tickets") &&
|
||||||
|
!repositoryPage.includes("RepositoryTicketKanban"),
|
||||||
|
"Repository detail should not keep a second Repository Tickets surface",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
ticketDetailLoad.includes("/repositories") &&
|
||||||
|
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||||
|
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
||||||
|
ticketDetailPage.includes('mutate("review", "/review"') &&
|
||||||
|
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||||
|
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
||||||
|
ticketDetailPage.includes("ticket.relations.outgoing"),
|
||||||
|
"Ticket detail should expose typed lifecycle actions, relations, target selection, and role Worker launch",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -199,7 +234,10 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||||
new URL("../sidebar/MemoryNavSection.svelte", import.meta.url),
|
new URL("../sidebar/MemoryNavSection.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const memoryDocumentLoad = await Deno.readTextFile(
|
const memoryDocumentLoad = await Deno.readTextFile(
|
||||||
new URL("./../../../routes/w/[workspaceId]/memory/+page.ts", import.meta.url),
|
new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/memory/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const memoryDocumentPage = await Deno.readTextFile(
|
const memoryDocumentPage = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
|
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { RepositoryTicketsResponse, TicketSummary } from '$lib/workspace/sidebar/types';
|
|
||||||
|
|
||||||
const INITIAL_VISIBLE_ROWS = 30;
|
|
||||||
const VISIBLE_ROW_INCREMENT = 30;
|
|
||||||
const NEAR_BOTTOM_PX = 96;
|
|
||||||
|
|
||||||
type KanbanGroup = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
states: string[];
|
|
||||||
items: TicketSummary[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type GroupMetadata = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
states: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
let { tickets }: { tickets: RepositoryTicketsResponse } = $props();
|
|
||||||
|
|
||||||
let visibleRowsByGroup = $state<Record<string, number>>({});
|
|
||||||
let groups = $derived(buildGroups(tickets.columns));
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const groupKeys = new Set(groups.map((group) => group.key));
|
|
||||||
const nextVisibleRows = { ...visibleRowsByGroup };
|
|
||||||
let changed = false;
|
|
||||||
|
|
||||||
for (const group of groups) {
|
|
||||||
if (nextVisibleRows[group.key] === undefined) {
|
|
||||||
nextVisibleRows[group.key] = INITIAL_VISIBLE_ROWS;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of Object.keys(nextVisibleRows)) {
|
|
||||||
if (!groupKeys.has(key)) {
|
|
||||||
delete nextVisibleRows[key];
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
visibleRowsByGroup = nextVisibleRows;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildGroups(columns: RepositoryTicketsResponse['columns']): KanbanGroup[] {
|
|
||||||
const groupsByKey = new Map<string, KanbanGroup>();
|
|
||||||
|
|
||||||
for (const column of columns) {
|
|
||||||
const metadata = groupMetadataForState(column.state);
|
|
||||||
let group = groupsByKey.get(metadata.key);
|
|
||||||
if (!group) {
|
|
||||||
group = {
|
|
||||||
key: metadata.key,
|
|
||||||
label: metadata.label,
|
|
||||||
states: metadata.states,
|
|
||||||
items: []
|
|
||||||
};
|
|
||||||
groupsByKey.set(metadata.key, group);
|
|
||||||
}
|
|
||||||
group.items.push(...column.items);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(groupsByKey.values()).map((group) => ({
|
|
||||||
...group,
|
|
||||||
items: sortGroupItems(group.items)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupMetadataForState(state: string): GroupMetadata {
|
|
||||||
if (state === 'planning' || state === 'ready') {
|
|
||||||
return {
|
|
||||||
key: 'ready-planning',
|
|
||||||
label: 'Ready / Planning',
|
|
||||||
states: ['ready', 'planning']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (state === 'queued' || state === 'inprogress') {
|
|
||||||
return {
|
|
||||||
key: 'inprogress-queued',
|
|
||||||
label: 'In progress / Queued',
|
|
||||||
states: ['inprogress', 'queued']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (state === 'other') {
|
|
||||||
return {
|
|
||||||
key: 'state:other',
|
|
||||||
label: 'Other states',
|
|
||||||
states: ['other']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key: `state:${state}`,
|
|
||||||
label: state,
|
|
||||||
states: [state]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortGroupItems(items: TicketSummary[]): TicketSummary[] {
|
|
||||||
return items
|
|
||||||
.map((ticket, index) => ({ ticket, index }))
|
|
||||||
.sort((left, right) => {
|
|
||||||
const stateOrder = statePriority(left.ticket.state) - statePriority(right.ticket.state);
|
|
||||||
if (stateOrder !== 0) {
|
|
||||||
return stateOrder;
|
|
||||||
}
|
|
||||||
return left.index - right.index;
|
|
||||||
})
|
|
||||||
.map(({ ticket }) => ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
function statePriority(state: string): number {
|
|
||||||
if (state === 'ready' || state === 'inprogress') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (state === 'planning' || state === 'queued') {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibleCount(groupKey: string): number {
|
|
||||||
return visibleRowsByGroup[groupKey] ?? INITIAL_VISIBLE_ROWS;
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibleTickets(group: KanbanGroup): TicketSummary[] {
|
|
||||||
return group.items.slice(0, visibleCount(group.key));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasMore(group: KanbanGroup): boolean {
|
|
||||||
return visibleCount(group.key) < group.items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onGroupScroll(group: KanbanGroup, event: Event) {
|
|
||||||
if (!hasMore(group)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = event.currentTarget;
|
|
||||||
if (!(target instanceof HTMLElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
|
||||||
if (distanceFromBottom > NEAR_BOTTOM_PX) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
visibleRowsByGroup = {
|
|
||||||
...visibleRowsByGroup,
|
|
||||||
[group.key]: Math.min(group.items.length, visibleCount(group.key) + VISIBLE_ROW_INCREMENT)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined): string {
|
|
||||||
return value ?? 'not recorded';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="repository-ticket-kanban">
|
|
||||||
{#each groups as group (group.key)}
|
|
||||||
<article class="ticket-group" aria-labelledby={`${group.key}-heading`}>
|
|
||||||
<header class="ticket-group-heading">
|
|
||||||
<div>
|
|
||||||
<h3 id={`${group.key}-heading`}>{group.label}</h3>
|
|
||||||
<p>{group.states.join(' + ')}</p>
|
|
||||||
</div>
|
|
||||||
<span>{group.items.length}</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if group.items.length === 0}
|
|
||||||
<p class="muted">No tickets.</p>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="ticket-list-scroll"
|
|
||||||
aria-label={`${group.label} tickets`}
|
|
||||||
onscroll={(event) => onGroupScroll(group, event)}
|
|
||||||
>
|
|
||||||
<ul class="ticket-list">
|
|
||||||
{#each visibleTickets(group) as ticket (ticket.id)}
|
|
||||||
<li class="ticket-row">
|
|
||||||
<div class="ticket-row-heading">
|
|
||||||
<strong>{ticket.title}</strong>
|
|
||||||
<span class="ticket-state">{ticket.state}</span>
|
|
||||||
</div>
|
|
||||||
<small><code>{ticket.id}</code> · updated {formatDate(ticket.updated_at)}</small>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{#if hasMore(group)}
|
|
||||||
<p class="lazy-note">Showing {visibleCount(group.key)} of {group.items.length}; scroll for more.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.repository-ticket-kanban {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-5);
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-group {
|
|
||||||
min-width: 0;
|
|
||||||
padding: var(--space-4) 0 0;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-group-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-group-heading h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-group-heading p,
|
|
||||||
.ticket-group-heading span,
|
|
||||||
.lazy-note {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-group-heading p,
|
|
||||||
.lazy-note {
|
|
||||||
margin: var(--space-1) 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-list-scroll {
|
|
||||||
max-height: 34rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: var(--space-2);
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-list {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-row {
|
|
||||||
padding-left: var(--space-3);
|
|
||||||
border-left: 2px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-row-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-row-heading strong {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-state {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -390,62 +390,17 @@ export type MemoryStagingListResponse = {
|
||||||
items: MemoryStagingEntry[];
|
items: MemoryStagingEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TicketSummary = {
|
export type {
|
||||||
id: string;
|
DerivedTicketRelation,
|
||||||
title: string;
|
TicketDetail,
|
||||||
state: string;
|
TicketEventDetail,
|
||||||
priority?: string | null;
|
TicketListResponse,
|
||||||
updated_at?: string | null;
|
TicketRelation,
|
||||||
queued_by?: string | null;
|
TicketRelationBlocker,
|
||||||
queued_at?: string | null;
|
TicketRelationNotice,
|
||||||
workspace_action_priority?:
|
TicketRelationView,
|
||||||
| "ready_for_queue"
|
TicketSummary,
|
||||||
| "active_work"
|
} from "$lib/generated/ticket-api";
|
||||||
| "background"
|
|
||||||
| null;
|
|
||||||
record_source?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketListResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
limit: number;
|
|
||||||
items: TicketSummary[];
|
|
||||||
invalid_records: InvalidProjectRecord[];
|
|
||||||
record_authority: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketDetail = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
state: string;
|
|
||||||
priority?: string | null;
|
|
||||||
created_at?: string | null;
|
|
||||||
updated_at?: string | null;
|
|
||||||
queued_by?: string | null;
|
|
||||||
queued_at?: string | null;
|
|
||||||
risk_flags: string[];
|
|
||||||
body: string;
|
|
||||||
body_truncated: boolean;
|
|
||||||
event_count: number;
|
|
||||||
artifact_count: number;
|
|
||||||
record_source: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketKanbanColumn = {
|
|
||||||
state: string;
|
|
||||||
items: TicketSummary[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepositoryTicketsResponse = {
|
|
||||||
workspace_id: string;
|
|
||||||
repository_id: string;
|
|
||||||
limit: number;
|
|
||||||
columns: TicketKanbanColumn[];
|
|
||||||
invalid_records: InvalidProjectRecord[];
|
|
||||||
record_authority: string;
|
|
||||||
source: string;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ObjectiveSummary = {
|
export type ObjectiveSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,28 @@ Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, reposi
|
||||||
assertEquals(form.working_directory_selector, "HEAD");
|
assertEquals(form.working_directory_selector, "HEAD");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => {
|
||||||
|
const reviewerOptions = {
|
||||||
|
...options,
|
||||||
|
profiles: [
|
||||||
|
...options.profiles,
|
||||||
|
{ id: "builtin:reviewer", label: "Reviewer", description: "review" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const form = defaultWorkerLaunchForm(reviewerOptions, {
|
||||||
|
runtime_id: "",
|
||||||
|
display_name: "Review worker",
|
||||||
|
profile: "builtin:reviewer",
|
||||||
|
initial_text: "Review the ticket.",
|
||||||
|
working_directory_id: "",
|
||||||
|
working_directory_repository_id: "repo",
|
||||||
|
working_directory_selector: "HEAD",
|
||||||
|
relative_cwd: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(form.profile, "builtin:reviewer");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => {
|
Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => {
|
||||||
const form = defaultWorkerLaunchForm(
|
const form = defaultWorkerLaunchForm(
|
||||||
{
|
{
|
||||||
|
|
@ -116,8 +138,45 @@ Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(form.working_directory_id, "");
|
assertEquals(form.working_directory_id, "");
|
||||||
assertEquals(form.working_directory_repository_id, "repo");
|
});
|
||||||
assertEquals(form.working_directory_selector, "HEAD");
|
|
||||||
|
Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => {
|
||||||
|
const form = defaultWorkerLaunchForm(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
repositories: [
|
||||||
|
...options.repositories,
|
||||||
|
{
|
||||||
|
id: "ticket-repo",
|
||||||
|
display_name: "Ticket repo",
|
||||||
|
default_selector: "main",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
working_directories: [
|
||||||
|
options.working_directories[0],
|
||||||
|
{
|
||||||
|
...options.working_directories[0],
|
||||||
|
working_directory_id: "ticket-workdir",
|
||||||
|
repository_id: "ticket-repo",
|
||||||
|
requested_selector: "work/ticket",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
runtime_id: "",
|
||||||
|
display_name: "Ticket worker",
|
||||||
|
profile: "builtin:coder",
|
||||||
|
initial_text: "Work on a ticket.",
|
||||||
|
working_directory_id: "",
|
||||||
|
working_directory_repository_id: "ticket-repo",
|
||||||
|
working_directory_selector: "work/ticket",
|
||||||
|
relative_cwd: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(form.working_directory_id, "ticket-workdir");
|
||||||
|
assertEquals(form.working_directory_repository_id, "ticket-repo");
|
||||||
|
assertEquals(form.working_directory_selector, "work/ticket");
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relative cwd only", () => {
|
Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relative cwd only", () => {
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,12 @@ export function defaultWorkerLaunchForm(
|
||||||
) ??
|
) ??
|
||||||
options?.runtimes.find((runtime) => runtime.can_spawn_worker) ??
|
options?.runtimes.find((runtime) => runtime.can_spawn_worker) ??
|
||||||
options?.runtimes[0];
|
options?.runtimes[0];
|
||||||
const preferredProfile =
|
const coderProfile =
|
||||||
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
|
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
|
||||||
options?.profiles[0];
|
options?.profiles[0];
|
||||||
|
const preferredProfile =
|
||||||
|
options?.profiles.find((candidate) => candidate.id === current.profile) ??
|
||||||
|
coderProfile;
|
||||||
const availableWorkingDirectories =
|
const availableWorkingDirectories =
|
||||||
options?.working_directories.filter((directory) =>
|
options?.working_directories.filter((directory) =>
|
||||||
directory.status === "active" &&
|
directory.status === "active" &&
|
||||||
|
|
@ -51,7 +54,17 @@ export function defaultWorkerLaunchForm(
|
||||||
selectedRuntime?.working_directory_required === false;
|
selectedRuntime?.working_directory_required === false;
|
||||||
const preferredWorkingDirectory = workdirlessRuntime
|
const preferredWorkingDirectory = workdirlessRuntime
|
||||||
? undefined
|
? undefined
|
||||||
: availableWorkingDirectories[0];
|
: availableWorkingDirectories.find((directory) =>
|
||||||
|
Boolean(current.working_directory_repository_id) &&
|
||||||
|
directory.repository_id === current.working_directory_repository_id &&
|
||||||
|
(!current.working_directory_selector ||
|
||||||
|
directory.requested_selector === current.working_directory_selector)
|
||||||
|
) ?? availableWorkingDirectories.find((directory) =>
|
||||||
|
Boolean(current.working_directory_repository_id) &&
|
||||||
|
directory.repository_id === current.working_directory_repository_id
|
||||||
|
) ?? (current.working_directory_repository_id
|
||||||
|
? undefined
|
||||||
|
: availableWorkingDirectories[0]);
|
||||||
const preferredRepository =
|
const preferredRepository =
|
||||||
options?.repositories.find((repository) =>
|
options?.repositories.find((repository) =>
|
||||||
repository.id === current.working_directory_repository_id
|
repository.id === current.working_directory_repository_id
|
||||||
|
|
|
||||||
368
web/workspace/src/lib/workspace/styles/tickets.css
Normal file
368
web/workspace/src/lib/workspace/styles/tickets.css
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
@layer reset, tokens, base, layout, components;
|
||||||
|
@layer components {
|
||||||
|
.workspace-primary-button,
|
||||||
|
.workspace-secondary-button,
|
||||||
|
.workspace-danger-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.35rem;
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.workspace-primary-button {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
.workspace-secondary-button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
.workspace-danger-button {
|
||||||
|
border: 1px solid #a63f3f;
|
||||||
|
background: #a63f3f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.workspace-primary-button:disabled,
|
||||||
|
.workspace-secondary-button:disabled,
|
||||||
|
.workspace-danger-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.workspace-back-link {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.workspace-callout {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
}
|
||||||
|
.workspace-callout.is-error {
|
||||||
|
border-color: #a63f3f;
|
||||||
|
color: #d66;
|
||||||
|
}
|
||||||
|
.workspace-empty-copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.ticket-panel-page {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.ticket-panel-header,
|
||||||
|
.ticket-detail-header,
|
||||||
|
.ticket-detail-kicker,
|
||||||
|
.ticket-section-heading,
|
||||||
|
.ticket-control-card > header,
|
||||||
|
.ticket-lane-header,
|
||||||
|
.ticket-lane-header > div,
|
||||||
|
.ticket-card-meta,
|
||||||
|
.ticket-role-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ticket-panel-header, .ticket-detail-header {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.ticket-panel-summary {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.ticket-panel-summary strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.ticket-kanban {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(18rem, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.ticket-lane {
|
||||||
|
min-height: 24rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
|
||||||
|
}
|
||||||
|
.ticket-lane-header {
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.ticket-lane-header > div {
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.ticket-lane-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.ticket-state-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
.ticket-lane[data-state="ready-planning"] .ticket-state-dot {
|
||||||
|
background: #43a66d;
|
||||||
|
}
|
||||||
|
.ticket-lane[data-state="inprogress-queued"] .ticket-state-dot {
|
||||||
|
background: #d09b37;
|
||||||
|
}
|
||||||
|
.ticket-lane[data-state="done-closed"] .ticket-state-dot {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.ticket-lane-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.ticket-lane-cards {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
.ticket-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-strong);
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.ticket-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.ticket-card-id {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ticket-card strong {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.ticket-card-meta {
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
.ticket-risk-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.ticket-risk-list span {
|
||||||
|
border: 1px solid #a97531;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #d3a55d;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
.ticket-lane-empty {
|
||||||
|
padding: 1.2rem 0.4rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ticket-detail-page {
|
||||||
|
gap: var(--space-4);
|
||||||
|
max-width: 88rem;
|
||||||
|
}
|
||||||
|
.ticket-detail-header h1 {
|
||||||
|
margin: 0.45rem 0 0.25rem;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: clamp(1.5rem, 3vw, 2.4rem);
|
||||||
|
}
|
||||||
|
.ticket-detail-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.ticket-detail-kicker {
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.ticket-detail-kicker code {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.workspace-status-pill {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.ticket-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem);
|
||||||
|
gap: var(--space-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.ticket-detail-main, .ticket-control-rail {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.ticket-detail-section, .ticket-control-card, .ticket-editor {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
.ticket-section-heading, .ticket-control-card > header {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.ticket-section-heading h2, .ticket-control-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.ticket-section-heading span, .ticket-control-card header span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.ticket-editor, .ticket-control-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.ticket-editor label, .ticket-control-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.ticket-editor input,
|
||||||
|
.ticket-editor textarea,
|
||||||
|
.ticket-control-form input,
|
||||||
|
.ticket-control-form select,
|
||||||
|
.ticket-control-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-strong);
|
||||||
|
padding: 0.55rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.ticket-worker-card p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.ticket-role-actions {
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.ticket-role-actions > * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.ticket-queue-button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
details.ticket-control-card summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
details.ticket-control-card[open] summary {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
.ticket-close-card {
|
||||||
|
border-color: color-mix(in srgb, #a63f3f 55%, var(--line));
|
||||||
|
}
|
||||||
|
.ticket-relations-list, .ticket-blocker-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.ticket-blocker-list {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.ticket-relations-list a, .ticket-blocker-list a {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.18rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
color: var(--text-strong);
|
||||||
|
padding: 0.6rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.ticket-blocker-list a {
|
||||||
|
border-color: #a97531;
|
||||||
|
}
|
||||||
|
.ticket-relations-list span,
|
||||||
|
.ticket-relations-list small,
|
||||||
|
.ticket-blocker-list span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
.ticket-timeline {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.ticket-timeline article {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.65rem minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-height: 4rem;
|
||||||
|
}
|
||||||
|
.ticket-timeline-marker {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.ticket-timeline-marker::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.55rem;
|
||||||
|
bottom: -3.3rem;
|
||||||
|
left: 0.15rem;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
.ticket-timeline article:last-child .ticket-timeline-marker::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ticket-timeline header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.ticket-timeline time, .ticket-event-author {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
.ticket-event-author {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 64rem) {
|
||||||
|
.ticket-detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.ticket-control-rail {
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -92,6 +92,30 @@
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
.worker-ticket-context {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
max-width: 48rem;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
.worker-ticket-context > div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.worker-ticket-context span,
|
||||||
|
.worker-ticket-context code {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.worker-ticket-context a {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
.worker-launch-form {
|
.worker-launch-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
|
|
|
||||||
99
web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts
Normal file
99
web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import {
|
||||||
|
ticketLanes,
|
||||||
|
ticketWorkerLaunchHref,
|
||||||
|
ticketWorkerMessage,
|
||||||
|
} from "./ticket-panel.ts";
|
||||||
|
import type {
|
||||||
|
TicketDetail,
|
||||||
|
TicketSummary,
|
||||||
|
} from "../../generated/ticket-api.ts";
|
||||||
|
|
||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertEquals<T>(actual: T, expected: T): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("ticketLanes combines workflow states and sorts by state then update time", () => {
|
||||||
|
const tickets = [
|
||||||
|
{
|
||||||
|
id: "planning",
|
||||||
|
state: "planning",
|
||||||
|
title: "Planning",
|
||||||
|
updated_at: "2026-07-30T12:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ready-old",
|
||||||
|
state: "ready",
|
||||||
|
title: "Ready old",
|
||||||
|
updated_at: "2026-07-29T12:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ready-new",
|
||||||
|
state: "ready",
|
||||||
|
title: "Ready new",
|
||||||
|
updated_at: "2026-07-30T12:00:00Z",
|
||||||
|
},
|
||||||
|
{ id: "queued", state: "queued", title: "Queued", updated_at: null },
|
||||||
|
{
|
||||||
|
id: "inprogress",
|
||||||
|
state: "inprogress",
|
||||||
|
title: "In progress",
|
||||||
|
updated_at: null,
|
||||||
|
},
|
||||||
|
{ id: "closed", state: "closed", title: "Closed", updated_at: null },
|
||||||
|
{ id: "done", state: "done", title: "Done", updated_at: null },
|
||||||
|
] as TicketSummary[];
|
||||||
|
|
||||||
|
const lanes = ticketLanes(tickets);
|
||||||
|
assertEquals(lanes.map((lane) => lane.id), [
|
||||||
|
"ready-planning",
|
||||||
|
"inprogress-queued",
|
||||||
|
"done-closed",
|
||||||
|
]);
|
||||||
|
assertEquals(lanes[0].tickets.map((ticket) => ticket.id), [
|
||||||
|
"ready-new",
|
||||||
|
"ready-old",
|
||||||
|
"planning",
|
||||||
|
]);
|
||||||
|
assertEquals(lanes[1].tickets.map((ticket) => ticket.id), [
|
||||||
|
"inprogress",
|
||||||
|
"queued",
|
||||||
|
]);
|
||||||
|
assertEquals(lanes[2].tickets.map((ticket) => ticket.id), [
|
||||||
|
"done",
|
||||||
|
"closed",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => {
|
||||||
|
const ticket = {
|
||||||
|
id: "00001KYRRDVH9",
|
||||||
|
title: "Ticket panel API",
|
||||||
|
repository_id: "main repo",
|
||||||
|
ref_selector: "work/ticket",
|
||||||
|
} as TicketDetail;
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ticketWorkerMessage(ticket.id, "coder"),
|
||||||
|
"Work on Ticket 00001KYRRDVH9 as its coder.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const href = ticketWorkerLaunchHref("workspace one", ticket, "reviewer");
|
||||||
|
const url = new URL(href, "https://example.test");
|
||||||
|
assertEquals(url.pathname, "/w/workspace%20one/workers/new");
|
||||||
|
assertEquals(url.searchParams.get("ticketId"), ticket.id);
|
||||||
|
assertEquals(url.searchParams.get("ticketRole"), "reviewer");
|
||||||
|
assertEquals(url.searchParams.get("repositoryId"), "main repo");
|
||||||
|
assertEquals(url.searchParams.get("refSelector"), "work/ticket");
|
||||||
|
assertEquals(
|
||||||
|
url.searchParams.get("initialInput"),
|
||||||
|
"Work on Ticket 00001KYRRDVH9 as its reviewer.",
|
||||||
|
);
|
||||||
|
});
|
||||||
111
web/workspace/src/lib/workspace/tickets/ticket-panel.ts
Normal file
111
web/workspace/src/lib/workspace/tickets/ticket-panel.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api";
|
||||||
|
|
||||||
|
export const TICKET_STATES = [
|
||||||
|
"planning",
|
||||||
|
"ready",
|
||||||
|
"queued",
|
||||||
|
"inprogress",
|
||||||
|
"done",
|
||||||
|
"closed",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TicketState = (typeof TICKET_STATES)[number];
|
||||||
|
export type TicketWorkerRole = "coder" | "reviewer";
|
||||||
|
|
||||||
|
const LANE_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
id: "ready-planning",
|
||||||
|
label: "Ready + Planning",
|
||||||
|
states: ["ready", "planning"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inprogress-queued",
|
||||||
|
label: "In progress + Queued",
|
||||||
|
states: ["inprogress", "queued"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "done-closed",
|
||||||
|
label: "Done + Closed",
|
||||||
|
states: ["done", "closed"],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const STATE_SORT_ORDER = new Map<string, number>([
|
||||||
|
["ready", 0],
|
||||||
|
["planning", 1],
|
||||||
|
["inprogress", 0],
|
||||||
|
["queued", 1],
|
||||||
|
["done", 0],
|
||||||
|
["closed", 1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type TicketLane = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
states: readonly TicketState[];
|
||||||
|
tickets: TicketSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function updatedAt(ticket: TicketSummary): number {
|
||||||
|
if (!ticket.updated_at) return 0;
|
||||||
|
const parsed = Date.parse(ticket.updated_at);
|
||||||
|
return Number.isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortTickets(tickets: TicketSummary[]): TicketSummary[] {
|
||||||
|
return [...tickets].sort((left, right) => {
|
||||||
|
const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) -
|
||||||
|
(STATE_SORT_ORDER.get(right.state) ?? 99);
|
||||||
|
if (stateDelta !== 0) return stateDelta;
|
||||||
|
const updatedDelta = updatedAt(right) - updatedAt(left);
|
||||||
|
if (updatedDelta !== 0) return updatedDelta;
|
||||||
|
return left.id.localeCompare(right.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ticketLanes(tickets: TicketSummary[]): TicketLane[] {
|
||||||
|
return LANE_DEFINITIONS.map((definition) => ({
|
||||||
|
...definition,
|
||||||
|
tickets: sortTickets(
|
||||||
|
tickets.filter((ticket) =>
|
||||||
|
(definition.states as readonly string[]).includes(ticket.state)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ticketWorkerMessage(
|
||||||
|
ticketId: string,
|
||||||
|
role: TicketWorkerRole,
|
||||||
|
): string {
|
||||||
|
return `Work on Ticket ${ticketId} as its ${role}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ticketWorkerLaunchHref(
|
||||||
|
workspaceId: string,
|
||||||
|
ticket: Pick<
|
||||||
|
TicketDetail,
|
||||||
|
"id" | "title" | "repository_id" | "ref_selector"
|
||||||
|
>,
|
||||||
|
role: TicketWorkerRole,
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ticketId: ticket.id,
|
||||||
|
ticketTitle: ticket.title,
|
||||||
|
ticketRole: role,
|
||||||
|
initialInput: ticketWorkerMessage(ticket.id, role),
|
||||||
|
});
|
||||||
|
if (ticket.repository_id) {
|
||||||
|
params.set("repositoryId", ticket.repository_id);
|
||||||
|
}
|
||||||
|
if (ticket.ref_selector) {
|
||||||
|
params.set("refSelector", ticket.ref_selector);
|
||||||
|
}
|
||||||
|
return `/w/${
|
||||||
|
encodeURIComponent(workspaceId)
|
||||||
|
}/workers/new?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relationLabel(kind: string): string {
|
||||||
|
return kind.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
||||||
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
||||||
import '$lib/workspace/styles/workspace-pages.css';
|
import '$lib/workspace/styles/workspace-pages.css';
|
||||||
|
import '$lib/workspace/styles/tickets.css';
|
||||||
import '$lib/workspace/styles/workers.css';
|
import '$lib/workspace/styles/workers.css';
|
||||||
import type { LayoutProps } from './$types';
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatDate } from '$lib/workspace/api/http';
|
import { formatDate } from '$lib/workspace/api/http';
|
||||||
import RepositoryTicketKanban from '$lib/workspace/pages/RepositoryTicketKanban.svelte';
|
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
@ -92,14 +91,3 @@
|
||||||
<p>Loading repository commits…</p>
|
<p>Loading repository commits…</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card repository-tickets-card">
|
|
||||||
<h2>Repository Tickets</h2>
|
|
||||||
{#if data.repositoryTickets}
|
|
||||||
<RepositoryTicketKanban tickets={data.repositoryTickets} />
|
|
||||||
{:else if data.repositoryTicketsError}
|
|
||||||
<p class="error">{data.repositoryTicketsError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Loading repository tickets…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import type {
|
import type {
|
||||||
RepositoryDetailResponse,
|
RepositoryDetailResponse,
|
||||||
RepositoryLogResponse,
|
RepositoryLogResponse,
|
||||||
RepositoryTicketsResponse,
|
|
||||||
} from "$lib/workspace/sidebar/types";
|
} from "$lib/workspace/sidebar/types";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
||||||
const repositoryId = params.repositoryId;
|
const repositoryId = params.repositoryId;
|
||||||
const [repository, log, tickets] = await Promise.all([
|
const [repository, log] = await Promise.all([
|
||||||
loadJson<RepositoryDetailResponse>(
|
loadJson<RepositoryDetailResponse>(
|
||||||
fetch,
|
fetch,
|
||||||
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`),
|
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`),
|
||||||
|
|
@ -18,10 +17,6 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
fetch,
|
fetch,
|
||||||
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`),
|
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`),
|
||||||
),
|
),
|
||||||
loadJson<RepositoryTicketsResponse>(
|
|
||||||
fetch,
|
|
||||||
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/tickets`),
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -30,7 +25,5 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
repositoryError: repository.error,
|
repositoryError: repository.error,
|
||||||
repositoryLog: log.data,
|
repositoryLog: log.data,
|
||||||
repositoryLogError: log.error,
|
repositoryLogError: log.error,
|
||||||
repositoryTickets: tickets.data,
|
|
||||||
repositoryTicketsError: tickets.error,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,392 +1,76 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
import { untrack } from "svelte";
|
||||||
import type { TicketSummary } from '$lib/workspace/sidebar/types';
|
import type { ApiResult } from "$lib/workspace/api/http";
|
||||||
import type { PageProps } from './$types';
|
import { ticketLanes } from "$lib/workspace/tickets/ticket-panel";
|
||||||
|
import type {
|
||||||
|
TicketListResponse,
|
||||||
|
TicketSummary,
|
||||||
|
} from "$lib/workspace/sidebar/types";
|
||||||
|
|
||||||
type SortKey = 'panel' | 'title' | 'state' | 'priority' | 'updated_at' | 'queued_at' | 'id';
|
const { data } = $props<{
|
||||||
type SortDirection = 'asc' | 'desc';
|
data: {
|
||||||
|
workspaceId: string;
|
||||||
|
tickets: ApiResult<TicketListResponse>;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
|
||||||
|
let tickets = $state<TicketSummary[]>(initialTickets);
|
||||||
|
let lanes = $derived(ticketLanes(tickets));
|
||||||
|
|
||||||
let query = $state('');
|
function prettyDate(value?: string | null): string {
|
||||||
let visibilityFilter = $state<'open' | 'closed' | 'all'>('open');
|
if (!value) return "—";
|
||||||
let stateFilter = $state('all');
|
const date = new Date(value);
|
||||||
let priorityFilter = $state('all');
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
|
||||||
let queuedFilter = $state('all');
|
|
||||||
let sortKey = $state<SortKey>('panel');
|
|
||||||
let sortDirection = $state<SortDirection>('asc');
|
|
||||||
|
|
||||||
const tickets = $derived(data.tickets.data?.items ?? []);
|
|
||||||
const states = $derived(uniqueValues(tickets.map((ticket) => ticket.state)));
|
|
||||||
const priorities = $derived(uniqueValues(tickets.map((ticket) => ticket.priority).filter(isPresent)));
|
|
||||||
const filteredTickets = $derived(filterTickets(tickets));
|
|
||||||
const visibleTickets = $derived(sortTickets(filteredTickets));
|
|
||||||
|
|
||||||
function isPresent(value: string | null | undefined): value is string {
|
|
||||||
return Boolean(value && value.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueValues(values: string[]): string[] {
|
|
||||||
return [...new Set(values.filter((value) => value.trim()))].sort((left, right) =>
|
|
||||||
left.localeCompare(right),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterTickets(items: TicketSummary[]): TicketSummary[] {
|
|
||||||
const needle = query.trim().toLowerCase();
|
|
||||||
return items.filter((ticket) => {
|
|
||||||
if (visibilityFilter === 'open' && ticket.state === 'closed') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (visibilityFilter === 'closed' && ticket.state !== 'closed') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (stateFilter !== 'all' && ticket.state !== stateFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (priorityFilter !== 'all' && (ticket.priority ?? '') !== priorityFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (queuedFilter === 'queued' && !ticket.queued_at) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (queuedFilter === 'unqueued' && ticket.queued_at) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!needle) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return [ticket.id, ticket.title, ticket.state, ticket.priority, ticket.queued_by, ticket.record_source]
|
|
||||||
.filter(isPresent)
|
|
||||||
.some((value) => value.toLowerCase().includes(needle));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTickets(items: TicketSummary[]): TicketSummary[] {
|
|
||||||
return [...items].sort((left, right) => {
|
|
||||||
const result = compareTicketValues(left, right, sortKey);
|
|
||||||
return sortDirection === 'asc' ? result : -result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareTicketValues(left: TicketSummary, right: TicketSummary, key: SortKey): number {
|
|
||||||
if (key === 'panel') {
|
|
||||||
return comparePanelOrder(left, right);
|
|
||||||
}
|
|
||||||
if (key === 'updated_at' || key === 'queued_at') {
|
|
||||||
return compareDate(left[key], right[key]);
|
|
||||||
}
|
|
||||||
return compareText(ticketValue(left, key), ticketValue(right, key));
|
|
||||||
}
|
|
||||||
|
|
||||||
function comparePanelOrder(left: TicketSummary, right: TicketSummary): number {
|
|
||||||
return compareNumber(panelActionPriority(left), panelActionPriority(right))
|
|
||||||
|| compareDate(right.updated_at, left.updated_at)
|
|
||||||
|| compareText(left.title, right.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
function panelActionPriority(ticket: TicketSummary): number {
|
|
||||||
if (ticket.workspace_action_priority) {
|
|
||||||
if (ticket.workspace_action_priority === 'ready_for_queue') return 0;
|
|
||||||
if (ticket.workspace_action_priority === 'active_work') return 1;
|
|
||||||
if (ticket.workspace_action_priority === 'background') return 2;
|
|
||||||
}
|
|
||||||
if (ticket.state === 'ready') return 0;
|
|
||||||
if (ticket.state === 'queued' || ticket.state === 'inprogress') return 1;
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareNumber(left: number, right: number): number {
|
|
||||||
return left - right;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ticketValue(ticket: TicketSummary, key: SortKey): string | null | undefined {
|
|
||||||
if (key === 'id') return ticket.id;
|
|
||||||
if (key === 'title') return ticket.title;
|
|
||||||
if (key === 'state') return ticket.state;
|
|
||||||
if (key === 'priority') return ticket.priority;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareText(left: string | null | undefined, right: string | null | undefined): number {
|
|
||||||
const leftText = left?.trim() ?? '';
|
|
||||||
const rightText = right?.trim() ?? '';
|
|
||||||
if (!leftText && rightText) return 1;
|
|
||||||
if (leftText && !rightText) return -1;
|
|
||||||
return leftText.localeCompare(rightText);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareDate(left: string | null | undefined, right: string | null | undefined): number {
|
|
||||||
const leftTime = left ? Date.parse(left) : Number.NEGATIVE_INFINITY;
|
|
||||||
const rightTime = right ? Date.parse(right) : Number.NEGATIVE_INFINITY;
|
|
||||||
return leftTime - rightTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSort(key: SortKey) {
|
|
||||||
if (sortKey === key) {
|
|
||||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sortKey = key;
|
|
||||||
sortDirection = key === 'updated_at' || key === 'queued_at' ? 'desc' : 'asc';
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortLabel(key: SortKey): string {
|
|
||||||
if (sortKey !== key) return '';
|
|
||||||
return sortDirection === 'asc' ? '↑' : '↓';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
query = '';
|
|
||||||
visibilityFilter = 'open';
|
|
||||||
stateFilter = 'all';
|
|
||||||
priorityFilter = 'all';
|
|
||||||
queuedFilter = 'all';
|
|
||||||
sortKey = 'panel';
|
|
||||||
sortDirection = 'asc';
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head><title>Tickets · Yoi</title></svelte:head>
|
||||||
<title>Tickets · Yoi Workspace</title>
|
|
||||||
<meta name="description" content="Workspace Tickets" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="card ticket-database-card">
|
<div class="workspace-page ticket-panel-page">
|
||||||
<div class="detail-heading">
|
<header class="workspace-page-header ticket-panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Workspace records</p>
|
<p class="workspace-eyebrow">Delivery</p>
|
||||||
<h2>Tickets</h2>
|
<h1>Tickets</h1>
|
||||||
|
<p class="workspace-page-lede">
|
||||||
|
Plan, route, review, and close work without leaving the workspace.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if data.tickets.data}
|
<div class="ticket-panel-summary" aria-label="Ticket summary">
|
||||||
<span>{visibleTickets.length} / {data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span>
|
<strong>{tickets.length}</strong>
|
||||||
{/if}
|
<span>tickets</span>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<p class="section-note">
|
<section class="ticket-kanban" aria-label="Ticket workflow board">
|
||||||
Tickets are read from the typed Ticket backend. This read-only table supports Notion-style filtering and sorting for browsing imported workspace Tickets.
|
{#each lanes as lane (lane.id)}
|
||||||
</p>
|
<section class="ticket-lane" data-state={lane.id}>
|
||||||
|
<header class="ticket-lane-header">
|
||||||
|
<div>
|
||||||
|
<span class="ticket-state-dot"></span>
|
||||||
|
<h2>{lane.label}</h2>
|
||||||
|
</div>
|
||||||
|
<span class="ticket-lane-count">{lane.tickets.length}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
{#if data.tickets.data}
|
<div class="ticket-lane-cards">
|
||||||
{#if data.tickets.data.items.length === 0}
|
{#each lane.tickets as ticket (ticket.id)}
|
||||||
<p>No Ticket records are present.</p>
|
<a
|
||||||
{:else}
|
class="ticket-card"
|
||||||
<div class="ticket-database-toolbar" aria-label="Ticket table controls">
|
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`}
|
||||||
<label class="ticket-filter ticket-search">
|
>
|
||||||
<span>Search</span>
|
<span class="ticket-card-id">{ticket.id}</span>
|
||||||
<input bind:value={query} type="search" placeholder="Title, id, state, source…" />
|
<strong>{ticket.title}</strong>
|
||||||
</label>
|
<div class="ticket-card-meta">
|
||||||
<label class="ticket-filter">
|
<span>{ticket.state} · {ticket.priority}</span>
|
||||||
<span>Visibility</span>
|
<time>{prettyDate(ticket.updated_at)}</time>
|
||||||
<select bind:value={visibilityFilter}>
|
</div>
|
||||||
<option value="open">Open</option>
|
</a>
|
||||||
<option value="closed">Closed</option>
|
{:else}
|
||||||
<option value="all">All</option>
|
<div class="ticket-lane-empty">No tickets</div>
|
||||||
</select>
|
{/each}
|
||||||
</label>
|
</div>
|
||||||
<label class="ticket-filter">
|
</section>
|
||||||
<span>State</span>
|
{/each}
|
||||||
<select bind:value={stateFilter}>
|
</section>
|
||||||
<option value="all">All states</option>
|
</div>
|
||||||
{#each states as state}
|
|
||||||
<option value={state}>{state}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="ticket-filter">
|
|
||||||
<span>Priority</span>
|
|
||||||
<select bind:value={priorityFilter}>
|
|
||||||
<option value="all">All priorities</option>
|
|
||||||
{#each priorities as priority}
|
|
||||||
<option value={priority}>{priority}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="ticket-filter">
|
|
||||||
<span>Queue</span>
|
|
||||||
<select bind:value={queuedFilter}>
|
|
||||||
<option value="all">All</option>
|
|
||||||
<option value="queued">Queued</option>
|
|
||||||
<option value="unqueued">Unqueued</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button class="secondary-button" type="button" onclick={() => toggleSort('panel')}>Panel order {sortLabel('panel')}</button>
|
|
||||||
<button class="secondary-button" type="button" onclick={resetFilters}>Reset</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ticket-table-wrap" aria-label="Workspace Tickets table">
|
|
||||||
<table class="ticket-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th><button type="button" onclick={() => toggleSort('title')}>Title {sortLabel('title')}</button></th>
|
|
||||||
<th><button type="button" onclick={() => toggleSort('state')}>State {sortLabel('state')}</button></th>
|
|
||||||
<th><button type="button" onclick={() => toggleSort('priority')}>Priority {sortLabel('priority')}</button></th>
|
|
||||||
<th><button type="button" onclick={() => toggleSort('updated_at')}>Updated {sortLabel('updated_at')}</button></th>
|
|
||||||
<th><button type="button" onclick={() => toggleSort('queued_at')}>Queued {sortLabel('queued_at')}</button></th>
|
|
||||||
<th><button type="button" onclick={() => toggleSort('id')}>ID {sortLabel('id')}</button></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each visibleTickets as ticket (ticket.id)}
|
|
||||||
<tr>
|
|
||||||
<td class="ticket-title-cell">
|
|
||||||
<a href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>{ticket.title}</a>
|
|
||||||
<span>{ticket.record_source ?? 'ticket backend'}</span>
|
|
||||||
</td>
|
|
||||||
<td><span class="state-pill">{ticket.state}</span></td>
|
|
||||||
<td>{ticket.priority || '—'}</td>
|
|
||||||
<td>{ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</td>
|
|
||||||
<td>
|
|
||||||
{#if ticket.queued_at}
|
|
||||||
<span>{formatDate(ticket.queued_at)}</span>
|
|
||||||
{#if ticket.queued_by}
|
|
||||||
<small>{ticket.queued_by}</small>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span class="muted">—</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td><code>{ticket.id}</code></td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if visibleTickets.length === 0}
|
|
||||||
<p class="section-note">No tickets match the current filters.</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data.tickets.data.invalid_records.length > 0}
|
|
||||||
<p class="error">{data.tickets.data.invalid_records.length} invalid Ticket record(s) hidden.</p>
|
|
||||||
{/if}
|
|
||||||
{:else if data.tickets.error}
|
|
||||||
<p class="error">{data.tickets.error}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.ticket-database-card {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-database-toolbar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(16rem, 1.8fr) repeat(4, minmax(9rem, 1fr)) auto auto;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: end;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-filter {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-filter input,
|
|
||||||
.ticket-filter select {
|
|
||||||
min-height: 2.35rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: normal;
|
|
||||||
padding: 0 0.7rem;
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary-button {
|
|
||||||
min-height: 2.35rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-table-wrap {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 0.8rem;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-table {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 58rem;
|
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-table th,
|
|
||||||
.ticket-table td {
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
padding: 0.72rem 0.8rem;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-table th {
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.76rem;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
position: sticky;
|
|
||||||
text-transform: uppercase;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-table th button {
|
|
||||||
all: unset;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-table tbody tr:hover {
|
|
||||||
background: var(--interactive-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-title-cell {
|
|
||||||
min-width: 22rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-title-cell a {
|
|
||||||
color: inherit;
|
|
||||||
display: block;
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-title-cell a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-title-cell span,
|
|
||||||
.ticket-table small,
|
|
||||||
.muted {
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: block;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.ticket-database-toolbar {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,362 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
import { untrack } from "svelte";
|
||||||
import type { PageProps } from './$types';
|
import RichMarkdown from "$lib/workspace/console/RichMarkdown.svelte";
|
||||||
|
import {
|
||||||
|
workspaceApiJsonWithBody,
|
||||||
|
workspaceApiPath,
|
||||||
|
} from "$lib/workspace/api/http";
|
||||||
|
import {
|
||||||
|
relationLabel,
|
||||||
|
TICKET_STATES,
|
||||||
|
ticketWorkerLaunchHref,
|
||||||
|
} from "$lib/workspace/tickets/ticket-panel";
|
||||||
|
import type { ApiResult } from "$lib/workspace/api/http";
|
||||||
|
import type {
|
||||||
|
RepositoryListResponse,
|
||||||
|
TicketDetail,
|
||||||
|
} from "$lib/workspace/sidebar/types";
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
const { data } = $props<{
|
||||||
|
data: {
|
||||||
|
workspaceId: string;
|
||||||
|
ticketId: string;
|
||||||
|
ticket: ApiResult<TicketDetail>;
|
||||||
|
repositories: ApiResult<RepositoryListResponse>;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const initialData = untrack(() => data);
|
||||||
|
const loadedTicket = initialData.ticket.data;
|
||||||
|
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||||
|
const loadedRepositories = initialData.repositories.data;
|
||||||
|
|
||||||
|
let ticket = $state<TicketDetail>(loadedTicket);
|
||||||
|
let editing = $state(false);
|
||||||
|
let editTitle = $state(loadedTicket.title);
|
||||||
|
let editBody = $state(loadedTicket.body);
|
||||||
|
let repositoryId = $state(loadedTicket.repository_id ?? "");
|
||||||
|
let refSelector = $state(loadedTicket.ref_selector ?? "");
|
||||||
|
let nextState = $state(loadedTicket.state);
|
||||||
|
let transitionReason = $state("");
|
||||||
|
let threadRole = $state("comment");
|
||||||
|
let threadBody = $state("");
|
||||||
|
let reviewResult = $state("approve");
|
||||||
|
let reviewBody = $state("");
|
||||||
|
let resolution = $state("");
|
||||||
|
let busy = $state<string | null>(null);
|
||||||
|
let errorMessage = $state<string | null>(null);
|
||||||
|
|
||||||
|
const ticketPath = $derived(
|
||||||
|
workspaceApiPath(
|
||||||
|
data.workspaceId,
|
||||||
|
`/tickets/${encodeURIComponent(data.ticketId)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function applyTicket(updatedTicket: TicketDetail): void {
|
||||||
|
ticket = updatedTicket;
|
||||||
|
editTitle = ticket.title;
|
||||||
|
editBody = ticket.body;
|
||||||
|
repositoryId = ticket.repository_id ?? "";
|
||||||
|
refSelector = ticket.ref_selector ?? "";
|
||||||
|
nextState = ticket.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutate(
|
||||||
|
action: string,
|
||||||
|
suffix: string,
|
||||||
|
body?: Record<string, unknown>,
|
||||||
|
method = "POST",
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (busy) return false;
|
||||||
|
busy = action;
|
||||||
|
errorMessage = null;
|
||||||
|
try {
|
||||||
|
const path = `${ticketPath}${suffix}`;
|
||||||
|
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
|
||||||
|
method,
|
||||||
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
|
});
|
||||||
|
applyTicket(response);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
busy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (
|
||||||
|
await mutate("edit", "", {
|
||||||
|
title: editTitle.trim(),
|
||||||
|
body: editBody,
|
||||||
|
}, "PATCH")
|
||||||
|
) editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTarget(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
await mutate("target", "", {
|
||||||
|
target: repositoryId
|
||||||
|
? {
|
||||||
|
action: "set",
|
||||||
|
repository_id: repositoryId,
|
||||||
|
ref_selector: refSelector.trim() || null,
|
||||||
|
}
|
||||||
|
: { action: "clear" },
|
||||||
|
}, "PATCH");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transition(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (
|
||||||
|
await mutate("state", "/state", {
|
||||||
|
state: nextState,
|
||||||
|
reason: transitionReason.trim() || null,
|
||||||
|
})
|
||||||
|
) transitionReason = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendThread(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!threadBody.trim()) return;
|
||||||
|
if (
|
||||||
|
await mutate("thread", "/thread", {
|
||||||
|
role: threadRole,
|
||||||
|
body: threadBody.trim(),
|
||||||
|
})
|
||||||
|
) threadBody = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function review(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!reviewBody.trim()) return;
|
||||||
|
if (
|
||||||
|
await mutate("review", "/review", {
|
||||||
|
result: reviewResult,
|
||||||
|
body: reviewBody.trim(),
|
||||||
|
})
|
||||||
|
) reviewBody = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeTicket(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!resolution.trim()) return;
|
||||||
|
if (
|
||||||
|
await mutate("close", "/close", { resolution: resolution.trim() })
|
||||||
|
) resolution = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTitle(kind: string): string {
|
||||||
|
return relationLabel(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyDate(value?: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head><title>{ticket.title} · Yoi</title></svelte:head>
|
||||||
<title>{data.ticket.data?.title ?? data.ticketId} · Tickets · Yoi Workspace</title>
|
|
||||||
<meta name="description" content="Workspace Ticket detail" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section class="card">
|
<div class="workspace-page ticket-detail-page">
|
||||||
<p class="breadcrumb"><a href={workspaceRoute(data.workspaceId, '/tickets')}>Tickets</a> / {data.ticketId}</p>
|
<a class="workspace-back-link" href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>
|
||||||
|
← Ticket board
|
||||||
|
</a>
|
||||||
|
|
||||||
{#if data.ticket.data}
|
<header class="ticket-detail-header">
|
||||||
<div class="detail-heading">
|
<div>
|
||||||
<div>
|
<div class="ticket-detail-kicker">
|
||||||
<p class="eyebrow">{data.ticket.data.id}</p>
|
<span class="workspace-status-pill" data-status={ticket.state}>{ticket.state}</span>
|
||||||
<h2>{data.ticket.data.title}</h2>
|
<code>{ticket.id}</code>
|
||||||
</div>
|
</div>
|
||||||
<span class="state-pill">{data.ticket.data.state}</span>
|
<h1>{ticket.title}</h1>
|
||||||
|
<p>Updated {prettyDate(ticket.updated_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="workspace-secondary-button" type="button" onclick={() => editing = !editing}>
|
||||||
|
{editing ? "Cancel edit" : "Edit ticket"}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<dl class="ticket-detail-grid">
|
{#if errorMessage}
|
||||||
<div>
|
<div class="workspace-callout is-error" role="alert">{errorMessage}</div>
|
||||||
<dt>Priority</dt>
|
|
||||||
<dd>{data.ticket.data.priority ?? 'unspecified'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Updated</dt>
|
|
||||||
<dd>{data.ticket.data.updated_at ? formatDate(data.ticket.data.updated_at) : 'unknown'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Created</dt>
|
|
||||||
<dd>{data.ticket.data.created_at ? formatDate(data.ticket.data.created_at) : 'unknown'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Events</dt>
|
|
||||||
<dd>{data.ticket.data.event_count}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Artifacts</dt>
|
|
||||||
<dd>{data.ticket.data.artifact_count}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Source</dt>
|
|
||||||
<dd>{data.ticket.data.record_source}</dd>
|
|
||||||
</div>
|
|
||||||
{#if data.ticket.data.queued_at || data.ticket.data.queued_by}
|
|
||||||
<div>
|
|
||||||
<dt>Queued</dt>
|
|
||||||
<dd>
|
|
||||||
{data.ticket.data.queued_at ? formatDate(data.ticket.data.queued_at) : 'queued'}{data.ticket.data.queued_by ? ` by ${data.ticket.data.queued_by}` : ''}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{#if data.ticket.data.risk_flags.length > 0}
|
|
||||||
<div class="risk-flags" aria-label="Risk flags">
|
|
||||||
{#each data.ticket.data.risk_flags as flag}
|
|
||||||
<span>{flag}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section class="ticket-body" aria-labelledby="ticket-body-heading">
|
|
||||||
<div class="detail-heading compact">
|
|
||||||
<h3 id="ticket-body-heading">Body</h3>
|
|
||||||
{#if data.ticket.data.body_truncated}
|
|
||||||
<span class="warning-pill">truncated</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<pre>{data.ticket.data.body || 'No body text is available.'}</pre>
|
|
||||||
</section>
|
|
||||||
{:else if data.ticket.error}
|
|
||||||
<p class="error">{data.ticket.error}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets/{data.ticketId}</code>…</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
|
||||||
|
{#if editing}
|
||||||
|
<form class="ticket-editor" onsubmit={saveEdit}>
|
||||||
|
<label>Title<input bind:value={editTitle} required /></label>
|
||||||
|
<label>Body<textarea bind:value={editBody} rows="12"></textarea></label>
|
||||||
|
<button class="workspace-primary-button" type="submit" disabled={busy === "edit" || !editTitle.trim()}>
|
||||||
|
{busy === "edit" ? "Saving…" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ticket-detail-grid">
|
||||||
|
<main class="ticket-detail-main">
|
||||||
|
<section class="ticket-detail-section">
|
||||||
|
<div class="ticket-section-heading"><h2>Intent</h2></div>
|
||||||
|
{#if ticket.body}
|
||||||
|
<RichMarkdown text={ticket.body} />
|
||||||
|
{:else}
|
||||||
|
<p class="workspace-empty-copy">No body has been recorded.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ticket-detail-section">
|
||||||
|
<div class="ticket-section-heading">
|
||||||
|
<h2>Relations</h2>
|
||||||
|
<span>{ticket.relations.outgoing.length + ticket.relations.incoming.length}</span>
|
||||||
|
</div>
|
||||||
|
{#if ticket.relations.blockers.length > 0}
|
||||||
|
<div class="ticket-blocker-list">
|
||||||
|
{#each ticket.relations.blockers as blocker}
|
||||||
|
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_ticket)}`}>
|
||||||
|
<strong>Blocked by {blocker.blocking_ticket}</strong>
|
||||||
|
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="ticket-relations-list">
|
||||||
|
{#each ticket.relations.outgoing as relation}
|
||||||
|
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target)}`}>
|
||||||
|
<span>{relationLabel(relation.kind)}</span>
|
||||||
|
<strong>{relation.target}</strong>
|
||||||
|
{#if relation.note}<small>{relation.note}</small>{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{#each ticket.relations.incoming as relation}
|
||||||
|
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_ticket)}`}>
|
||||||
|
<span>{relationLabel(relation.inverse_kind)}</span>
|
||||||
|
<strong>{relation.source_ticket}</strong>
|
||||||
|
{#if relation.note}<small>{relation.note}</small>{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{#if ticket.relations.outgoing.length === 0 && ticket.relations.incoming.length === 0}
|
||||||
|
<p class="workspace-empty-copy">No Ticket relations.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ticket-detail-section">
|
||||||
|
<div class="ticket-section-heading">
|
||||||
|
<h2>Timeline</h2><span>{ticket.event_count}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-timeline">
|
||||||
|
{#each ticket.events as event (event.sequence)}
|
||||||
|
<article>
|
||||||
|
<div class="ticket-timeline-marker"></div>
|
||||||
|
<div>
|
||||||
|
<header>
|
||||||
|
<strong>{event.heading ?? eventTitle(event.kind)}</strong>
|
||||||
|
<time>{prettyDate(event.at)}</time>
|
||||||
|
</header>
|
||||||
|
{#if event.author}<p class="ticket-event-author">{event.author}</p>{/if}
|
||||||
|
{#if event.from || event.to}<p>{event.from ?? "—"} → {event.to ?? "—"}</p>{/if}
|
||||||
|
{#if event.reason}<p>{event.reason}</p>{/if}
|
||||||
|
{#if event.body}<RichMarkdown text={event.body} />{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{:else}
|
||||||
|
<p class="workspace-empty-copy">No timeline events.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="ticket-control-rail">
|
||||||
|
<section class="ticket-control-card ticket-worker-card">
|
||||||
|
<header><h2>Start a Worker</h2><span>Ticket role</span></header>
|
||||||
|
<p class="ticket-assignment-line">
|
||||||
|
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
|
||||||
|
</p>
|
||||||
|
<p>The common launch flow carries a short canonical Ticket message and the target below.</p>
|
||||||
|
<div class="ticket-role-actions">
|
||||||
|
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
||||||
|
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "reviewer")}>Reviewer</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ticket-control-card">
|
||||||
|
<header><h2>Repository target</h2></header>
|
||||||
|
<form class="ticket-control-form" onsubmit={saveTarget}>
|
||||||
|
<label>Repository
|
||||||
|
<select bind:value={repositoryId}>
|
||||||
|
<option value="">Not assigned</option>
|
||||||
|
{#each loadedRepositories?.items ?? [] as repository}
|
||||||
|
<option value={repository.id}>{repository.display_name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label>
|
||||||
|
<button class="workspace-secondary-button" type="submit" disabled={busy === "target"}>
|
||||||
|
{busy === "target" ? "Saving…" : "Save target"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ticket-control-card">
|
||||||
|
<header><h2>Workflow</h2></header>
|
||||||
|
<form class="ticket-control-form" onsubmit={transition}>
|
||||||
|
<label>State
|
||||||
|
<select bind:value={nextState}>
|
||||||
|
{#each TICKET_STATES as state}<option value={state}>{state}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Reason<input bind:value={transitionReason} placeholder="Optional decision context" /></label>
|
||||||
|
<button class="workspace-secondary-button" type="submit" disabled={busy === "state" || nextState === ticket.state}>
|
||||||
|
Apply state
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if ticket.state === "ready"}
|
||||||
|
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue"} onclick={() => mutate("queue", "/queue", {})}>
|
||||||
|
{busy === "queue" ? "Queueing…" : "Queue ticket"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="ticket-control-card">
|
||||||
|
<summary>Append timeline event</summary>
|
||||||
|
<form class="ticket-control-form" onsubmit={appendThread}>
|
||||||
|
<label>Role<select bind:value={threadRole}>
|
||||||
|
<option value="comment">Comment</option>
|
||||||
|
<option value="plan">Plan</option>
|
||||||
|
<option value="decision">Decision</option>
|
||||||
|
<option value="implementation_report">Implementation report</option>
|
||||||
|
</select></label>
|
||||||
|
<label>Body<textarea bind:value={threadBody} rows="5" required></textarea></label>
|
||||||
|
<button class="workspace-secondary-button" type="submit" disabled={busy === "thread" || !threadBody.trim()}>Append event</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="ticket-control-card">
|
||||||
|
<summary>Record review</summary>
|
||||||
|
<form class="ticket-control-form" onsubmit={review}>
|
||||||
|
<label>Result<select bind:value={reviewResult}>
|
||||||
|
<option value="approve">Approve</option>
|
||||||
|
<option value="request_changes">Request changes</option>
|
||||||
|
</select></label>
|
||||||
|
<label>Review body<textarea bind:value={reviewBody} rows="5" required></textarea></label>
|
||||||
|
<button class="workspace-secondary-button" type="submit" disabled={busy === "review" || !reviewBody.trim()}>Record review</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{#if ticket.state !== "closed"}
|
||||||
|
<details class="ticket-control-card ticket-close-card">
|
||||||
|
<summary>Close ticket</summary>
|
||||||
|
<form class="ticket-control-form" onsubmit={closeTicket}>
|
||||||
|
<label>Resolution<textarea bind:value={resolution} rows="5" required></textarea></label>
|
||||||
|
<button class="workspace-danger-button" type="submit" disabled={busy === "close" || !resolution.trim()}>Close ticket</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{:else if ticket.resolution}
|
||||||
|
<section class="ticket-control-card"><header><h2>Resolution</h2></header><RichMarkdown text={ticket.resolution} /></section>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,29 @@
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import type { TicketDetail } from "$lib/workspace/sidebar/types";
|
import type {
|
||||||
|
RepositoryListResponse,
|
||||||
|
TicketDetail,
|
||||||
|
} from "$lib/workspace/sidebar/types";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load = (async ({ fetch, params }) => {
|
export const load = (async ({ fetch, params }) => {
|
||||||
const ticketId = params.ticketId;
|
const [ticket, repositories] = await Promise.all([
|
||||||
const ticket = await loadJson<TicketDetail>(
|
loadJson<TicketDetail>(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(
|
workspaceApiPath(
|
||||||
params.workspaceId,
|
params.workspaceId,
|
||||||
`/tickets/${encodeURIComponent(ticketId)}`,
|
`/tickets/${encodeURIComponent(params.ticketId)}`,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
loadJson<RepositoryListResponse>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(params.workspaceId, "/repositories"),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
ticketId,
|
ticketId: params.ticketId,
|
||||||
ticket,
|
ticket,
|
||||||
|
repositories,
|
||||||
};
|
};
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||||
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
|
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let workspaceId = $derived(data.workspaceId);
|
let workspaceId = $derived(data.workspaceId);
|
||||||
|
const ticketContext = untrack(() => data.ticketContext);
|
||||||
|
|
||||||
const NEW_WORKING_DIRECTORY_VALUE = '__new_working_directory__';
|
const NEW_WORKING_DIRECTORY_VALUE = '__new_working_directory__';
|
||||||
|
|
||||||
|
|
@ -31,13 +33,19 @@
|
||||||
let optionsError = $state<string | null>(null);
|
let optionsError = $state<string | null>(null);
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let submitError = $state<DisplayError | null>(null);
|
let submitError = $state<DisplayError | null>(null);
|
||||||
let displayName = $state('Coding Worker');
|
let displayName = $state(
|
||||||
|
ticketContext
|
||||||
|
? `${ticketContext.ticketTitle} · ${ticketContext.ticketRole || 'Worker'}`
|
||||||
|
: 'Coding Worker',
|
||||||
|
);
|
||||||
let runtimeId = $state('');
|
let runtimeId = $state('');
|
||||||
let profile = $state('builtin:coder');
|
let profile = $state(
|
||||||
let initialText = $state('');
|
ticketContext?.ticketRole === 'reviewer' ? 'builtin:reviewer' : 'builtin:coder',
|
||||||
|
);
|
||||||
|
let initialText = $state(ticketContext?.initialInput ?? '');
|
||||||
let workingDirectoryId = $state('');
|
let workingDirectoryId = $state('');
|
||||||
let workingDirectoryRepositoryId = $state('');
|
let workingDirectoryRepositoryId = $state(ticketContext?.repositoryId ?? '');
|
||||||
let workingDirectorySelector = $state('HEAD');
|
let workingDirectorySelector = $state(ticketContext?.refSelector ?? 'HEAD');
|
||||||
let relativeCwd = $state('');
|
let relativeCwd = $state('');
|
||||||
let creatingWorkingDirectory = $state(false);
|
let creatingWorkingDirectory = $state(false);
|
||||||
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
|
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
|
||||||
|
|
@ -107,7 +115,8 @@
|
||||||
runtimeId = form.runtime_id;
|
runtimeId = form.runtime_id;
|
||||||
displayName = form.display_name;
|
displayName = form.display_name;
|
||||||
profile = form.profile;
|
profile = form.profile;
|
||||||
workingDirectoryId = form.working_directory_id;
|
workingDirectoryId = form.working_directory_id ||
|
||||||
|
(ticketContext?.repositoryId ? NEW_WORKING_DIRECTORY_VALUE : '');
|
||||||
workingDirectoryRepositoryId = form.working_directory_repository_id;
|
workingDirectoryRepositoryId = form.working_directory_repository_id;
|
||||||
workingDirectorySelector = form.working_directory_selector;
|
workingDirectorySelector = form.working_directory_selector;
|
||||||
relativeCwd = form.relative_cwd;
|
relativeCwd = form.relative_cwd;
|
||||||
|
|
@ -254,6 +263,17 @@
|
||||||
<a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a>
|
<a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if ticketContext}
|
||||||
|
<aside class="worker-ticket-context">
|
||||||
|
<div>
|
||||||
|
<span>Ticket {ticketContext.ticketRole || 'Worker'}</span>
|
||||||
|
<strong>{ticketContext.ticketTitle}</strong>
|
||||||
|
<code>{ticketContext.ticketId}</code>
|
||||||
|
</div>
|
||||||
|
<a href={`/w/${workspaceId}/tickets/${encodeURIComponent(ticketContext.ticketId)}`}>View ticket</a>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="section-state">Loading launch options…</p>
|
<p class="section-state">Loading launch options…</p>
|
||||||
{:else if optionsError}
|
{:else if optionsError}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
export function load({ params }: { params: { workspaceId: string } }) {
|
export function load(
|
||||||
|
{ params, url }: { params: { workspaceId: string }; url: URL },
|
||||||
|
) {
|
||||||
|
const ticketId = url.searchParams.get("ticketId") ?? "";
|
||||||
|
const ticketRole = url.searchParams.get("ticketRole") ?? "";
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
|
ticketContext: ticketId
|
||||||
|
? {
|
||||||
|
ticketId,
|
||||||
|
ticketTitle: url.searchParams.get("ticketTitle") ?? ticketId,
|
||||||
|
ticketRole,
|
||||||
|
initialInput: url.searchParams.get("initialInput") ?? "",
|
||||||
|
repositoryId: url.searchParams.get("repositoryId") ?? "",
|
||||||
|
refSelector: url.searchParams.get("refSelector") ?? "HEAD",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user