diff --git a/README.md b/README.md index 59bf2cd..155a614 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ Agent ticket system for coding agents. `ticket-flow` owns ticket lifecycle state The source-of-truth boundary is the `ticket-flow` CLI, MCP server, and TypeScript store API. All ticket creation, mutation, status changes, logs, links, and -checkpoints should go through those surfaces. +checkpoints should go through those surfaces. Migration/import is also a +ticket-flow mutation and should use `ticket-flow import`, `ticket_import`, or +`importTicketStore`. The filesystem is the current storage implementation, not the public SSOT contract: @@ -20,7 +22,8 @@ contract: Existing external ticket data should be migrated into the `ticket-flow` store before use. Do not share another product's ticket directory in place as the live -ticket state. +ticket state. Import copies parsed ticket records into the configured +`ticket-flow` store and leaves the source directory as a source artifact only. ## Usage @@ -29,13 +32,14 @@ bun install bun run cli create --title "Fix compaction session recovery" --source discord:123 bun run cli list bun run cli show T-20260707-001 +bun run cli import /path/to/legacy-ticket-store bun run cli checkpoint T-20260707-001 --phase qa --next-type agent_action --next-command "finish verification" bun run mcp ``` The MCP server exposes the same ticket mutations as tools, including -`ticket_create`, `ticket_update_status`, `ticket_checkpoint`, and -`ticket_agent_actions`. +`ticket_create`, `ticket_import`, `ticket_update_status`, `ticket_checkpoint`, +and `ticket_agent_actions`. ## macmini Setup diff --git a/biome.jsonc b/biome.jsonc index 6e0bd3f..8748077 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,13 @@ { "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!.omo", "!rust-ticket-flow/target"] + }, "assist": { "actions": { "source": { diff --git a/docs/CLAWHIP-INTEGRATION.md b/docs/CLAWHIP-INTEGRATION.md index bfee43d..a59bf1e 100644 --- a/docs/CLAWHIP-INTEGRATION.md +++ b/docs/CLAWHIP-INTEGRATION.md @@ -2,14 +2,17 @@ `ticket-flow` can emit compact `ticket.*` events to a running [`clawhip`](https://github.com/Yeachan-Heo/clawhip) daemon. The integration keeps -the local ticket JSON store as the source of truth and uses `clawhip` only as -the event routing layer. +the `ticket-flow` CLI/MCP/store API boundary as the source of truth and uses +`clawhip` only as the event routing layer. The local filesystem is the current +storage implementation behind ticket-flow, not a shared state contract for +clawhip or other tools. ## Responsibilities `ticket-flow` owns: -- ticket creation, status changes, checkpoints, and the active/archive JSON store +- ticket creation, import/migration, status changes, checkpoints, and the + active/archive storage implementation - projection from a ticket record into a compact event payload - best-effort delivery to the configured `clawhip` daemon @@ -22,6 +25,8 @@ the event routing layer. The integration intentionally does not persist `clawhip` delivery state in ticket JSON, and it does not send raw logs, artifacts, goals, or acceptance criteria. +`clawhip` must consume `ticket.*` events; it must not create, import, or mutate +ticket state directly. ## Event Sources diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index d9307e1..d51e71e 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -26,6 +26,7 @@ Commands exercised: ```bash TICKET_FLOW_HOME=/tmp/ticket-flow-qa bun run src/cli.ts create --title 'QA ticket' --type feature --priority high --goal 'prove CLI surface' --acceptance 'ticket json exists' --tag qa,mcp --source discord:999 +TICKET_FLOW_HOME=/tmp/ticket-flow-import-dest bun run src/cli.ts import /tmp/ticket-flow-import-source TICKET_FLOW_HOME=/tmp/ticket-flow-qa bun run src/cli.ts status T-20260701-001 doing --note 'start QA' TICKET_FLOW_HOME=/tmp/ticket-flow-qa bun run src/cli.ts link T-20260701-001 --thread 123456 TICKET_FLOW_HOME=/tmp/ticket-flow-qa bun run src/cli.ts log T-20260701-001 'manual QA note' @@ -36,6 +37,7 @@ TICKET_FLOW_HOME=/tmp/ticket-flow-qa bun run src/cli.ts agent-actions Observed output included: ```text +imported 3 ticket(s): active=1 archived=2 T-20260701-001: open -> doing T-20260701-001: linked threads -> 123456 T-20260701-001: logged @@ -53,14 +55,17 @@ printf '%s\n' \ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ticket_create","arguments":{"title":"MCP QA ticket","priority":"low","type":"chore","source":"qa:mcp"}}}' \ + '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ticket_import","arguments":{"sourceRoot":"/tmp/ticket-flow-import-source"}}}' \ | TICKET_FLOW_HOME=/tmp/ticket-flow-mcp-qa bun run src/mcp.ts ``` Observed: - `initialize` returned protocol version `2025-06-18`. -- `tools/list` returned all eight ticket tools. +- `tools/list` returned `ticket_import` with the other ticket tools. - `ticket_create` returned `T-20260701-001` with existing JSON contract fields. +- `ticket_import` returned an import summary and wrote parsed source tickets into + the configured destination store. - `ticket_agent_actions` returned an empty JSON array on an empty active-action store. - stdout contained JSON-RPC messages only. diff --git a/rust-ticket-flow/.gitignore b/rust-ticket-flow/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/rust-ticket-flow/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/rust-ticket-flow/Cargo.lock b/rust-ticket-flow/Cargo.lock new file mode 100644 index 0000000..f445c70 --- /dev/null +++ b/rust-ticket-flow/Cargo.lock @@ -0,0 +1,1730 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ticket-flow-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "predicates", + "serde", + "serde_json", + "tempfile", + "ticket-flow-core", +] + +[[package]] +name = "ticket-flow-core" +version = "0.1.0" +dependencies = [ + "dirs", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "time", + "uuid", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust-ticket-flow/Cargo.toml b/rust-ticket-flow/Cargo.toml new file mode 100644 index 0000000..60b36f6 --- /dev/null +++ b/rust-ticket-flow/Cargo.toml @@ -0,0 +1,46 @@ +[workspace] +members = [ + "crates/ticket-flow-core", + "crates/ticket-flow-cli", +] +resolver = "2" + +[workspace.package] +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.94" +version = "0.1.0" + +[workspace.dependencies] +anyhow = "1" +assert_cmd = "2" +clap = { version = "4", features = ["derive", "env", "wrap_help", "color"] } +dirs = "6" +predicates = "3" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tempfile = "3" +thiserror = "2" +time = { version = "0.3", features = ["formatting", "macros"] } +uuid = { version = "1", features = ["v7", "serde"] } + +[workspace.lints.rust] +unsafe_code = "deny" +unused_must_use = "deny" +non_ascii_idents = "deny" + +[workspace.lints.clippy] +dbg_macro = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +unwrap_used = "deny" + +[profile.release] +codegen-units = 1 +lto = "fat" +opt-level = 3 +panic = "abort" +strip = "symbols" diff --git a/rust-ticket-flow/README.md b/rust-ticket-flow/README.md new file mode 100644 index 0000000..289bcee --- /dev/null +++ b/rust-ticket-flow/README.md @@ -0,0 +1,88 @@ +# ticket-flow Rust implementation + +This folder is an independent Rust implementation of ticket-flow as an +agent-ticket single source of truth. It can run beside the TypeScript package +while the project evaluates a Rust-first path. + +## SSOT Rules + +- All ticket creation, mutation, status changes, checkpoints, imports, links, + logs, handoffs, approvals, evidence, views, and context packs go through the + Rust CLI, MCP server, or `ticket-flow-core` store API. +- Existing ticket stores are imported into ticket-flow. They are not shared in + place. +- clawhip consumes ticket-flow events. It does not create or own ticket state. +- The filesystem is the store implementation: `active/`, `archive/YYYY-MM/`, + `events/`, and `index.json`. + +## CLI + +```sh +cargo run -p ticket-flow-cli -- create --title "Investigate issue" +cargo run -p ticket-flow-cli -- list --format json +cargo run -p ticket-flow-cli -- checkpoint T-20260708-001 --phase qa --next-type agent_action +cargo run -p ticket-flow-cli -- import /path/to/old-ticket-store +``` + +Set `TICKET_FLOW_HOME` to choose the store root. Without it the CLI uses +`$HOME/.ticket-flow/tickets`. + +## MCP + +The Rust stdio MCP server is exposed as `ticket-flow-mcp`. + +```sh +cargo run -p ticket-flow-cli --bin ticket-flow-mcp +``` + +Implemented tools: + +- `ticket_create` +- `ticket_list` +- `ticket_get` +- `ticket_import` +- `ticket_update_status` +- `ticket_link` +- `ticket_add_log` +- `ticket_checkpoint` +- `ticket_agent_actions` + +## Import And Archive Layout + +`ticket-flow import ` reads `active/*.json` and +`archive/YYYY-MM/*.json`, rejects duplicate source IDs, rejects destination +collisions before writing, copies non-`done` tickets into `active/`, copies +`done` tickets into `archive/YYYY-MM/`, updates `index.json`, and appends a +`ticket.imported` event for each ticket. + +Archival status transitions also write to `archive/YYYY-MM/`. The reader keeps +compatibility with older flat `archive/.json` files. + +## clawhip + +Explicit event replay: + +```sh +cargo run -p ticket-flow-cli -- clawhip event T-20260708-001 --kind ticket.created --print +cargo run -p ticket-flow-cli -- clawhip event T-20260708-001 --kind ticket.created --send --url http://127.0.0.1:25294 +``` + +Opt-in auto delivery: + +```sh +TICKET_FLOW_CLAWHIP=1 \ +TICKET_FLOW_CLAWHIP_URL=http://127.0.0.1:25294 \ +TICKET_FLOW_REPO_PATH=/path/to/repo \ +cargo run -p ticket-flow-cli -- create --title "Route to clawhip" +``` + +Auto delivery is best-effort: ticket mutations still succeed when clawhip is +down. Explicit `--send` is strict and exits non-zero on delivery failure. + +## Verification + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace +``` diff --git a/rust-ticket-flow/clippy.toml b/rust-ticket-flow/clippy.toml new file mode 100644 index 0000000..b8043f9 --- /dev/null +++ b/rust-ticket-flow/clippy.toml @@ -0,0 +1,5 @@ +allow-dbg-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-print-in-tests = true +allow-unwrap-in-tests = true diff --git a/rust-ticket-flow/crates/ticket-flow-cli/Cargo.toml b/rust-ticket-flow/crates/ticket-flow-cli/Cargo.toml new file mode 100644 index 0000000..7fe4efe --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "ticket-flow-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[[bin]] +name = "ticket-flow" +path = "src/main.rs" + +[[bin]] +name = "ticket-flow-mcp" +path = "src/mcp_main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +serde.workspace = true +serde_json.workspace = true +ticket-flow-core = { path = "../ticket-flow-core" } + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +serde_json.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/rust-ticket-flow/crates/ticket-flow-cli/src/args.rs b/rust-ticket-flow/crates/ticket-flow-cli/src/args.rs new file mode 100644 index 0000000..c5993ee --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/src/args.rs @@ -0,0 +1,222 @@ +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +#[derive(Debug, Parser)] +#[command(name = "ticket-flow", version, about = "Agent-native ticket-flow CLI")] +pub struct Cli { + #[arg(long, env = "TICKET_FLOW_HOME", global = true)] + pub home: Option, + #[arg(long, value_enum, default_value_t = OutputFormat::Pretty, global = true)] + pub format: OutputFormat, + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum OutputFormat { + Pretty, + Json, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + Create(CreateArgs), + Show(IdArgs), + List(ListArgs), + Status(StatusArgs), + Checkpoint(CheckpointArgs), + Import(ImportArgs), + ReadyCheck(IdArgs), + Evidence { + #[command(subcommand)] + command: EvidenceCommand, + }, + Handoff { + #[command(subcommand)] + command: HandoffCommand, + }, + Approval { + #[command(subcommand)] + command: ApprovalCommand, + }, + Clawhip { + #[command(subcommand)] + command: ClawhipCommand, + }, + View { + #[command(subcommand)] + command: ViewCommand, + }, + ContextPack(ContextPackArgs), +} + +#[derive(Debug, Args)] +pub struct CreateArgs { + #[arg(long)] + pub title: String, + #[arg(long = "type", default_value = "chore")] + pub ticket_type: String, + #[arg(long, default_value = "medium")] + pub priority: String, + #[arg(long, default_value = "")] + pub goal: String, + #[arg(long)] + pub parent: Option, + #[arg(long, default_value = "iyen")] + pub assignee: String, + #[arg(long)] + pub acceptance: Vec, + #[arg(long = "tag")] + pub tags: Vec, + #[arg(long)] + pub source: Option, +} + +#[derive(Debug, Args)] +pub struct IdArgs { + pub id: String, +} + +#[derive(Debug, Args)] +pub struct ListArgs { + #[arg(long)] + pub status: Option, +} + +#[derive(Debug, Args)] +pub struct StatusArgs { + pub id: String, + pub status: String, + #[arg(long)] + pub artifact: Option, + #[arg(long)] + pub evidence: Option, + #[arg(long)] + pub note: Option, +} + +#[derive(Debug, Args)] +pub struct CheckpointArgs { + pub id: String, + #[arg(long)] + pub phase: Option, + #[arg(long)] + pub decision: Option, + #[arg(long)] + pub evidence: Option, + #[arg(long)] + pub blocker: Option, + #[arg(long)] + pub next: Option, + #[arg(long)] + pub note: Option, + #[arg(long = "next-type")] + pub next_type: Option, + #[arg(long = "next-command")] + pub next_command: Option, + #[arg(long = "next-owner")] + pub next_owner: Option, +} + +#[derive(Debug, Args)] +pub struct ImportArgs { + pub source: PathBuf, +} + +#[derive(Debug, Subcommand)] +pub enum EvidenceCommand { + Attach(EvidenceAttachArgs), +} + +#[derive(Debug, Args)] +pub struct EvidenceAttachArgs { + pub id: String, + #[arg(long = "type", default_value = "artifact")] + pub artifact_type: String, + #[arg(long)] + pub value: String, +} + +#[derive(Debug, Subcommand)] +pub enum HandoffCommand { + Request(HandoffRequestArgs), + Ack(HandoffAckArgs), +} + +#[derive(Debug, Args)] +pub struct HandoffRequestArgs { + pub id: String, + #[arg(long)] + pub from: String, + #[arg(long)] + pub to: String, + #[arg(long)] + pub reason: String, +} + +#[derive(Debug, Args)] +pub struct HandoffAckArgs { + pub id: String, + #[arg(long = "handoff-id")] + pub handoff_id: String, +} + +#[derive(Debug, Subcommand)] +pub enum ApprovalCommand { + Request(ApprovalRequestArgs), + Respond(ApprovalRespondArgs), +} + +#[derive(Debug, Args)] +pub struct ApprovalRequestArgs { + pub id: String, + #[arg(long)] + pub owner: String, + #[arg(long)] + pub question: String, +} + +#[derive(Debug, Args)] +pub struct ApprovalRespondArgs { + pub id: String, + #[arg(long = "approval-id")] + pub approval_id: String, + #[arg(long)] + pub decision: String, +} + +#[derive(Debug, Subcommand)] +pub enum ClawhipCommand { + Event(ClawhipEventArgs), +} + +#[derive(Debug, Args)] +pub struct ClawhipEventArgs { + pub id: String, + #[arg(long)] + pub kind: String, + #[arg(long)] + pub print: bool, + #[arg(long)] + pub send: bool, + #[arg(long, default_value = "http://127.0.0.1:25294")] + pub url: String, + #[arg(long = "timeout-ms", default_value_t = 1_000)] + pub timeout_ms: u64, +} + +#[derive(Debug, Subcommand)] +pub enum ViewCommand { + AgentQueue, + Board, + Review, + Coordination, +} + +#[derive(Debug, Args)] +pub struct ContextPackArgs { + pub id: String, + #[arg(long, default_value = "agent_execution")] + pub audience: String, +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/src/clawhip_cmd.rs b/rust-ticket-flow/crates/ticket-flow-cli/src/clawhip_cmd.rs new file mode 100644 index 0000000..e492bc8 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/src/clawhip_cmd.rs @@ -0,0 +1,57 @@ +use std::process::ExitCode; +use std::str::FromStr; + +use anyhow::Result; +use ticket_flow_core::clawhip::{ + BuildClawhipEventInput, ClawhipEventKind, ClawhipSendMode, SendClawhipEventInput, + build_clawhip_event, send_clawhip_event, +}; +use ticket_flow_core::{TicketId, TicketStore}; + +use crate::args::{ClawhipCommand, ClawhipEventArgs}; + +pub(crate) fn run(store: &TicketStore, command: ClawhipCommand) -> Result { + match command { + ClawhipCommand::Event(args) => event(store, args), + } +} + +fn event(store: &TicketStore, args: ClawhipEventArgs) -> Result { + let id = TicketId::parse(&args.id)?; + let ticket = store.get_ticket(&id)?; + let kind = ClawhipEventKind::from_str(&args.kind)?; + let event = build_clawhip_event(BuildClawhipEventInput { + kind, + ticket: &ticket, + repo_path: std::env::var("TICKET_FLOW_REPO_PATH") + .ok() + .or_else(current_dir_text), + worktree_path: std::env::var("TICKET_FLOW_WORKTREE_PATH").ok(), + from_status: None, + to_status: None, + correlation_id: None, + }); + if args.print || !args.send { + println!("{}", serde_json::to_string_pretty(&event)?); + } + if args.send { + let result = send_clawhip_event(SendClawhipEventInput { + url: Some(args.url), + event, + mode: ClawhipSendMode::Strict, + timeout_ms: args.timeout_ms, + })?; + eprintln!( + "clawhip: sent {} status={}", + kind, + result.status.unwrap_or_default() + ); + } + Ok(ExitCode::SUCCESS) +} + +fn current_dir_text() -> Option { + std::env::current_dir() + .ok() + .map(|path| path.display().to_string()) +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/src/main.rs b/rust-ticket-flow/crates/ticket-flow-cli/src/main.rs new file mode 100644 index 0000000..4ec57db --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/src/main.rs @@ -0,0 +1,22 @@ +#![forbid(unsafe_code)] + +mod args; +mod clawhip_cmd; +mod run; +mod support; + +use std::process::ExitCode; + +use clap::Parser; + +use crate::args::Cli; + +fn main() -> ExitCode { + match run::run(Cli::parse()) { + Ok(code) => code, + Err(error) => { + eprintln!("Error: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/src/mcp.rs b/rust-ticket-flow/crates/ticket-flow-cli/src/mcp.rs new file mode 100644 index 0000000..ffdf416 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/src/mcp.rs @@ -0,0 +1,86 @@ +use std::io::{self, BufRead, Write}; + +use anyhow::Result; +use serde_json::{Value, json}; + +mod schema; +mod tools; + +pub(crate) fn run_stdio() -> Result<()> { + let stdin = io::stdin(); + let mut stdout = io::stdout().lock(); + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + if let Some(response) = handle_line(&line) { + serde_json::to_writer(&mut stdout, &response)?; + stdout.write_all(b"\n")?; + } + } + Ok(()) +} + +fn handle_line(line: &str) -> Option { + match serde_json::from_str::(line) { + Ok(request) => handle_request(request), + Err(error) => Some(error_response(Value::Null, -32700, &error.to_string())), + } +} + +fn handle_request(request: Value) -> Option { + let id = request.get("id").cloned(); + let method = request.get("method").and_then(Value::as_str); + match method { + Some("initialize") => id.map(initialize_response), + Some("notifications/initialized") => None, + Some("tools/list") => id.map(tools_list_response), + Some("tools/call") => Some(call_tool_response(id.unwrap_or(Value::Null), request)), + Some(other) => { + id.map(|value| error_response(value, -32601, &format!("unknown method {other}"))) + } + None => Some(error_response( + id.unwrap_or(Value::Null), + -32600, + "missing method", + )), + } +} + +fn initialize_response(id: Value) -> Value { + success_response( + id, + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "ticket-flow", "version": env!("CARGO_PKG_VERSION")} + }), + ) +} + +fn tools_list_response(id: Value) -> Value { + success_response(id, json!({ "tools": tools::tool_definitions() })) +} + +fn call_tool_response(id: Value, request: Value) -> Value { + let Some(params) = request.get("params") else { + return error_response(id, -32602, "tools/call requires params"); + }; + match tools::call(params) { + Ok(result) => success_response(id, result), + Err(error) => error_response(id, -32602, &error.to_string()), + } +} + +fn success_response(id: Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn error_response(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message } + }) +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/src/mcp/schema.rs b/rust-ticket-flow/crates/ticket-flow-cli/src/mcp/schema.rs new file mode 100644 index 0000000..c602f53 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/src/mcp/schema.rs @@ -0,0 +1,129 @@ +use serde_json::{Value, json}; + +pub(crate) fn tool_definitions() -> Value { + json!([ + tool( + "ticket_create", + "Create an agent ticket.", + object( + json!({ + "title": string("Ticket title"), + "type": string("Ticket type"), + "priority": string("Ticket priority"), + "goal": string("Goal statement"), + "assignee": string("Ticket owner"), + "acceptance": string_array("Acceptance criteria"), + "tags": string_array("Tags"), + "parent": string("Parent ticket ID"), + "source": string("Source reference as kind:ref") + }), + &["title"], + ) + ), + tool( + "ticket_list", + "List active agent tickets.", + object(json!({"status": status_enum()}), &[],) + ), + tool( + "ticket_get", + "Read an active or archived ticket.", + id_schema() + ), + tool( + "ticket_import", + "Import a source ticket store into ticket-flow.", + object( + json!({"sourceRoot": string("Source ticket store root")}), + &["sourceRoot"], + ) + ), + tool( + "ticket_update_status", + "Apply a ticket status transition.", + object( + json!({ + "id": string("Ticket ID"), + "status": status_enum(), + "artifact": string("Artifact value for review/done transitions"), + "evidence": string("Evidence artifact value"), + "note": string("Status transition note") + }), + &["id", "status"], + ) + ), + tool( + "ticket_link", + "Attach an external reference to a ticket.", + object( + json!({ + "id": string("Ticket ID"), + "kind": {"type":"string","enum":["github_issues","prs","threads","cron_jobs"]}, + "value": string("Reference value") + }), + &["id", "kind", "value"], + ) + ), + tool( + "ticket_add_log", + "Append a note log entry to a ticket.", + object( + json!({"id": string("Ticket ID"), "note": string("Log note")}), + &["id", "note"], + ) + ), + tool( + "ticket_checkpoint", + "Write the current checkpoint payload.", + object( + json!({ + "id": string("Ticket ID"), + "phase": string("Current phase"), + "decision": string("Current decision"), + "evidence": string("Evidence summary"), + "blocker": string("Blocker summary"), + "next": string("Next step summary"), + "note": string("Checkpoint note"), + "nextType": {"type":"string","enum":["agent_action","owner_gate","release_gate","blocked"]}, + "nextCommand": string("Agent command"), + "nextOwner": string("Next owner") + }), + &["id"], + ) + ), + tool( + "ticket_agent_actions", + "List tickets with agent_action next actions.", + object(json!({}), &[],) + ) + ]) +} + +fn tool(name: &str, description: &str, input_schema: Value) -> Value { + json!({ "name": name, "description": description, "inputSchema": input_schema }) +} + +fn id_schema() -> Value { + object(json!({"id": string("Ticket ID")}), &["id"]) +} + +fn object(properties: Value, required: &[&str]) -> Value { + json!({ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": false + }) +} + +fn string(description: &str) -> Value { + json!({ "type": "string", "description": description }) +} + +fn string_array(description: &str) -> Value { + json!({ "type": "array", "items": {"type":"string"}, "description": description }) +} + +fn status_enum() -> Value { + json!({ "type":"string", "enum":["open","doing","review","blocked","done"] }) +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/src/mcp/tools.rs b/rust-ticket-flow/crates/ticket-flow-cli/src/mcp/tools.rs new file mode 100644 index 0000000..882a359 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/src/mcp/tools.rs @@ -0,0 +1,231 @@ +use anyhow::{Result, bail}; +use serde::Deserialize; +use serde::Serialize; +use serde_json::{Value, json}; +use ticket_flow_core::{ + AddLogInput, CheckpointPatch, CreateTicket, LinkKind, LinkTicketInput, StatusPatch, TicketId, + TicketStatus, TicketStore, agent_queue, +}; + +pub(crate) use super::schema::tool_definitions; + +pub(crate) fn call(params: &Value) -> Result { + let call = serde_json::from_value::(params.clone())?; + let store = TicketStore::new(ticket_flow_core::StorePaths::from_env_or_default()?); + match call.name.as_str() { + "ticket_create" => ticket_create(&store, call.arguments), + "ticket_list" => ticket_list(&store, call.arguments), + "ticket_get" => ticket_get(&store, call.arguments), + "ticket_import" => ticket_import(&store, call.arguments), + "ticket_update_status" => ticket_update_status(&store, call.arguments), + "ticket_link" => ticket_link(&store, call.arguments), + "ticket_add_log" => ticket_add_log(&store, call.arguments), + "ticket_checkpoint" => ticket_checkpoint(&store, call.arguments), + "ticket_agent_actions" => text_result(&agent_queue(&store)?), + other => bail!("unknown tool {other}"), + } +} + +#[derive(Deserialize)] +struct ToolCallParams { + name: String, + #[serde(default = "empty_arguments")] + arguments: Value, +} + +#[derive(Deserialize)] +struct CreateToolArgs { + title: String, + #[serde(default = "default_ticket_type", rename = "type")] + ticket_type: String, + #[serde(default = "default_priority")] + priority: String, + #[serde(default)] + goal: String, + #[serde(default = "default_assignee")] + assignee: String, + #[serde(default)] + acceptance: Vec, + #[serde(default)] + tags: Vec, + #[serde(default)] + parent: Option, + #[serde(default)] + source: Option, +} + +#[derive(Deserialize)] +struct IdToolArgs { + id: String, +} + +#[derive(Deserialize)] +struct ListToolArgs { + status: Option, +} + +#[derive(Deserialize)] +struct ImportToolArgs { + #[serde(rename = "sourceRoot")] + source_root: String, +} + +#[derive(Deserialize)] +struct StatusToolArgs { + id: String, + status: TicketStatus, + artifact: Option, + evidence: Option, + note: Option, +} + +#[derive(Deserialize)] +struct LinkToolArgs { + id: String, + kind: LinkKind, + value: String, +} + +#[derive(Deserialize)] +struct AddLogToolArgs { + id: String, + note: String, +} + +#[derive(Deserialize)] +struct CheckpointToolArgs { + id: String, + phase: Option, + decision: Option, + evidence: Option, + blocker: Option, + next: Option, + note: Option, + #[serde(rename = "nextType")] + next_action_type: Option, + #[serde(rename = "nextCommand")] + next_command: Option, + #[serde(rename = "nextOwner")] + next_owner: Option, +} + +fn ticket_create(store: &TicketStore, value: Value) -> Result { + let args = serde_json::from_value::(value)?; + if args.title.trim().is_empty() { + bail!("title must not be empty"); + } + let mut input = CreateTicket::new(args.title); + input.ticket_type = args.ticket_type; + input.priority = args.priority; + input.goal = args.goal; + input.assignee = args.assignee; + input.acceptance = args.acceptance; + input.tags = args.tags; + input.parent = args.parent; + input.source = source_value(args.source)?; + text_result(&store.create_ticket(input)?) +} + +fn ticket_list(store: &TicketStore, value: Value) -> Result { + let args = serde_json::from_value::(value)?; + let tickets = store + .list_tickets()? + .into_iter() + .filter(|ticket| args.status.is_none_or(|status| ticket.status == status)) + .collect::>(); + text_result(&tickets) +} + +fn ticket_get(store: &TicketStore, value: Value) -> Result { + let args = serde_json::from_value::(value)?; + text_result(&store.get_ticket(&TicketId::parse(&args.id)?)?) +} + +fn ticket_import(store: &TicketStore, value: Value) -> Result { + let args = serde_json::from_value::(value)?; + text_result(&store.import_store(args.source_root)?) +} + +fn ticket_update_status(store: &TicketStore, value: Value) -> Result { + let args = serde_json::from_value::(value)?; + let id = TicketId::parse(&args.id)?; + text_result(&store.update_status( + &id, + StatusPatch { + status: args.status, + artifact: args.artifact, + evidence: args.evidence, + note: args.note, + }, + )?) +} + +fn ticket_link(store: &TicketStore, value: Value) -> Result { + let args = serde_json::from_value::(value)?; + let id = TicketId::parse(&args.id)?; + text_result(&store.link_ticket( + &id, + LinkTicketInput { + kind: args.kind, + value: args.value, + }, + )?) +} + +fn ticket_add_log(store: &TicketStore, value: Value) -> Result { + let args = serde_json::from_value::(value)?; + let id = TicketId::parse(&args.id)?; + text_result(&store.add_log(&id, AddLogInput { note: args.note })?) +} + +fn ticket_checkpoint(store: &TicketStore, value: Value) -> Result { + let args = serde_json::from_value::(value)?; + let id = TicketId::parse(&args.id)?; + text_result(&store.checkpoint_ticket( + &id, + CheckpointPatch { + phase: args.phase, + decision: args.decision, + evidence: args.evidence, + blocker: args.blocker, + next: args.next, + note: args.note, + next_action_type: args.next_action_type, + next_command: args.next_command, + next_owner: args.next_owner, + }, + )?) +} + +fn text_result(value: &impl Serialize) -> Result { + Ok(json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(value)? }] })) +} + +fn source_value(value: Option) -> Result> { + let Some(source) = value else { + return Ok(None); + }; + let Some((kind, reference)) = source.split_once(':') else { + bail!("invalid source {source}"); + }; + if kind.trim().is_empty() || reference.trim().is_empty() { + bail!("invalid source {source}"); + } + Ok(Some(json!({ "type": kind, "ref": reference }))) +} + +fn empty_arguments() -> Value { + json!({}) +} + +fn default_ticket_type() -> String { + "chore".to_owned() +} + +fn default_priority() -> String { + "medium".to_owned() +} + +fn default_assignee() -> String { + "iyen".to_owned() +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/src/mcp_main.rs b/rust-ticket-flow/crates/ticket-flow-cli/src/mcp_main.rs new file mode 100644 index 0000000..4a69000 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/src/mcp_main.rs @@ -0,0 +1,12 @@ +#![forbid(unsafe_code)] + +mod mcp; + +use std::process::ExitCode; + +use anyhow::Result; + +fn main() -> Result { + mcp::run_stdio()?; + Ok(ExitCode::SUCCESS) +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/src/run.rs b/rust-ticket-flow/crates/ticket-flow-cli/src/run.rs new file mode 100644 index 0000000..6f73cb3 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/src/run.rs @@ -0,0 +1,235 @@ +use std::process::ExitCode; + +use anyhow::Result; +use serde_json::json; +use ticket_flow_core::{ + ApprovalRequest, CheckpointPatch, CreateTicket, EvidenceInput, HandoffAck, HandoffRequest, + StatusPatch, TicketId, TicketStore, ack_handoff, attach_evidence, blocked_gate, board, + build_context_pack, coordination, ready_gate, request_approval, request_handoff, + respond_approval, review, +}; + +use crate::args::{ + ApprovalCommand, Cli, Command, ContextPackArgs, CreateArgs, EvidenceCommand, HandoffCommand, + IdArgs, ImportArgs, ListArgs, OutputFormat, ViewCommand, +}; +use crate::support::{ + parse_approval_decision, parse_audience, parse_optional_next_action, parse_source, + parse_status, paths, print_json_or_pretty, print_subject, +}; + +pub fn run(cli: Cli) -> Result { + let format = cli.format; + let store = TicketStore::new(paths(cli.home)?); + match cli.command { + Command::Create(args) => create(&store, args), + Command::Show(args) => show(&store, args, format), + Command::List(args) => list(&store, args, format), + Command::Status(args) => { + let id = TicketId::parse(&args.id)?; + let status = parse_status(&args.status)?; + let ticket = store.update_status( + &id, + StatusPatch { + status, + artifact: args.artifact, + evidence: args.evidence, + note: args.note, + }, + )?; + print_subject( + format, + &ticket, + &format!("{}: status {}", ticket.id, ticket.status), + ) + } + Command::Checkpoint(args) => { + let id = TicketId::parse(&args.id)?; + let patch = CheckpointPatch { + phase: args.phase, + decision: args.decision, + evidence: args.evidence, + blocker: args.blocker, + next: args.next, + note: args.note, + next_action_type: parse_optional_next_action(args.next_type)?, + next_command: args.next_command, + next_owner: args.next_owner, + }; + let ticket = store.checkpoint_ticket(&id, patch)?; + print_subject(format, &ticket, &format!("{id}: checkpoint logged")) + } + Command::Import(args) => import_store(&store, args, format), + Command::ReadyCheck(args) => ready_check(&store, args, format), + Command::Evidence { command } => evidence(&store, command, format), + Command::Handoff { command } => handoff(&store, command, format), + Command::Approval { command } => approval(&store, command, format), + Command::Clawhip { command } => crate::clawhip_cmd::run(&store, command), + Command::View { command } => view(&store, command, format), + Command::ContextPack(args) => context_pack(&store, args, format), + } +} + +fn import_store(store: &TicketStore, args: ImportArgs, format: OutputFormat) -> Result { + let summary = store.import_store(args.source)?; + print_subject( + format, + &summary, + &format!( + "imported {} ticket(s): active={} archived={}", + summary.imported, summary.active, summary.archived + ), + ) +} + +fn create(store: &TicketStore, args: CreateArgs) -> Result { + let mut input = CreateTicket::new(args.title); + input.ticket_type = args.ticket_type; + input.priority = args.priority; + input.goal = args.goal; + input.parent = args.parent; + input.assignee = args.assignee; + input.acceptance = args.acceptance; + input.tags = args.tags; + input.source = parse_source(args.source)?; + let ticket = store.create_ticket(input)?; + println!("{}", ticket.id); + Ok(ExitCode::SUCCESS) +} + +fn show(store: &TicketStore, args: IdArgs, format: OutputFormat) -> Result { + let id = TicketId::parse(&args.id)?; + let ticket = store.get_ticket(&id)?; + print_subject(format, &ticket, &format!("{} {}", ticket.id, ticket.title)) +} + +fn list(store: &TicketStore, args: ListArgs, format: OutputFormat) -> Result { + let status = match args.status { + Some(value) => Some(parse_status(&value)?), + None => None, + }; + let tickets = store + .list_tickets()? + .into_iter() + .filter(|ticket| status.is_none_or(|wanted| ticket.status == wanted)) + .collect::>(); + print_subject(format, &tickets, &format!("{} ticket(s)", tickets.len())) +} + +fn ready_check(store: &TicketStore, args: IdArgs, format: OutputFormat) -> Result { + let id = TicketId::parse(&args.id)?; + let ticket = store.get_ticket(&id)?; + let ready = ready_gate(&ticket); + let blocked = blocked_gate(&ticket); + let payload = json!({ + "passed": ready.passed && blocked.passed, + "gates": [ready, blocked], + }); + print_json_or_pretty(format, &payload, "ready check")?; + if payload["passed"].as_bool() == Some(true) { + Ok(ExitCode::SUCCESS) + } else { + Ok(ExitCode::FAILURE) + } +} + +fn evidence( + store: &TicketStore, + command: EvidenceCommand, + format: OutputFormat, +) -> Result { + match command { + EvidenceCommand::Attach(args) => { + let id = TicketId::parse(&args.id)?; + let ticket = attach_evidence( + store, + &id, + EvidenceInput { + artifact_type: args.artifact_type, + value: args.value, + }, + )?; + print_subject(format, &ticket, &format!("{id}: evidence attached")) + } + } +} + +fn handoff(store: &TicketStore, command: HandoffCommand, format: OutputFormat) -> Result { + match command { + HandoffCommand::Request(args) => { + let id = TicketId::parse(&args.id)?; + let ticket = request_handoff( + store, + &id, + HandoffRequest { + from: args.from, + to: args.to, + reason: args.reason, + }, + )?; + print_subject(format, &ticket, &format!("{id}: handoff requested")) + } + HandoffCommand::Ack(args) => { + let id = TicketId::parse(&args.id)?; + let ticket = ack_handoff( + store, + &id, + HandoffAck { + handoff_id: args.handoff_id, + }, + )?; + print_subject(format, &ticket, &format!("{id}: handoff acknowledged")) + } + } +} + +fn approval( + store: &TicketStore, + command: ApprovalCommand, + format: OutputFormat, +) -> Result { + match command { + ApprovalCommand::Request(args) => { + let id = TicketId::parse(&args.id)?; + let ticket = request_approval( + store, + &id, + ApprovalRequest { + owner: args.owner, + question: args.question, + }, + )?; + print_subject(format, &ticket, &format!("{id}: approval requested")) + } + ApprovalCommand::Respond(args) => { + let id = TicketId::parse(&args.id)?; + let decision = parse_approval_decision(&args.decision)?; + let ticket = respond_approval(store, &id, &args.approval_id, decision)?; + print_subject(format, &ticket, &format!("{id}: approval responded")) + } + } +} + +fn view(store: &TicketStore, command: ViewCommand, format: OutputFormat) -> Result { + match command { + ViewCommand::AgentQueue => print_subject( + format, + &ticket_flow_core::views::agent_queue(store)?, + "agent queue", + ), + ViewCommand::Board => print_subject(format, &board(store)?, "board"), + ViewCommand::Review => print_subject(format, &review(store)?, "review"), + ViewCommand::Coordination => print_subject(format, &coordination(store)?, "coordination"), + } +} + +fn context_pack( + store: &TicketStore, + args: ContextPackArgs, + format: OutputFormat, +) -> Result { + let id = TicketId::parse(&args.id)?; + let audience = parse_audience(&args.audience)?; + let pack = build_context_pack(store, &id, audience)?; + print_subject(format, &pack, "context pack") +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/src/support.rs b/rust-ticket-flow/crates/ticket-flow-cli/src/support.rs new file mode 100644 index 0000000..f69c867 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/src/support.rs @@ -0,0 +1,86 @@ +use std::path::PathBuf; +use std::process::ExitCode; +use std::str::FromStr; + +use anyhow::Result; +use serde::Serialize; +use serde_json::json; +use ticket_flow_core::{ + ApprovalDecision, ContextAudience, NextActionType, StorePaths, TicketStatus, +}; + +use crate::args::OutputFormat; + +pub(crate) fn paths(home: Option) -> Result { + match home { + Some(root) => Ok(StorePaths::new(root)), + None => Ok(StorePaths::from_env_or_default()?), + } +} + +pub(crate) fn print_subject( + format: OutputFormat, + value: &T, + pretty: &str, +) -> Result { + print_json_or_pretty(format, value, pretty)?; + Ok(ExitCode::SUCCESS) +} + +pub(crate) fn print_json_or_pretty( + format: OutputFormat, + value: &T, + pretty: &str, +) -> Result<()> { + match format { + OutputFormat::Pretty => { + println!("{pretty}"); + Ok(()) + } + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) + } + } +} + +pub(crate) fn parse_optional_next_action(value: Option) -> Result> { + value + .map(|text| NextActionType::from_str(&text)) + .transpose() + .map_err(Into::into) +} + +pub(crate) fn parse_status(value: &str) -> Result { + TicketStatus::from_str(value).map_err(Into::into) +} + +pub(crate) fn parse_approval_decision(value: &str) -> Result { + match value { + "approved" => Ok(ApprovalDecision::Approved), + "rejected" => Ok(ApprovalDecision::Rejected), + other => anyhow::bail!("approval decision must be approved or rejected, got {other}"), + } +} + +pub(crate) fn parse_audience(value: &str) -> Result { + match value { + "agent_execution" => Ok(ContextAudience::AgentExecution), + "owner_review" => Ok(ContextAudience::OwnerReview), + "release_review" => Ok(ContextAudience::ReleaseReview), + other => anyhow::bail!("unknown context audience {other}"), + } +} + +pub(crate) fn parse_source(value: Option) -> Result> { + let Some(source) = value else { + return Ok(None); + }; + let Some((kind, reference)) = source.split_once(':') else { + anyhow::bail!("invalid source {source}"); + }; + if kind.trim().is_empty() || reference.trim().is_empty() { + anyhow::bail!("invalid source {source}"); + } + Ok(Some(json!({ "type": kind, "ref": reference }))) +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/tests/clawhip_contract.rs b/rust-ticket-flow/crates/ticket-flow-cli/tests/clawhip_contract.rs new file mode 100644 index 0000000..f826fc7 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/tests/clawhip_contract.rs @@ -0,0 +1,120 @@ +use std::io::{self, Read, Write}; +use std::net::TcpListener; +use std::sync::mpsc::{self, Receiver}; +use std::thread; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; + +#[test] +fn clawhip_strict_send_rejects_http_500() -> Result<(), Box> { + // Given: a ticket and a clawhip endpoint returning HTTP 500. + let temp = tempdir()?; + let output = Command::cargo_bin("ticket-flow")? + .env("TICKET_FLOW_HOME", temp.path()) + .args(["create", "--title", "Strict clawhip"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let id = String::from_utf8(output)?.trim().to_owned(); + let server = StatusServer::start(500)?; + + // When/Then: strict send exits non-zero and reports the HTTP status. + Command::cargo_bin("ticket-flow")? + .env("TICKET_FLOW_HOME", temp.path()) + .args([ + "clawhip", + "event", + &id, + "--kind", + "ticket.created", + "--send", + "--url", + &server.url, + ]) + .assert() + .failure() + .stderr(predicate::str::contains("clawhip returned HTTP 500")); + let _ = server.body()?; + Ok(()) +} + +struct StatusServer { + url: String, + body_rx: Receiver>, +} + +impl StatusServer { + fn start(status: u16) -> io::Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let url = format!("http://{}", listener.local_addr()?); + let (body_tx, body_rx) = mpsc::channel(); + thread::spawn(move || { + let body = capture_http_body(listener, status); + let _ = body_tx.send(body); + }); + Ok(Self { url, body_rx }) + } + + fn body(self) -> io::Result { + self.body_rx + .recv() + .map_err(|error| io::Error::other(error.to_string()))? + } +} + +fn capture_http_body(listener: TcpListener, status: u16) -> io::Result { + let (mut stream, _) = listener.accept()?; + let body = read_http_body(&mut stream)?; + let response = format!( + "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: 12\r\n\r\n{{\"ok\":false}}" + ); + stream.write_all(response.as_bytes())?; + Ok(body) +} + +fn read_http_body(stream: &mut impl Read) -> io::Result { + let mut buffer = Vec::new(); + let mut temp = [0_u8; 1024]; + loop { + let count = stream.read(&mut temp)?; + if count == 0 { + break; + } + buffer.extend_from_slice(&temp[..count]); + if let Some(body_start) = header_end(&buffer) { + let headers = String::from_utf8(buffer[..body_start].to_vec()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let length = content_length(&headers)?; + let body_len = buffer.len() - body_start; + if body_len >= length { + return String::from_utf8(buffer[body_start..body_start + length].to_vec()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); + } + } + } + Ok(String::new()) +} + +fn header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) +} + +fn content_length(headers: &str) -> io::Result { + headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .or_else(|| { + headers + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + }) + .and_then(|value| value.trim().parse::().ok()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing content-length")) +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/tests/cli_integrations.rs b/rust-ticket-flow/crates/ticket-flow-cli/tests/cli_integrations.rs new file mode 100644 index 0000000..8abc686 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/tests/cli_integrations.rs @@ -0,0 +1,282 @@ +use std::fs; +use std::io::{self, Read, Write}; +use std::net::TcpListener; +use std::path::Path; +use std::sync::mpsc::{self, Receiver}; +use std::thread; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::{Value, json}; +use tempfile::tempdir; + +#[test] +fn cli_import_prints_summary() -> Result<(), Box> { + // Given: a source ticket store with one active ticket. + let source = tempdir()?; + let destination = tempdir()?; + write_ticket_fixture( + &source.path().join("active").join("T-20260708-004.json"), + json!({ + "id": "T-20260708-004", + "title": "CLI import active", + "status": "open" + }), + )?; + + // When/Then: the CLI imports it and prints the summary. + Command::cargo_bin("ticket-flow")? + .env("TICKET_FLOW_HOME", destination.path()) + .args(["import", &source.path().display().to_string()]) + .assert() + .success() + .stdout(predicate::str::contains( + "imported 1 ticket(s): active=1 archived=0", + )); + Ok(()) +} + +#[test] +fn mcp_stdio_lists_calls_tools_and_reports_bad_input() -> Result<(), Box> { + // Given: a source store and MCP JSON-RPC messages over stdio. + let source = tempdir()?; + let destination = tempdir()?; + write_ticket_fixture( + &source.path().join("active").join("T-20260708-009.json"), + json!({ + "id": "T-20260708-009", + "title": "MCP imported", + "status": "open" + }), + )?; + let stdin = mcp_stdin(&[ + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0.0.0"}}}), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}), + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ticket_create","arguments":{"title":"MCP created","type":"feature","priority":"high"}}}), + json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ticket_import","arguments":{"sourceRoot":source.path().display().to_string()}}}), + json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ticket_get","arguments":{"id":"T-20260708-009"}}}), + json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ticket_update_status","arguments":{"id":"T-20260708-009","status":"doing"}}}), + json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"ticket_checkpoint","arguments":{"id":"T-20260708-009","phase":"qa","nextType":"agent_action","nextCommand":"cargo test","nextOwner":"codex"}}}), + json!({"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"ticket_agent_actions","arguments":{}}}), + json!({"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"ticket_create","arguments":{}}}), + ]); + + // When: the MCP binary handles the conversation. + let output = Command::cargo_bin("ticket-flow-mcp")? + .env("TICKET_FLOW_HOME", destination.path()) + .write_stdin(stdin) + .assert() + .success() + .get_output() + .stdout + .clone(); + + // Then: tools are listed, calls mutate the ticket store, and bad input is an error. + let responses = json_lines(&output)?; + let tools = &response_by_id(&responses, 2)?["result"]["tools"]; + assert!(tools.to_string().contains("ticket_create")); + assert!(tools.to_string().contains("ticket_import")); + assert!(tools.to_string().contains("ticket_agent_actions")); + assert!(mcp_text(&response_by_id(&responses, 3)?)?.contains("MCP created")); + assert_eq!( + serde_json::from_str::(&mcp_text(&response_by_id(&responses, 4)?)?)?, + json!({"imported":1,"active":1,"archived":0}) + ); + assert!(mcp_text(&response_by_id(&responses, 5)?)?.contains("MCP imported")); + assert!(mcp_text(&response_by_id(&responses, 8)?)?.contains("cargo test")); + assert_eq!( + response_by_id(&responses, 9)?["error"]["code"], + json!(-32602) + ); + Ok(()) +} + +#[test] +fn cli_clawhip_prints_sends_and_auto_emit_is_best_effort() -> Result<(), Box> +{ + // Given: a ticket store and clawhip auto emit pointing at an unavailable daemon. + let temp = tempdir()?; + let output = Command::cargo_bin("ticket-flow")? + .env("TICKET_FLOW_HOME", temp.path()) + .env("TICKET_FLOW_CLAWHIP", "1") + .env("TICKET_FLOW_CLAWHIP_URL", "http://127.0.0.1:9") + .env("TICKET_FLOW_CLAWHIP_TIMEOUT_MS", "50") + .args(["create", "--title", "Clawhip routeable"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let id = String::from_utf8(output)?.trim().to_owned(); + + // When: the operator prints and sends the routeable event. + let printed = Command::cargo_bin("ticket-flow")? + .env("TICKET_FLOW_HOME", temp.path()) + .env("TICKET_FLOW_REPO_PATH", "/repo/ticket-flow") + .args([ + "clawhip", + "event", + &id, + "--kind", + "ticket.created", + "--print", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let server = CaptureServer::start()?; + Command::cargo_bin("ticket-flow")? + .env("TICKET_FLOW_HOME", temp.path()) + .env("TICKET_FLOW_REPO_PATH", "/repo/ticket-flow") + .args([ + "clawhip", + "event", + &id, + "--kind", + "ticket.created", + "--send", + "--url", + &server.url, + ]) + .assert() + .success() + .stderr(predicate::str::contains( + "clawhip: sent ticket.created status=202", + )); + + // Then: printed and captured payloads are compact ticket-flow IncomingEvent JSON. + let printed_json: Value = serde_json::from_slice(&printed)?; + assert_eq!(printed_json["type"], "ticket.created"); + assert_eq!(printed_json["payload"]["provider"], "ticket-flow"); + assert_eq!(printed_json["payload"]["ticket_id"], id); + assert_eq!(printed_json["payload"]["repo_path"], "/repo/ticket-flow"); + let captured: Value = serde_json::from_str(&server.body()?)?; + assert_eq!(captured["payload"]["ticket_id"], id); + Ok(()) +} + +fn write_ticket_fixture(path: &Path, mut value: Value) -> Result<(), Box> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + value["priority"] = json!("medium"); + value["type"] = json!("chore"); + value["created"] = json!("2026-07-08T00:00:00Z"); + value["updated"] = json!("2026-07-08T00:00:00Z"); + fs::write(path, serde_json::to_vec_pretty(&value)?)?; + Ok(()) +} + +fn mcp_stdin(messages: &[Value]) -> String { + let mut stdin = String::new(); + for message in messages { + stdin.push_str(&message.to_string()); + stdin.push('\n'); + } + stdin +} + +fn json_lines(output: &[u8]) -> Result, Box> { + String::from_utf8(output.to_owned())? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str(line) + .map_err(|error| -> Box { Box::new(error) }) + }) + .collect() +} + +fn response_by_id(responses: &[Value], id: i64) -> Result { + responses + .iter() + .find(|response| response["id"] == json!(id)) + .cloned() + .ok_or_else(|| io::Error::other(format!("missing response id {id}"))) +} + +fn mcp_text(response: &Value) -> Result { + response["result"]["content"][0]["text"] + .as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| io::Error::other("missing MCP text content")) +} + +struct CaptureServer { + url: String, + body_rx: Receiver>, +} + +impl CaptureServer { + fn start() -> io::Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let url = format!("http://{}", listener.local_addr()?); + let (body_tx, body_rx) = mpsc::channel(); + thread::spawn(move || { + let body = capture_http_body(listener); + let _ = body_tx.send(body); + }); + Ok(Self { url, body_rx }) + } + + fn body(self) -> io::Result { + self.body_rx + .recv() + .map_err(|error| io::Error::other(error.to_string()))? + } +} + +fn capture_http_body(listener: TcpListener) -> io::Result { + let (mut stream, _) = listener.accept()?; + let body = read_http_body(&mut stream)?; + stream.write_all( + b"HTTP/1.1 202 Accepted\r\nContent-Type: application/json\r\nContent-Length: 33\r\n\r\n{\"ok\":true,\"event_id\":\"evt-test\"}", + )?; + Ok(body) +} + +fn read_http_body(stream: &mut impl Read) -> io::Result { + let mut buffer = Vec::new(); + let mut temp = [0_u8; 1024]; + loop { + let count = stream.read(&mut temp)?; + if count == 0 { + break; + } + buffer.extend_from_slice(&temp[..count]); + if let Some(body_start) = header_end(&buffer) { + let headers = String::from_utf8(buffer[..body_start].to_vec()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let length = content_length(&headers)?; + let body_len = buffer.len() - body_start; + if body_len >= length { + return String::from_utf8(buffer[body_start..body_start + length].to_vec()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); + } + } + } + Ok(String::new()) +} + +fn header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) +} + +fn content_length(headers: &str) -> io::Result { + headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .or_else(|| { + headers + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + }) + .and_then(|value| value.trim().parse::().ok()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing content length")) +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/tests/cli_slice.rs b/rust-ticket-flow/crates/ticket-flow-cli/tests/cli_slice.rs new file mode 100644 index 0000000..29178a9 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/tests/cli_slice.rs @@ -0,0 +1,203 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +#[test] +fn cli_create_prints_ticket_id_and_writes_store() { + // Given: an empty ticket-flow store. + let temp = tempdir().expect("tempdir"); + + // When: the CLI creates a ticket. + let output = Command::cargo_bin("ticket-flow") + .expect("binary") + .env("TICKET_FLOW_HOME", temp.path()) + .args([ + "create", + "--title", + "CLI create", + "--goal", + "prove create", + "--acceptance", + "id is printed", + ]) + .assert() + .success() + .stdout(predicate::str::contains("T-")) + .get_output() + .stdout + .clone(); + + // Then: the ticket file exists in the store. + let id = String::from_utf8(output).expect("utf8").trim().to_owned(); + assert!( + temp.path() + .join("active") + .join(format!("{id}.json")) + .exists() + ); +} + +#[test] +fn cli_ready_check_json_exits_one_when_missing_fields() { + // Given: a ticket missing ready data. + let temp = tempdir().expect("tempdir"); + let output = Command::cargo_bin("ticket-flow") + .expect("binary") + .env("TICKET_FLOW_HOME", temp.path()) + .args(["create", "--title", "Missing ready data"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let id = String::from_utf8(output).expect("utf8").trim().to_owned(); + + // When: ready-check runs as JSON. + let output = Command::cargo_bin("ticket-flow") + .expect("binary") + .env("TICKET_FLOW_HOME", temp.path()) + .args(["ready-check", &id, "--format", "json"]) + .assert() + .failure() + .get_output() + .stdout + .clone(); + + // Then: missing fields are machine-readable. + let value: Value = serde_json::from_slice(&output).expect("json"); + assert_eq!(value["passed"], false); + assert!( + value["gates"][0]["missing"] + .as_array() + .expect("missing") + .contains(&Value::String("goal".to_owned())) + ); + assert!( + value["gates"][0]["missing"] + .as_array() + .expect("missing") + .contains(&Value::String("acceptance".to_owned())) + ); + assert!( + value["gates"][0]["missing"] + .as_array() + .expect("missing") + .contains(&Value::String("current.next_action".to_owned())) + ); +} + +#[test] +fn cli_empty_checkpoint_exits_one() { + // Given: an existing ticket. + let temp = tempdir().expect("tempdir"); + let output = Command::cargo_bin("ticket-flow") + .expect("binary") + .env("TICKET_FLOW_HOME", temp.path()) + .args(["create", "--title", "Empty checkpoint"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let id = String::from_utf8(output).expect("utf8").trim().to_owned(); + + // When/Then: checkpoint without fields is rejected. + Command::cargo_bin("ticket-flow") + .expect("binary") + .env("TICKET_FLOW_HOME", temp.path()) + .args(["checkpoint", &id]) + .assert() + .failure() + .stderr(predicate::str::contains( + "checkpoint requires at least one field", + )); +} + +#[test] +fn cli_context_pack_json_contains_audience() { + // Given: a ticket with next action and evidence. + let temp = tempdir().expect("tempdir"); + let output = Command::cargo_bin("ticket-flow") + .expect("binary") + .env("TICKET_FLOW_HOME", temp.path()) + .args([ + "create", + "--title", + "Rust v2 happy", + "--type", + "agent_action", + "--priority", + "high", + "--goal", + "prove rust implementation", + "--acceptance", + "agent queue has command", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let id = String::from_utf8(output).expect("utf8").trim().to_owned(); + Command::cargo_bin("ticket-flow") + .expect("binary") + .env("TICKET_FLOW_HOME", temp.path()) + .args([ + "checkpoint", + &id, + "--phase", + "implement", + "--decision", + "use Rust store", + "--evidence", + "cargo test planned", + "--next", + "run agent command", + "--next-type", + "agent_action", + "--next-command", + "cargo test", + "--next-owner", + "codex", + ]) + .assert() + .success(); + Command::cargo_bin("ticket-flow") + .expect("binary") + .env("TICKET_FLOW_HOME", temp.path()) + .args([ + "evidence", + "attach", + &id, + "--type", + "test_output", + "--value", + "cargo test pass", + ]) + .assert() + .success(); + + // When: context-pack runs as JSON. + let output = Command::cargo_bin("ticket-flow") + .expect("binary") + .env("TICKET_FLOW_HOME", temp.path()) + .args([ + "context-pack", + &id, + "--audience", + "agent_execution", + "--format", + "json", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + // Then: the JSON has the audience and next action command. + let value: Value = serde_json::from_slice(&output).expect("json"); + assert_eq!(value["audience"], "agent_execution"); + assert_eq!(value["next_action"]["command"], "cargo test"); +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/tests/import_cli_errors.rs b/rust-ticket-flow/crates/ticket-flow-cli/tests/import_cli_errors.rs new file mode 100644 index 0000000..5e8a6d4 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/tests/import_cli_errors.rs @@ -0,0 +1,23 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::tempdir; + +#[test] +fn cli_import_rejects_missing_source_root() -> Result<(), Box> { + // Given: a source path that does not exist. + let source_parent = tempdir()?; + let destination = tempdir()?; + let source = source_parent.path().join("missing-import-source"); + + // When/Then: the CLI imports from the missing source path. + Command::cargo_bin("ticket-flow")? + .env("TICKET_FLOW_HOME", destination.path()) + .args(["import", &source.display().to_string()]) + .assert() + .failure() + .stderr(predicate::str::contains(format!( + "invalid import source {}", + source.display() + ))); + Ok(()) +} diff --git a/rust-ticket-flow/crates/ticket-flow-cli/tests/mcp_contract.rs b/rust-ticket-flow/crates/ticket-flow-cli/tests/mcp_contract.rs new file mode 100644 index 0000000..9c2e55f --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-cli/tests/mcp_contract.rs @@ -0,0 +1,69 @@ +use std::io; + +use assert_cmd::Command; +use serde_json::{Value, json}; +use tempfile::tempdir; + +#[test] +fn mcp_tools_list_exposes_required_fields_and_enums() -> Result<(), Box> { + // Given: a tools/list request. + let destination = tempdir()?; + let stdin = format!( + "{}\n{}\n", + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0.0.0"}}}), + json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}) + ); + + // When: the MCP server lists tools. + let output = Command::cargo_bin("ticket-flow-mcp")? + .env("TICKET_FLOW_HOME", destination.path()) + .write_stdin(stdin) + .assert() + .success() + .get_output() + .stdout + .clone(); + + // Then: clients can discover required fields and allowed enums. + let responses = json_lines(&output)?; + let tools = response_by_id(&responses, 2)?["result"]["tools"] + .as_array() + .ok_or_else(|| io::Error::other("tools must be an array"))? + .clone(); + let create = tool_by_name(&tools, "ticket_create")?; + assert_eq!(create["inputSchema"]["required"], json!(["title"])); + let status = tool_by_name(&tools, "ticket_update_status")?; + assert_eq!(status["inputSchema"]["required"], json!(["id", "status"])); + assert_eq!( + status["inputSchema"]["properties"]["status"]["enum"], + json!(["open", "doing", "review", "blocked", "done"]) + ); + Ok(()) +} + +fn json_lines(output: &[u8]) -> Result, Box> { + String::from_utf8(output.to_owned())? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str(line) + .map_err(|error| -> Box { Box::new(error) }) + }) + .collect() +} + +fn response_by_id(responses: &[Value], id: i64) -> Result { + responses + .iter() + .find(|response| response["id"] == json!(id)) + .cloned() + .ok_or_else(|| io::Error::other(format!("missing response id {id}"))) +} + +fn tool_by_name(tools: &[Value], name: &str) -> Result { + tools + .iter() + .find(|tool| tool["name"] == json!(name)) + .cloned() + .ok_or_else(|| io::Error::other(format!("missing tool {name}"))) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/Cargo.toml b/rust-ticket-flow/crates/ticket-flow-core/Cargo.toml new file mode 100644 index 0000000..19011f7 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ticket-flow-core" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +dirs.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +time.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/approval.rs b/rust-ticket-flow/crates/ticket-flow-core/src/approval.rs new file mode 100644 index 0000000..aabf657 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/approval.rs @@ -0,0 +1,84 @@ +use serde_json::json; +use uuid::Uuid; + +use crate::error::{Result, TicketFlowError}; +use crate::event::{EventEnvelope, TICKET_APPROVAL_REQUESTED, TICKET_APPROVAL_RESPONDED}; +use crate::model::{ApprovalRecord, Ticket, TicketId}; +use crate::store::TicketStore; +use crate::time::now_rfc3339; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalRequest { + pub owner: String, + pub question: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalDecision { + Approved, + Rejected, +} + +impl ApprovalDecision { + pub fn as_str(&self) -> &'static str { + match self { + Self::Approved => "approved", + Self::Rejected => "rejected", + } + } +} + +pub fn request_approval( + store: &TicketStore, + id: &TicketId, + input: ApprovalRequest, +) -> Result { + let mut ticket = store.load_active(id)?; + let approval = ApprovalRecord { + id: format!("A-{}", Uuid::now_v7()), + owner: input.owner, + question: input.question, + status: "requested".to_owned(), + requested_at: now_rfc3339(), + outcome: None, + responded_at: None, + }; + let mut coordination = ticket.coordination.clone().unwrap_or_default(); + coordination.approvals.push(approval.clone()); + ticket.coordination = Some(coordination); + ticket.updated = now_rfc3339(); + let event = EventEnvelope::new(id.clone(), TICKET_APPROVAL_REQUESTED, json!(approval)); + store.commit_active(&ticket, event)?; + Ok(ticket) +} + +pub fn respond_approval( + store: &TicketStore, + id: &TicketId, + approval_id: &str, + decision: ApprovalDecision, +) -> Result { + let mut ticket = store.load_active(id)?; + let mut coordination = ticket.coordination.clone().unwrap_or_default(); + let mut found = false; + for approval in &mut coordination.approvals { + if approval.id == approval_id { + approval.status = "responded".to_owned(); + approval.outcome = Some(decision.as_str().to_owned()); + approval.responded_at = Some(now_rfc3339()); + found = true; + } + } + if !found { + return Err(TicketFlowError::ApprovalNotFound(approval_id.to_owned())); + } + ticket.coordination = Some(coordination); + ticket.updated = now_rfc3339(); + let event = EventEnvelope::new( + id.clone(), + TICKET_APPROVAL_RESPONDED, + json!({ "approval_id": approval_id, "outcome": decision.as_str() }), + ); + store.commit_active(&ticket, event)?; + Ok(ticket) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/clawhip.rs b/rust-ticket-flow/crates/ticket-flow-core/src/clawhip.rs new file mode 100644 index 0000000..d9115ac --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/clawhip.rs @@ -0,0 +1,305 @@ +use std::fmt::{Display, Formatter}; +use std::path::Path; +use std::str::FromStr; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Result, TicketFlowError}; +use crate::model::{NextActionType, Ticket, TicketStatus}; + +const DEFAULT_CLAWHIP_URL: &str = "http://127.0.0.1:25294"; +const DEFAULT_TIMEOUT_MS: u64 = 1_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClawhipEventKind { + #[serde(rename = "ticket.created")] + Created, + #[serde(rename = "ticket.status_changed")] + StatusChanged, + #[serde(rename = "ticket.checkpointed")] + Checkpointed, + #[serde(rename = "ticket.agent_action_available")] + AgentActionAvailable, + #[serde(rename = "ticket.blocked")] + Blocked, + #[serde(rename = "ticket.review_ready")] + ReviewReady, +} + +impl ClawhipEventKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::Created => "ticket.created", + Self::StatusChanged => "ticket.status_changed", + Self::Checkpointed => "ticket.checkpointed", + Self::AgentActionAvailable => "ticket.agent_action_available", + Self::Blocked => "ticket.blocked", + Self::ReviewReady => "ticket.review_ready", + } + } +} + +impl Display for ClawhipEventKind { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for ClawhipEventKind { + type Err = TicketFlowError; + + fn from_str(value: &str) -> std::result::Result { + match value { + "ticket.created" => Ok(Self::Created), + "ticket.status_changed" => Ok(Self::StatusChanged), + "ticket.checkpointed" => Ok(Self::Checkpointed), + "ticket.agent_action_available" => Ok(Self::AgentActionAvailable), + "ticket.blocked" => Ok(Self::Blocked), + "ticket.review_ready" => Ok(Self::ReviewReady), + other => Err(TicketFlowError::InvalidClawhipEventKind(other.to_owned())), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClawhipTicketEvent { + #[serde(rename = "type")] + pub event_type: ClawhipEventKind, + pub payload: ClawhipTicketPayload, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClawhipTicketPayload { + pub provider: String, + pub event: ClawhipEventKind, + pub ticket_id: String, + pub title: String, + pub status: TicketStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub ticket_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub assignee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub blocker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_action_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_action_command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_action_owner: Option, + pub summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub question_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_name: Option, + pub event_timestamp: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, +} + +#[derive(Debug, Clone)] +pub struct BuildClawhipEventInput<'a> { + pub kind: ClawhipEventKind, + pub ticket: &'a Ticket, + pub repo_path: Option, + pub worktree_path: Option, + pub from_status: Option, + pub to_status: Option, + pub correlation_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClawhipSendMode { + BestEffort, + Strict, +} + +#[derive(Debug, Clone)] +pub struct SendClawhipEventInput { + pub url: Option, + pub event: ClawhipTicketEvent, + pub mode: ClawhipSendMode, + pub timeout_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClawhipSendResult { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub fn build_clawhip_event(input: BuildClawhipEventInput<'_>) -> ClawhipTicketEvent { + let current = input.ticket.current.as_ref(); + let next_action = current.and_then(|state| state.next_action.as_ref()); + let repo_path = resolve_text(input.repo_path); + let worktree_path = resolve_text(input.worktree_path).or_else(|| repo_path.clone()); + ClawhipTicketEvent { + event_type: input.kind, + payload: ClawhipTicketPayload { + provider: "ticket-flow".to_owned(), + event: input.kind, + ticket_id: input.ticket.id.to_string(), + title: input.ticket.title.clone(), + status: input.ticket.status, + priority: resolve_text(Some(input.ticket.priority.clone())), + ticket_type: resolve_text(Some(input.ticket.ticket_type.clone())), + assignee: resolve_text(Some(input.ticket.assignee.clone())), + from_status: input.from_status, + to_status: input.to_status, + phase: current.and_then(|state| resolve_text(state.phase.clone())), + decision: current.and_then(|state| resolve_text(state.decision.clone())), + blocker: current.and_then(|state| resolve_text(state.blocker.clone())), + next: current.and_then(|state| resolve_text(state.next.clone())), + next_action_type: next_action.and_then(|action| action.action_type), + next_action_command: next_action + .and_then(|action| resolve_text(action.command.clone())), + next_action_owner: next_action.and_then(|action| resolve_text(action.owner.clone())), + summary: format!( + "{} [{}] {}", + input.ticket.id, input.ticket.status, input.ticket.title + ), + question_summary: question_summary(input.kind, input.ticket), + repo_name: repo_path.as_deref().and_then(repo_name), + repo_path, + worktree_path, + event_timestamp: input.ticket.updated.clone(), + correlation_id: resolve_text(input.correlation_id), + }, + } +} + +pub fn send_clawhip_event(input: SendClawhipEventInput) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_millis(input.timeout_ms)) + .build()?; + let response = client + .post(event_endpoint( + input.url.as_deref().unwrap_or(DEFAULT_CLAWHIP_URL), + )) + .json(&input.event) + .send(); + match response { + Ok(value) if value.status().is_success() => Ok(ClawhipSendResult { + ok: true, + status: Some(value.status().as_u16()), + error: None, + }), + Ok(value) if input.mode == ClawhipSendMode::BestEffort => { + let status = value.status(); + Ok(ClawhipSendResult { + ok: false, + status: Some(status.as_u16()), + error: Some(format!("clawhip returned HTTP {status}")), + }) + } + Ok(value) => Err(TicketFlowError::ClawhipHttpStatus(value.status().as_u16())), + Err(error) if input.mode == ClawhipSendMode::BestEffort => Ok(ClawhipSendResult { + ok: false, + status: None, + error: Some(error.to_string()), + }), + Err(error) => Err(error.into()), + } +} + +pub struct EnvClawhipEmit<'a> { + pub kind: ClawhipEventKind, + pub ticket: &'a Ticket, + pub from_status: Option, + pub to_status: Option, +} + +pub fn emit_ticket_event_from_env(input: EnvClawhipEmit<'_>) { + if !clawhip_enabled() { + return; + } + let event = build_clawhip_event(BuildClawhipEventInput { + kind: input.kind, + ticket: input.ticket, + repo_path: std::env::var("TICKET_FLOW_REPO_PATH").ok(), + worktree_path: std::env::var("TICKET_FLOW_WORKTREE_PATH").ok(), + from_status: input.from_status, + to_status: input.to_status, + correlation_id: None, + }); + let _ = send_clawhip_event(SendClawhipEventInput { + url: std::env::var("TICKET_FLOW_CLAWHIP_URL").ok(), + event, + mode: ClawhipSendMode::BestEffort, + timeout_ms: clawhip_timeout_ms(), + }); +} + +fn event_endpoint(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + if trimmed.ends_with("/event") { + trimmed.to_owned() + } else { + format!("{trimmed}/event") + } +} + +fn resolve_text(value: Option) -> Option { + value.and_then(|text| { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + }) +} + +fn question_summary(kind: ClawhipEventKind, ticket: &Ticket) -> Option { + match kind { + ClawhipEventKind::Blocked | ClawhipEventKind::ReviewReady => ticket + .current + .as_ref() + .and_then(|state| state.blocker.clone().or_else(|| state.next.clone())) + .or_else(|| Some(ticket.title.clone())), + ClawhipEventKind::Created + | ClawhipEventKind::StatusChanged + | ClawhipEventKind::Checkpointed + | ClawhipEventKind::AgentActionAvailable => None, + } +} + +fn repo_name(path: &str) -> Option { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) +} + +fn clawhip_enabled() -> bool { + std::env::var("TICKET_FLOW_CLAWHIP") + .map(|value| matches!(value.as_str(), "1" | "true" | "yes")) + .unwrap_or(false) +} + +fn clawhip_timeout_ms() -> u64 { + std::env::var("TICKET_FLOW_CLAWHIP_TIMEOUT_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_TIMEOUT_MS) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/context_pack.rs b/rust-ticket-flow/crates/ticket-flow-core/src/context_pack.rs new file mode 100644 index 0000000..f2c01dd --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/context_pack.rs @@ -0,0 +1,101 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::model::{NextAction, Ticket, TicketId}; +use crate::store::TicketStore; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextAudience { + AgentExecution, + OwnerReview, + ReleaseReview, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ContextPack { + pub audience: ContextAudience, + pub ticket_id: TicketId, + pub goal: String, + pub current_state: Option, + pub next_action: Option, + pub constraints: Vec, + pub acceptance: Vec, + pub blockers: Vec, + pub evidence_refs: Vec, + pub risk_flags: Vec, +} + +pub fn build_context_pack( + store: &TicketStore, + id: &TicketId, + audience: ContextAudience, +) -> Result { + let ticket = store.get_ticket(id)?; + Ok(context_pack_from_ticket(&ticket, audience)) +} + +fn context_pack_from_ticket(ticket: &Ticket, audience: ContextAudience) -> ContextPack { + let constraints = ticket + .shared + .as_ref() + .map(|shared| shared.constraints.clone()) + .unwrap_or_default(); + let blockers = ticket + .coordination + .as_ref() + .map(|coordination| coordination.blockers.clone()) + .unwrap_or_default(); + let evidence_refs = ticket + .evidence + .as_ref() + .map(|evidence| { + evidence + .artifacts + .iter() + .map(|artifact| artifact.value.clone()) + .collect::>() + }) + .unwrap_or_default(); + ContextPack { + audience, + ticket_id: ticket.id.clone(), + goal: ticket.goal.clone(), + current_state: ticket + .current + .as_ref() + .and_then(|current| current.phase.clone().or_else(|| current.decision.clone())), + next_action: ticket + .current + .as_ref() + .and_then(|current| current.next_action.clone()), + constraints, + acceptance: ticket.acceptance.clone(), + blockers, + evidence_refs, + risk_flags: risk_flags(ticket), + } +} + +fn risk_flags(ticket: &Ticket) -> Vec { + let mut flags = Vec::new(); + if ticket.acceptance.is_empty() { + flags.push("missing_acceptance".to_owned()); + } + if ticket + .current + .as_ref() + .and_then(|current| current.blocker.as_deref()) + .is_some_and(|blocker| !blocker.trim().is_empty()) + { + flags.push("blocked".to_owned()); + } + if ticket + .evidence + .as_ref() + .is_none_or(|evidence| evidence.artifacts.is_empty()) + { + flags.push("missing_evidence".to_owned()); + } + flags +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/error.rs b/rust-ticket-flow/crates/ticket-flow-core/src/error.rs new file mode 100644 index 0000000..b2269bf --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/error.rs @@ -0,0 +1,57 @@ +use crate::model::TicketStatus; + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum TicketFlowError { + #[error("approval outcome not allowed")] + ApprovalOutcomeNotAllowed, + #[error("blocked gate failed: needed_from or next_owner required")] + BlockedGateFailed, + #[error("clawhip returned HTTP {0}")] + ClawhipHttpStatus(u16), + #[error("duplicate destination ticket {0}")] + DuplicateDestinationTicket(String), + #[error("duplicate source ticket {0}")] + DuplicateSourceTicket(String), + #[error("checkpoint requires at least one field")] + EmptyCheckpoint, + #[error("evidence value must not be empty")] + EmptyEvidence, + #[error("home directory is not available")] + HomeDirectoryUnavailable, + #[error("id sequence exhausted for date {0}")] + IdSequenceExhausted(String), + #[error("invalid source {0}")] + InvalidSource(String), + #[error("invalid import source {0}")] + InvalidImportSource(String), + #[error("invalid status {0}")] + InvalidStatus(String), + #[error("invalid link kind {0}")] + InvalidLinkKind(String), + #[error("invalid clawhip event kind {0}")] + InvalidClawhipEventKind(String), + #[error("invalid ticket id {0}")] + InvalidTicketId(String), + #[error("invalid transition {from} -> {to}")] + InvalidTransition { + from: TicketStatus, + to: TicketStatus, + }, + #[error("approval {0} not found")] + ApprovalNotFound(String), + #[error("handoff {0} not found")] + HandoffNotFound(String), + #[error("review requires --artifact")] + ReviewArtifactRequired, + #[error("ticket {0} not found")] + TicketNotFound(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Http(#[from] reqwest::Error), +} + +pub type Result = std::result::Result; diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/event.rs b/rust-ticket-flow/crates/ticket-flow-core/src/event.rs new file mode 100644 index 0000000..03dafc9 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/event.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::model::TicketId; +use crate::time::now_rfc3339; + +pub const TICKET_AGENT_ACTION_AVAILABLE: &str = "ticket.agent_action_available"; +pub const TICKET_APPROVAL_REQUESTED: &str = "ticket.approval_requested"; +pub const TICKET_APPROVAL_RESPONDED: &str = "ticket.approval_responded"; +pub const TICKET_CHECKPOINTED: &str = "ticket.checkpointed"; +pub const TICKET_CREATED: &str = "ticket.created"; +pub const TICKET_EVIDENCE_ATTACHED: &str = "ticket.evidence_attached"; +pub const TICKET_HANDOFF_ACKED: &str = "ticket.handoff_acked"; +pub const TICKET_HANDOFF_REQUESTED: &str = "ticket.handoff_requested"; +pub const TICKET_IMPORTED: &str = "ticket.imported"; +pub const TICKET_LINKED: &str = "ticket.linked"; +pub const TICKET_LOG_ADDED: &str = "ticket.log_added"; +pub const TICKET_STATUS_CHANGED: &str = "ticket.status_changed"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EventEnvelope { + pub id: String, + pub ticket_id: TicketId, + #[serde(rename = "type")] + pub event_type: String, + pub at: String, + pub actor: String, + pub payload: Value, +} + +impl EventEnvelope { + pub fn new(ticket_id: TicketId, event_type: impl Into, payload: Value) -> Self { + Self { + id: Uuid::now_v7().to_string(), + ticket_id, + event_type: event_type.into(), + at: now_rfc3339(), + actor: "ticket-flow".to_owned(), + payload, + } + } +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/evidence.rs b/rust-ticket-flow/crates/ticket-flow-core/src/evidence.rs new file mode 100644 index 0000000..76239e0 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/evidence.rs @@ -0,0 +1,34 @@ +use serde_json::json; + +use crate::error::{Result, TicketFlowError}; +use crate::event::{EventEnvelope, TICKET_EVIDENCE_ATTACHED}; +use crate::model::{Artifact, EvidenceStatus, Ticket, TicketId}; +use crate::store::TicketStore; +use crate::time::now_rfc3339; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvidenceInput { + pub artifact_type: String, + pub value: String, +} + +pub fn attach_evidence(store: &TicketStore, id: &TicketId, input: EvidenceInput) -> Result { + if input.value.trim().is_empty() { + return Err(TicketFlowError::EmptyEvidence); + } + let mut ticket = store.load_active(id)?; + let artifact = Artifact::new(input.artifact_type, input.value, now_rfc3339()); + ticket.artifacts.push(artifact.clone()); + let mut evidence = ticket.evidence.clone().unwrap_or_default(); + evidence.artifacts.push(artifact); + evidence.evidence_status = EvidenceStatus::Partial; + ticket.evidence = Some(evidence); + ticket.updated = now_rfc3339(); + let event = EventEnvelope::new( + id.clone(), + TICKET_EVIDENCE_ATTACHED, + json!({ "evidence_status": EvidenceStatus::Partial }), + ); + store.commit_active(&ticket, event)?; + Ok(ticket) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/gates.rs b/rust-ticket-flow/crates/ticket-flow-core/src/gates.rs new file mode 100644 index 0000000..2ab5d68 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/gates.rs @@ -0,0 +1,108 @@ +use serde::{Deserialize, Serialize}; + +use crate::model::{EvidenceStatus, NextActionType, Ticket, TicketStatus}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GateResult { + pub gate: String, + pub passed: bool, + pub missing: Vec, + pub warnings: Vec, +} + +impl GateResult { + fn new(gate: impl Into, missing: Vec, warnings: Vec) -> Self { + Self { + gate: gate.into(), + passed: missing.is_empty(), + missing, + warnings, + } + } +} + +pub fn ready_gate(ticket: &Ticket) -> GateResult { + let mut missing = Vec::new(); + if ticket.goal.trim().is_empty() { + missing.push("goal".to_owned()); + } + if ticket.acceptance.is_empty() { + missing.push("acceptance".to_owned()); + } + let next_action = ticket + .current + .as_ref() + .and_then(|current| current.next_action.as_ref()); + if next_action.and_then(|action| action.action_type).is_none() { + missing.push("current.next_action".to_owned()); + } + GateResult::new("ready", missing, Vec::new()) +} + +pub fn blocked_gate(ticket: &Ticket) -> GateResult { + let current = ticket.current.as_ref(); + let has_blocker = ticket.status == TicketStatus::Blocked + || current + .and_then(|state| state.blocker.as_deref()) + .is_some_and(|value| !value.trim().is_empty()); + if !has_blocker { + return GateResult::new("blocked", Vec::new(), Vec::new()); + } + let has_owner = current + .and_then(|state| state.next_action.as_ref()) + .and_then(|action| action.owner.as_deref()) + .is_some_and(|owner| !owner.trim().is_empty()); + let missing = if has_owner { + Vec::new() + } else { + vec!["current.next_action.owner".to_owned()] + }; + GateResult::new("blocked", missing, Vec::new()) +} + +pub fn review_gate(ticket: &Ticket) -> GateResult { + let has_artifact = !ticket.artifacts.is_empty() + || ticket + .evidence + .as_ref() + .is_some_and(|evidence| !evidence.artifacts.is_empty()); + let missing = if ticket.status == TicketStatus::Review && !has_artifact { + vec!["evidence.artifacts".to_owned()] + } else { + Vec::new() + }; + GateResult::new("review", missing, Vec::new()) +} + +pub fn done_gate(ticket: &Ticket) -> GateResult { + let evidence_status = ticket + .evidence + .as_ref() + .map(|evidence| &evidence.evidence_status); + let has_evidence = matches!( + evidence_status, + Some(EvidenceStatus::Partial | EvidenceStatus::Sufficient) + ) || !ticket.artifacts.is_empty(); + let mut missing = Vec::new(); + if ticket.acceptance.is_empty() { + missing.push("acceptance".to_owned()); + } + if !has_evidence { + missing.push("evidence".to_owned()); + } + GateResult::new("done", missing, Vec::new()) +} + +pub fn next_action_gate(ticket: &Ticket, expected: NextActionType) -> GateResult { + let actual = ticket + .current + .as_ref() + .and_then(|state| state.next_action.as_ref()) + .and_then(|action| action.action_type); + let missing = if actual == Some(expected) { + Vec::new() + } else { + vec!["current.next_action.type".to_owned()] + }; + GateResult::new("next_action", missing, Vec::new()) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/handoff.rs b/rust-ticket-flow/crates/ticket-flow-core/src/handoff.rs new file mode 100644 index 0000000..a454e46 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/handoff.rs @@ -0,0 +1,69 @@ +use serde_json::json; +use uuid::Uuid; + +use crate::error::{Result, TicketFlowError}; +use crate::event::{EventEnvelope, TICKET_HANDOFF_ACKED, TICKET_HANDOFF_REQUESTED}; +use crate::model::{HandoffRecord, Ticket, TicketId}; +use crate::store::TicketStore; +use crate::time::now_rfc3339; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandoffRequest { + pub from: String, + pub to: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandoffAck { + pub handoff_id: String, +} + +pub fn request_handoff( + store: &TicketStore, + id: &TicketId, + input: HandoffRequest, +) -> Result { + let mut ticket = store.load_active(id)?; + let handoff = HandoffRecord { + id: format!("H-{}", Uuid::now_v7()), + from: input.from, + to: input.to, + reason: input.reason, + status: "requested".to_owned(), + requested_at: now_rfc3339(), + acknowledged_at: None, + }; + let mut coordination = ticket.coordination.clone().unwrap_or_default(); + coordination.handoffs.push(handoff.clone()); + ticket.coordination = Some(coordination); + ticket.updated = now_rfc3339(); + let event = EventEnvelope::new(id.clone(), TICKET_HANDOFF_REQUESTED, json!(handoff)); + store.commit_active(&ticket, event)?; + Ok(ticket) +} + +pub fn ack_handoff(store: &TicketStore, id: &TicketId, input: HandoffAck) -> Result { + let mut ticket = store.load_active(id)?; + let mut coordination = ticket.coordination.clone().unwrap_or_default(); + let mut found = false; + for handoff in &mut coordination.handoffs { + if handoff.id == input.handoff_id { + handoff.status = "acknowledged".to_owned(); + handoff.acknowledged_at = Some(now_rfc3339()); + found = true; + } + } + if !found { + return Err(TicketFlowError::HandoffNotFound(input.handoff_id)); + } + ticket.coordination = Some(coordination); + ticket.updated = now_rfc3339(); + let event = EventEnvelope::new( + id.clone(), + TICKET_HANDOFF_ACKED, + json!({ "handoff_id": input.handoff_id }), + ); + store.commit_active(&ticket, event)?; + Ok(ticket) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/lib.rs b/rust-ticket-flow/crates/ticket-flow-core/src/lib.rs new file mode 100644 index 0000000..6d5806d --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/lib.rs @@ -0,0 +1,33 @@ +#![forbid(unsafe_code)] + +pub mod approval; +pub mod clawhip; +pub mod context_pack; +pub mod error; +pub mod event; +pub mod evidence; +pub mod gates; +pub mod handoff; +pub mod model; +pub mod store; +pub mod time; +pub mod views; + +pub use approval::{ApprovalDecision, ApprovalRequest, request_approval, respond_approval}; +pub use context_pack::{ContextAudience, ContextPack, build_context_pack}; +pub use error::{Result, TicketFlowError}; +pub use evidence::{EvidenceInput, attach_evidence}; +pub use gates::{GateResult, blocked_gate, done_gate, ready_gate, review_gate}; +pub use handoff::{HandoffAck, HandoffRequest, ack_handoff, request_handoff}; +pub use model::{ + Artifact, CheckpointPatch, CreateTicket, EvidenceStatus, NextAction, NextActionType, Ticket, + TicketId, TicketIndex, TicketStatus, +}; +pub use store::{ + AddLogInput, ImportTicketStoreSummary, LinkKind, LinkTicketInput, StatusPatch, StorePaths, + TicketStore, +}; +pub use views::{ + AgentQueueItem, BoardView, CoordinationView, ReviewView, agent_queue, board, coordination, + review, +}; diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/model.rs b/rust-ticket-flow/crates/ticket-flow-core/src/model.rs new file mode 100644 index 0000000..033fe8e --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/model.rs @@ -0,0 +1,204 @@ +use std::collections::BTreeMap; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +use crate::error::TicketFlowError; + +mod artifact; +mod sections; +mod ticket; + +pub use artifact::{Artifact, LinkMap, LogEntry}; +pub use sections::{ + ApprovalRecord, CoordinationSection, EvidenceSection, EvidenceStatus, ExecutionSection, + HandoffRecord, LocalSection, MemorySection, SharedSection, +}; +pub use ticket::{CreateTicket, Ticket, TicketIndex, TicketSummary}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(transparent)] +pub struct TicketId(String); + +impl TicketId { + pub fn parse(value: &str) -> crate::Result { + if is_ticket_id(value) { + Ok(Self(value.to_owned())) + } else { + Err(TicketFlowError::InvalidTicketId(value.to_owned())) + } + } + + pub fn from_parts(day: &str, sequence: u16) -> crate::Result { + let value = format!("T-{day}-{sequence:03}"); + Self::parse(&value) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Display for TicketId { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for TicketId { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +fn is_ticket_id(value: &str) -> bool { + let mut parts = value.split('-'); + let valid_day = parts.next().is_some_and(|day| day == "T") + && parts + .next() + .is_some_and(|day| day.len() == 8 && day.chars().all(|c| c.is_ascii_digit())); + let valid_sequence = parts + .next() + .is_some_and(|seq| seq.len() == 3 && seq.chars().all(|c| c.is_ascii_digit())); + valid_day && valid_sequence && parts.next().is_none() +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketStatus { + #[default] + Open, + Doing, + Review, + Blocked, + Done, +} + +impl Display for TicketStatus { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let value = match self { + Self::Open => "open", + Self::Doing => "doing", + Self::Review => "review", + Self::Blocked => "blocked", + Self::Done => "done", + }; + formatter.write_str(value) + } +} + +impl FromStr for TicketStatus { + type Err = TicketFlowError; + + fn from_str(value: &str) -> std::result::Result { + match value { + "open" => Ok(Self::Open), + "doing" => Ok(Self::Doing), + "review" => Ok(Self::Review), + "blocked" => Ok(Self::Blocked), + "done" => Ok(Self::Done), + other => Err(TicketFlowError::InvalidStatus(other.to_owned())), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NextActionType { + AgentAction, + OwnerGate, + ReleaseGate, + Blocked, +} + +impl FromStr for NextActionType { + type Err = TicketFlowError; + + fn from_str(value: &str) -> std::result::Result { + match value { + "agent_action" => Ok(Self::AgentAction), + "owner_gate" => Ok(Self::OwnerGate), + "release_gate" => Ok(Self::ReleaseGate), + "blocked" => Ok(Self::Blocked), + other => Err(TicketFlowError::InvalidStatus(other.to_owned())), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct NextAction { + #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] + pub action_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, +} + +impl NextAction { + pub fn has_fields(&self) -> bool { + self.action_type.is_some() || self.command.is_some() || self.owner.is_some() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct CurrentState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocker: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_action: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct CheckpointPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocker: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_action_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_owner: Option, +} + +impl CheckpointPatch { + pub fn is_empty(&self) -> bool { + self.phase.is_none() + && self.decision.is_none() + && self.evidence.is_none() + && self.blocker.is_none() + && self.next.is_none() + && self.note.is_none() + && self.next_action_type.is_none() + && self.next_command.is_none() + && self.next_owner.is_none() + } +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/model/artifact.rs b/rust-ticket-flow/crates/ticket-flow-core/src/model/artifact.rs new file mode 100644 index 0000000..6cc899d --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/model/artifact.rs @@ -0,0 +1,105 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +use super::NextAction; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Artifact { + #[serde(rename = "type")] + pub artifact_type: String, + pub value: String, + pub ts: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl Artifact { + pub fn new(artifact_type: impl Into, value: impl Into, ts: String) -> Self { + Self { + artifact_type: artifact_type.into(), + value: value.into(), + ts, + extra: BTreeMap::new(), + } + } +} + +impl<'de> Deserialize<'de> for Artifact { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum ArtifactWire { + Text(String), + Object(ArtifactObject), + } + + #[derive(Deserialize)] + struct ArtifactObject { + #[serde(default = "default_artifact_type", rename = "type")] + artifact_type: String, + #[serde(default)] + value: String, + #[serde(default)] + ts: String, + #[serde(flatten)] + extra: BTreeMap, + } + + fn default_artifact_type() -> String { + "artifact".to_owned() + } + + match ArtifactWire::deserialize(deserializer)? { + ArtifactWire::Text(value) => Ok(Self::new("artifact", value, String::new())), + ArtifactWire::Object(object) => Ok(Self { + artifact_type: object.artifact_type, + value: object.value, + ts: object.ts, + extra: object.extra, + }), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkMap { + #[serde(default)] + pub github_issues: Vec, + #[serde(default)] + pub prs: Vec, + #[serde(default)] + pub threads: Vec, + #[serde(default)] + pub cron_jobs: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LogEntry { + #[serde(default)] + pub ts: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocker: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_action: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/model/sections.rs b/rust-ticket-flow/crates/ticket-flow-core/src/model/sections.rs new file mode 100644 index 0000000..ebfe32a --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/model/sections.rs @@ -0,0 +1,106 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::Artifact; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceStatus { + #[default] + None, + Partial, + Sufficient, + Stale, + Disputed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct SharedSection { + #[serde(default)] + pub constraints: Vec, + #[serde(default)] + pub assumptions: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct CoordinationSection { + #[serde(default)] + pub owners: Vec, + #[serde(default)] + pub handoffs: Vec, + #[serde(default)] + pub approvals: Vec, + #[serde(default)] + pub blockers: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HandoffRecord { + pub id: String, + pub from: String, + pub to: String, + pub reason: String, + pub status: String, + pub requested_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acknowledged_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApprovalRecord { + pub id: String, + pub owner: String, + pub question: String, + pub status: String, + pub requested_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outcome: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub responded_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct EvidenceSection { + #[serde(default)] + pub artifacts: Vec, + #[serde(default)] + pub evidence_status: EvidenceStatus, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ExecutionSection { + #[serde(default)] + pub commands: Vec, + #[serde(default)] + pub checkpoints: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct MemorySection { + #[serde(default)] + pub notes: Vec, + #[serde(default)] + pub decisions: Vec, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct LocalSection { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub machine: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/model/ticket.rs b/rust-ticket-flow/crates/ticket-flow-core/src/model/ticket.rs new file mode 100644 index 0000000..80b27da --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/model/ticket.rs @@ -0,0 +1,185 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{ + Artifact, CoordinationSection, CurrentState, EvidenceSection, ExecutionSection, LinkMap, + LocalSection, LogEntry, MemorySection, SharedSection, TicketId, TicketStatus, +}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Ticket { + pub id: TicketId, + pub title: String, + #[serde(default)] + pub status: TicketStatus, + #[serde(default = "default_priority")] + pub priority: String, + #[serde(default = "default_ticket_type", rename = "type")] + pub ticket_type: String, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub goal: String, + #[serde(default)] + pub acceptance: Vec, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub artifacts: Vec, + #[serde(default)] + pub links: LinkMap, + #[serde(default)] + pub parent: Option, + #[serde(default)] + pub children: Vec, + #[serde(default = "default_assignee")] + pub assignee: String, + pub created: String, + pub updated: String, + #[serde(default)] + pub closed: Option, + #[serde(default)] + pub log: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shared: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coordination: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl Ticket { + pub fn new(id: TicketId, input: CreateTicket, now: String) -> Self { + Self { + id, + title: input.title, + status: TicketStatus::Open, + priority: input.priority, + ticket_type: input.ticket_type, + source: input.source, + goal: input.goal, + acceptance: input.acceptance, + tags: input.tags, + artifacts: Vec::new(), + links: LinkMap::default(), + parent: input.parent, + children: Vec::new(), + assignee: input.assignee, + created: now.clone(), + updated: now, + closed: None, + log: Vec::new(), + current: None, + shared: None, + coordination: None, + evidence: None, + execution: None, + memory: None, + local: None, + extra: BTreeMap::new(), + } + } + + pub fn summary(&self) -> TicketSummary { + TicketSummary { + title: self.title.clone(), + status: self.status, + priority: self.priority.clone(), + ticket_type: self.ticket_type.clone(), + updated: self.updated.clone(), + extra: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateTicket { + pub title: String, + pub ticket_type: String, + pub priority: String, + pub goal: String, + pub parent: Option, + pub assignee: String, + pub acceptance: Vec, + pub tags: Vec, + pub source: Option, +} + +impl CreateTicket { + pub fn new(title: String) -> Self { + Self { + title, + ticket_type: default_ticket_type(), + priority: default_priority(), + goal: String::new(), + parent: None, + assignee: default_assignee(), + acceptance: Vec::new(), + tags: Vec::new(), + source: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TicketSummary { + pub title: String, + pub status: TicketStatus, + pub priority: String, + #[serde(rename = "type")] + pub ticket_type: String, + pub updated: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TicketIndex { + #[serde(default = "default_index_version")] + pub version: u32, + #[serde(default, rename = "lastId")] + pub last_id: Option, + #[serde(default)] + pub tickets: BTreeMap, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl Default for TicketIndex { + fn default() -> Self { + Self { + version: default_index_version(), + last_id: None, + tickets: BTreeMap::new(), + extra: BTreeMap::new(), + } + } +} + +fn default_priority() -> String { + "medium".to_owned() +} + +fn default_ticket_type() -> String { + "chore".to_owned() +} + +fn default_assignee() -> String { + "iyen".to_owned() +} + +fn default_index_version() -> u32 { + 1 +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/store.rs b/rust-ticket-flow/crates/ticket-flow-core/src/store.rs new file mode 100644 index 0000000..912e9e9 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/store.rs @@ -0,0 +1,109 @@ +use std::path::PathBuf; + +use crate::clawhip::{ClawhipEventKind, EnvClawhipEmit, emit_ticket_event_from_env}; +use crate::error::{Result, TicketFlowError}; +use crate::event::{EventEnvelope, TICKET_CREATED}; +use crate::model::{CreateTicket, Ticket, TicketId}; +use crate::time::today_yyyymmdd; + +mod checkpoint; +mod import; +mod io; +mod mutations; +mod status; + +pub use import::ImportTicketStoreSummary; +pub use mutations::{AddLogInput, LinkKind, LinkTicketInput}; +pub use status::StatusPatch; + +#[derive(Debug, Clone)] +pub struct StorePaths { + pub root: PathBuf, + pub active: PathBuf, + pub archive: PathBuf, + pub events: PathBuf, + pub index: PathBuf, +} + +impl StorePaths { + pub fn new(root: PathBuf) -> Self { + Self { + active: root.join("active"), + archive: root.join("archive"), + events: root.join("events"), + index: root.join("index.json"), + root, + } + } + + pub fn from_env_or_default() -> Result { + match std::env::var_os("TICKET_FLOW_HOME") { + Some(value) => Ok(Self::new(PathBuf::from(value))), + None => { + let home = dirs::home_dir().ok_or(TicketFlowError::HomeDirectoryUnavailable)?; + Ok(Self::new(home.join(".ticket-flow").join("tickets"))) + } + } + } +} + +#[derive(Debug, Clone)] +pub struct TicketStore { + paths: StorePaths, +} + +impl TicketStore { + pub fn new(paths: StorePaths) -> Self { + Self { paths } + } + + pub fn paths(&self) -> &StorePaths { + &self.paths + } + + pub fn create_ticket(&self, input: CreateTicket) -> Result { + self.ensure_store()?; + let id = self.next_ticket_id(&today_yyyymmdd())?; + let now = crate::time::now_rfc3339(); + let ticket = Ticket::new(id.clone(), input, now); + let event = EventEnvelope::new( + id.clone(), + TICKET_CREATED, + serde_json::json!({ "title": ticket.title }), + ); + self.append_event(&event)?; + self.write_ticket_active(&ticket)?; + let mut index = self.read_index()?; + index.last_id = Some(id); + index.tickets.insert(ticket.id.clone(), ticket.summary()); + self.write_index(&index)?; + emit_ticket_event_from_env(EnvClawhipEmit { + kind: ClawhipEventKind::Created, + ticket: &ticket, + from_status: None, + to_status: None, + }); + Ok(ticket) + } + + pub fn get_ticket(&self, id: &TicketId) -> Result { + self.ensure_store()?; + let active = self.ticket_path(&self.paths.active, id); + if active.exists() { + return self.read_ticket(&active); + } + if let Some(archived) = self.archived_ticket_path(id)? { + return self.read_ticket(&archived); + } + Err(TicketFlowError::TicketNotFound(id.to_string())) + } + + pub fn list_tickets(&self) -> Result> { + self.ensure_store()?; + let mut tickets = Vec::new(); + for id in self.ticket_ids_in_dir(&self.paths.active)? { + tickets.push(self.get_ticket(&id)?); + } + Ok(tickets) + } +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/store/checkpoint.rs b/rust-ticket-flow/crates/ticket-flow-core/src/store/checkpoint.rs new file mode 100644 index 0000000..fa3927f --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/store/checkpoint.rs @@ -0,0 +1,130 @@ +use serde_json::json; + +use super::TicketStore; +use crate::clawhip::{ClawhipEventKind, EnvClawhipEmit, emit_ticket_event_from_env}; +use crate::error::{Result, TicketFlowError}; +use crate::event::{EventEnvelope, TICKET_AGENT_ACTION_AVAILABLE, TICKET_CHECKPOINTED}; +use crate::model::{ + CheckpointPatch, CurrentState, LogEntry, NextAction, NextActionType, Ticket, TicketId, + TicketStatus, +}; +use crate::time::now_rfc3339; + +impl TicketStore { + pub fn checkpoint_ticket(&self, id: &TicketId, patch: CheckpointPatch) -> Result { + if patch.is_empty() { + return Err(TicketFlowError::EmptyCheckpoint); + } + let mut ticket = self.load_active(id)?; + let now = now_rfc3339(); + let mut current = ticket.current.clone().unwrap_or_default(); + apply_checkpoint(&mut current, &patch); + let next_action = current.next_action.clone(); + ticket.current = Some(current); + ticket + .log + .push(checkpoint_log(&patch, next_action.clone(), &now)); + ticket.updated = now; + if patch.blocker.is_some() + || matches!(patch.next_action_type, Some(NextActionType::Blocked)) + { + ticket.status = TicketStatus::Blocked; + } + let event = EventEnvelope::new( + id.clone(), + TICKET_CHECKPOINTED, + serde_json::to_value(&patch)?, + ); + self.commit_active(&ticket, event)?; + emit_ticket_event_from_env(EnvClawhipEmit { + kind: ClawhipEventKind::Checkpointed, + ticket: &ticket, + from_status: None, + to_status: None, + }); + self.emit_agent_action_if_needed(id, next_action, &ticket)?; + if matches!(ticket.status, TicketStatus::Blocked) { + emit_ticket_event_from_env(EnvClawhipEmit { + kind: ClawhipEventKind::Blocked, + ticket: &ticket, + from_status: None, + to_status: None, + }); + } + Ok(ticket) + } + + fn emit_agent_action_if_needed( + &self, + id: &TicketId, + next_action: Option, + ticket: &Ticket, + ) -> Result<()> { + if matches!( + next_action.and_then(|action| action.action_type), + Some(NextActionType::AgentAction) + ) { + self.append_event(&EventEnvelope::new( + id.clone(), + TICKET_AGENT_ACTION_AVAILABLE, + json!({ "next_action": ticket.current.as_ref().and_then(|c| c.next_action.clone()) }), + ))?; + emit_ticket_event_from_env(EnvClawhipEmit { + kind: ClawhipEventKind::AgentActionAvailable, + ticket, + from_status: None, + to_status: None, + }); + } + Ok(()) + } +} + +fn checkpoint_log(patch: &CheckpointPatch, next_action: Option, now: &str) -> LogEntry { + LogEntry { + ts: now.to_owned(), + action: Some("checkpoint".to_owned()), + note: patch.note.clone(), + phase: patch.phase.clone(), + decision: patch.decision.clone(), + evidence: patch.evidence.clone(), + blocker: patch.blocker.clone(), + next: patch.next.clone(), + next_action, + extra: Default::default(), + } +} + +fn apply_checkpoint(current: &mut CurrentState, patch: &CheckpointPatch) { + if let Some(value) = &patch.phase { + current.phase = Some(value.clone()); + } + if let Some(value) = &patch.decision { + current.decision = Some(value.clone()); + } + if let Some(value) = &patch.evidence { + current.evidence = Some(value.clone()); + } + if let Some(value) = &patch.blocker { + current.blocker = Some(value.clone()); + } + if let Some(value) = &patch.next { + current.next = Some(value.clone()); + } + if let Some(value) = &patch.note { + current.note = Some(value.clone()); + } + let mut next_action = current.next_action.clone().unwrap_or_default(); + if let Some(value) = patch.next_action_type { + next_action.action_type = Some(value); + } + if let Some(value) = &patch.next_command { + next_action.command = Some(value.clone()); + } + if let Some(value) = &patch.next_owner { + next_action.owner = Some(value.clone()); + } + if next_action.has_fields() { + current.next_action = Some(next_action); + } +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/store/import.rs b/rust-ticket-flow/crates/ticket-flow-core/src/store/import.rs new file mode 100644 index 0000000..3907368 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/store/import.rs @@ -0,0 +1,173 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use super::TicketStore; +use super::io::{archive_month_from_timestamp, is_archive_month}; +use crate::error::{Result, TicketFlowError}; +use crate::event::{EventEnvelope, TICKET_IMPORTED}; +use crate::model::{Ticket, TicketStatus}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportTicketStoreSummary { + pub imported: usize, + pub active: usize, + pub archived: usize, +} + +struct SourceTicket { + ticket: Ticket, + source_archive_month: Option, +} + +impl TicketStore { + pub fn import_store(&self, source_root: impl AsRef) -> Result { + let source_root = source_root.as_ref(); + if !source_root.is_dir() { + return Err(invalid_import_source(source_root)); + } + if fs::read_dir(source_root).is_err() { + return Err(invalid_import_source(source_root)); + } + let source_tickets = match read_source_tickets(source_root) { + Ok(tickets) => tickets, + Err(TicketFlowError::Io(_)) => return Err(invalid_import_source(source_root)), + Err(error) => return Err(error), + }; + reject_duplicate_sources(&source_tickets)?; + self.ensure_store()?; + self.reject_destination_collisions(&source_tickets)?; + + let mut summary = ImportTicketStoreSummary { + imported: source_tickets.len(), + active: 0, + archived: 0, + }; + let mut index = self.read_index()?; + for source_ticket in source_tickets { + let id = source_ticket.ticket.id.clone(); + self.append_event(&EventEnvelope::new( + id.clone(), + TICKET_IMPORTED, + json!({ "source_root": source_root.display().to_string() }), + ))?; + if source_ticket.ticket.status == TicketStatus::Done { + let month = archive_month(&source_ticket); + self.write_ticket_archived_month(&source_ticket.ticket, &month)?; + index.tickets.remove(&id); + summary.archived += 1; + } else { + self.write_ticket_active(&source_ticket.ticket)?; + index.tickets.insert(id, source_ticket.ticket.summary()); + summary.active += 1; + } + } + self.write_index(&index)?; + Ok(summary) + } + + fn reject_destination_collisions(&self, source_tickets: &[SourceTicket]) -> Result<()> { + let mut destination_ids = self.ticket_ids_in_dir(&self.paths.active)?; + destination_ids.extend(self.archive_ticket_ids()?); + for source_ticket in source_tickets { + if destination_ids.contains(&source_ticket.ticket.id) { + return Err(TicketFlowError::DuplicateDestinationTicket( + source_ticket.ticket.id.to_string(), + )); + } + } + Ok(()) + } +} + +fn invalid_import_source(source_root: &Path) -> TicketFlowError { + TicketFlowError::InvalidImportSource(source_root.display().to_string()) +} + +fn read_source_tickets(source_root: &Path) -> Result> { + let mut tickets = read_source_dir(&source_root.join("active"), None)?; + tickets.extend(read_source_archive(&source_root.join("archive"))?); + Ok(tickets) +} + +fn read_source_archive(archive_root: &Path) -> Result> { + let mut tickets = read_source_dir(archive_root, None)?; + if !archive_root.exists() { + return Ok(tickets); + } + for entry in fs::read_dir(archive_root)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let name = entry.file_name(); + let Some(month) = name.to_str() else { + continue; + }; + if !is_archive_month(month) { + continue; + } + tickets.extend(read_source_dir(&entry.path(), Some(month.to_owned()))?); + } + Ok(tickets) +} + +fn read_source_dir(dir: &Path, source_archive_month: Option) -> Result> { + let mut tickets = Vec::new(); + for path in ticket_files(dir)? { + let data = fs::read(path)?; + tickets.push(SourceTicket { + ticket: serde_json::from_slice(&data)?, + source_archive_month: source_archive_month.clone(), + }); + } + Ok(tickets) +} + +fn ticket_files(dir: &Path) -> Result> { + let mut paths = Vec::new(); + if !dir.exists() { + return Ok(paths); + } + for entry in fs::read_dir(dir)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let path = entry.path(); + if path + .extension() + .is_some_and(|extension| extension == "json") + { + paths.push(path); + } + } + paths.sort(); + Ok(paths) +} + +fn reject_duplicate_sources(source_tickets: &[SourceTicket]) -> Result<()> { + let mut seen = BTreeSet::new(); + for source_ticket in source_tickets { + if !seen.insert(source_ticket.ticket.id.clone()) { + return Err(TicketFlowError::DuplicateSourceTicket( + source_ticket.ticket.id.to_string(), + )); + } + } + Ok(()) +} + +fn archive_month(source_ticket: &SourceTicket) -> String { + source_ticket + .ticket + .closed + .as_deref() + .and_then(archive_month_from_timestamp) + .or_else(|| source_ticket.source_archive_month.clone()) + .or_else(|| archive_month_from_timestamp(&source_ticket.ticket.updated)) + .unwrap_or_else(|| "unknown".to_owned()) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/store/io.rs b/rust-ticket-flow/crates/ticket-flow-core/src/store/io.rs new file mode 100644 index 0000000..489363f --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/store/io.rs @@ -0,0 +1,239 @@ +use std::collections::BTreeSet; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use uuid::Uuid; + +use super::TicketStore; +use crate::error::{Result, TicketFlowError}; +use crate::event::EventEnvelope; +use crate::model::{Ticket, TicketId, TicketIndex}; + +impl TicketStore { + pub fn ensure_store(&self) -> Result<()> { + fs::create_dir_all(&self.paths.active)?; + fs::create_dir_all(&self.paths.archive)?; + fs::create_dir_all(&self.paths.events)?; + if !self.paths.index.exists() { + self.write_json_atomic(&self.paths.index, &TicketIndex::default())?; + } + Ok(()) + } + + pub(crate) fn load_active(&self, id: &TicketId) -> Result { + self.ensure_store()?; + let path = self.ticket_path(&self.paths.active, id); + if path.exists() { + self.read_ticket(&path) + } else { + Err(TicketFlowError::TicketNotFound(id.to_string())) + } + } + + pub(crate) fn commit_active(&self, ticket: &Ticket, event: EventEnvelope) -> Result<()> { + self.append_event(&event)?; + self.write_ticket_active(ticket)?; + let mut index = self.read_index()?; + index.tickets.insert(ticket.id.clone(), ticket.summary()); + self.write_index(&index) + } + + pub(crate) fn append_event(&self, event: &EventEnvelope) -> Result<()> { + self.ensure_store()?; + let path = self + .paths + .events + .join(format!("{}.ndjson", event.ticket_id.as_str())); + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + serde_json::to_writer(&mut file, event)?; + file.write_all(b"\n")?; + Ok(()) + } + + pub(crate) fn archive_ticket(&self, ticket: &Ticket, event: EventEnvelope) -> Result<()> { + self.append_event(&event)?; + let month = archive_month_for_ticket(ticket); + self.write_ticket_archived_month(ticket, &month)?; + let active = self.ticket_path(&self.paths.active, &ticket.id); + if active.exists() { + fs::remove_file(active)?; + } + let mut index = self.read_index()?; + index.tickets.remove(&ticket.id); + self.write_index(&index) + } + + pub(crate) fn read_index(&self) -> Result { + self.ensure_store()?; + if !self.paths.index.exists() { + return Ok(TicketIndex::default()); + } + let data = fs::read(&self.paths.index)?; + Ok(serde_json::from_slice(&data)?) + } + + pub(crate) fn write_index(&self, index: &TicketIndex) -> Result<()> { + self.write_json_atomic(&self.paths.index, index) + } + + pub(crate) fn read_ticket(&self, path: &Path) -> Result { + let data = fs::read(path)?; + Ok(serde_json::from_slice(&data)?) + } + + pub(crate) fn write_ticket_active(&self, ticket: &Ticket) -> Result<()> { + self.write_json_atomic(&self.ticket_path(&self.paths.active, &ticket.id), ticket) + } + + pub(crate) fn write_ticket_archived_month(&self, ticket: &Ticket, month: &str) -> Result<()> { + let path = self + .paths + .archive + .join(month) + .join(format!("{}.json", ticket.id)); + self.write_json_atomic(&path, ticket) + } + + pub(crate) fn ticket_path(&self, dir: &Path, id: &TicketId) -> PathBuf { + dir.join(format!("{}.json", id.as_str())) + } + + pub(crate) fn next_ticket_id(&self, day: &str) -> Result { + let mut max_suffix = self + .read_index()? + .last_id + .as_ref() + .and_then(|id| suffix_for_day(id, day)) + .unwrap_or(0); + for id in self.ticket_ids_in_dir(&self.paths.active)? { + max_suffix = max_suffix.max(suffix_for_day(&id, day).unwrap_or(0)); + } + for id in self.archive_ticket_ids()? { + max_suffix = max_suffix.max(suffix_for_day(&id, day).unwrap_or(0)); + } + let next = max_suffix + .checked_add(1) + .ok_or_else(|| TicketFlowError::IdSequenceExhausted(day.to_owned()))?; + TicketId::from_parts(day, next) + } + + pub(crate) fn ticket_ids_in_dir(&self, dir: &Path) -> Result> { + let mut ids = BTreeSet::new(); + if !dir.exists() { + return Ok(ids); + } + for entry in fs::read_dir(dir)? { + let entry = entry?; + let file_name = entry.file_name(); + let Some(name) = file_name.to_str() else { + continue; + }; + let Some(stem) = name.strip_suffix(".json") else { + continue; + }; + if let Ok(id) = TicketId::parse(stem) { + ids.insert(id); + } + } + Ok(ids) + } + + pub(crate) fn archived_ticket_path(&self, id: &TicketId) -> Result> { + let legacy = self.ticket_path(&self.paths.archive, id); + if legacy.exists() { + return Ok(Some(legacy)); + } + for month in self.archive_month_dirs()? { + let path = self.paths.archive.join(month).join(format!("{id}.json")); + if path.exists() { + return Ok(Some(path)); + } + } + Ok(None) + } + + pub(crate) fn archive_ticket_ids(&self) -> Result> { + let mut ids = self.ticket_ids_in_dir(&self.paths.archive)?; + for month in self.archive_month_dirs()? { + ids.extend(self.ticket_ids_in_dir(&self.paths.archive.join(month))?); + } + Ok(ids) + } + + pub(crate) fn archive_month_dirs(&self) -> Result> { + let mut months = BTreeSet::new(); + if !self.paths.archive.exists() { + return Ok(months); + } + for entry in fs::read_dir(&self.paths.archive)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let name = entry.file_name(); + let Some(month) = name.to_str() else { + continue; + }; + if is_archive_month(month) { + months.insert(month.to_owned()); + } + } + Ok(months) + } + + fn write_json_atomic(&self, path: &Path, value: &T) -> Result<()> + where + T: Serialize, + { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let temp = path.with_extension(format!("tmp-{}.json", Uuid::now_v7())); + let data = serde_json::to_vec_pretty(value)?; + fs::write(&temp, data)?; + fs::rename(temp, path)?; + Ok(()) + } +} + +fn suffix_for_day(id: &TicketId, day: &str) -> Option { + let prefix = format!("T-{day}-"); + id.as_str() + .strip_prefix(&prefix) + .and_then(|suffix| suffix.parse::().ok()) +} + +fn archive_month_for_ticket(ticket: &Ticket) -> String { + ticket + .closed + .as_deref() + .and_then(archive_month_from_timestamp) + .or_else(|| archive_month_from_timestamp(&ticket.updated)) + .unwrap_or_else(|| "unknown".to_owned()) +} + +pub(crate) fn archive_month_from_timestamp(value: &str) -> Option { + let month = value.chars().take(7).collect::(); + if is_archive_month(&month) { + Some(month) + } else { + None + } +} + +pub(crate) fn is_archive_month(value: &str) -> bool { + let mut parts = value.split('-'); + let Some(year) = parts.next() else { + return false; + }; + let Some(month) = parts.next() else { + return false; + }; + parts.next().is_none() + && year.len() == 4 + && year.chars().all(|c| c.is_ascii_digit()) + && month.len() == 2 + && month.chars().all(|c| c.is_ascii_digit()) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/store/mutations.rs b/rust-ticket-flow/crates/ticket-flow-core/src/store/mutations.rs new file mode 100644 index 0000000..5fb25d4 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/store/mutations.rs @@ -0,0 +1,98 @@ +use std::collections::BTreeMap; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::TicketStore; +use crate::error::{Result, TicketFlowError}; +use crate::event::{EventEnvelope, TICKET_LINKED, TICKET_LOG_ADDED}; +use crate::model::{LinkMap, LogEntry, Ticket}; +use crate::time::now_rfc3339; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LinkKind { + GithubIssues, + Prs, + Threads, + CronJobs, +} + +impl FromStr for LinkKind { + type Err = TicketFlowError; + + fn from_str(value: &str) -> std::result::Result { + match value { + "github_issues" => Ok(Self::GithubIssues), + "prs" => Ok(Self::Prs), + "threads" => Ok(Self::Threads), + "cron_jobs" => Ok(Self::CronJobs), + other => Err(TicketFlowError::InvalidLinkKind(other.to_owned())), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkTicketInput { + pub kind: LinkKind, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddLogInput { + pub note: String, +} + +impl TicketStore { + pub fn link_ticket( + &self, + id: &crate::model::TicketId, + input: LinkTicketInput, + ) -> Result { + let mut ticket = self.load_active(id)?; + let now = now_rfc3339(); + let links = links_for_kind(&mut ticket.links, input.kind); + if !links.contains(&input.value) { + links.push(input.value.clone()); + } + ticket.updated = now; + self.commit_active( + &ticket, + EventEnvelope::new(id.clone(), TICKET_LINKED, serde_json::to_value(input.kind)?), + )?; + Ok(ticket) + } + + pub fn add_log(&self, id: &crate::model::TicketId, input: AddLogInput) -> Result { + let mut ticket = self.load_active(id)?; + let now = now_rfc3339(); + ticket.log.push(LogEntry { + ts: now.clone(), + action: Some("note".to_owned()), + note: Some(input.note.clone()), + phase: None, + decision: None, + evidence: None, + blocker: None, + next: None, + next_action: None, + extra: BTreeMap::::new(), + }); + ticket.updated = now; + self.commit_active( + &ticket, + EventEnvelope::new(id.clone(), TICKET_LOG_ADDED, json!({ "note": input.note })), + )?; + Ok(ticket) + } +} + +fn links_for_kind(links: &mut LinkMap, kind: LinkKind) -> &mut Vec { + match kind { + LinkKind::GithubIssues => &mut links.github_issues, + LinkKind::Prs => &mut links.prs, + LinkKind::Threads => &mut links.threads, + LinkKind::CronJobs => &mut links.cron_jobs, + } +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/store/status.rs b/rust-ticket-flow/crates/ticket-flow-core/src/store/status.rs new file mode 100644 index 0000000..4b9bc42 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/store/status.rs @@ -0,0 +1,107 @@ +use std::collections::BTreeMap; + +use serde_json::json; + +use super::TicketStore; +use crate::clawhip::{ClawhipEventKind, EnvClawhipEmit, emit_ticket_event_from_env}; +use crate::error::{Result, TicketFlowError}; +use crate::event::{EventEnvelope, TICKET_STATUS_CHANGED}; +use crate::model::{Artifact, LogEntry, Ticket, TicketId, TicketStatus}; +use crate::time::now_rfc3339; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusPatch { + pub status: TicketStatus, + pub artifact: Option, + pub evidence: Option, + pub note: Option, +} + +impl TicketStore { + pub fn update_status(&self, id: &TicketId, patch: StatusPatch) -> Result { + let mut ticket = self.load_active(id)?; + let from = ticket.status; + if !can_transition(from, patch.status) { + return Err(TicketFlowError::InvalidTransition { + from, + to: patch.status, + }); + } + if patch.status == TicketStatus::Review + && patch.artifact.is_none() + && ticket.artifacts.is_empty() + { + return Err(TicketFlowError::ReviewArtifactRequired); + } + + let now = now_rfc3339(); + ticket.status = patch.status; + ticket.updated = now.clone(); + if patch.status == TicketStatus::Done { + ticket.closed = Some(now.clone()); + } + append_status_artifacts(&mut ticket, &patch, &now); + ticket + .log + .push(status_log(from, patch.status, patch.note.clone(), now)); + + let event = EventEnvelope::new( + id.clone(), + TICKET_STATUS_CHANGED, + json!({ "from": from, "to": patch.status, "note": patch.note }), + ); + if patch.status == TicketStatus::Done { + self.archive_ticket(&ticket, event)?; + } else { + self.commit_active(&ticket, event)?; + } + emit_ticket_event_from_env(EnvClawhipEmit { + kind: ClawhipEventKind::StatusChanged, + ticket: &ticket, + from_status: Some(from), + to_status: Some(patch.status), + }); + Ok(ticket) + } +} + +fn append_status_artifacts(ticket: &mut Ticket, patch: &StatusPatch, now: &str) { + if let Some(value) = &patch.artifact { + ticket + .artifacts + .push(Artifact::new("artifact", value.clone(), now.to_owned())); + } + if let Some(value) = &patch.evidence { + ticket + .artifacts + .push(Artifact::new("evidence", value.clone(), now.to_owned())); + } +} + +fn status_log(from: TicketStatus, to: TicketStatus, note: Option, now: String) -> LogEntry { + LogEntry { + ts: now, + action: Some(format!("{from} -> {to}")), + note, + phase: None, + decision: None, + evidence: None, + blocker: None, + next: None, + next_action: None, + extra: BTreeMap::new(), + } +} + +const fn can_transition(from: TicketStatus, to: TicketStatus) -> bool { + match from { + TicketStatus::Open => matches!(to, TicketStatus::Doing | TicketStatus::Blocked), + TicketStatus::Doing => matches!( + to, + TicketStatus::Review | TicketStatus::Blocked | TicketStatus::Open + ), + TicketStatus::Review => matches!(to, TicketStatus::Done | TicketStatus::Open), + TicketStatus::Blocked => matches!(to, TicketStatus::Doing | TicketStatus::Open), + TicketStatus::Done => false, + } +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/time.rs b/rust-ticket-flow/crates/ticket-flow-core/src/time.rs new file mode 100644 index 0000000..d50b157 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/time.rs @@ -0,0 +1,16 @@ +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +pub fn now_rfc3339() -> String { + let now = OffsetDateTime::now_utc(); + match now.format(&Rfc3339) { + Ok(value) => value, + Err(_) => now.unix_timestamp().to_string(), + } +} + +pub fn today_yyyymmdd() -> String { + let now = OffsetDateTime::now_utc(); + let month = u8::from(now.month()); + format!("{:04}{:02}{:02}", now.year(), month, now.day()) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/src/views.rs b/rust-ticket-flow/crates/ticket-flow-core/src/views.rs new file mode 100644 index 0000000..257d8d1 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/src/views.rs @@ -0,0 +1,136 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::model::{NextActionType, Ticket, TicketId, TicketStatus}; +use crate::store::TicketStore; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketViewItem { + pub id: TicketId, + pub title: String, + pub status: TicketStatus, + pub priority: String, + pub updated: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentQueueItem { + pub id: TicketId, + pub title: String, + pub status: TicketStatus, + pub owner: Option, + pub command: Option, + pub next: Option, + pub updated: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoardView { + pub open: Vec, + pub doing: Vec, + pub review: Vec, + pub blocked: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewView { + pub tickets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoordinationView { + pub tickets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoordinationTicket { + pub id: TicketId, + pub title: String, + pub handoffs: usize, + pub approvals: usize, + pub blockers: Vec, +} + +pub fn agent_queue(store: &TicketStore) -> Result> { + let tickets = store.list_tickets()?; + Ok(tickets + .into_iter() + .filter_map(agent_queue_item) + .collect::>()) +} + +pub fn board(store: &TicketStore) -> Result { + let mut view = BoardView { + open: Vec::new(), + doing: Vec::new(), + review: Vec::new(), + blocked: Vec::new(), + }; + for ticket in store.list_tickets()? { + match ticket.status { + TicketStatus::Open => view.open.push(ticket_item(&ticket)), + TicketStatus::Doing => view.doing.push(ticket_item(&ticket)), + TicketStatus::Review => view.review.push(ticket_item(&ticket)), + TicketStatus::Blocked => view.blocked.push(ticket_item(&ticket)), + TicketStatus::Done => {} + } + } + Ok(view) +} + +pub fn review(store: &TicketStore) -> Result { + let tickets = store + .list_tickets()? + .into_iter() + .filter(|ticket| ticket.status == TicketStatus::Review) + .map(|ticket| ticket_item(&ticket)) + .collect(); + Ok(ReviewView { tickets }) +} + +pub fn coordination(store: &TicketStore) -> Result { + let tickets = store + .list_tickets()? + .into_iter() + .filter_map(|ticket| { + ticket + .coordination + .as_ref() + .map(|coordination| CoordinationTicket { + id: ticket.id.clone(), + title: ticket.title.clone(), + handoffs: coordination.handoffs.len(), + approvals: coordination.approvals.len(), + blockers: coordination.blockers.clone(), + }) + }) + .collect(); + Ok(CoordinationView { tickets }) +} + +fn agent_queue_item(ticket: Ticket) -> Option { + let current = ticket.current.as_ref()?; + let next_action = current.next_action.as_ref()?; + if next_action.action_type != Some(NextActionType::AgentAction) { + return None; + } + Some(AgentQueueItem { + id: ticket.id, + title: ticket.title, + status: ticket.status, + owner: next_action.owner.clone(), + command: next_action.command.clone(), + next: current.next.clone(), + updated: ticket.updated, + }) +} + +fn ticket_item(ticket: &Ticket) -> TicketViewItem { + TicketViewItem { + id: ticket.id.clone(), + title: ticket.title.clone(), + status: ticket.status, + priority: ticket.priority.clone(), + updated: ticket.updated.clone(), + } +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/tests/import_contract.rs b/rust-ticket-flow/crates/ticket-flow-core/tests/import_contract.rs new file mode 100644 index 0000000..0dd5173 --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/tests/import_contract.rs @@ -0,0 +1,268 @@ +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use serde_json::{Value, json}; +use tempfile::tempdir; +use ticket_flow_core::{StorePaths, TicketFlowError, TicketId, TicketIndex, TicketStore}; + +#[test] +fn import_store_copies_active_archived_index_events_and_leaves_source() +-> Result<(), Box> { + // Given: a source store with active and archived tickets. + let source = tempdir()?; + let destination = tempdir()?; + write_ticket_fixture( + &source.path().join("active").join("T-20260630-001.json"), + json!({ + "id": "T-20260630-001", + "title": "import active", + "status": "open", + "updated": "2026-06-30T00:00:00Z" + }), + )?; + write_ticket_fixture( + &source.path().join("active").join("T-20260630-002.json"), + json!({ + "id": "T-20260630-002", + "title": "import done from active", + "status": "done", + "closed": "2026-07-08T00:00:00Z", + "updated": "2026-06-30T00:00:00Z" + }), + )?; + write_ticket_fixture( + &source + .path() + .join("archive") + .join("2026-06") + .join("T-20260629-001.json"), + json!({ + "id": "T-20260629-001", + "title": "import archived", + "status": "done", + "updated": "2026-06-29T00:00:00Z" + }), + )?; + let store = TicketStore::new(StorePaths::new(destination.path().to_path_buf())); + + // When: the destination imports the source store. + let summary = store.import_store(source.path())?; + + // Then: active tickets are indexed, done tickets are archived by month, and source files remain. + assert_eq!(summary.imported, 3); + assert_eq!(summary.active, 1); + assert_eq!(summary.archived, 2); + assert_file_exists( + &destination + .path() + .join("active") + .join("T-20260630-001.json"), + ); + assert_file_exists( + &destination + .path() + .join("archive") + .join("2026-07") + .join("T-20260630-002.json"), + ); + assert_file_exists( + &destination + .path() + .join("archive") + .join("2026-06") + .join("T-20260629-001.json"), + ); + assert_file_exists(&source.path().join("active").join("T-20260630-001.json")); + assert_file_exists( + &source + .path() + .join("archive") + .join("2026-06") + .join("T-20260629-001.json"), + ); + let index: TicketIndex = read_json(&destination.path().join("index.json"))?; + let active_id = TicketId::parse("T-20260630-001")?; + let done_id = TicketId::parse("T-20260630-002")?; + assert!(index.tickets.contains_key(&active_id)); + assert!(!index.tickets.contains_key(&done_id)); + assert_events_contain_imported(destination.path(), "T-20260630-001")?; + assert_events_contain_imported(destination.path(), "T-20260630-002")?; + assert_events_contain_imported(destination.path(), "T-20260629-001")?; + Ok(()) +} + +#[test] +fn import_store_rejects_duplicate_source_ids() -> Result<(), Box> { + // Given: the same ticket ID appears in source active and archive. + let source = tempdir()?; + let destination = tempdir()?; + write_ticket_fixture( + &source.path().join("active").join("T-20260630-003.json"), + json!({"id": "T-20260630-003", "title": "active", "status": "open"}), + )?; + write_ticket_fixture( + &source + .path() + .join("archive") + .join("2026-06") + .join("T-20260630-003.json"), + json!({"id": "T-20260630-003", "title": "archived", "status": "done"}), + )?; + let store = TicketStore::new(StorePaths::new(destination.path().to_path_buf())); + + // When: the source store is imported. + let result = store.import_store(source.path()); + + // Then: duplicate source IDs are rejected. + let error = result.err().ok_or("expected duplicate source")?; + assert!( + error + .to_string() + .contains("duplicate source ticket T-20260630-003") + ); + Ok(()) +} + +#[test] +fn import_store_rejects_destination_collision_without_overwrite() +-> Result<(), Box> { + // Given: the destination already has a ticket with a source ID. + let source = tempdir()?; + let destination = tempdir()?; + let existing = json!({ + "id": "T-20260630-004", + "title": "destination keeps this", + "status": "open" + }); + write_ticket_fixture( + &destination + .path() + .join("active") + .join("T-20260630-004.json"), + existing.clone(), + )?; + write_ticket_fixture( + &source.path().join("active").join("T-20260630-004.json"), + json!({"id": "T-20260630-004", "title": "source must not overwrite", "status": "open"}), + )?; + let store = TicketStore::new(StorePaths::new(destination.path().to_path_buf())); + + // When: the source store is imported. + let result = store.import_store(source.path()); + + // Then: the collision is rejected and the destination ticket is unchanged. + let error = result.err().ok_or("expected destination collision")?; + assert!( + error + .to_string() + .contains("duplicate destination ticket T-20260630-004") + ); + let unchanged: Value = read_json( + &destination + .path() + .join("active") + .join("T-20260630-004.json"), + )?; + assert_eq!(unchanged["title"], existing["title"]); + Ok(()) +} + +#[test] +fn import_store_rejects_missing_source_root() -> Result<(), Box> { + // Given: a source path that does not exist. + let source_parent = tempdir()?; + let destination = tempdir()?; + let source = source_parent.path().join("missing-import-source"); + let store = TicketStore::new(StorePaths::new(destination.path().to_path_buf())); + + // When: the missing source store is imported. + let result = store.import_store(&source); + + // Then: the source path is rejected instead of reporting an empty successful import. + let Err(TicketFlowError::InvalidImportSource(invalid_source)) = result else { + return Err("expected invalid import source".into()); + }; + assert_eq!(invalid_source, source.display().to_string()); + Ok(()) +} + +#[test] +fn import_store_rejects_source_below_file() -> Result<(), Box> { + // Given: a source path whose parent is a file. + let source_parent = tempdir()?; + let destination = tempdir()?; + let parent_file = source_parent.path().join("import-source-file"); + fs::write(&parent_file, b"not a directory")?; + let source = parent_file.join("nested"); + let store = TicketStore::new(StorePaths::new(destination.path().to_path_buf())); + + // When: the invalid source store is imported. + let result = store.import_store(&source); + + // Then: the typed invalid-import-source variant is returned. + let Err(TicketFlowError::InvalidImportSource(invalid_source)) = result else { + return Err("expected invalid import source".into()); + }; + assert_eq!(invalid_source, source.display().to_string()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn import_store_rejects_unreadable_source_before_creating_destination() +-> Result<(), Box> { + // Given: a source store whose active directory cannot be read. + let source = tempdir()?; + let destination_parent = tempdir()?; + let destination = destination_parent.path().join("uninitialized-destination"); + let active = source.path().join("active"); + fs::create_dir(&active)?; + fs::set_permissions(&active, fs::Permissions::from_mode(0o000))?; + let store = TicketStore::new(StorePaths::new(destination.clone())); + + // When: the unreadable source store is imported. + let result = store.import_store(source.path()); + fs::set_permissions(&active, fs::Permissions::from_mode(0o700))?; + + // Then: the typed error is returned before the destination is created. + let Err(TicketFlowError::InvalidImportSource(invalid_source)) = result else { + return Err("expected invalid import source".into()); + }; + assert_eq!(invalid_source, source.path().display().to_string()); + assert!(!destination.exists()); + Ok(()) +} + +fn write_ticket_fixture(path: &Path, mut value: Value) -> Result<(), Box> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + value["priority"] = json!("medium"); + value["type"] = json!("chore"); + value["created"] = json!("2026-06-30T00:00:00Z"); + if value.get("updated").is_none() { + value["updated"] = json!("2026-06-30T00:00:00Z"); + } + fs::write(path, serde_json::to_vec_pretty(&value)?)?; + Ok(()) +} + +fn assert_file_exists(path: &Path) { + assert!(path.exists(), "expected {} to exist", path.display()); +} + +fn assert_events_contain_imported(root: &Path, id: &str) -> Result<(), Box> { + let events = fs::read_to_string(root.join("events").join(format!("{id}.ndjson")))?; + assert!(events.contains("\"type\":\"ticket.imported\"")); + Ok(()) +} + +fn read_json(path: &Path) -> Result> +where + T: serde::de::DeserializeOwned, +{ + let bytes = fs::read(path)?; + Ok(serde_json::from_slice(&bytes)?) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/tests/serde_contract.rs b/rust-ticket-flow/crates/ticket-flow-core/tests/serde_contract.rs new file mode 100644 index 0000000..f688fbd --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/tests/serde_contract.rs @@ -0,0 +1,132 @@ +use serde_json::json; +use ticket_flow_core::{EvidenceStatus, Ticket, TicketId, TicketIndex, TicketStatus}; + +#[test] +fn legacy_ticket_defaults_and_unknown_fields_round_trip() { + // Given: a legacy ticket with omitted optional fields and one future field. + let ticket_json = json!({ + "id": "T-20260708-001", + "title": "legacy", + "status": "open", + "priority": "medium", + "type": "chore", + "created": "2026-07-08T00:00:00Z", + "updated": "2026-07-08T00:00:00Z", + "future_field": {"keep": true} + }); + + // When: Rust parses and serializes it. + let ticket: Ticket = serde_json::from_value(ticket_json).expect("parse legacy ticket"); + let round_trip = serde_json::to_value(&ticket).expect("serialize ticket"); + + // Then: defaults are filled and unknown fields survive. + assert_eq!(ticket.links.github_issues, Vec::::new()); + assert_eq!(round_trip["future_field"], json!({"keep": true})); +} + +#[test] +fn legacy_string_artifact_normalizes_to_object() { + // Given: TypeScript-compatible legacy string artifacts. + let ticket_json = json!({ + "id": "T-20260708-002", + "title": "legacy artifact", + "status": "open", + "priority": "medium", + "type": "chore", + "created": "2026-07-08T00:00:00Z", + "updated": "2026-07-08T00:00:00Z", + "artifacts": ["legacy artifact"] + }); + + // When: the ticket crosses the Rust serde boundary. + let ticket: Ticket = serde_json::from_value(ticket_json).expect("parse artifact"); + + // Then: the artifact is normalized to the object shape. + let artifact = ticket.artifacts.first().expect("artifact exists"); + assert_eq!(artifact.artifact_type, "artifact"); + assert_eq!(artifact.value, "legacy artifact"); + assert_eq!(artifact.ts, ""); +} + +#[test] +fn index_summary_contract_matches_typescript() { + // Given: a TypeScript index file. + let index_json = json!({ + "version": 1, + "lastId": "T-20260708-002", + "tickets": { + "T-20260708-002": { + "title": "indexed", + "status": "doing", + "priority": "high", + "type": "agent_action", + "updated": "2026-07-08T00:00:00Z" + } + } + }); + + // When: Rust parses the index. + let index: TicketIndex = serde_json::from_value(index_json).expect("parse index"); + + // Then: the summary key and status remain typed. + let id = TicketId::parse("T-20260708-002").expect("id parses"); + assert_eq!(index.last_id, Some(id.clone())); + assert_eq!( + index.tickets.get(&id).expect("summary").status, + TicketStatus::Doing + ); +} + +#[test] +fn v2_optional_sections_round_trip() { + // Given: a v2 ticket with evidence and coordination sections. + let ticket_json = json!({ + "id": "T-20260708-003", + "title": "v2", + "status": "open", + "priority": "medium", + "type": "chore", + "created": "2026-07-08T00:00:00Z", + "updated": "2026-07-08T00:00:00Z", + "evidence": {"evidence_status": "partial", "artifacts": []}, + "coordination": {"owners": ["codex"], "handoffs": [], "approvals": [], "blockers": []} + }); + + // When: Rust parses and serializes it. + let ticket: Ticket = serde_json::from_value(ticket_json).expect("parse v2 ticket"); + let serialized = serde_json::to_value(&ticket).expect("serialize v2 ticket"); + + // Then: optional sections can be present and round-trip. + assert_eq!( + ticket.evidence.expect("evidence").evidence_status, + EvidenceStatus::Partial + ); + assert_eq!(serialized["coordination"]["owners"], json!(["codex"])); +} + +#[test] +fn rejects_invalid_ticket_id_and_status() { + // Given: invalid boundary values. + let bad_id = json!({ + "id": "bad", + "title": "bad", + "status": "open", + "priority": "medium", + "type": "chore", + "created": "2026-07-08T00:00:00Z", + "updated": "2026-07-08T00:00:00Z" + }); + let bad_status = json!({ + "id": "T-20260708-004", + "title": "bad", + "status": "bogus", + "priority": "medium", + "type": "chore", + "created": "2026-07-08T00:00:00Z", + "updated": "2026-07-08T00:00:00Z" + }); + + // When/Then: serde rejects them at the boundary. + assert!(serde_json::from_value::(bad_id).is_err()); + assert!(serde_json::from_value::(bad_status).is_err()); +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/tests/status_contract.rs b/rust-ticket-flow/crates/ticket-flow-core/tests/status_contract.rs new file mode 100644 index 0000000..75c63fc --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/tests/status_contract.rs @@ -0,0 +1,92 @@ +use tempfile::tempdir; +use ticket_flow_core::{CreateTicket, StatusPatch, StorePaths, TicketStatus, TicketStore}; + +#[test] +fn status_rejects_open_directly_to_done() -> Result<(), Box> { + // Given: a fresh open ticket. + let temp = tempdir()?; + let store = TicketStore::new(StorePaths::new(temp.path().to_path_buf())); + let ticket = store.create_ticket(CreateTicket::new("transition".to_owned()))?; + + // When: done is requested without passing through review. + let result = store.update_status( + &ticket.id, + StatusPatch { + status: TicketStatus::Done, + artifact: None, + evidence: None, + note: None, + }, + ); + + // Then: the transition is rejected. + let error = result.err().ok_or("expected invalid transition")?; + assert!( + error + .to_string() + .contains("invalid transition open -> done") + ); + Ok(()) +} + +#[test] +fn status_persists_artifact_evidence_and_note() -> Result<(), Box> { + // Given: an open ticket. + let temp = tempdir()?; + let store = TicketStore::new(StorePaths::new(temp.path().to_path_buf())); + let ticket = store.create_ticket(CreateTicket::new("status fields".to_owned()))?; + + // When: the ticket moves to doing with artifact, evidence, and note. + let updated = store.update_status( + &ticket.id, + StatusPatch { + status: TicketStatus::Doing, + artifact: Some("artifact path".to_owned()), + evidence: Some("test passed".to_owned()), + note: Some("started".to_owned()), + }, + )?; + + // Then: all status payload fields are persisted. + assert_eq!(updated.artifacts[0].artifact_type, "artifact"); + assert_eq!(updated.artifacts[0].value, "artifact path"); + assert_eq!(updated.artifacts[1].artifact_type, "evidence"); + assert_eq!(updated.artifacts[1].value, "test passed"); + assert_eq!(updated.log[0].action, Some("open -> doing".to_owned())); + assert_eq!(updated.log[0].note, Some("started".to_owned())); + Ok(()) +} + +#[test] +fn review_uses_existing_artifact() -> Result<(), Box> { + // Given: a doing ticket that already has an artifact. + let temp = tempdir()?; + let store = TicketStore::new(StorePaths::new(temp.path().to_path_buf())); + let ticket = store.create_ticket(CreateTicket::new("review".to_owned()))?; + let doing = store.update_status( + &ticket.id, + StatusPatch { + status: TicketStatus::Doing, + artifact: Some("existing artifact".to_owned()), + evidence: None, + note: None, + }, + )?; + + // When: review is requested without a new artifact. + let reviewed = store.update_status( + &doing.id, + StatusPatch { + status: TicketStatus::Review, + artifact: None, + evidence: None, + note: Some("ready".to_owned()), + }, + )?; + + // Then: the existing artifact satisfies the review gate. + assert_eq!(reviewed.status, TicketStatus::Review); + assert_eq!(reviewed.artifacts.len(), 1); + assert_eq!(reviewed.log[1].action, Some("doing -> review".to_owned())); + Ok(()) +} diff --git a/rust-ticket-flow/crates/ticket-flow-core/tests/workflow_contract.rs b/rust-ticket-flow/crates/ticket-flow-core/tests/workflow_contract.rs new file mode 100644 index 0000000..6c85e2a --- /dev/null +++ b/rust-ticket-flow/crates/ticket-flow-core/tests/workflow_contract.rs @@ -0,0 +1,84 @@ +use tempfile::tempdir; +use ticket_flow_core::{ + CheckpointPatch, ContextAudience, CreateTicket, EvidenceInput, NextActionType, StorePaths, + TicketStore, attach_evidence, build_context_pack, ready_gate, +}; + +#[test] +fn checkpoint_requires_at_least_one_field() { + // Given: a new ticket in a temporary store. + let temp = tempdir().expect("tempdir"); + let store = TicketStore::new(StorePaths::new(temp.path().to_path_buf())); + let ticket = store + .create_ticket(CreateTicket::new("empty checkpoint".to_owned())) + .expect("create ticket"); + + // When: an empty checkpoint is submitted. + let result = store.checkpoint_ticket(&ticket.id, CheckpointPatch::default()); + + // Then: it is rejected. + assert!(result.is_err()); +} + +#[test] +fn ready_gate_reports_missing_goal_acceptance_next_action() { + // Given: a ticket with no goal, acceptance, or next action. + let temp = tempdir().expect("tempdir"); + let store = TicketStore::new(StorePaths::new(temp.path().to_path_buf())); + let ticket = store + .create_ticket(CreateTicket::new("missing ready data".to_owned())) + .expect("create ticket"); + + // When: the ready gate runs. + let result = ready_gate(&ticket); + + // Then: the missing fields are explicit. + assert!(!result.passed); + assert!(result.missing.contains(&"goal".to_owned())); + assert!(result.missing.contains(&"acceptance".to_owned())); + assert!(result.missing.contains(&"current.next_action".to_owned())); +} + +#[test] +fn agent_context_pack_contains_goal_constraints_next_action_evidence() { + // Given: a ticket with enough agent execution context. + let temp = tempdir().expect("tempdir"); + let store = TicketStore::new(StorePaths::new(temp.path().to_path_buf())); + let mut input = CreateTicket::new("agent context".to_owned()); + input.goal = "ship ticket-flow parity".to_owned(); + input.acceptance = vec!["agent queue has command".to_owned()]; + let ticket = store.create_ticket(input).expect("create ticket"); + store + .checkpoint_ticket( + &ticket.id, + CheckpointPatch { + phase: Some("implement".to_owned()), + next_action_type: Some(NextActionType::AgentAction), + next_command: Some("cargo test".to_owned()), + next_owner: Some("codex".to_owned()), + ..CheckpointPatch::default() + }, + ) + .expect("checkpoint"); + attach_evidence( + &store, + &ticket.id, + EvidenceInput { + artifact_type: "test_output".to_owned(), + value: "cargo test pass".to_owned(), + }, + ) + .expect("attach evidence"); + + // When: an agent context pack is built. + let pack = + build_context_pack(&store, &ticket.id, ContextAudience::AgentExecution).expect("context"); + + // Then: the pack carries the working state. + assert_eq!(pack.goal, "ship ticket-flow parity"); + assert_eq!( + pack.next_action.expect("next action").command, + Some("cargo test".to_owned()) + ); + assert_eq!(pack.evidence_refs, vec!["cargo test pass".to_owned()]); +} diff --git a/rust-ticket-flow/rust-toolchain.toml b/rust-ticket-flow/rust-toolchain.toml new file mode 100644 index 0000000..a9f1213 --- /dev/null +++ b/rust-ticket-flow/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy", "rust-src"] +profile = "default" diff --git a/rust-ticket-flow/rustfmt.toml b/rust-ticket-flow/rustfmt.toml new file mode 100644 index 0000000..d9456c1 --- /dev/null +++ b/rust-ticket-flow/rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2024" +max_width = 100 +newline_style = "Unix" +reorder_imports = true +reorder_modules = true diff --git a/src/cli.ts b/src/cli.ts index 953e766..1b8bc93 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,9 +21,11 @@ import { checkpointTicket, createTicket, getTicket, + importTicketStore, linkTicket, listAgentActions, listTickets, + resolveStorePaths, updateStatus, } from "./store" @@ -108,6 +110,19 @@ program }) }) +program + .command("import") + .description("Import a legacy/source ticket store into the configured ticket-flow store.") + .argument("") + .action(async (sourceRoot: string) => { + await runBoundary(async () => { + const summary = await importTicketStore(sourceRoot, resolveStorePaths()) + console.log( + `imported ${summary.imported} ticket(s): active=${summary.active} archived=${summary.archived}`, + ) + }) + }) + program .command("link") .argument("") diff --git a/src/errors.ts b/src/errors.ts index 81ad045..c1d7959 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -32,3 +32,30 @@ export class InvalidSourceError extends Error { super(`invalid source ${source}`) } } + +export class DuplicateDestinationTicketError extends Error { + readonly name = "DuplicateDestinationTicketError" + + constructor(readonly ticketId: string) { + super(`duplicate destination ticket ${ticketId}`) + } +} + +export class DuplicateSourceTicketError extends Error { + readonly name = "DuplicateSourceTicketError" + + constructor(readonly ticketId: string) { + super(`duplicate source ticket ${ticketId}`) + } +} + +export class InvalidImportSourceError extends Error { + readonly name = "InvalidImportSourceError" + + constructor( + readonly sourceRoot: string, + options?: ErrorOptions, + ) { + super(`invalid import source ${sourceRoot}`, options) + } +} diff --git a/src/mcp.ts b/src/mcp.ts index 787f06c..4c7f2fc 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -13,9 +13,11 @@ import { checkpointTicket, createTicket, getTicket, + importTicketStore, linkTicket, listAgentActions, listTickets, + resolveStorePaths, updateStatus, } from "./store" @@ -73,6 +75,21 @@ server.registerTool( }, ) +server.registerTool( + "ticket_import", + { + description: + "Import a legacy/source ticket store into the configured ticket-flow store without sharing source files in place.", + inputSchema: { sourceRoot: z.string().min(1) }, + }, + async ({ sourceRoot }) => { + const summary = await importTicketStore(sourceRoot, resolveStorePaths()) + return { + content: [{ type: "text", text: JSON.stringify(summary, null, 2) }], + } + }, +) + server.registerTool( "ticket_update_status", { diff --git a/src/store.ts b/src/store.ts index e6cceb2..221c2d6 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,4 +1,6 @@ export { createTicket } from "./store/create" +export type { ImportTicketStoreSummary } from "./store/import" +export { importTicketStore } from "./store/import" export { addLog, checkpointTicket, linkTicket } from "./store/mutations" export type { TicketStorePaths } from "./store/paths" export { ensureStore, resolveStorePaths } from "./store/paths" diff --git a/src/store/import.ts b/src/store/import.ts new file mode 100644 index 0000000..3c5e58b --- /dev/null +++ b/src/store/import.ts @@ -0,0 +1,170 @@ +import { mkdir, readdir, stat } from "node:fs/promises" +import { dirname, join } from "node:path" +import { + DuplicateDestinationTicketError, + DuplicateSourceTicketError, + InvalidImportSourceError, +} from "../errors" +import type { Ticket } from "../schema" +import { + idsInDirectory, + readTicketFile, + removeIndex, + upsertIndex, + writeJson, + writeTicket, +} from "./io" +import type { TicketStorePaths } from "./paths" +import { ensureStore } from "./paths" + +export type ImportTicketStoreSummary = { + readonly imported: number + readonly active: number + readonly archived: number +} + +type SourceTicket = { + readonly ticket: Ticket + readonly sourceArchiveMonth?: string +} + +export async function importTicketStore( + sourceRoot: string, + destination: TicketStorePaths, +): Promise { + const sourceTickets = await readImportSourceTickets(sourceRoot) + rejectDuplicateSources(sourceTickets) + await ensureStore(destination) + await rejectDestinationCollisions(destination, sourceTickets) + + let active = 0 + let archived = 0 + for (const sourceTicket of sourceTickets) { + if (sourceTicket.ticket.status === "done") { + await writeArchivedTicket(destination, sourceTicket) + await removeIndex(destination, sourceTicket.ticket.id) + archived += 1 + } else { + await writeTicket(destination, sourceTicket.ticket) + await upsertIndex(destination, sourceTicket.ticket) + active += 1 + } + } + return { imported: sourceTickets.length, active, archived } +} + +async function readImportSourceTickets(sourceRoot: string): Promise { + try { + const sourceMetadata = await stat(sourceRoot) + if (!sourceMetadata.isDirectory()) { + throw new InvalidImportSourceError(sourceRoot) + } + return await readSourceTickets(sourceRoot) + } catch (error) { + if (error instanceof InvalidImportSourceError) { + throw error + } + if (error instanceof Error && "code" in error) { + throw new InvalidImportSourceError(sourceRoot, { cause: error }) + } + throw error + } +} + +async function readSourceTickets(sourceRoot: string): Promise { + return [ + ...(await readActiveSourceTickets(sourceRoot)), + ...(await readArchivedSourceTickets(sourceRoot)), + ] +} + +async function readActiveSourceTickets(sourceRoot: string): Promise { + const activeRoot = join(sourceRoot, "active") + const ids = await idsInDirectory(activeRoot) + return Promise.all( + ids.map(async (id) => ({ + ticket: await readTicketFile(join(activeRoot, `${id}.json`)), + })), + ) +} + +async function readArchivedSourceTickets(sourceRoot: string): Promise { + const archiveRoot = join(sourceRoot, "archive") + const monthDirs = await archiveMonthDirs(archiveRoot) + const groups = await Promise.all( + monthDirs.map(async (month) => { + const ids = await idsInDirectory(join(archiveRoot, month)) + return Promise.all( + ids.map(async (id) => ({ + ticket: await readTicketFile(join(archiveRoot, month, `${id}.json`)), + sourceArchiveMonth: month, + })), + ) + }), + ) + return groups.flat() +} + +async function archiveMonthDirs(archiveRoot: string): Promise { + try { + const entries = await readdir(archiveRoot, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory() && /^\d{4}-\d{2}$/.test(entry.name)) + .map((entry) => entry.name) + .sort() + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return [] + } + throw error + } +} + +function rejectDuplicateSources(sourceTickets: readonly SourceTicket[]): void { + const seen = new Set() + for (const sourceTicket of sourceTickets) { + if (seen.has(sourceTicket.ticket.id)) { + throw new DuplicateSourceTicketError(sourceTicket.ticket.id) + } + seen.add(sourceTicket.ticket.id) + } +} + +async function rejectDestinationCollisions( + destination: TicketStorePaths, + sourceTickets: readonly SourceTicket[], +): Promise { + const destinationIds = await destinationTicketIds(destination) + for (const sourceTicket of sourceTickets) { + if (destinationIds.has(sourceTicket.ticket.id)) { + throw new DuplicateDestinationTicketError(sourceTicket.ticket.id) + } + } +} + +async function destinationTicketIds(destination: TicketStorePaths): Promise> { + const activeIds = await idsInDirectory(destination.active) + const monthDirs = await archiveMonthDirs(destination.archive) + const archiveIdGroups = await Promise.all( + monthDirs.map((month) => idsInDirectory(join(destination.archive, month))), + ) + return new Set([...activeIds, ...archiveIdGroups.flat()]) +} + +async function writeArchivedTicket( + destination: TicketStorePaths, + sourceTicket: SourceTicket, +): Promise { + const month = archiveMonth(sourceTicket) + const file = join(destination.archive, month, `${sourceTicket.ticket.id}.json`) + await mkdir(dirname(file), { recursive: true }) + await writeJson(file, sourceTicket.ticket) +} + +function archiveMonth(sourceTicket: SourceTicket): string { + return ( + sourceTicket.ticket.closed?.slice(0, 7) ?? + sourceTicket.sourceArchiveMonth ?? + sourceTicket.ticket.updated.slice(0, 7) + ) +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index b46cd19..592d953 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises" +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -114,6 +114,80 @@ describe("CLI compatibility", () => { expect(result.stderr).toContain("checkpoint requires at least one field") }) + test("Given a source ticket store When import runs Then destination active tickets and index are populated", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "ticket-flow-cli-import-source-")) + await mkdir(join(sourceRoot, "active"), { recursive: true }) + await mkdir(join(sourceRoot, "archive", "2026-06"), { recursive: true }) + try { + await writeTicketFixture(join(sourceRoot, "active", "T-20260708-001.json"), { + id: "T-20260708-001", + title: "CLI import active", + status: "open", + updated: "2026-07-08T01:00:00Z", + }) + await writeTicketFixture(join(sourceRoot, "active", "T-20260708-002.json"), { + id: "T-20260708-002", + title: "CLI import done", + status: "done", + updated: "2026-07-08T02:00:00Z", + closed: "2026-07-08T02:30:00Z", + }) + await writeTicketFixture(join(sourceRoot, "archive", "2026-06", "T-20260630-001.json"), { + id: "T-20260630-001", + title: "CLI import archived", + status: "done", + updated: "2026-06-30T02:00:00Z", + }) + + const result = await runCli(["import", sourceRoot], storeRoot) + + expect(result.exitCode).toBe(0) + expect(result.stderr).toBe("") + expect(result.stdout).toContain("imported 3 ticket(s): active=1 archived=2") + const active = JSON.parse( + await readFile(join(storeRoot, "active", "T-20260708-001.json"), "utf8"), + ) + const index = JSON.parse(await readFile(join(storeRoot, "index.json"), "utf8")) + expect(active.title).toBe("CLI import active") + expect(Object.keys(index.tickets)).toEqual(["T-20260708-001"]) + expect(await exists(join(storeRoot, "archive", "2026-07", "T-20260708-002.json"))).toBe(true) + expect(await exists(join(storeRoot, "archive", "2026-06", "T-20260630-001.json"))).toBe(true) + expect(await exists(join(sourceRoot, "active", "T-20260708-001.json"))).toBe(true) + } finally { + await rm(sourceRoot, { recursive: true, force: true }) + } + }) + + test("Given duplicate destination ticket When import runs Then it fails without overwriting", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "ticket-flow-cli-import-source-")) + await mkdir(join(sourceRoot, "active"), { recursive: true }) + try { + await writeTicketFixture(join(storeRoot, "active", "T-20260708-003.json"), { + id: "T-20260708-003", + title: "destination original", + status: "open", + updated: "2026-07-08T01:00:00Z", + }) + await writeTicketFixture(join(sourceRoot, "active", "T-20260708-003.json"), { + id: "T-20260708-003", + title: "source duplicate", + status: "open", + updated: "2026-07-08T02:00:00Z", + }) + + const result = await runCli(["import", sourceRoot], storeRoot) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("duplicate destination ticket T-20260708-003") + const destination = JSON.parse( + await readFile(join(storeRoot, "active", "T-20260708-003.json"), "utf8"), + ) + expect(destination.title).toBe("destination original") + } finally { + await rm(sourceRoot, { recursive: true, force: true }) + } + }) + test("Given an existing ticket When clawhip event prints ticket.created Then it returns routeable JSON", async () => { const create = await runCli( ["create", "--title", "Print clawhip event", "--assignee", "codex"], @@ -152,3 +226,50 @@ describe("CLI compatibility", () => { expect(result.stderr).toContain("Invalid option") }) }) + +async function exists(path: string): Promise { + try { + await access(path) + return true + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return false + } + throw error + } +} + +async function writeTicketFixture( + path: string, + overrides: { + readonly id: string + readonly title: string + readonly status: "open" | "doing" | "review" | "blocked" | "done" + readonly updated: string + readonly closed?: string | null + }, +): Promise { + await writeFile( + path, + `${JSON.stringify({ + id: overrides.id, + title: overrides.title, + status: overrides.status, + priority: "medium", + type: "chore", + source: null, + goal: "", + acceptance: [], + tags: [], + artifacts: [], + links: { github_issues: [], prs: [], threads: [], cron_jobs: [] }, + parent: null, + children: [], + assignee: "iyen", + created: "2026-07-08T00:00:00Z", + updated: overrides.updated, + closed: overrides.closed ?? null, + log: [], + })}\n`, + ) +} diff --git a/tests/contract.test.ts b/tests/contract.test.ts index 7689ed1..4ea9253 100644 --- a/tests/contract.test.ts +++ b/tests/contract.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { getTicket, listTickets, updateStatus } from "../src/store" import { resolveStorePaths } from "../src/store/paths" +import { exists } from "./support/store-fixtures" describe("historical ticket contract", () => { let storeRoot = "" @@ -148,15 +149,3 @@ describe("historical ticket contract", () => { expect(paths.active).toEndWith("/.ticket-flow/tickets/active") }) }) - -async function exists(path: string): Promise { - try { - await stat(path) - return true - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return false - } - throw error - } -} diff --git a/tests/import-cli-error.test.ts b/tests/import-cli-error.test.ts new file mode 100644 index 0000000..a8f617e --- /dev/null +++ b/tests/import-cli-error.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +test("Given a missing source root When import runs Then it exits with an error", async () => { + const storeRoot = await mkdtemp(join(tmpdir(), "ticket-flow-import-cli-error-")) + await mkdir(join(storeRoot, "active"), { recursive: true }) + await mkdir(join(storeRoot, "archive"), { recursive: true }) + const sourceRoot = join(storeRoot, "missing-import-source") + try { + const process = Bun.spawn(["bun", "run", "src/cli.ts", "import", sourceRoot], { + cwd: import.meta.dir.replace(/\/tests$/, ""), + env: { ...Bun.env, TICKET_FLOW_HOME: storeRoot }, + stdout: "pipe", + stderr: "pipe", + }) + const [stderr, exitCode] = await Promise.all([ + new Response(process.stderr).text(), + process.exited, + ]) + + expect(exitCode).toBe(1) + expect(stderr).toContain(`invalid import source ${sourceRoot}`) + } finally { + await rm(storeRoot, { recursive: true, force: true }) + } +}) diff --git a/tests/import-contract.test.ts b/tests/import-contract.test.ts new file mode 100644 index 0000000..61c6837 --- /dev/null +++ b/tests/import-contract.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { InvalidImportSourceError } from "../src/errors" +import { importTicketStore, listTickets } from "../src/store" +import { exists, writeTicketFixture } from "./support/store-fixtures" + +describe("ticket import contract", () => { + let storeRoot = "" + + beforeEach(async () => { + storeRoot = await mkdtemp(join(tmpdir(), "ticket-flow-contract-")) + await mkdir(join(storeRoot, "active"), { recursive: true }) + await mkdir(join(storeRoot, "archive", "2026-05"), { recursive: true }) + await writeFile(join(storeRoot, "index.json"), `{"version":1,"lastId":null,"tickets":{}}\n`) + }) + + afterEach(async () => { + await rm(storeRoot, { recursive: true, force: true }) + }) + + test("Given source active and archived tickets When importing Then active tickets are indexed and completed tickets are archived", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "ticket-flow-import-source-")) + await mkdir(join(sourceRoot, "active"), { recursive: true }) + await mkdir(join(sourceRoot, "archive", "2026-06"), { recursive: true }) + try { + await writeTicketFixture(join(sourceRoot, "active", "T-20260630-001.json"), { + id: "T-20260630-001", + title: "import active", + status: "open", + updated: "2026-06-30T10:00:00Z", + }) + await writeTicketFixture(join(sourceRoot, "active", "T-20260630-002.json"), { + id: "T-20260630-002", + title: "import done from active", + status: "done", + updated: "2026-06-30T11:00:00Z", + closed: "2026-07-01T00:00:00Z", + }) + await writeTicketFixture(join(sourceRoot, "archive", "2026-06", "T-20260629-001.json"), { + id: "T-20260629-001", + title: "import archived", + status: "done", + updated: "2026-06-29T09:00:00Z", + closed: null, + }) + + const summary = await importTicketStore(sourceRoot, { + root: storeRoot, + active: join(storeRoot, "active"), + archive: join(storeRoot, "archive"), + index: join(storeRoot, "index.json"), + }) + + const active = await listTickets({ + root: storeRoot, + active: join(storeRoot, "active"), + archive: join(storeRoot, "archive"), + index: join(storeRoot, "index.json"), + }) + const index = JSON.parse(await readFile(join(storeRoot, "index.json"), "utf8")) + expect(summary).toEqual({ imported: 3, active: 1, archived: 2 }) + expect(active.map((ticket) => ticket.id)).toEqual(["T-20260630-001"]) + expect(index.tickets["T-20260630-001"]).toMatchObject({ title: "import active" }) + expect(index.tickets["T-20260630-002"]).toBeUndefined() + expect(await exists(join(storeRoot, "archive", "2026-07", "T-20260630-002.json"))).toBe(true) + expect(await exists(join(storeRoot, "archive", "2026-06", "T-20260629-001.json"))).toBe(true) + } finally { + await rm(sourceRoot, { recursive: true, force: true }) + } + }) + + test("Given a destination ticket with the same ID When importing Then it fails without overwriting", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "ticket-flow-import-source-")) + await mkdir(join(sourceRoot, "active"), { recursive: true }) + try { + await writeTicketFixture(join(storeRoot, "active", "T-20260630-003.json"), { + id: "T-20260630-003", + title: "existing destination", + status: "open", + updated: "2026-06-30T10:00:00Z", + }) + await writeTicketFixture(join(sourceRoot, "active", "T-20260630-003.json"), { + id: "T-20260630-003", + title: "source duplicate", + status: "open", + updated: "2026-06-30T11:00:00Z", + }) + + await expect( + importTicketStore(sourceRoot, { + root: storeRoot, + active: join(storeRoot, "active"), + archive: join(storeRoot, "archive"), + index: join(storeRoot, "index.json"), + }), + ).rejects.toThrow("duplicate destination ticket T-20260630-003") + + const destination = JSON.parse( + await readFile(join(storeRoot, "active", "T-20260630-003.json"), "utf8"), + ) + expect(destination.title).toBe("existing destination") + } finally { + await rm(sourceRoot, { recursive: true, force: true }) + } + }) + + test("Given a missing source root When importing Then it rejects the invalid source", async () => { + const sourceRoot = join(storeRoot, "missing-import-source") + + const imported = importTicketStore(sourceRoot, { + root: storeRoot, + active: join(storeRoot, "active"), + archive: join(storeRoot, "archive"), + index: join(storeRoot, "index.json"), + }) + + await expect(imported).rejects.toBeInstanceOf(InvalidImportSourceError) + await expect(imported).rejects.toThrow(`invalid import source ${sourceRoot}`) + }) + + test("Given a file source root When importing Then it rejects the invalid source", async () => { + const sourceRoot = join(storeRoot, "import-source.json") + await writeFile(sourceRoot, "{}") + + const imported = importTicketStore(sourceRoot, { + root: storeRoot, + active: join(storeRoot, "active"), + archive: join(storeRoot, "archive"), + index: join(storeRoot, "index.json"), + }) + + await expect(imported).rejects.toBeInstanceOf(InvalidImportSourceError) + await expect(imported).rejects.toThrow(`invalid import source ${sourceRoot}`) + }) + + test("Given a source below a file When importing Then it rejects the invalid source", async () => { + const sourceParent = join(storeRoot, "import-source-file") + const sourceRoot = join(sourceParent, "nested") + await writeFile(sourceParent, "not a directory") + + const imported = importTicketStore(sourceRoot, { + root: storeRoot, + active: join(storeRoot, "active"), + archive: join(storeRoot, "archive"), + index: join(storeRoot, "index.json"), + }) + + await expect(imported).rejects.toBeInstanceOf(InvalidImportSourceError) + await expect(imported).rejects.toThrow(`invalid import source ${sourceRoot}`) + }) + + test("Given an unreadable source When importing Then it rejects before creating the destination", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "ticket-flow-unreadable-source-")) + const activeRoot = join(sourceRoot, "active") + const destinationRoot = join(storeRoot, "uninitialized-destination") + await mkdir(activeRoot) + await chmod(activeRoot, 0) + try { + const imported = importTicketStore(sourceRoot, { + root: destinationRoot, + active: join(destinationRoot, "active"), + archive: join(destinationRoot, "archive"), + index: join(destinationRoot, "index.json"), + }) + + await expect(imported).rejects.toBeInstanceOf(InvalidImportSourceError) + expect(await exists(destinationRoot)).toBe(false) + } finally { + await chmod(activeRoot, 0o700) + await rm(sourceRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 95d30b6..837cc8d 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" +import { z } from "zod" type JsonRpcResponse = { readonly jsonrpc: "2.0" @@ -10,6 +11,15 @@ type JsonRpcResponse = { readonly error?: unknown } +const McpTextResultSchema = z.object({ + content: z.array(z.object({ type: z.literal("text"), text: z.string() })), +}) + +function mcpText(result: unknown): string { + const parsed = McpTextResultSchema.parse(result) + return parsed.content.map((item) => item.text).join("\n") +} + async function runMcp( messages: readonly object[], storeRoot: string, @@ -92,4 +102,91 @@ describe("MCP stdio server", () => { expect(JSON.stringify(created?.result)).toContain("T-") expect(JSON.stringify(created?.result)).toContain("Create through MCP") }) + + test("Given a source ticket store When ticket_import is called Then it imports through the MCP surface", async () => { + const sourceRoot = await mkdtemp(join(tmpdir(), "ticket-flow-mcp-import-source-")) + await mkdir(join(sourceRoot, "active"), { recursive: true }) + try { + await writeTicketFixture(join(sourceRoot, "active", "T-20260708-004.json"), { + id: "T-20260708-004", + title: "MCP import active", + status: "open", + updated: "2026-07-08T04:00:00Z", + }) + + const responses = await runMcp( + [ + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "ticket-flow-test", version: "0.0.0" }, + }, + }, + { jsonrpc: "2.0", method: "notifications/initialized" }, + { jsonrpc: "2.0", id: 2, method: "tools/list" }, + { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "ticket_import", + arguments: { sourceRoot }, + }, + }, + ], + storeRoot, + ) + + const tools = responses.find((response) => response.id === 2) + expect(JSON.stringify(tools?.result)).toContain("ticket_import") + + const imported = responses.find((response) => response.id === 3) + expect(JSON.parse(mcpText(imported?.result))).toEqual({ imported: 1, active: 1, archived: 0 }) + const ticket = JSON.parse( + await readFile(join(storeRoot, "active", "T-20260708-004.json"), "utf8"), + ) + expect(ticket.title).toBe("MCP import active") + } finally { + await rm(sourceRoot, { recursive: true, force: true }) + } + }) }) + +async function writeTicketFixture( + path: string, + overrides: { + readonly id: string + readonly title: string + readonly status: "open" | "doing" | "review" | "blocked" | "done" + readonly updated: string + readonly closed?: string | null + }, +): Promise { + await writeFile( + path, + `${JSON.stringify({ + id: overrides.id, + title: overrides.title, + status: overrides.status, + priority: "medium", + type: "chore", + source: null, + goal: "", + acceptance: [], + tags: [], + artifacts: [], + links: { github_issues: [], prs: [], threads: [], cron_jobs: [] }, + parent: null, + children: [], + assignee: "iyen", + created: "2026-07-08T00:00:00Z", + updated: overrides.updated, + closed: overrides.closed ?? null, + log: [], + })}\n`, + ) +} diff --git a/tests/support/store-fixtures.ts b/tests/support/store-fixtures.ts new file mode 100644 index 0000000..8377152 --- /dev/null +++ b/tests/support/store-fixtures.ts @@ -0,0 +1,50 @@ +import { stat, writeFile } from "node:fs/promises" + +type TicketFixtureOverrides = { + readonly id: string + readonly title: string + readonly status: "open" | "doing" | "review" | "blocked" | "done" + readonly updated: string + readonly closed?: string | null +} + +export async function exists(path: string): Promise { + try { + await stat(path) + return true + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return false + } + throw error + } +} + +export async function writeTicketFixture( + path: string, + overrides: TicketFixtureOverrides, +): Promise { + await writeFile( + path, + `${JSON.stringify({ + id: overrides.id, + title: overrides.title, + status: overrides.status, + priority: "medium", + type: "chore", + source: null, + goal: "", + acceptance: [], + tags: [], + artifacts: [], + links: { github_issues: [], prs: [], threads: [], cron_jobs: [] }, + parent: null, + children: [], + assignee: "iyen", + created: "2026-06-01T00:00:00Z", + updated: overrides.updated, + closed: overrides.closed ?? null, + log: [], + })}\n`, + ) +}