feat: add shared Workspace Ticket classification tags

This commit is contained in:
2026-10-11 01:23:30 +09:00
parent 6a12b6cc34
commit e9397fa0dd
29 changed files with 3959 additions and 49 deletions
+203
View File
@@ -376,6 +376,11 @@ impl_openapi_schema!(
TicketStateUpdateRequest,
TicketSummarySearchQuery,
TicketThreadEventRequest,
WorkspaceTicketTagListRequest,
WorkspaceTicketTagMutationRequest,
TicketTagsSetRequest,
WorkspaceTicketTagList,
WorkspaceTicketTag,
RuntimeManagementApiError,
RuntimeWorkersQuery,
RuntimeWorkerSpawnRequest,
@@ -2976,6 +2981,31 @@ pub trait ServerApi {
#[body] request: CreateTicketRecordRequest,
) -> Result<TicketRecordRef, RepositoryApiError>;
#[get("/api/w/{workspace_id}/ticket-tags", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn workspace_ticket_tag_list(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[query] request: WorkspaceTicketTagListRequest,
) -> Result<WorkspaceTicketTagList, RepositoryApiError>;
#[post("/api/w/{workspace_id}/ticket-tags", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn workspace_ticket_tag_mutate(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: WorkspaceTicketTagMutationRequest,
) -> Result<WorkspaceTicketTag, RepositoryApiError>;
#[put("/api/w/{workspace_id}/tickets/{id}/tags", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_tags_set(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketTagsSetRequest,
) -> Result<WorkspaceTicketTagList, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/query", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_query(
&self,
@@ -7260,6 +7290,9 @@ pub struct InvalidProjectRecord {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketListItemSummary {
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "Array<WorkspaceTag>"))]
pub tags: Vec<ticket::WorkspaceTag>,
pub id: String,
pub resource_key: String,
pub title: String,
@@ -7567,6 +7600,15 @@ pub struct TicketQueryRequest {
pub review_status: Option<String>,
#[serde(default)]
pub attention: Vec<String>,
/// Match tickets containing every supplied tag ID (AND).
#[serde(default)]
pub tag_ids: Vec<String>,
/// Match tickets containing at least one supplied tag ID (OR).
#[serde(default)]
pub tag_ids_any: Vec<String>,
/// Exclude tickets containing any supplied tag ID (NOT).
#[serde(default)]
pub tag_ids_none: Vec<String>,
pub related_ticket_id: Option<String>,
pub relation_kind: Option<String>,
pub linked_objective_id: Option<String>,
@@ -7583,6 +7625,8 @@ pub struct TicketQueryRequest {
#[serde(deny_unknown_fields)]
pub struct TicketQueryItem {
pub id: String,
#[cfg_attr(feature = "typescript", ts(type = "Array<WorkspaceTag>"))]
pub tags: Vec<ticket::WorkspaceTag>,
pub resource_key: String,
pub title: String,
pub state: String,
@@ -7638,6 +7682,8 @@ pub struct TicketShowRequest {
#[serde(deny_unknown_fields)]
pub struct TicketDetail {
pub id: String,
#[cfg_attr(feature = "typescript", ts(type = "Array<WorkspaceTag>"))]
pub tags: Vec<ticket::WorkspaceTag>,
pub resource_key: String,
pub title: String,
pub state: String,
@@ -7703,6 +7749,14 @@ macro_rules! transparent_ticket_dto {
}
transparent_ticket_dto!(TicketRecord, ticket::Ticket);
transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest);
transparent_ticket_dto!(
WorkspaceTicketTagMutationRequest,
ticket::WorkspaceTagMutation
);
transparent_ticket_dto!(TicketTagsSetRequest, ticket::TicketTagSetRequest);
transparent_ticket_dto!(WorkspaceTicketTagList, Vec<ticket::WorkspaceTag>);
transparent_ticket_dto!(WorkspaceTicketTag, ticket::WorkspaceTag);
transparent_ticket_dto!(TicketRecordRef, ticket::TicketRef);
transparent_ticket_dto!(TicketRecordSummaryList, Vec<ticket::TicketSummary>);
transparent_ticket_dto!(TicketDependencyCheckResponse, ticket::TicketDependencyCheck);
@@ -11121,6 +11175,155 @@ mod skill_typescript_tests {
mod tests {
use super::*;
#[test]
fn ticket_tag_query_filters_default_empty_and_roundtrip() {
let request: TicketQueryRequest = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(request.tag_ids.is_empty());
assert!(request.tag_ids_any.is_empty());
assert!(request.tag_ids_none.is_empty());
let request: TicketQueryRequest = serde_json::from_value(serde_json::json!({
"tag_ids": ["tag-a", "tag-b"],
"tag_ids_any": ["tag-c", "tag-d"],
"tag_ids_none": ["tag-e"]
}))
.unwrap();
let value = serde_json::to_value(request).unwrap();
assert_eq!(value["tag_ids"], serde_json::json!(["tag-a", "tag-b"]));
assert_eq!(value["tag_ids_any"], serde_json::json!(["tag-c", "tag-d"]));
assert_eq!(value["tag_ids_none"], serde_json::json!(["tag-e"]));
}
#[test]
fn ticket_tag_mutations_require_operation_keys_and_observed_cas() {
for action in [
serde_json::json!({"kind": "create", "name": "bug"}),
serde_json::json!({"kind": "rename", "tag_id": "tag-a", "expected_last_operation_key": "observed", "name": "defect"}),
serde_json::json!({"kind": "delete", "tag_id": "tag-a", "expected_last_operation_key": "observed"}),
] {
let value = serde_json::json!({"operation_key": "new-operation", "action": action});
let parsed: WorkspaceTicketTagMutationRequest =
serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), value);
let mut missing_operation = value.clone();
missing_operation
.as_object_mut()
.unwrap()
.remove("operation_key");
assert!(
serde_json::from_value::<WorkspaceTicketTagMutationRequest>(missing_operation)
.is_err()
);
if action["kind"] != "create" {
let mut missing_cas = value;
missing_cas["action"]
.as_object_mut()
.unwrap()
.remove("expected_last_operation_key");
assert!(
serde_json::from_value::<WorkspaceTicketTagMutationRequest>(missing_cas)
.is_err()
);
}
}
let value = serde_json::json!({
"operation_key": "replace-tags",
"expected_tag_ids": ["tag-a", "tag-b"],
"tag_ids": ["tag-b", "tag-c"]
});
let parsed: TicketTagsSetRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), value);
for key in ["operation_key", "expected_tag_ids", "tag_ids"] {
let mut missing = value.clone();
missing.as_object_mut().unwrap().remove(key);
assert!(
serde_json::from_value::<TicketTagsSetRequest>(missing).is_err(),
"{key}"
);
}
}
#[test]
fn ticket_tag_list_and_responses_are_transparent() {
let query = serde_json::json!({"after": "tag-a", "limit": 200});
let parsed: WorkspaceTicketTagListRequest = serde_json::from_value(query.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), query);
let tag = serde_json::json!({
"workspace_id": "workspace-a", "id": "tag-a", "name": "bug",
"last_operation_key": "create-tag", "deleted": false
});
let parsed: WorkspaceTicketTag = serde_json::from_value(tag.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), tag);
let list = serde_json::json!([tag]);
let parsed: WorkspaceTicketTagList = serde_json::from_value(list.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), list);
for schema in [
schemars::schema_for!(TicketDetail),
schemars::schema_for!(TicketQueryItem),
] {
let schema = serde_json::to_value(schema).unwrap();
assert_eq!(schema["properties"]["tags"]["type"], "array");
assert_eq!(
schema["properties"]["tags"]["items"]["$ref"],
"#/$defs/WorkspaceTag"
);
}
}
#[test]
fn ticket_tag_operations_are_typed_workspace_routes() {
for (name, method, path) in [
(
"workspace_ticket_tag_list",
HttpMethod::Get,
"/api/w/{workspace_id}/ticket-tags",
),
(
"workspace_ticket_tag_mutate",
HttpMethod::Post,
"/api/w/{workspace_id}/ticket-tags",
),
(
"ticket_tags_set",
HttpMethod::Put,
"/api/w/{workspace_id}/tickets/{id}/tags",
),
] {
let operation = ServerApiMetadata::OPERATIONS
.iter()
.find(|operation| operation.operation_id == name)
.unwrap();
assert_eq!(operation.method, method);
assert_eq!(operation.path, path);
}
let contract = canonical_openapi_document().unwrap().to_json().unwrap();
let contract: serde_json::Value = serde_json::from_str(&contract).unwrap();
for (path, method) in [
("/api/w/{workspace_id}/ticket-tags", "post"),
("/api/w/{workspace_id}/tickets/{id}/tags", "put"),
] {
let operation = &contract["paths"][path][method];
assert!(
operation["responses"].get("409").is_some(),
"CAS conflict must be documented"
);
assert!(operation["requestBody"]["required"].as_bool().unwrap());
}
}
#[cfg(feature = "typescript")]
#[test]
fn ticket_tag_typescript_references_are_registered() {
use ts_rs::TS;
let config = ts_rs::Config::default();
assert!(TicketDetail::decl(&config).contains("tags: Array<WorkspaceTag>"));
assert!(TicketQueryItem::decl(&config).contains("tags: Array<WorkspaceTag>"));
let contract = canonical_openapi_document().unwrap().to_json().unwrap();
let generated =
repository_openapi_typescript::generate_ticket_browser_typescript(&contract).unwrap();
assert!(generated.contains("export type WorkspaceTag ="));
assert!(generated.contains("tags: Array<WorkspaceTag>"));
}
#[test]
fn ticket_role_assignment_request_defaults_empty_bindings_and_requires_each_selected_connection()
{
@@ -423,6 +423,34 @@ fn validate_ticket_browser_operations(
paths: &Map<String, Value>,
) -> Result<BTreeSet<String>, GenerationError> {
let operations = [
OperationContract {
path: "/api/w/{workspace_id}/ticket-tags",
method: "get",
operation_id: "workspace_ticket_tag_list",
request: None,
successes: &[("200", "Array_of_WorkspaceTag")],
},
OperationContract {
path: "/api/w/{workspace_id}/ticket-tags",
method: "post",
operation_id: "workspace_ticket_tag_mutate",
request: Some("WorkspaceTagMutation"),
successes: &[("200", "WorkspaceTag")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/tags",
method: "put",
operation_id: "ticket_tags_set",
request: Some("TicketTagSetRequest"),
successes: &[("200", "Array_of_WorkspaceTag")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/query",
method: "post",
operation_id: "ticket_query",
request: Some("TicketQueryRequest"),
successes: &[("200", "TicketQueryResponse")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets",
method: "get",