From b2d3c028b5e7b8388c9fcf35df1b6cba6eac79c6 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 13:37:48 +0000 Subject: [PATCH 01/11] feat: add plan-to-git CLI --- .gitignore | 1 + Cargo.lock | 685 ++++++++++++++++-- Cargo.toml | 32 +- README.md | 345 +-------- .../20260530_104500_plan_to_git_mvp.md | 6 + examples/basic_usage.rs | 23 +- src/capture.rs | 134 ++++ src/error.rs | 26 + src/git.rs | 66 ++ src/github.rs | 106 +++ src/lib.rs | 12 +- src/main.rs | 142 +++- src/normalize.rs | 159 ++++ src/pr_body.rs | 33 + src/redact.rs | 27 + src/render.rs | 88 +++ src/store.rs | 337 +++++++++ src/sum.rs | 4 - tests/integration/cli.rs | 186 +++++ tests/integration/mod.rs | 2 +- tests/integration/sum.rs | 36 - tests/unit/mod.rs | 2 +- tests/unit/plan_capture.rs | 209 ++++++ tests/unit/sum.rs | 26 - 24 files changed, 2232 insertions(+), 455 deletions(-) create mode 100644 changelog.d/20260530_104500_plan_to_git_mvp.md create mode 100644 src/capture.rs create mode 100644 src/error.rs create mode 100644 src/git.rs create mode 100644 src/github.rs create mode 100644 src/normalize.rs create mode 100644 src/pr_body.rs create mode 100644 src/redact.rs create mode 100644 src/render.rs create mode 100644 src/store.rs delete mode 100644 src/sum.rs create mode 100644 tests/integration/cli.rs delete mode 100644 tests/integration/sum.rs create mode 100644 tests/unit/plan_capture.rs delete mode 100644 tests/unit/sum.rs diff --git a/.gitignore b/.gitignore index 2863516..ef84cf0 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ doc/ .env .env.local *.local +.agent-plan.json # Log files *.log diff --git a/Cargo.lock b/Cargo.lock index 68fcef1..21d5e9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -61,6 +70,67 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +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 = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.5.60" @@ -108,58 +178,184 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "ctor" -version = "0.4.3" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "ctor-proc-macro", - "dtor", + "libc", ] [[package]] -name = "ctor-proc-macro" -version = "0.0.6" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] [[package]] -name = "dotenvy" -version = "0.15.7" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "dtor" -version = "0.0.6" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "dtor-proc-macro", + "libc", + "windows-sys", ] [[package]] -name = "dtor-proc-macro" -version = "0.0.5" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "example-sum-package-name" -version = "0.16.0" +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[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 = [ - "clap", - "lino-arguments", - "regex", - "walkdir", + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -167,24 +363,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "lino-arguments" -version = "0.3.0" +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.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be512a5c5eacea6ef5ec015fb0c7e1725c8e4cda1befd31606e203f281069968" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ - "clap", - "ctor", - "dotenvy", - "lino-env", - "serde", - "thiserror", + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", ] [[package]] -name = "lino-env" +name = "leb128fmt" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f453c53827aabe91a3d3856d61d14ae3867ab1a4344db22f9fa5396664c8d0e" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "memchr" @@ -192,12 +410,57 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plan-to-git" +version = "0.16.0" +dependencies = [ + "chrono", + "clap", + "regex", + "serde", + "serde_json", + "sha2", + "tempfile", + "walkdir", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -216,6 +479,12 @@ 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 = "regex" version = "1.12.3" @@ -245,6 +514,25 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[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", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "same-file" version = "1.0.6" @@ -254,6 +542,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -284,6 +578,42 @@ dependencies = [ "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 = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "strsim" version = "0.11.1" @@ -302,24 +632,23 @@ dependencies = [ ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "thiserror-impl", + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -327,12 +656,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -343,6 +684,103 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -352,12 +790,65 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -366,3 +857,103 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 954bffb..3b062d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "example-sum-package-name" +name = "plan-to-git" version = "0.16.0" edition = "2021" -description = "A Rust package template for AI-driven development" +description = "Capture agent plans and sync them to GitHub pull requests" readme = "README.md" license = "Unlicense" -keywords = ["template", "rust", "ai-driven"] -categories = ["development-tools"] -repository = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template" -documentation = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template" -rust-version = "1.70" +keywords = ["agents", "github", "pull-request", "codex"] +categories = ["command-line-utilities", "development-tools"] +repository = "https://github.com/ProverCoderAI/plan-to-git" +documentation = "https://github.com/ProverCoderAI/plan-to-git" +rust-version = "1.85" # Narrow allowlist of files shipped in the published `.crate` archive. # Keeping this list tight prevents docs, case studies, generated CI artifacts, @@ -18,25 +18,29 @@ rust-version = "1.70" include = [ "src/**/*.rs", "examples/**/*.rs", - "README.md", - "LICENSE", - "CHANGELOG.md", + "/README.md", + "/LICENSE", + "/CHANGELOG.md", ] [lib] -name = "example_sum_package_name" +name = "plan_to_git" path = "src/lib.rs" [[bin]] -name = "example-sum-package-name" +name = "plan-to-git" path = "src/main.rs" [dependencies] -lino-arguments = "0.3" clap = { version = "4.4", features = ["derive", "env"] } +chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" [dev-dependencies] -regex = "1" +tempfile = "3" walkdir = "2" [lints.rust] diff --git a/README.md b/README.md index 2e615f5..4b125bc 100644 --- a/README.md +++ b/README.md @@ -1,332 +1,59 @@ -# rust-ai-driven-development-pipeline-template +# plan-to-git -A comprehensive template for AI-driven Rust development with full CI/CD pipeline support. +`plan-to-git` captures explicit plans produced by coding agents and keeps the current pull request description in sync. -[![CI/CD Pipeline](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/workflows/CI%2FCD%20Pipeline/badge.svg)](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/actions?workflow=CI%2FCD+Pipeline) -[![Crates.io](https://img.shields.io/crates/v/example-sum-package-name?label=crates.io&style=flat)](https://crates.io/crates/example-sum-package-name) -[![Docs.rs](https://docs.rs/example-sum-package-name/badge.svg)](https://docs.rs/example-sum-package-name) -[![Rust Version](https://img.shields.io/badge/rust-1.70%2B-blue.svg)](https://www.rust-lang.org/) -[![Codecov](https://codecov.io/gh/link-foundation/rust-ai-driven-development-pipeline-template/branch/main/graph/badge.svg)](https://codecov.io/gh/link-foundation/rust-ai-driven-development-pipeline-template) -[![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](http://unlicense.org/) +The MVP is Codex-first: -## Features +- reads Codex hook JSON from stdin; +- captures only explicit plan blocks such as `...` or `## Accepted Plan`; +- stores captured plans and planning Q/A decisions in `.agent-plan.json`; +- updates the current branch PR body between stable markers when a PR exists; +- leaves the local stack queued when no PR exists yet. -- **Rust stable support**: Works with Rust stable version -- **Cross-platform testing**: CI runs on Ubuntu, macOS, and Windows -- **Comprehensive testing**: Unit tests, integration tests, and doc tests -- **Code quality**: rustfmt + Clippy with pedantic lints -- **Pre-commit hooks**: Automated code quality checks before commits -- **CI/CD pipeline**: GitHub Actions with multi-platform support -- **Changelog management**: Fragment-based changelog (like Changesets/Scriv) -- **Code coverage**: Automated coverage reports with cargo-llvm-cov and Codecov -- **Release automation**: Automatic GitHub releases, crates.io publishing, and optional Docker Hub image publishing -- **Template-safe defaults**: CI/CD skips publishing when package name is `example-sum-package-name` - -## Quick Start - -### Using This Template - -1. Click "Use this template" on GitHub to create a new repository -2. Clone your new repository -3. Update `Cargo.toml`: - - Change `name` from `example-sum-package-name` to your package name - - Update `description`, `repository`, and `documentation` URLs - - Update `[lib]` name and `[[bin]]` name -4. Update imports in `src/main.rs`, `tests/`, and `examples/` -5. Build and start developing! - -### Development Setup +## CLI ```bash -# Clone the repository -git clone https://github.com/link-foundation/rust-ai-driven-development-pipeline-template.git -cd rust-ai-driven-development-pipeline-template - -# Build the project -cargo build - -# Run tests -cargo test - -# Run the CLI binary -cargo run -- --a 3 --b 7 - -# Run an example -cargo run --example basic_usage +plan-to-git hook --source codex < hook-payload.json +plan-to-git show +plan-to-git render +plan-to-git sync +plan-to-git clear --yes ``` -### Running Tests - -```bash -# Run all tests -cargo test +`hook` is intentionally quiet on stdout, because Codex hook stdout is interpreted by Codex. Operational messages go to stderr. -# Run tests with verbose output -cargo test --verbose +## Codex Hook Example -# Run doc tests -cargo test --doc +Add the command to Codex hook configuration for `Stop` and `UserPromptSubmit` events: -# Run a specific test -cargo test test_sum_positive_numbers +```toml +[[hooks.UserPromptSubmit]] +[[hooks.UserPromptSubmit.hooks]] +type = "command" +command = "plan-to-git hook --source codex" -# Run tests with output -cargo test -- --nocapture +[[hooks.Stop]] +[[hooks.Stop.hooks]] +type = "command" +command = "plan-to-git hook --source codex" ``` -CI caps each test-matrix job at 10 minutes. `cargo test` does not provide a portable global per-test timeout, so long-running network, IO, or async tests should use explicit test-level timeouts. Repositories that adopt `cargo nextest` can configure runner deadlines with options such as `--slow-timeout` and `--leak-timeout`. - -### Code Quality Checks - -```bash -# Format code -cargo fmt - -# Check formatting (CI style) -cargo fmt --check - -# Run Clippy lints -cargo clippy --all-targets --all-features - -# Check file size limits (requires rust-script: cargo install rust-script) -rust-script scripts/check-file-size.rs - -# Check the packaged crate stays under the crates.io 10 MiB upload limit -rust-script scripts/check-crate-size.rs - -# Run all checks -cargo fmt --check && cargo clippy --all-targets --all-features && rust-script scripts/check-file-size.rs -``` +Exact hook configuration shape can vary by Codex release. The hook command itself expects the release behavior documented by Codex hooks: `Stop` includes `last_assistant_message`, and `UserPromptSubmit` includes `prompt`. -## Project Structure +## Pull Request Block -``` -. -├── .github/ -│ └── workflows/ -│ └── release.yml # CI/CD pipeline configuration -├── changelog.d/ # Changelog fragments -│ ├── README.md # Fragment instructions -│ └── *.md # Individual changelog entries -├── examples/ -│ └── basic_usage.rs # Usage examples -├── experiments/ # Experiment and debug scripts -│ ├── test-changelog-parsing.rs # Changelog parsing validation -│ └── test-crates-io-check.rs # Crates.io version check validation -├── scripts/ # Rust scripts (via rust-script) -│ ├── bump-version.rs # Version bumping utility -│ ├── check-changelog-fragment.rs # Changelog fragment validation -│ ├── check-crate-size.rs # Crate archive size guard (crates.io 10 MiB limit) -│ ├── check-file-size.rs # File size validation script -│ ├── check-release-needed.rs # Release necessity check -│ ├── check-version-modification.rs # Version modification detection -│ ├── collect-changelog.rs # Changelog collection script -│ ├── create-changelog-fragment.rs # Changelog fragment creation -│ ├── create-github-release.rs # GitHub release creation -│ ├── detect-code-changes.rs # Code change detection for CI -│ ├── get-bump-type.rs # Version bump type determination -│ ├── get-version.rs # Version extraction from Cargo.toml -│ ├── git-config.rs # Git configuration for CI -│ ├── publish-crate.rs # Crates.io publishing -│ ├── rust-paths.rs # Rust root path detection -│ ├── version-and-commit.rs # CI/CD version management -│ └── wait-for-crate.rs # Crates.io availability wait before image publishing -├── src/ -│ ├── lib.rs # Library entry point -│ ├── main.rs # CLI binary (uses lino-arguments) -│ └── sum.rs # Sum function module -├── tests/ -│ ├── unit_tests.rs # Unit test entry point -│ ├── unit/ -│ │ ├── mod.rs -│ │ ├── sum.rs # Unit tests for sum function -│ │ └── ci-cd/ -│ │ ├── mod.rs -│ │ └── changelog_parsing.rs # CI/CD changelog parsing tests -│ ├── integration_tests.rs # Integration test entry point -│ └── integration/ -│ ├── mod.rs -│ └── sum.rs # CLI integration tests -├── .gitignore # Git ignore patterns -├── .pre-commit-config.yaml # Pre-commit hooks configuration -├── Cargo.toml # Project configuration -├── CHANGELOG.md # Project changelog -├── CONTRIBUTING.md # Contribution guidelines -├── LICENSE # Unlicense (public domain) -└── README.md # This file -``` - -## Design Choices - -### Example Application - -The template includes a simple CLI sum application using [lino-arguments](https://github.com/link-foundation/lino-arguments) (a drop-in replacement for clap that also supports `.lenv` and `.env` files). This demonstrates: - -- Library module (`src/sum.rs`) with a pure function -- CLI binary (`src/main.rs`) using `lino-arguments` for argument parsing -- Unit tests (`tests/unit/sum.rs`) testing the function directly -- Integration tests (`tests/integration/sum.rs`) testing the full CLI binary - -### Code Quality Tools - -- **rustfmt**: Standard Rust code formatter -- **Clippy**: Rust linter with pedantic and nursery lints enabled -- **Pre-commit hooks**: Automated checks before each commit - -### Testing Strategy - -The template supports multiple levels of testing: - -- **Unit tests**: In `tests/unit/` directory, testing functions directly -- **Integration tests**: In `tests/integration/` directory, testing CLI binary -- **CI/CD tests**: In `tests/unit/ci-cd/` directory, testing CI/CD script logic -- **Doc tests**: In documentation examples using `///` comments -- **Examples**: In `examples/` directory (also serve as documentation) - -Users can easily delete CI/CD tests in `tests/unit/ci-cd/` if not needed. - -### Changelog Management - -This template uses a fragment-based changelog system similar to [Changesets](https://github.com/changesets/changesets) and [Scriv](https://scriv.readthedocs.io/). - -```bash -# Create a changelog fragment -touch changelog.d/$(date +%Y%m%d_%H%M%S)_my_change.md - -# Edit the fragment to document your changes -``` - -### CI/CD Pipeline - -The GitHub Actions workflow provides: - -1. **Change detection**: Only runs relevant jobs based on changed files -2. **Changelog check**: Validates changelog fragments on PRs with code changes -3. **Version check**: Prevents manual version modification in PRs -4. **Linting**: rustfmt and Clippy checks -5. **Test matrix**: 3 OS (Ubuntu, macOS, Windows) with Rust stable -6. **Code coverage**: cargo-llvm-cov with Codecov upload -7. **Building**: Release build and package validation -8. **Auto release**: Automatic releases when changelog fragments are merged to main -9. **Manual release**: Workflow dispatch with version bump type selection -10. **Optional Docker Hub publishing**: Pushes `latest` and version tags after the matching crates.io version is visible -11. **Documentation**: Automatic docs deployment to GitHub Pages after release - -### Template-Safe Defaults - -The default package name `example-sum-package-name` triggers skip logic in CI/CD scripts: -- `publish-crate.rs` skips crates.io publishing -- `create-github-release.rs` skips GitHub release creation -- Docker Hub publishing stays disabled unless `DOCKERHUB_IMAGE` is configured and a root `Dockerfile` exists - -Rename the package in `Cargo.toml` to enable full CI/CD publishing. - -## Configuration - -### Updating Package Name - -After creating a repository from this template: - -1. Update `Cargo.toml`: - - Change `name` field from `example-sum-package-name` - - Update `repository` and `documentation` URLs - - Change `[lib]` name and `[[bin]]` name - -2. Update imports: - - `src/main.rs` - - `tests/unit/sum.rs` - - `tests/integration/sum.rs` - - `examples/basic_usage.rs` - -3. Update badges in this `README.md` - -### Optional Docker Hub Publishing - -Projects that ship a Docker image can publish Docker Hub releases from the same Rust release workflow. Add a root `Dockerfile`, then configure: - -| Name | Type | Example | Purpose | -| ---- | ---- | ------- | ------- | -| `DOCKERHUB_IMAGE` | Repository variable | `my-dockerhub-user/my-image` | Docker Hub repository to publish | -| `DOCKERHUB_USERNAME` | Repository variable or secret | `my-dockerhub-user` | Docker Hub login username | -| `DOCKERHUB_TOKEN` | Repository secret | Docker Hub access token | Docker Hub login token | - -When configured, the release workflow publishes both `latest` and the Cargo package version tag, for example `my-dockerhub-user/my-image:0.10.0`. Docker publishing runs only after crates.io reports the matching version as available, and release checks rerun missing Docker Hub or GitHub release artifacts without bumping the version again. - -Add a visible Docker Hub badge next to the crates.io badge in repositories that enable image publishing: +When `gh pr view` finds a PR for the current branch, `plan-to-git` inserts or replaces only this section: ```markdown -[![Docker Hub](https://img.shields.io/docker/v/my-dockerhub-user/my-image?label=docker%20hub)](https://hub.docker.com/r/my-dockerhub-user/my-image) -``` + +## Agent Plan Stack -## Deploying API documentation - -The `deploy-docs` job in `.github/workflows/release.yml` publishes `cargo doc --no-deps --all-features` output to GitHub Pages on every push to `main` and on `workflow_dispatch` with `release_mode == 'instant'`. It uses the official `actions/configure-pages` / `actions/upload-pages-artifact` / `actions/deploy-pages` flow, which requires the repository's Pages source to be set to **GitHub Actions**. - -Before the first run on `main`, open **Settings → Pages** of the new repository and set **Source = GitHub Actions**. This is a one-time manual step and cannot be configured from a workflow. The `deploy-docs` job will then provision the Pages site on its first run. - -If this step is skipped, the first `deploy-docs` run fails on `actions/deploy-pages@v5` with `Error: Get Pages site failed.` / `Error: Failed to create deployment`. Flip the Pages source as described above and re-run the failed job; no workflow changes are required. - -## Scripts Reference - -All scripts in `scripts/` are Rust scripts that use [rust-script](https://github.com/fornwall/rust-script). -Install rust-script with: `cargo install rust-script` - -| Command | Description | -| ------------------------------------- | ------------------------ | -| `cargo test` | Run all tests | -| `cargo fmt` | Format code | -| `cargo clippy` | Run lints | -| `cargo run -- --a 3 --b 7` | Run CLI (sum 3 + 7) | -| `cargo run --example basic_usage` | Run example | -| `rust-script scripts/check-file-size.rs` | Check file size limits | -| `rust-script scripts/check-crate-size.rs` | Check crate archive size (crates.io 10 MiB limit) | -| `rust-script scripts/bump-version.rs` | Bump version | - -## Example Usage - -```rust -use example_sum_package_name::sum; - -fn main() { - let result = sum(2, 3); - println!("2 + 3 = {result}"); -} +... + ``` -See `examples/basic_usage.rs` for more examples. - -## Contributing - -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. - -### Development Workflow - -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/my-feature` -3. Make your changes and add tests -4. Run quality checks: `cargo fmt && cargo clippy && cargo test` -5. Add a changelog fragment -6. Commit your changes (pre-commit hooks will run automatically) -7. Push and create a Pull Request - -## License - -[Unlicense](LICENSE) - Public Domain - -This is free and unencumbered software released into the public domain. See [LICENSE](LICENSE) for details. - -## Acknowledgments - -Inspired by: -- [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) -- [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) -- [lino-arguments](https://github.com/link-foundation/lino-arguments) -- [trees-rs](https://github.com/linksplatform/trees-rs) +If only one marker exists, sync fails rather than risking corruption of the human-written PR body. -## Resources +## Safety -- [Rust Book](https://doc.rust-lang.org/book/) -- [Cargo Book](https://doc.rust-lang.org/cargo/) -- [Clippy Documentation](https://rust-lang.github.io/rust-clippy/) -- [rustfmt Documentation](https://rust-lang.github.io/rustfmt/) -- [Pre-commit Documentation](https://pre-commit.com/) +The tool does not scrape raw `~/.codex` or `~/.claude` transcripts. It only uses stable hook payload fields and explicitly marked plan text. Captured content is redacted before local storage and PR sync. diff --git a/changelog.d/20260530_104500_plan_to_git_mvp.md b/changelog.d/20260530_104500_plan_to_git_mvp.md new file mode 100644 index 0000000..babfd6d --- /dev/null +++ b/changelog.d/20260530_104500_plan_to_git_mvp.md @@ -0,0 +1,6 @@ +--- +bump: minor +--- + +### Added +- Added the `plan-to-git` CLI for capturing Codex plan hooks, storing a local plan stack, and syncing it into GitHub pull request bodies. diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index be9a37f..a26bae5 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,7 +1,22 @@ -use example_sum_package_name::sum; +use plan_to_git::render::render_plan_block; +use plan_to_git::store::{AgentPlanState, AgentSource, NewPlanItem}; fn main() { - println!("2 + 3 = {}", sum(2, 3)); - println!("-5 + 10 = {}", sum(-5, 10)); - println!("1000 + 2000 = {}", sum(1000, 2000)); + let mut state = AgentPlanState::default(); + state.set_context( + Some("example/repo".to_owned()), + Some("feature/plan-sync".to_owned()), + Some("abcdef1234567890".to_owned()), + ); + state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Example Plan".to_owned()), + content: "- Capture the plan.\n- Sync it to the pull request.".to_owned(), + branch: Some("feature/plan-sync".to_owned()), + head_sha: Some("abcdef1234567890".to_owned()), + session_id: None, + turn_id: None, + }); + + println!("{}", render_plan_block(&state)); } diff --git a/src/capture.rs b/src/capture.rs new file mode 100644 index 0000000..cfdea16 --- /dev/null +++ b/src/capture.rs @@ -0,0 +1,134 @@ +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +use crate::error::AppResult; +use crate::git; +use crate::github::{self, SyncStatus}; +use crate::normalize::{extract_marked_plans, extract_questions}; +use crate::store::{ + load_state, save_state, AgentSource, NewDecision, NewPendingQuestion, NewPlanItem, + PendingQuestion, STATE_FILE_NAME, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookOutcome { + pub changed: bool, + pub captured_plans: usize, + pub captured_decisions: usize, + pub pending_questions: usize, + pub sync_status: SyncStatus, +} + +#[derive(Debug, Deserialize)] +struct CodexHookInput { + #[serde(default)] + session_id: Option, + #[serde(default)] + cwd: Option, + hook_event_name: String, + #[serde(default)] + turn_id: Option, + #[serde(default)] + prompt: Option, + #[serde(default)] + last_assistant_message: Option, +} + +pub fn process_codex_hook(input: &str) -> AppResult { + let hook_input: CodexHookInput = serde_json::from_str(input)?; + let start_dir = hook_input.cwd.as_deref().unwrap_or_else(|| Path::new(".")); + let context = git::discover(start_dir)?; + let state_path = context.repo_root.join(STATE_FILE_NAME); + let mut state = load_state(&state_path)?; + state.set_context( + context.repo_slug.clone(), + context.branch.clone(), + context.head_sha.clone(), + ); + + let mut captured_plans = 0; + let mut captured_decisions = 0; + let mut changed = false; + + match hook_input.hook_event_name.as_str() { + "Stop" => { + if let Some(message) = hook_input.last_assistant_message.as_deref() { + for plan in extract_marked_plans(message) { + let added = state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: plan.title, + content: plan.content, + branch: context.branch.clone(), + head_sha: context.head_sha.clone(), + session_id: hook_input.session_id.clone(), + turn_id: hook_input.turn_id.clone(), + }); + if added { + captured_plans += 1; + changed = true; + } + } + + if captured_plans == 0 { + let questions = extract_questions(message); + if state.add_pending_question(NewPendingQuestion { + source: AgentSource::Codex, + questions, + branch: context.branch.clone(), + head_sha: context.head_sha.clone(), + session_id: hook_input.session_id.clone(), + turn_id: hook_input.turn_id.clone(), + }) { + changed = true; + } + } + } + } + "UserPromptSubmit" => { + if let Some(prompt) = hook_input.prompt.as_deref() { + if !prompt.trim().is_empty() { + let questions = drain_relevant_questions(&mut state.pending_questions); + if state.answer_pending_questions(NewDecision { + source: AgentSource::Codex, + questions, + answer: prompt.to_owned(), + branch: context.branch.clone(), + head_sha: context.head_sha.clone(), + session_id: hook_input.session_id.clone(), + turn_id: hook_input.turn_id.clone(), + }) { + captured_decisions += 1; + changed = true; + } + } + } + } + _ => {} + } + + if changed || !state.items.is_empty() || !state.pending_questions.is_empty() { + save_state(&state_path, &state)?; + } + + let sync_status = github::sync_state(&context, &state)?; + + Ok(HookOutcome { + changed, + captured_plans, + captured_decisions, + pending_questions: state.pending_questions.len(), + sync_status, + }) +} + +fn drain_relevant_questions(pending_questions: &mut Vec) -> Vec { + let mut questions = Vec::new(); + for pending_question in pending_questions.drain(..) { + for question in pending_question.questions { + if !questions.iter().any(|existing| existing == &question) { + questions.push(question); + } + } + } + questions +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..4d695a1 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,26 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +pub type AppResult = Result>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppError { + message: String, +} + +impl AppError { + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl Display for AppError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for AppError {} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..f7de0a5 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,66 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::error::{AppError, AppResult}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitContext { + pub repo_root: PathBuf, + pub repo_slug: Option, + pub branch: Option, + pub head_sha: Option, +} + +pub fn discover(start: &Path) -> AppResult { + let repo_root = git_output(start, ["rev-parse", "--show-toplevel"])?; + let repo_root = PathBuf::from(repo_root.trim()); + let branch = git_output(&repo_root, ["rev-parse", "--abbrev-ref", "HEAD"]).ok(); + let head_sha = git_output(&repo_root, ["rev-parse", "HEAD"]).ok(); + let remote = git_output(&repo_root, ["remote", "get-url", "origin"]).ok(); + + Ok(GitContext { + repo_root, + repo_slug: remote.as_deref().and_then(parse_github_slug), + branch: branch.map(|value| value.trim().to_owned()), + head_sha: head_sha.map(|value| value.trim().to_owned()), + }) +} + +#[must_use] +pub fn parse_github_slug(remote: &str) -> Option { + let remote = remote.trim().trim_end_matches(".git"); + + if let Some(path) = remote.strip_prefix("git@github.com:") { + return normalize_slug(path); + } + + if let Some(path) = remote.strip_prefix("https://github.com/") { + return normalize_slug(path); + } + + if let Some(path) = remote.strip_prefix("ssh://git@github.com/") { + return normalize_slug(path); + } + + None +} + +fn normalize_slug(path: &str) -> Option { + let mut parts = path.split('/'); + let owner = parts.next()?; + let repo = parts.next()?; + if owner.is_empty() || repo.is_empty() { + return None; + } + Some(format!("{owner}/{repo}")) +} + +fn git_output(cwd: &Path, args: [&str; N]) -> AppResult { + let output = Command::new("git").arg("-C").arg(cwd).args(args).output()?; + if output.status.success() { + return Ok(String::from_utf8(output.stdout)?.trim().to_owned()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + Err(AppError::new(format!("git command failed: {stderr}")).into()) +} diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 0000000..0d51b9d --- /dev/null +++ b/src/github.rs @@ -0,0 +1,106 @@ +use std::fs; +use std::path::Path; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; + +use crate::error::{AppError, AppResult}; +use crate::git::GitContext; +use crate::pr_body::upsert_marker_block; +use crate::render::{has_current_branch_items, render_plan_block}; +use crate::store::AgentPlanState; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyncStatus { + NoItems, + NoPullRequest, + Unchanged { number: u64 }, + Updated { number: u64 }, +} + +#[derive(Debug, Deserialize)] +struct PullRequest { + number: u64, + #[serde(default)] + body: Option, +} + +pub fn sync_state(context: &GitContext, state: &AgentPlanState) -> AppResult { + if !has_current_branch_items(state) { + return Ok(SyncStatus::NoItems); + } + + let Some(pull_request) = view_current_pr(&context.repo_root)? else { + return Ok(SyncStatus::NoPullRequest); + }; + + let body = pull_request.body.unwrap_or_default(); + let plan_block = render_plan_block(state); + let updated_body = upsert_marker_block(&body, &plan_block)?; + + if body.trim_end() == updated_body.trim_end() { + return Ok(SyncStatus::Unchanged { + number: pull_request.number, + }); + } + + edit_pr_body(&context.repo_root, &updated_body)?; + Ok(SyncStatus::Updated { + number: pull_request.number, + }) +} + +fn view_current_pr(repo_root: &Path) -> AppResult> { + let output = Command::new("gh") + .current_dir(repo_root) + .args(["pr", "view", "--json", "number,body"]) + .output()?; + + if output.status.success() { + return Ok(Some(serde_json::from_slice(&output.stdout)?)); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("no pull requests found") { + return Ok(None); + } + + Err(AppError::new(format!("gh pr view failed: {stderr}")).into()) +} + +fn edit_pr_body(repo_root: &Path, body: &str) -> AppResult<()> { + let body_file = temp_body_path(); + fs::write(&body_file, body)?; + + let output = Command::new("gh") + .current_dir(repo_root) + .args(["pr", "edit", "--body-file"]) + .arg(&body_file) + .output(); + + let remove_result = fs::remove_file(&body_file); + let output = output?; + if let Err(error) = remove_result { + return Err(error.into()); + } + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + Err(AppError::new(format!("gh pr edit failed: {stderr}")).into()) +} + +fn temp_body_path() -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + path.push(format!( + "plan-to-git-pr-body-{}-{timestamp}.md", + std::process::id() + )); + path +} diff --git a/src/lib.rs b/src/lib.rs index 490125b..6d70ea2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,9 @@ -pub mod sum; - -pub use sum::sum; +pub mod capture; +pub mod error; +pub mod git; +pub mod github; +pub mod normalize; +pub mod pr_body; +pub mod redact; +pub mod render; +pub mod store; diff --git a/src/main.rs b/src/main.rs index 3b8bdfe..5a9f1ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,140 @@ -use lino_arguments::Parser; +use clap::{Parser, Subcommand, ValueEnum}; +use std::fs; +use std::io::{self, Read}; -use example_sum_package_name::sum; +use plan_to_git::capture; +use plan_to_git::error::{AppError, AppResult}; +use plan_to_git::git; +use plan_to_git::github::{self, SyncStatus}; +use plan_to_git::render::render_plan_block; +use plan_to_git::store::{load_state, save_state, STATE_FILE_NAME}; #[derive(Parser, Debug)] -#[command(name = "example-sum-package-name", about = "Sum two numbers")] -struct Args { - #[arg(long, env = "A", default_value = "0", allow_hyphen_values = true)] - a: i64, +#[command( + name = "plan-to-git", + about = "Capture agent plans and sync them to GitHub pull requests" +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Process an agent hook JSON payload from stdin. + Hook { + #[arg(long, value_enum)] + source: HookSource, + }, + /// Sync the local plan stack into the current branch pull request. + Sync, + /// Print the local plan stack JSON. + Show, + /// Render the pull request markdown block. + Render, + /// Clear local plan stack state. + Clear { + #[arg(long)] + yes: bool, + }, +} - #[arg(long, env = "B", default_value = "0", allow_hyphen_values = true)] - b: i64, +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +enum HookSource { + Codex, } fn main() { - let args = Args::parse(); - println!("{}", sum(args.a, args.b)); + let cli = Cli::parse(); + + match &cli.command { + Commands::Hook { source } => { + if let Err(error) = run_hook(*source) { + eprintln!("plan-to-git hook error: {error}"); + } + } + command => { + if let Err(error) = run(command) { + eprintln!("plan-to-git error: {error}"); + std::process::exit(1); + } + } + } +} + +fn run(command: &Commands) -> AppResult<()> { + match command { + Commands::Hook { .. } => Ok(()), + Commands::Sync => { + let (context, state_path) = state_context()?; + let mut state = load_state(&state_path)?; + state.set_context( + context.repo_slug.clone(), + context.branch.clone(), + context.head_sha.clone(), + ); + save_state(&state_path, &state)?; + print_sync_status(&github::sync_state(&context, &state)?); + Ok(()) + } + Commands::Show => { + let (_, state_path) = state_context()?; + let state = load_state(&state_path)?; + println!("{}", serde_json::to_string_pretty(&state)?); + Ok(()) + } + Commands::Render => { + let (_, state_path) = state_context()?; + let state = load_state(&state_path)?; + println!("{}", render_plan_block(&state)); + Ok(()) + } + Commands::Clear { yes } => { + if !*yes { + return Err(AppError::new("refusing to clear state without --yes").into()); + } + let (_, state_path) = state_context()?; + if state_path.exists() { + fs::remove_file(state_path)?; + } + println!("cleared {STATE_FILE_NAME}"); + Ok(()) + } + } +} + +fn run_hook(source: HookSource) -> AppResult<()> { + let mut input = String::new(); + io::stdin().read_to_string(&mut input)?; + + match source { + HookSource::Codex => { + let outcome = capture::process_codex_hook(&input)?; + eprintln!( + "plan-to-git: captured {} plan(s), {} decision(s), {} pending question set(s), sync={:?}", + outcome.captured_plans, + outcome.captured_decisions, + outcome.pending_questions, + outcome.sync_status + ); + } + } + + Ok(()) +} + +fn state_context() -> AppResult<(git::GitContext, std::path::PathBuf)> { + let cwd = std::env::current_dir()?; + let context = git::discover(&cwd)?; + let state_path = context.repo_root.join(STATE_FILE_NAME); + Ok((context, state_path)) +} + +fn print_sync_status(status: &SyncStatus) { + match status { + SyncStatus::NoItems => println!("no captured plan items to sync"), + SyncStatus::NoPullRequest => println!("no pull request found for the current branch"), + SyncStatus::Unchanged { number } => println!("pull request #{number} already up to date"), + SyncStatus::Updated { number } => println!("updated pull request #{number}"), + } } diff --git a/src/normalize.rs b/src/normalize.rs new file mode 100644 index 0000000..357ed52 --- /dev/null +++ b/src/normalize.rs @@ -0,0 +1,159 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapturedPlan { + pub title: Option, + pub content: String, +} + +#[must_use] +pub fn extract_marked_plans(message: &str) -> Vec { + let mut plans = extract_tagged_plans(message); + plans.extend(extract_accepted_plan_headings(message)); + plans + .into_iter() + .filter(|plan| !plan.content.trim().is_empty()) + .collect() +} + +#[must_use] +pub fn extract_questions(message: &str) -> Vec { + let mut questions = Vec::new(); + for line in message.lines() { + let candidate = strip_list_marker(line.trim()); + if !candidate.ends_with('?') || candidate.len() < 4 { + continue; + } + + let question = candidate.trim().trim_matches('`').trim(); + if question.len() > 240 { + continue; + } + + if !questions.iter().any(|existing| existing == question) { + questions.push(question.to_owned()); + } + + if questions.len() == 10 { + break; + } + } + questions +} + +fn extract_tagged_plans(message: &str) -> Vec { + let lower = message.to_lowercase(); + let open_tag = ""; + let close_tag = ""; + let mut cursor = 0; + let mut plans = Vec::new(); + + while let Some(relative_start) = lower[cursor..].find(open_tag) { + let content_start = cursor + relative_start + open_tag.len(); + let Some(relative_end) = lower[content_start..].find(close_tag) else { + break; + }; + let content_end = content_start + relative_end; + let content = message[content_start..content_end].trim(); + if !content.is_empty() { + plans.push(CapturedPlan { + title: first_heading(content), + content: content.to_owned(), + }); + } + cursor = content_end + close_tag.len(); + } + + plans +} + +fn extract_accepted_plan_headings(message: &str) -> Vec { + let lines: Vec<&str> = message.lines().collect(); + let mut plans = Vec::new(); + let mut index = 0; + + while index < lines.len() { + let trimmed = lines[index].trim(); + if !is_accepted_plan_heading(trimmed) { + index += 1; + continue; + } + + let current_heading_level = heading_level(trimmed).unwrap_or(6); + let title = clean_heading(trimmed); + let mut content_lines = Vec::new(); + index += 1; + + while index < lines.len() { + let next = lines[index].trim(); + if heading_level(next).is_some_and(|next_level| next_level <= current_heading_level) { + break; + } + content_lines.push(lines[index]); + index += 1; + } + + let content = content_lines.join("\n").trim().to_owned(); + if !content.is_empty() { + plans.push(CapturedPlan { + title: Some(title), + content, + }); + } + } + + plans +} + +fn is_accepted_plan_heading(line: &str) -> bool { + let normalized = clean_heading(line).to_lowercase(); + matches!( + normalized.as_str(), + "accepted plan" | "принятый план" | "актуальный план" + ) +} + +fn clean_heading(line: &str) -> String { + line.trim_start_matches('#') + .trim() + .trim_end_matches(':') + .trim() + .to_owned() +} + +fn first_heading(content: &str) -> Option { + content + .lines() + .map(str::trim) + .find(|line| heading_level(line).is_some()) + .map(clean_heading) +} + +fn heading_level(line: &str) -> Option { + let hashes = line + .chars() + .take_while(|character| *character == '#') + .count(); + if hashes == 0 || hashes > 6 { + return None; + } + line.chars() + .nth(hashes) + .is_some_and(char::is_whitespace) + .then_some(hashes) +} + +fn strip_list_marker(line: &str) -> &str { + let without_bullet = line + .strip_prefix("- ") + .or_else(|| line.strip_prefix("* ")) + .unwrap_or(line); + + let Some((marker, rest)) = without_bullet.split_once(". ") else { + return without_bullet; + }; + + if marker.chars().all(|character| character.is_ascii_digit()) { + rest + } else { + without_bullet + } +} diff --git a/src/pr_body.rs b/src/pr_body.rs new file mode 100644 index 0000000..a05617f --- /dev/null +++ b/src/pr_body.rs @@ -0,0 +1,33 @@ +use crate::error::{AppError, AppResult}; + +pub const START_MARKER: &str = ""; +pub const END_MARKER: &str = ""; + +pub fn upsert_marker_block(body: &str, block: &str) -> AppResult { + let start = body.find(START_MARKER); + let end = body.find(END_MARKER); + + match (start, end) { + (None, None) => Ok(append_block(body, block)), + (Some(start_index), Some(end_index)) if start_index < end_index => { + let replace_end = end_index + END_MARKER.len(); + let mut updated = String::new(); + updated.push_str(body[..start_index].trim_end()); + updated.push_str("\n\n"); + updated.push_str(block.trim()); + updated.push_str("\n\n"); + updated.push_str(body[replace_end..].trim_start()); + Ok(updated.trim_end().to_owned()) + } + (Some(_), Some(_)) => Err(AppError::new("plan-to-git PR markers are out of order").into()), + _ => Err(AppError::new("plan-to-git PR body has only one marker").into()), + } +} + +fn append_block(body: &str, block: &str) -> String { + if body.trim().is_empty() { + return block.trim().to_owned(); + } + + format!("{}\n\n{}", body.trim_end(), block.trim()) +} diff --git a/src/redact.rs b/src/redact.rs new file mode 100644 index 0000000..3be36a3 --- /dev/null +++ b/src/redact.rs @@ -0,0 +1,27 @@ +use regex::Regex; + +#[must_use] +pub fn redact(input: &str) -> String { + let mut output = input.to_owned(); + for (pattern, replacement) in secret_patterns() { + if let Ok(regex) = Regex::new(pattern) { + output = regex.replace_all(&output, replacement).into_owned(); + } + } + output +} + +const fn secret_patterns() -> [(&'static str, &'static str); 7] { + [ + ( + r#"(?i)(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*['"]?[^'"\s`]+"#, + "$1=[REDACTED]", + ), + (r"(?i)bearer\s+[a-z0-9._\-]{16,}", "Bearer [REDACTED]"), + (r"sk-[a-zA-Z0-9_\-]{16,}", "[REDACTED_OPENAI_KEY]"), + (r"ghp_[a-zA-Z0-9_]{20,}", "[REDACTED_GITHUB_TOKEN]"), + (r"github_pat_[a-zA-Z0-9_]{20,}", "[REDACTED_GITHUB_TOKEN]"), + (r"xox[baprs]-[a-zA-Z0-9\-]{20,}", "[REDACTED_SLACK_TOKEN]"), + (r"AKIA[0-9A-Z]{16}", "[REDACTED_AWS_KEY]"), + ] +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..018fbfc --- /dev/null +++ b/src/render.rs @@ -0,0 +1,88 @@ +use crate::pr_body::{END_MARKER, START_MARKER}; +use crate::store::{AgentPlanState, AgentSource, PlanItemKind, PlanStackItem}; + +#[must_use] +pub fn render_plan_block(state: &AgentPlanState) -> String { + let mut output = String::new(); + output.push_str(START_MARKER); + output.push('\n'); + output.push_str("## Agent Plan Stack\n\n"); + + if let Some(branch) = &state.branch { + output.push_str("_Branch: `"); + output.push_str(branch); + output.push('`'); + if let Some(head_sha) = &state.head_sha { + output.push_str(" at `"); + output.push_str(short_sha(head_sha)); + output.push('`'); + } + output.push_str("._\n\n"); + } + + let items = current_branch_items(state); + + if items.is_empty() { + output.push_str("_No captured plans yet._\n"); + } else { + for (index, item) in items.iter().enumerate() { + output.push_str("### "); + output.push_str(&(index + 1).to_string()); + output.push_str(". "); + output.push_str(item_title(item.kind)); + output.push('\n'); + output.push_str("Source: "); + output.push_str(source_label(item.source)); + output.push_str(" - Captured: "); + output.push_str(&item.created_at); + output.push_str("\n\n"); + output.push_str(item.content.trim()); + output.push_str("\n\n"); + } + } + + output.push_str(END_MARKER); + output +} + +#[must_use] +pub fn has_current_branch_items(state: &AgentPlanState) -> bool { + state + .items + .iter() + .any(|item| matches_current_branch(item, state.branch.as_deref())) +} + +fn current_branch_items(state: &AgentPlanState) -> Vec<&PlanStackItem> { + state + .items + .iter() + .filter(|item| matches_current_branch(item, state.branch.as_deref())) + .collect() +} + +fn matches_current_branch(item: &PlanStackItem, current_branch: Option<&str>) -> bool { + match (item.branch.as_deref(), current_branch) { + (Some(item_branch), Some(branch)) => item_branch == branch, + (Some(_), None) | (None, _) => true, + } +} + +const fn item_title(kind: PlanItemKind) -> &'static str { + match kind { + PlanItemKind::Plan => "Plan", + PlanItemKind::Decision => "Planning Decision", + } +} + +const fn source_label(source: AgentSource) -> &'static str { + match source { + AgentSource::Codex => "codex", + AgentSource::Claude => "claude", + AgentSource::Manual => "manual", + } +} + +fn short_sha(sha: &str) -> &str { + sha.get(..7).unwrap_or(sha) +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..83da527 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,337 @@ +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::Path; + +use crate::error::AppResult; +use crate::redact::redact; + +pub const STATE_FILE_NAME: &str = ".agent-plan.json"; +const SCHEMA_VERSION: u8 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentPlanState { + pub schema_version: u8, + pub repo: Option, + pub branch: Option, + pub head_sha: Option, + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub pending_questions: Vec, +} + +impl Default for AgentPlanState { + fn default() -> Self { + Self { + schema_version: SCHEMA_VERSION, + repo: None, + branch: None, + head_sha: None, + items: Vec::new(), + pending_questions: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PlanStackItem { + pub id: String, + pub kind: PlanItemKind, + pub source: AgentSource, + pub title: Option, + pub content: String, + #[serde(default)] + pub questions: Vec, + pub answer: Option, + pub branch: Option, + pub head_sha: Option, + pub session_id: Option, + pub turn_id: Option, + pub content_hash: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PlanItemKind { + Plan, + Decision, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentSource { + Codex, + Claude, + Manual, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingQuestion { + pub id: String, + pub source: AgentSource, + pub questions: Vec, + pub branch: Option, + pub head_sha: Option, + pub session_id: Option, + pub turn_id: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewPlanItem { + pub source: AgentSource, + pub title: Option, + pub content: String, + pub branch: Option, + pub head_sha: Option, + pub session_id: Option, + pub turn_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewPendingQuestion { + pub source: AgentSource, + pub questions: Vec, + pub branch: Option, + pub head_sha: Option, + pub session_id: Option, + pub turn_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewDecision { + pub source: AgentSource, + pub questions: Vec, + pub answer: String, + pub branch: Option, + pub head_sha: Option, + pub session_id: Option, + pub turn_id: Option, +} + +impl AgentPlanState { + pub fn set_context( + &mut self, + repo: Option, + branch: Option, + head_sha: Option, + ) { + self.repo = repo; + self.branch = branch; + self.head_sha = head_sha; + } + + pub fn add_plan(&mut self, new_item: NewPlanItem) -> bool { + let content = redact(new_item.content.trim()); + if content.is_empty() { + return false; + } + + let content_hash = item_hash( + PlanItemKind::Plan, + new_item.source, + &content, + &[], + None, + new_item.branch.as_deref(), + ); + + if self + .items + .iter() + .any(|item| item.content_hash == content_hash) + { + return false; + } + + self.items.push(PlanStackItem { + id: item_id("plan", &content_hash), + kind: PlanItemKind::Plan, + source: new_item.source, + title: new_item.title.map(|title| redact(title.trim())), + content, + questions: Vec::new(), + answer: None, + branch: new_item.branch, + head_sha: new_item.head_sha, + session_id: new_item.session_id, + turn_id: new_item.turn_id, + content_hash, + created_at: timestamp(), + }); + + true + } + + pub fn add_pending_question(&mut self, new_question: NewPendingQuestion) -> bool { + let questions: Vec = new_question + .questions + .into_iter() + .map(|question| redact(question.trim())) + .filter(|question| !question.is_empty()) + .collect(); + + if questions.is_empty() { + return false; + } + + let question_hash = stable_hash(&questions.join("\n")); + if self + .pending_questions + .iter() + .any(|question| question.id == item_id("question", &question_hash)) + { + return false; + } + + self.pending_questions.push(PendingQuestion { + id: item_id("question", &question_hash), + source: new_question.source, + questions, + branch: new_question.branch, + head_sha: new_question.head_sha, + session_id: new_question.session_id, + turn_id: new_question.turn_id, + created_at: timestamp(), + }); + + true + } + + pub fn answer_pending_questions(&mut self, new_decision: NewDecision) -> bool { + let answer = redact(new_decision.answer.trim()); + if answer.is_empty() || new_decision.questions.is_empty() { + return false; + } + + let questions: Vec = new_decision + .questions + .into_iter() + .map(|question| redact(question.trim())) + .filter(|question| !question.is_empty()) + .collect(); + + if questions.is_empty() { + return false; + } + + let content = render_decision_content(&questions, &answer); + let content_hash = item_hash( + PlanItemKind::Decision, + new_decision.source, + &content, + &questions, + Some(&answer), + new_decision.branch.as_deref(), + ); + + if self + .items + .iter() + .any(|item| item.content_hash == content_hash) + { + self.pending_questions.clear(); + return false; + } + + self.items.push(PlanStackItem { + id: item_id("decision", &content_hash), + kind: PlanItemKind::Decision, + source: new_decision.source, + title: Some(String::from("Planning decision")), + content, + questions, + answer: Some(answer), + branch: new_decision.branch, + head_sha: new_decision.head_sha, + session_id: new_decision.session_id, + turn_id: new_decision.turn_id, + content_hash, + created_at: timestamp(), + }); + self.pending_questions.clear(); + + true + } +} + +pub fn load_state(path: &Path) -> AppResult { + if !path.exists() { + return Ok(AgentPlanState::default()); + } + + let content = fs::read_to_string(path)?; + let mut state: AgentPlanState = serde_json::from_str(&content)?; + if state.schema_version == 0 { + state.schema_version = SCHEMA_VERSION; + } + Ok(state) +} + +pub fn save_state(path: &Path, state: &AgentPlanState) -> AppResult<()> { + let content = serde_json::to_string_pretty(state)?; + fs::write(path, format!("{content}\n"))?; + Ok(()) +} + +fn render_decision_content(questions: &[String], answer: &str) -> String { + let mut content = String::from("Questions:\n"); + for question in questions { + content.push_str("- "); + content.push_str(question); + content.push('\n'); + } + content.push_str("\nAnswer:\n"); + content.push_str(answer); + content +} + +fn item_hash( + kind: PlanItemKind, + source: AgentSource, + content: &str, + questions: &[String], + answer: Option<&str>, + branch: Option<&str>, +) -> String { + let mut input = format!("{kind:?}\n{source:?}\n{}\n", content.trim()); + if !questions.is_empty() { + input.push_str(&questions.join("\n")); + } + if let Some(answer) = answer { + input.push_str(answer); + } + if let Some(branch) = branch { + input.push_str(branch); + } + stable_hash(&input) +} + +fn item_id(prefix: &str, hash: &str) -> String { + let short_hash: String = hash.chars().take(12).collect(); + format!("{prefix}-{short_hash}") +} + +fn stable_hash(input: &str) -> String { + let digest = Sha256::digest(input.as_bytes()); + let mut output = String::with_capacity(digest.len() * 2); + for byte in digest { + output.push(hex_char(byte >> 4)); + output.push(hex_char(byte & 0x0f)); + } + output +} + +const fn hex_char(value: u8) -> char { + match value { + 0..=9 => (b'0' + value) as char, + _ => (b'a' + (value - 10)) as char, + } +} + +fn timestamp() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) +} diff --git a/src/sum.rs b/src/sum.rs deleted file mode 100644 index e0960d2..0000000 --- a/src/sum.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[must_use] -pub const fn sum(a: i64, b: i64) -> i64 { - a + b -} diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs new file mode 100644 index 0000000..a673709 --- /dev/null +++ b/tests/integration/cli.rs @@ -0,0 +1,186 @@ +#[cfg(unix)] +mod unix { + use std::fs; + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; + use std::process::{Command, Stdio}; + + use plan_to_git::store::STATE_FILE_NAME; + + #[test] + fn hook_captures_plan_and_handles_missing_pr() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_no_pr(&bin_dir); + + let payload = format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_assistant_message":"\n# MVP\n\n- Capture plan\n" + }}"#, + repo_dir.display() + ); + + let mut child = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("hook") + .arg("--source") + .arg("codex") + .current_dir(&repo_dir) + .env("PATH", path_with_fake_bin(&bin_dir)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn plan-to-git"); + + child + .stdin + .as_mut() + .expect("stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + + let output = child.wait_with_output().expect("wait"); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("Capture plan")); + } + + #[test] + fn hook_records_question_answer_decision() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_no_pr(&bin_dir); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn-1", + "last_assistant_message":"Should sync be automatic?" + }}"#, + repo_dir.display() + ), + ); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"UserPromptSubmit", + "turn_id":"turn-2", + "prompt":"Yes, sync automatically when a PR exists." + }}"#, + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("Should sync be automatic?")); + assert!(state.contains("Yes, sync automatically")); + assert!(state.contains("\"pending_questions\": []")); + } + + fn run_hook(repo_dir: &Path, bin_dir: &Path, payload: &str) { + let mut child = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("hook") + .arg("--source") + .arg("codex") + .current_dir(repo_dir) + .env("PATH", path_with_fake_bin(bin_dir)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn plan-to-git"); + + child + .stdin + .as_mut() + .expect("stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + + let output = child.wait_with_output().expect("wait"); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + } + + fn write_fake_git(bin_dir: &Path, repo_dir: &Path) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "-C" ]]; then + shift 2 +fi +case "$*" in + "rev-parse --show-toplevel") + printf '%s\n' "{}" + ;; + "rev-parse --abbrev-ref HEAD") + printf '%s\n' "feature/test" + ;; + "rev-parse HEAD") + printf '%s\n' "abcdef1234567890" + ;; + "remote get-url origin") + printf '%s\n' "https://github.com/example/repo.git" + ;; + *) + echo "unexpected git args: $*" >&2 + exit 1 + ;; +esac +"#, + repo_dir.display() + ); + write_executable(&bin_dir.join("git"), &script); + } + + fn write_fake_gh_no_pr(bin_dir: &Path) { + let script = r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view --json number,body" ]]; then + echo 'no pull requests found for branch "feature/test"' >&2 + exit 1 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#; + write_executable(&bin_dir.join("gh"), script); + } + + fn write_executable(path: &Path, content: &str) { + fs::write(path, content).expect("write script"); + let mut permissions = fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("permissions"); + } + + fn path_with_fake_bin(bin_dir: &Path) -> String { + format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ) + } +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 47e2280..26710c1 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -1 +1 @@ -mod sum; +mod cli; diff --git a/tests/integration/sum.rs b/tests/integration/sum.rs deleted file mode 100644 index b0d15bc..0000000 --- a/tests/integration/sum.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::process::Command; - -#[test] -fn test_cli_default_args() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "0"); -} - -#[test] -fn test_cli_sum_two_numbers() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .args(["--a", "3", "--b", "7"]) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "10"); -} - -#[test] -fn test_cli_negative_numbers() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .args(["--a", "-5", "--b", "3"]) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "-2"); -} diff --git a/tests/unit/mod.rs b/tests/unit/mod.rs index 7c158e6..cb1013d 100644 --- a/tests/unit/mod.rs +++ b/tests/unit/mod.rs @@ -1,4 +1,4 @@ -mod sum; +mod plan_capture; #[path = "ci-cd/mod.rs"] mod ci_cd; diff --git a/tests/unit/plan_capture.rs b/tests/unit/plan_capture.rs new file mode 100644 index 0000000..e1d79c7 --- /dev/null +++ b/tests/unit/plan_capture.rs @@ -0,0 +1,209 @@ +use plan_to_git::normalize::{extract_marked_plans, extract_questions}; +use plan_to_git::pr_body::{upsert_marker_block, END_MARKER, START_MARKER}; +use plan_to_git::redact::redact; +use plan_to_git::render::{has_current_branch_items, render_plan_block}; +use plan_to_git::store::{ + AgentPlanState, AgentSource, NewDecision, NewPendingQuestion, NewPlanItem, +}; + +#[test] +fn extracts_proposed_plan_blocks() { + let message = r" +before + +# Implement Plan + +- Add capture. +- Add sync. + +after +"; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title.as_deref(), Some("Implement Plan")); + assert!(plans[0].content.contains("- Add capture.")); +} + +#[test] +fn extracts_accepted_plan_headings() { + let message = r" +## Accepted Plan +- Capture marked plans only. +- Sync to PR markers. + +## Summary +Do not include this. +"; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title.as_deref(), Some("Accepted Plan")); + assert!(!plans[0].content.contains("Summary")); +} + +#[test] +fn extracts_accepted_plan_label_until_next_heading() { + let message = r" +Accepted Plan: +- Keep this. + +### Notes +Do not include this. +"; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title.as_deref(), Some("Accepted Plan")); + assert!(!plans[0].content.contains("Notes")); +} + +#[test] +fn rejects_unmarked_plan_like_text() { + let message = "Plan:\n- Read everything\n- Upload all context"; + + assert!(extract_marked_plans(message).is_empty()); +} + +#[test] +fn extracts_assistant_questions() { + let message = r" +I need two choices: +- Which agent should be first? +1. Should sync be automatic? +This line is not a question. +"; + + let questions = extract_questions(message); + + assert_eq!( + questions, + vec![ + "Which agent should be first?".to_owned(), + "Should sync be automatic?".to_owned() + ] + ); +} + +#[test] +fn redacts_common_secret_shapes() { + let redacted = redact( + "api_key=sk-abcdefghijklmnopqrstuvwxyz token: ghp_abcdefghijklmnopqrstuvwxyz ghp_abcdefghijklmnopqrstuvwxyz", + ); + + assert!(redacted.contains("api_key=[REDACTED]")); + assert!(redacted.contains("token=[REDACTED]")); + assert!(redacted.contains("[REDACTED_GITHUB_TOKEN]")); + assert!(!redacted.contains("ghp_abcdefghijklmnopqrstuvwxyz")); +} + +#[test] +fn state_deduplicates_plans_and_records_decisions() { + let mut state = AgentPlanState::default(); + + let first = state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Implement Plan".to_owned()), + content: "- Step 1".to_owned(), + branch: Some("feature".to_owned()), + head_sha: Some("abc123".to_owned()), + session_id: Some("session".to_owned()), + turn_id: Some("turn".to_owned()), + }); + let duplicate = state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Implement Plan".to_owned()), + content: "- Step 1".to_owned(), + branch: Some("feature".to_owned()), + head_sha: Some("abc123".to_owned()), + session_id: Some("session".to_owned()), + turn_id: Some("turn".to_owned()), + }); + + assert!(first); + assert!(!duplicate); + assert_eq!(state.items.len(), 1); + + assert!(state.add_pending_question(NewPendingQuestion { + source: AgentSource::Codex, + questions: vec!["Auto sync?".to_owned()], + branch: Some("feature".to_owned()), + head_sha: Some("abc123".to_owned()), + session_id: Some("session".to_owned()), + turn_id: Some("turn-2".to_owned()), + })); + + assert!(state.answer_pending_questions(NewDecision { + source: AgentSource::Codex, + questions: vec!["Auto sync?".to_owned()], + answer: "Yes, sync automatically.".to_owned(), + branch: Some("feature".to_owned()), + head_sha: Some("abc123".to_owned()), + session_id: Some("session".to_owned()), + turn_id: Some("turn-3".to_owned()), + })); + + assert_eq!(state.pending_questions.len(), 0); + assert_eq!(state.items.len(), 2); + assert!(state.items[1].content.contains("Yes, sync automatically.")); +} + +#[test] +fn render_filters_items_to_current_branch() { + let mut state = AgentPlanState::default(); + state.set_context( + Some("example/repo".to_owned()), + Some("feature/current".to_owned()), + Some("abcdef1234567890".to_owned()), + ); + assert!(state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Current".to_owned()), + content: "- Current branch plan".to_owned(), + branch: Some("feature/current".to_owned()), + head_sha: Some("abcdef1234567890".to_owned()), + session_id: None, + turn_id: None, + })); + assert!(state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Other".to_owned()), + content: "- Other branch plan".to_owned(), + branch: Some("feature/other".to_owned()), + head_sha: Some("1234567890abcdef".to_owned()), + session_id: None, + turn_id: None, + })); + + let rendered = render_plan_block(&state); + + assert!(has_current_branch_items(&state)); + assert!(rendered.contains("Current branch plan")); + assert!(!rendered.contains("Other branch plan")); +} + +#[test] +fn pr_body_appends_and_replaces_marker_block() { + let original = "Existing body"; + let block = format!("{START_MARKER}\n## Agent Plan Stack\n{END_MARKER}"); + + let appended = upsert_marker_block(original, &block).expect("append should work"); + assert!(appended.contains(original)); + assert!(appended.contains(START_MARKER)); + + let replacement = format!("{START_MARKER}\n## Updated\n{END_MARKER}"); + let replaced = upsert_marker_block(&appended, &replacement).expect("replace should work"); + assert!(replaced.contains("## Updated")); + assert!(!replaced.contains("## Agent Plan Stack")); +} + +#[test] +fn pr_body_rejects_partial_markers() { + let body = format!("Existing body\n\n{START_MARKER}\nmissing end"); + let block = format!("{START_MARKER}\n## Agent Plan Stack\n{END_MARKER}"); + + assert!(upsert_marker_block(&body, &block).is_err()); +} diff --git a/tests/unit/sum.rs b/tests/unit/sum.rs deleted file mode 100644 index 84937f6..0000000 --- a/tests/unit/sum.rs +++ /dev/null @@ -1,26 +0,0 @@ -use example_sum_package_name::sum; - -#[test] -fn test_sum_positive_numbers() { - assert_eq!(sum(2, 3), 5); -} - -#[test] -fn test_sum_negative_numbers() { - assert_eq!(sum(-1, -2), -3); -} - -#[test] -fn test_sum_zero() { - assert_eq!(sum(5, 0), 5); -} - -#[test] -fn test_sum_large_numbers() { - assert_eq!(sum(1_000_000, 2_000_000), 3_000_000); -} - -#[test] -fn test_sum_mixed_sign() { - assert_eq!(sum(-100, 50), -50); -} From 415c27647c2bfbb4a7d9cf521399709e658e98f0 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 13:40:36 +0000 Subject: [PATCH 02/11] fix: sync PR body through gh api --- src/github.rs | 29 +++++++++++++--------- tests/integration/cli.rs | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/src/github.rs b/src/github.rs index 0d51b9d..8268029 100644 --- a/src/github.rs +++ b/src/github.rs @@ -45,7 +45,7 @@ pub fn sync_state(context: &GitContext, state: &AgentPlanState) -> AppResult AppResult> { Err(AppError::new(format!("gh pr view failed: {stderr}")).into()) } -fn edit_pr_body(repo_root: &Path, body: &str) -> AppResult<()> { - let body_file = temp_body_path(); - fs::write(&body_file, body)?; +fn edit_pr_body(context: &GitContext, number: u64, body: &str) -> AppResult<()> { + let repo_slug = context + .repo_slug + .as_deref() + .ok_or_else(|| AppError::new("cannot sync PR body without a GitHub origin remote"))?; + let request_file = temp_request_path(); + let request = serde_json::json!({ "body": body }); + fs::write(&request_file, serde_json::to_vec(&request)?)?; let output = Command::new("gh") - .current_dir(repo_root) - .args(["pr", "edit", "--body-file"]) - .arg(&body_file) + .current_dir(&context.repo_root) + .args(["api", "--method", "PATCH"]) + .arg(format!("repos/{repo_slug}/pulls/{number}")) + .args(["--input"]) + .arg(&request_file) .output(); - let remove_result = fs::remove_file(&body_file); + let remove_result = fs::remove_file(&request_file); let output = output?; if let Err(error) = remove_result { return Err(error.into()); @@ -90,16 +97,16 @@ fn edit_pr_body(repo_root: &Path, body: &str) -> AppResult<()> { } let stderr = String::from_utf8_lossy(&output.stderr); - Err(AppError::new(format!("gh pr edit failed: {stderr}")).into()) + Err(AppError::new(format!("gh api PR update failed: {stderr}")).into()) } -fn temp_body_path() -> std::path::PathBuf { +fn temp_request_path() -> std::path::PathBuf { let mut path = std::env::temp_dir(); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |duration| duration.as_nanos()); path.push(format!( - "plan-to-git-pr-body-{}-{timestamp}.md", + "plan-to-git-pr-body-{}-{timestamp}.json", std::process::id() )); path diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs index a673709..62223f4 100644 --- a/tests/integration/cli.rs +++ b/tests/integration/cli.rs @@ -101,6 +101,38 @@ mod unix { assert!(state.contains("\"pending_questions\": []")); } + #[test] + fn hook_updates_open_pr_body_through_gh_api() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + let captured_request = temp_dir.path().join("request.json"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_open_pr(&bin_dir, &captured_request); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_assistant_message":"\n# MVP\n\n- Update PR body\n" + }}"#, + repo_dir.display() + ), + ); + + let request = fs::read_to_string(captured_request).expect("captured request"); + assert!(request.contains("Original PR body")); + assert!(request.contains("plan-to-git:start")); + assert!(request.contains("Update PR body")); + } + fn run_hook(repo_dir: &Path, bin_dir: &Path, payload: &str) { let mut child = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) .arg("hook") @@ -169,6 +201,26 @@ exit 1 write_executable(&bin_dir.join("gh"), script); } + fn write_fake_gh_open_pr(bin_dir: &Path, captured_request: &Path) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view --json number,body" ]]; then + printf '%s\n' '{{"number":17,"body":"Original PR body"}}' + exit 0 +fi +if [[ "$1 $2 $3" == "api --method PATCH" && "$4" == "repos/example/repo/pulls/17" && "$5" == "--input" ]]; then + cp "$6" "{}" + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#, + captured_request.display() + ); + write_executable(&bin_dir.join("gh"), &script); + } + fn write_executable(path: &Path, content: &str) { fs::write(path, content).expect("write script"); let mut permissions = fs::metadata(path).expect("metadata").permissions(); From 94587af658b7101b504ca0b7fe69c85b1a292ea6 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 18:02:59 +0000 Subject: [PATCH 03/11] feat: import codex history plans --- README.md | 6 +- .../20260530_104500_plan_to_git_mvp.md | 1 + examples/basic_usage.rs | 1 + src/capture.rs | 1 + src/codex_history.rs | 423 ++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 66 +++ src/store.rs | 3 +- tests/integration/cli.rs | 48 ++ tests/unit/plan_capture.rs | 4 + 10 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 src/codex_history.rs diff --git a/README.md b/README.md index 4b125bc..8ccee45 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ plan-to-git hook --source codex < hook-payload.json plan-to-git show plan-to-git render plan-to-git sync +plan-to-git import-codex --dry-run +plan-to-git import-codex plan-to-git clear --yes ``` @@ -56,4 +58,6 @@ If only one marker exists, sync fails rather than risking corruption of the huma ## Safety -The tool does not scrape raw `~/.codex` or `~/.claude` transcripts. It only uses stable hook payload fields and explicitly marked plan text. Captured content is redacted before local storage and PR sync. +The hook path only uses stable hook payload fields and explicitly marked plan text. `import-codex` can backfill previous plans from `~/.codex/sessions`, but it only reads assistant message events from sessions that match the current repository and branch, and it still imports only explicit markers such as `...` or `## Accepted Plan`. + +Captured content is redacted before local storage and PR sync. `.agent-plan.json` also acts as the sent-plan registry: content hashes prevent the same plan from being added and uploaded again for the same branch. diff --git a/changelog.d/20260530_104500_plan_to_git_mvp.md b/changelog.d/20260530_104500_plan_to_git_mvp.md index babfd6d..6f01c55 100644 --- a/changelog.d/20260530_104500_plan_to_git_mvp.md +++ b/changelog.d/20260530_104500_plan_to_git_mvp.md @@ -4,3 +4,4 @@ bump: minor ### Added - Added the `plan-to-git` CLI for capturing Codex plan hooks, storing a local plan stack, and syncing it into GitHub pull request bodies. +- Added `plan-to-git import-codex` for backfilling explicitly marked plans from matching Codex session history without re-uploading duplicates. diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index a26bae5..78f05b5 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -16,6 +16,7 @@ fn main() { head_sha: Some("abcdef1234567890".to_owned()), session_id: None, turn_id: None, + created_at: None, }); println!("{}", render_plan_block(&state)); diff --git a/src/capture.rs b/src/capture.rs index cfdea16..620b78a 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -62,6 +62,7 @@ pub fn process_codex_hook(input: &str) -> AppResult { head_sha: context.head_sha.clone(), session_id: hook_input.session_id.clone(), turn_id: hook_input.turn_id.clone(), + created_at: None, }); if added { captured_plans += 1; diff --git a/src/codex_history.rs b/src/codex_history.rs new file mode 100644 index 0000000..3bd62c0 --- /dev/null +++ b/src/codex_history.rs @@ -0,0 +1,423 @@ +use serde_json::Value; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use crate::error::AppResult; +use crate::git::{parse_github_slug, GitContext}; +use crate::normalize::extract_marked_plans; +use crate::store::{AgentPlanState, AgentSource, NewPlanItem}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexHistoryImportOutcome { + pub files_scanned: usize, + pub files_matched: usize, + pub lines_scanned: usize, + pub parse_errors: usize, + pub plans_found: usize, + pub plans_added: usize, + pub duplicates: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SessionMetadata { + id: Option, + repo_slug: Option, + branch: Option, + cwd: Option, +} + +pub fn import_codex_history( + codex_home: &Path, + context: &GitContext, + state: &mut AgentPlanState, +) -> AppResult { + let mut outcome = CodexHistoryImportOutcome { + files_scanned: 0, + files_matched: 0, + lines_scanned: 0, + parse_errors: 0, + plans_found: 0, + plans_added: 0, + duplicates: 0, + }; + + let mut files = codex_session_files(codex_home)?; + files.sort(); + + for path in files { + outcome.files_scanned += 1; + import_session_file(&path, context, state, &mut outcome)?; + } + + Ok(outcome) +} + +fn import_session_file( + path: &Path, + context: &GitContext, + state: &mut AgentPlanState, + outcome: &mut CodexHistoryImportOutcome, +) -> AppResult<()> { + let file = File::open(path)?; + let reader = BufReader::new(file); + let mut metadata: Option = None; + let mut file_matches = false; + + for (line_index, line) in reader.lines().enumerate() { + outcome.lines_scanned += 1; + let line = line?; + let Ok(event) = serde_json::from_str::(&line) else { + outcome.parse_errors += 1; + continue; + }; + + if event.get("type").and_then(Value::as_str) == Some("session_meta") { + metadata = Some(parse_session_metadata(&event)); + file_matches = metadata + .as_ref() + .is_some_and(|session| session_matches_context(session, context)); + if file_matches { + outcome.files_matched += 1; + } + continue; + } + + if !file_matches { + continue; + } + + let Some(message) = plan_message_text(&event) else { + continue; + }; + + let session_id = metadata + .as_ref() + .and_then(|session| session.id.clone()) + .or_else(|| session_id_from_path(path)); + let turn_id = event + .get("payload") + .and_then(|payload| payload.get("turn_id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| line_turn_id(path, line_index + 1)); + let created_at = event + .get("timestamp") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + + for plan in extract_marked_plans(&message) { + outcome.plans_found += 1; + let added = state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: plan.title, + content: plan.content, + branch: context.branch.clone(), + head_sha: context.head_sha.clone(), + session_id: session_id.clone(), + turn_id: turn_id.clone(), + created_at: created_at.clone(), + }); + + if added { + outcome.plans_added += 1; + } else { + outcome.duplicates += 1; + } + } + } + + Ok(()) +} + +fn codex_session_files(codex_home: &Path) -> AppResult> { + let sessions_dir = codex_home.join("sessions"); + if !sessions_dir.exists() { + return Ok(Vec::new()); + } + + let mut files = Vec::new(); + collect_jsonl_files(&sessions_dir, &mut files)?; + Ok(files) +} + +fn collect_jsonl_files(dir: &Path, files: &mut Vec) -> AppResult<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_jsonl_files(&path, files)?; + } else if path + .extension() + .is_some_and(|extension| extension == "jsonl") + { + files.push(path); + } + } + Ok(()) +} + +fn parse_session_metadata(event: &Value) -> SessionMetadata { + let payload = event.get("payload"); + let id = payload + .and_then(|payload| payload.get("id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let branch = payload + .and_then(|payload| payload.get("git")) + .and_then(|git| git.get("branch")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let repo_slug = payload + .and_then(|payload| payload.get("git")) + .and_then(|git| git.get("repository_url")) + .and_then(Value::as_str) + .and_then(parse_github_slug); + let cwd = payload + .and_then(|payload| payload.get("cwd")) + .and_then(Value::as_str) + .map(PathBuf::from); + + SessionMetadata { + id, + repo_slug, + branch, + cwd, + } +} + +fn session_matches_context(session: &SessionMetadata, context: &GitContext) -> bool { + let repo_matches = match (&context.repo_slug, &session.repo_slug) { + (Some(current), Some(history)) => current == history, + _ => session + .cwd + .as_ref() + .is_some_and(|cwd| cwd.starts_with(&context.repo_root)), + }; + let branch_matches = match (&context.branch, &session.branch) { + (Some(current), Some(history)) => current == history, + _ => true, + }; + + repo_matches && branch_matches +} + +fn plan_message_text(event: &Value) -> Option { + assistant_message_text(event).or_else(|| task_complete_message_text(event)) +} + +fn assistant_message_text(event: &Value) -> Option { + if event.get("type").and_then(Value::as_str) != Some("response_item") { + return None; + } + + let payload = event.get("payload")?; + if payload.get("type").and_then(Value::as_str) != Some("message") { + return None; + } + if payload.get("role").and_then(Value::as_str) != Some("assistant") { + return None; + } + + let text = payload + .get("content")? + .as_array()? + .iter() + .filter_map(|content| content.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + + (!text.trim().is_empty()).then_some(text) +} + +fn task_complete_message_text(event: &Value) -> Option { + if event.get("type").and_then(Value::as_str) != Some("event_msg") { + return None; + } + + let payload = event.get("payload")?; + if payload.get("type").and_then(Value::as_str) != Some("task_complete") { + return None; + } + + let text = payload + .get("last_agent_message") + .or_else(|| payload.get("last_assistant_message")) + .and_then(Value::as_str)?; + + (!text.trim().is_empty()).then_some(text.to_owned()) +} + +fn session_id_from_path(path: &Path) -> Option { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(ToOwned::to_owned) +} + +fn line_turn_id(path: &Path, line_number: usize) -> Option { + let stem = path.file_stem()?.to_str()?; + Some(format!("{stem}:{line_number}")) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use crate::git::GitContext; + use crate::store::AgentPlanState; + + use super::import_codex_history; + + #[test] + fn imports_marked_assistant_plans_and_skips_duplicates() { + let temp_dir = tempdir().expect("temp dir"); + let repo_root = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&repo_root).expect("repo root"); + fs::create_dir_all(&session_dir).expect("session dir"); + + let session = session_dir.join("rollout-2026-05-31T00-00-00-session.jsonl"); + fs::write( + &session, + format!( + r#"{{"type":"session_meta","payload":{{"id":"session","cwd":"{}","git":{{"branch":"feature/test","repository_url":"https://github.com/example/repo.git"}}}}}} +{{"type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"ignore user text"}}]}}}} +{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"not a marked plan"}}]}}}} +{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"\n# Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n"}}]}}}} +{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"\n# Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n"}}]}}}} +"#, + repo_root.display() + ), + ) + .expect("write session"); + + let context = GitContext { + repo_root, + repo_slug: Some("example/repo".to_owned()), + branch: Some("feature/test".to_owned()), + head_sha: Some("abcdef".to_owned()), + }; + let mut state = AgentPlanState::default(); + + let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import"); + + assert_eq!(outcome.files_scanned, 1); + assert_eq!(outcome.files_matched, 1); + assert_eq!(outcome.plans_found, 2); + assert_eq!(outcome.plans_added, 1); + assert_eq!(outcome.duplicates, 1); + assert_eq!(state.items.len(), 1); + assert!(state.items[0].content.contains("Import old plans")); + assert!(state.items[0].content.contains("api_key=[REDACTED]")); + assert!(!state.items[0].content.contains("secret-value")); + assert_eq!( + state.items[0].turn_id.as_deref(), + Some("rollout-2026-05-31T00-00-00-session:4") + ); + } + + #[test] + fn skips_sessions_from_other_branches() { + let temp_dir = tempdir().expect("temp dir"); + let repo_root = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&repo_root).expect("repo root"); + fs::create_dir_all(&session_dir).expect("session dir"); + + fs::write( + session_dir.join("rollout-2026-05-31T00-00-00-other.jsonl"), + format!( + r#"{{"type":"session_meta","payload":{{"id":"session","cwd":"{}","git":{{"branch":"feature/other","repository_url":"https://github.com/example/repo.git"}}}}}} +{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"\n# Other\n\n- Wrong branch\n"}}]}}}} +"#, + repo_root.display() + ), + ) + .expect("write session"); + + let context = GitContext { + repo_root, + repo_slug: Some("example/repo".to_owned()), + branch: Some("feature/test".to_owned()), + head_sha: Some("abcdef".to_owned()), + }; + let mut state = AgentPlanState::default(); + + let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import"); + + assert_eq!(outcome.files_scanned, 1); + assert_eq!(outcome.files_matched, 0); + assert_eq!(outcome.plans_found, 0); + assert!(state.items.is_empty()); + } + + #[test] + fn imports_task_complete_last_agent_message() { + let temp_dir = tempdir().expect("temp dir"); + let repo_root = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&repo_root).expect("repo root"); + fs::create_dir_all(&session_dir).expect("session dir"); + + fs::write( + session_dir.join("rollout-2026-05-31T00-00-00-complete.jsonl"), + format!( + r#"{{"type":"session_meta","payload":{{"id":"session","cwd":"{}","git":{{"branch":"feature/test","repository_url":"https://github.com/example/repo.git"}}}}}} +{{"timestamp":"2026-05-31T12:34:56Z","type":"event_msg","payload":{{"type":"task_complete","last_agent_message":"\n# Complete\n\n- Import completion text\n"}}}} +"#, + repo_root.display() + ), + ) + .expect("write session"); + + let context = GitContext { + repo_root, + repo_slug: Some("example/repo".to_owned()), + branch: Some("feature/test".to_owned()), + head_sha: Some("abcdef".to_owned()), + }; + let mut state = AgentPlanState::default(); + + let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import"); + + assert_eq!(outcome.plans_added, 1); + assert!(state.items[0].content.contains("Import completion text")); + assert_eq!(state.items[0].created_at, "2026-05-31T12:34:56Z"); + } + + #[test] + fn skips_sessions_without_positive_repo_or_cwd_match() { + let temp_dir = tempdir().expect("temp dir"); + let repo_root = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&repo_root).expect("repo root"); + fs::create_dir_all(&session_dir).expect("session dir"); + + fs::write( + session_dir.join("rollout-2026-05-31T00-00-00-no-context.jsonl"), + r#"{"type":"session_meta","payload":{"id":"session","git":{"branch":"feature/test"}}} +{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"\n# No Context\n\n- Do not import\n"}]}} +"#, + ) + .expect("write session"); + + let context = GitContext { + repo_root, + repo_slug: Some("example/repo".to_owned()), + branch: Some("feature/test".to_owned()), + head_sha: Some("abcdef".to_owned()), + }; + let mut state = AgentPlanState::default(); + + let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import"); + + assert_eq!(outcome.files_matched, 0); + assert!(state.items.is_empty()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6d70ea2..17b3357 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod capture; +pub mod codex_history; pub mod error; pub mod git; pub mod github; diff --git a/src/main.rs b/src/main.rs index 5a9f1ed..07636ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ use clap::{Parser, Subcommand, ValueEnum}; use std::fs; use std::io::{self, Read}; +use std::path::PathBuf; use plan_to_git::capture; +use plan_to_git::codex_history::{self, CodexHistoryImportOutcome}; use plan_to_git::error::{AppError, AppResult}; use plan_to_git::git; use plan_to_git::github::{self, SyncStatus}; @@ -26,6 +28,19 @@ enum Commands { #[arg(long, value_enum)] source: HookSource, }, + /// Import explicitly marked plans from previous Codex session files. + #[command(visible_alias = "backfill-codex")] + ImportCodex { + /// Codex home directory. Defaults to `CODEX_HOME` or `~/.codex`. + #[arg(long)] + codex_home: Option, + /// Scan and report what would be imported without writing or syncing. + #[arg(long)] + dry_run: bool, + /// Save imported plans locally without syncing the pull request body. + #[arg(long)] + no_sync: bool, + }, /// Sync the local plan stack into the current branch pull request. Sync, /// Print the local plan stack JSON. @@ -65,6 +80,38 @@ fn main() { fn run(command: &Commands) -> AppResult<()> { match command { Commands::Hook { .. } => Ok(()), + Commands::ImportCodex { + codex_home, + dry_run, + no_sync, + } => { + let (context, state_path) = state_context()?; + let mut state = load_state(&state_path)?; + state.set_context( + context.repo_slug.clone(), + context.branch.clone(), + context.head_sha.clone(), + ); + + let codex_home = codex_home + .clone() + .or_else(default_codex_home) + .ok_or_else(|| AppError::new("cannot locate Codex home directory"))?; + let outcome = codex_history::import_codex_history(&codex_home, &context, &mut state)?; + + if !*dry_run && outcome.plans_added > 0 { + save_state(&state_path, &state)?; + } + + print_import_outcome(&outcome, *dry_run); + + if *dry_run || *no_sync || outcome.plans_found == 0 { + return Ok(()); + } + + print_sync_status(&github::sync_state(&context, &state)?); + Ok(()) + } Commands::Sync => { let (context, state_path) = state_context()?; let mut state = load_state(&state_path)?; @@ -138,3 +185,22 @@ fn print_sync_status(status: &SyncStatus) { SyncStatus::Updated { number } => println!("updated pull request #{number}"), } } + +fn default_codex_home() -> Option { + std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex"))) +} + +fn print_import_outcome(outcome: &CodexHistoryImportOutcome, dry_run: bool) { + let mode = if dry_run { "dry-run" } else { "import" }; + println!( + "{mode}: scanned {} file(s), matched {} current repo/branch file(s), found {} plan(s), added {}, skipped {} duplicate(s), parse errors {}", + outcome.files_scanned, + outcome.files_matched, + outcome.plans_found, + outcome.plans_added, + outcome.duplicates, + outcome.parse_errors + ); +} diff --git a/src/store.rs b/src/store.rs index 83da527..be06bbd 100644 --- a/src/store.rs +++ b/src/store.rs @@ -89,6 +89,7 @@ pub struct NewPlanItem { pub head_sha: Option, pub session_id: Option, pub turn_id: Option, + pub created_at: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -160,7 +161,7 @@ impl AgentPlanState { session_id: new_item.session_id, turn_id: new_item.turn_id, content_hash, - created_at: timestamp(), + created_at: new_item.created_at.unwrap_or_else(timestamp), }); true diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs index 62223f4..be2c602 100644 --- a/tests/integration/cli.rs +++ b/tests/integration/cli.rs @@ -133,6 +133,39 @@ mod unix { assert!(request.contains("Update PR body")); } + #[test] + fn import_codex_backfills_history_once_without_syncing() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + fs::create_dir_all(&session_dir).expect("session dir"); + write_fake_git(&bin_dir, &repo_dir); + + fs::write( + session_dir.join("rollout-2026-05-31T00-00-00-session.jsonl"), + format!( + r#"{{"type":"session_meta","payload":{{"id":"session","cwd":"{}","git":{{"branch":"feature/test","repository_url":"https://github.com/example/repo.git"}}}}}} +{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"\n# Archived Plan\n\n- Import archived plan\n"}}]}}}} +"#, + repo_dir.display() + ), + ) + .expect("write session"); + + let first = run_import_codex(&repo_dir, &bin_dir, &codex_home); + assert!(first.contains("found 1 plan(s), added 1")); + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("Import archived plan")); + + let second = run_import_codex(&repo_dir, &bin_dir, &codex_home); + assert!(second.contains("found 1 plan(s), added 0")); + assert!(second.contains("skipped 1 duplicate(s)")); + } + fn run_hook(repo_dir: &Path, bin_dir: &Path, payload: &str) { let mut child = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) .arg("hook") @@ -157,6 +190,21 @@ mod unix { assert!(output.stdout.is_empty()); } + fn run_import_codex(repo_dir: &Path, bin_dir: &Path, codex_home: &Path) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("import-codex") + .arg("--codex-home") + .arg(codex_home) + .arg("--no-sync") + .current_dir(repo_dir) + .env("PATH", path_with_fake_bin(bin_dir)) + .output() + .expect("run import-codex"); + + assert!(output.status.success()); + String::from_utf8(output.stdout).expect("stdout") + } + fn write_fake_git(bin_dir: &Path, repo_dir: &Path) { let script = format!( r#"#!/usr/bin/env bash diff --git a/tests/unit/plan_capture.rs b/tests/unit/plan_capture.rs index e1d79c7..debea77 100644 --- a/tests/unit/plan_capture.rs +++ b/tests/unit/plan_capture.rs @@ -112,6 +112,7 @@ fn state_deduplicates_plans_and_records_decisions() { head_sha: Some("abc123".to_owned()), session_id: Some("session".to_owned()), turn_id: Some("turn".to_owned()), + created_at: None, }); let duplicate = state.add_plan(NewPlanItem { source: AgentSource::Codex, @@ -121,6 +122,7 @@ fn state_deduplicates_plans_and_records_decisions() { head_sha: Some("abc123".to_owned()), session_id: Some("session".to_owned()), turn_id: Some("turn".to_owned()), + created_at: None, }); assert!(first); @@ -167,6 +169,7 @@ fn render_filters_items_to_current_branch() { head_sha: Some("abcdef1234567890".to_owned()), session_id: None, turn_id: None, + created_at: None, })); assert!(state.add_plan(NewPlanItem { source: AgentSource::Codex, @@ -176,6 +179,7 @@ fn render_filters_items_to_current_branch() { head_sha: Some("1234567890abcdef".to_owned()), session_id: None, turn_id: None, + created_at: None, })); let rendered = render_plan_block(&state); From 4ef787288e8af2bf202953e7b8cdcb403dd9197a Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 18:09:33 +0000 Subject: [PATCH 04/11] fix: parse proposed plan tags line-wise --- src/normalize.rs | 38 +++++++++++++++++++++++--------------- tests/unit/plan_capture.rs | 17 +++++++++++++++++ 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/normalize.rs b/src/normalize.rs index 357ed52..59de642 100644 --- a/src/normalize.rs +++ b/src/normalize.rs @@ -40,26 +40,34 @@ pub fn extract_questions(message: &str) -> Vec { } fn extract_tagged_plans(message: &str) -> Vec { - let lower = message.to_lowercase(); - let open_tag = ""; - let close_tag = ""; - let mut cursor = 0; let mut plans = Vec::new(); + let mut content_lines: Option> = None; - while let Some(relative_start) = lower[cursor..].find(open_tag) { - let content_start = cursor + relative_start + open_tag.len(); - let Some(relative_end) = lower[content_start..].find(close_tag) else { - break; - }; - let content_end = content_start + relative_end; - let content = message[content_start..content_end].trim(); - if !content.is_empty() { + for line in message.lines() { + let trimmed = line.trim(); + if trimmed.eq_ignore_ascii_case("") { + content_lines = Some(Vec::new()); + continue; + } + + if trimmed.eq_ignore_ascii_case("") { + let Some(lines) = content_lines.take() else { + continue; + }; + let content = lines.join("\n").trim().to_owned(); + if content.is_empty() { + continue; + } plans.push(CapturedPlan { - title: first_heading(content), - content: content.to_owned(), + title: first_heading(&content), + content, }); + continue; + } + + if let Some(lines) = content_lines.as_mut() { + lines.push(line); } - cursor = content_end + close_tag.len(); } plans diff --git a/tests/unit/plan_capture.rs b/tests/unit/plan_capture.rs index debea77..f23f5d1 100644 --- a/tests/unit/plan_capture.rs +++ b/tests/unit/plan_capture.rs @@ -26,6 +26,23 @@ after assert!(plans[0].content.contains("- Add capture.")); } +#[test] +fn tagged_plan_ignores_inline_tag_examples() { + let message = r" + +# Parser Plan + +- Capture `...` examples as prose. +- Continue until the real closing marker. + +"; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert!(plans[0].content.contains("Continue until the real")); +} + #[test] fn extracts_accepted_plan_headings() { let message = r" From 283dcb7c43831249b950518236c6899c4c849c3d Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 18:12:08 +0000 Subject: [PATCH 05/11] fix: keep inline tag examples in plans --- src/normalize.rs | 51 +++++++++++++++++++++++--------------- tests/unit/plan_capture.rs | 10 ++++++++ 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/normalize.rs b/src/normalize.rs index 59de642..4a10e24 100644 --- a/src/normalize.rs +++ b/src/normalize.rs @@ -40,39 +40,50 @@ pub fn extract_questions(message: &str) -> Vec { } fn extract_tagged_plans(message: &str) -> Vec { + let lower = message.to_lowercase(); + let open_tag = ""; + let close_tag = ""; + let mut cursor = 0; let mut plans = Vec::new(); - let mut content_lines: Option> = None; - for line in message.lines() { - let trimmed = line.trim(); - if trimmed.eq_ignore_ascii_case("") { - content_lines = Some(Vec::new()); - continue; - } + while let Some(relative_start) = lower[cursor..].find(open_tag) { + let content_start = cursor + relative_start + open_tag.len(); + let mut close_cursor = content_start; - if trimmed.eq_ignore_ascii_case("") { - let Some(lines) = content_lines.take() else { - continue; + let Some(content_end) = (loop { + let Some(relative_end) = lower[close_cursor..].find(close_tag) else { + break None; }; - let content = lines.join("\n").trim().to_owned(); - if content.is_empty() { - continue; + let candidate_start = close_cursor + relative_end; + let candidate_end = candidate_start + close_tag.len(); + if closes_plan_block(message, candidate_end) { + break Some(candidate_start); } + close_cursor = candidate_end; + }) else { + break; + }; + + let content = message[content_start..content_end].trim(); + if !content.is_empty() { plans.push(CapturedPlan { - title: first_heading(&content), - content, + title: first_heading(content), + content: content.to_owned(), }); - continue; - } - - if let Some(lines) = content_lines.as_mut() { - lines.push(line); } + cursor = content_end + close_tag.len(); } plans } +fn closes_plan_block(message: &str, close_tag_end: usize) -> bool { + message[close_tag_end..] + .lines() + .next() + .is_none_or(|rest_of_line| rest_of_line.trim().is_empty()) +} + fn extract_accepted_plan_headings(message: &str) -> Vec { let lines: Vec<&str> = message.lines().collect(); let mut plans = Vec::new(); diff --git a/tests/unit/plan_capture.rs b/tests/unit/plan_capture.rs index f23f5d1..38d1ea3 100644 --- a/tests/unit/plan_capture.rs +++ b/tests/unit/plan_capture.rs @@ -43,6 +43,16 @@ fn tagged_plan_ignores_inline_tag_examples() { assert!(plans[0].content.contains("Continue until the real")); } +#[test] +fn extracts_single_line_tagged_plan() { + let message = "# Inline\n- Keep it"; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert!(plans[0].content.contains("Keep it")); +} + #[test] fn extracts_accepted_plan_headings() { let message = r" From 979a739337b6ccdd205c1bd1eb26611940ee3861 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 18:15:17 +0000 Subject: [PATCH 06/11] fix: avoid importing rendered plan stacks --- src/codex_history.rs | 48 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 3 ++- src/pr_body.rs | 2 +- src/render.rs | 8 ++++++- tests/unit/plan_capture.rs | 37 +++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/codex_history.rs b/src/codex_history.rs index 3bd62c0..6ca8414 100644 --- a/src/codex_history.rs +++ b/src/codex_history.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use crate::error::AppResult; use crate::git::{parse_github_slug, GitContext}; use crate::normalize::extract_marked_plans; +use crate::pr_body::{END_MARKER, START_MARKER}; use crate::store::{AgentPlanState, AgentSource, NewPlanItem}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -17,6 +18,7 @@ pub struct CodexHistoryImportOutcome { pub plans_found: usize, pub plans_added: usize, pub duplicates: usize, + pub rendered_stacks_skipped: usize, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -40,6 +42,7 @@ pub fn import_codex_history( plans_found: 0, plans_added: 0, duplicates: 0, + rendered_stacks_skipped: 0, }; let mut files = codex_session_files(codex_home)?; @@ -108,6 +111,10 @@ fn import_session_file( for plan in extract_marked_plans(&message) { outcome.plans_found += 1; + if looks_like_rendered_plan_stack(&plan.content) { + outcome.rendered_stacks_skipped += 1; + continue; + } let added = state.add_plan(NewPlanItem { source: AgentSource::Codex, title: plan.title, @@ -157,6 +164,12 @@ fn collect_jsonl_files(dir: &Path, files: &mut Vec) -> AppResult<()> { Ok(()) } +fn looks_like_rendered_plan_stack(content: &str) -> bool { + content.contains("## Agent Plan Stack") + && content.contains(START_MARKER) + && content.contains(END_MARKER) +} + fn parse_session_metadata(event: &Value) -> SessionMetadata { let payload = event.get("payload"); let id = payload @@ -420,4 +433,39 @@ mod tests { assert_eq!(outcome.files_matched, 0); assert!(state.items.is_empty()); } + + #[test] + fn skips_rendered_plan_stack_blocks() { + let temp_dir = tempdir().expect("temp dir"); + let repo_root = temp_dir.path().join("repo"); + let codex_home = temp_dir.path().join("codex"); + let session_dir = codex_home.join("sessions/2026/05/31"); + fs::create_dir_all(&repo_root).expect("repo root"); + fs::create_dir_all(&session_dir).expect("session dir"); + + fs::write( + session_dir.join("rollout-2026-05-31T00-00-00-rendered.jsonl"), + format!( + r#"{{"type":"session_meta","payload":{{"id":"session","cwd":"{}","git":{{"branch":"feature/test","repository_url":"https://github.com/example/repo.git"}}}}}} +{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"\n\n## Agent Plan Stack\n\n"}}]}}}} +"#, + repo_root.display() + ), + ) + .expect("write session"); + + let context = GitContext { + repo_root, + repo_slug: Some("example/repo".to_owned()), + branch: Some("feature/test".to_owned()), + head_sha: Some("abcdef".to_owned()), + }; + let mut state = AgentPlanState::default(); + + let outcome = import_codex_history(&codex_home, &context, &mut state).expect("import"); + + assert_eq!(outcome.plans_found, 1); + assert_eq!(outcome.rendered_stacks_skipped, 1); + assert!(state.items.is_empty()); + } } diff --git a/src/main.rs b/src/main.rs index 07636ac..0b015f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -195,12 +195,13 @@ fn default_codex_home() -> Option { fn print_import_outcome(outcome: &CodexHistoryImportOutcome, dry_run: bool) { let mode = if dry_run { "dry-run" } else { "import" }; println!( - "{mode}: scanned {} file(s), matched {} current repo/branch file(s), found {} plan(s), added {}, skipped {} duplicate(s), parse errors {}", + "{mode}: scanned {} file(s), matched {} current repo/branch file(s), found {} plan(s), added {}, skipped {} duplicate(s), skipped {} rendered stack(s), parse errors {}", outcome.files_scanned, outcome.files_matched, outcome.plans_found, outcome.plans_added, outcome.duplicates, + outcome.rendered_stacks_skipped, outcome.parse_errors ); } diff --git a/src/pr_body.rs b/src/pr_body.rs index a05617f..5a177a5 100644 --- a/src/pr_body.rs +++ b/src/pr_body.rs @@ -5,7 +5,7 @@ pub const END_MARKER: &str = ""; pub fn upsert_marker_block(body: &str, block: &str) -> AppResult { let start = body.find(START_MARKER); - let end = body.find(END_MARKER); + let end = body.rfind(END_MARKER); match (start, end) { (None, None) => Ok(append_block(body, block)), diff --git a/src/render.rs b/src/render.rs index 018fbfc..cb830d2 100644 --- a/src/render.rs +++ b/src/render.rs @@ -36,7 +36,7 @@ pub fn render_plan_block(state: &AgentPlanState) -> String { output.push_str(" - Captured: "); output.push_str(&item.created_at); output.push_str("\n\n"); - output.push_str(item.content.trim()); + output.push_str(&escape_plan_markers(item.content.trim())); output.push_str("\n\n"); } } @@ -86,3 +86,9 @@ const fn source_label(source: AgentSource) -> &'static str { fn short_sha(sha: &str) -> &str { sha.get(..7).unwrap_or(sha) } + +fn escape_plan_markers(content: &str) -> String { + content + .replace(START_MARKER, "<!-- plan-to-git:start -->") + .replace(END_MARKER, "<!-- plan-to-git:end -->") +} diff --git a/tests/unit/plan_capture.rs b/tests/unit/plan_capture.rs index 38d1ea3..db9e07d 100644 --- a/tests/unit/plan_capture.rs +++ b/tests/unit/plan_capture.rs @@ -216,6 +216,28 @@ fn render_filters_items_to_current_branch() { assert!(!rendered.contains("Other branch plan")); } +#[test] +fn render_escapes_nested_plan_markers() { + let mut state = AgentPlanState::default(); + state.set_context(None, Some("feature/current".to_owned()), None); + assert!(state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Markers".to_owned()), + content: format!("inside {START_MARKER} and {END_MARKER}"), + branch: Some("feature/current".to_owned()), + head_sha: None, + session_id: None, + turn_id: None, + created_at: None, + })); + + let rendered = render_plan_block(&state); + + assert_eq!(rendered.matches(START_MARKER).count(), 1); + assert_eq!(rendered.matches(END_MARKER).count(), 1); + assert!(rendered.contains("<!-- plan-to-git:start -->")); +} + #[test] fn pr_body_appends_and_replaces_marker_block() { let original = "Existing body"; @@ -231,6 +253,21 @@ fn pr_body_appends_and_replaces_marker_block() { assert!(!replaced.contains("## Agent Plan Stack")); } +#[test] +fn pr_body_replaces_through_last_marker() { + let original = + format!("Intro\n\n{START_MARKER}\nold\n{END_MARKER}\nstale\n{END_MARKER}\nOutro"); + let block = format!("{START_MARKER}\nnew\n{END_MARKER}"); + + let replaced = upsert_marker_block(&original, &block).expect("replace should work"); + + assert!(replaced.contains("Intro")); + assert!(replaced.contains("new")); + assert!(replaced.contains("Outro")); + assert!(!replaced.contains("old")); + assert!(!replaced.contains("stale")); +} + #[test] fn pr_body_rejects_partial_markers() { let body = format!("Existing body\n\n{START_MARKER}\nmissing end"); From 002a9f9371d9c552e86264302dcdda162d65d266 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 18:25:33 +0000 Subject: [PATCH 07/11] feat: post plan updates as PR comments --- README.md | 16 ++--- .../20260530_104500_plan_to_git_mvp.md | 2 +- src/capture.rs | 5 +- src/github.rs | 66 +++++++++++------- src/main.rs | 32 ++++++--- src/render.rs | 52 ++++++++++---- src/store.rs | 69 +++++++++++++++++++ tests/integration/cli.rs | 23 ++++--- tests/unit/plan_capture.rs | 33 ++++++++- 9 files changed, 230 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 8ccee45..990cca0 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # plan-to-git -`plan-to-git` captures explicit plans produced by coding agents and keeps the current pull request description in sync. +`plan-to-git` captures explicit plans produced by coding agents and posts new pull request comments for plan updates. The MVP is Codex-first: - reads Codex hook JSON from stdin; - captures only explicit plan blocks such as `...` or `## Accepted Plan`; - stores captured plans and planning Q/A decisions in `.agent-plan.json`; -- updates the current branch PR body between stable markers when a PR exists; +- posts a new PR comment with newly captured current-branch items when a PR exists; - leaves the local stack queued when no PR exists yet. ## CLI @@ -42,22 +42,20 @@ command = "plan-to-git hook --source codex" Exact hook configuration shape can vary by Codex release. The hook command itself expects the release behavior documented by Codex hooks: `Stop` includes `last_assistant_message`, and `UserPromptSubmit` includes `prompt`. -## Pull Request Block +## Pull Request Comments -When `gh pr view` finds a PR for the current branch, `plan-to-git` inserts or replaces only this section: +When `gh pr view` finds a PR for the current branch, `plan-to-git` creates a new issue comment on that PR containing items that have not been posted before: ```markdown - -## Agent Plan Stack +## Agent Plan Update ... - ``` -If only one marker exists, sync fails rather than risking corruption of the human-written PR body. +The PR description is not edited. After a comment is created, `.agent-plan.json` records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, or `import-codex` runs do not post the same plan again. ## Safety The hook path only uses stable hook payload fields and explicitly marked plan text. `import-codex` can backfill previous plans from `~/.codex/sessions`, but it only reads assistant message events from sessions that match the current repository and branch, and it still imports only explicit markers such as `...` or `## Accepted Plan`. -Captured content is redacted before local storage and PR sync. `.agent-plan.json` also acts as the sent-plan registry: content hashes prevent the same plan from being added and uploaded again for the same branch. +Captured content is redacted before local storage and PR sync. `.agent-plan.json` also acts as the sent-plan registry: content hashes prevent the same plan from being added and commented again for the same branch. diff --git a/changelog.d/20260530_104500_plan_to_git_mvp.md b/changelog.d/20260530_104500_plan_to_git_mvp.md index 6f01c55..5bae08f 100644 --- a/changelog.d/20260530_104500_plan_to_git_mvp.md +++ b/changelog.d/20260530_104500_plan_to_git_mvp.md @@ -3,5 +3,5 @@ bump: minor --- ### Added -- Added the `plan-to-git` CLI for capturing Codex plan hooks, storing a local plan stack, and syncing it into GitHub pull request bodies. +- Added the `plan-to-git` CLI for capturing Codex plan hooks, storing a local plan stack, and posting new GitHub pull request comments for unposted plans. - Added `plan-to-git import-codex` for backfilling explicitly marked plans from matching Codex session history without re-uploading duplicates. diff --git a/src/capture.rs b/src/capture.rs index 620b78a..61f6088 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -111,7 +111,10 @@ pub fn process_codex_hook(input: &str) -> AppResult { save_state(&state_path, &state)?; } - let sync_status = github::sync_state(&context, &state)?; + let sync_status = github::sync_state(&context, &mut state)?; + if changed || !state.items.is_empty() || !state.pending_questions.is_empty() { + save_state(&state_path, &state)?; + } Ok(HookOutcome { changed, diff --git a/src/github.rs b/src/github.rs index 8268029..9df1617 100644 --- a/src/github.rs +++ b/src/github.rs @@ -7,27 +7,35 @@ use serde::Deserialize; use crate::error::{AppError, AppResult}; use crate::git::GitContext; -use crate::pr_body::upsert_marker_block; -use crate::render::{has_current_branch_items, render_plan_block}; +use crate::render::render_plan_comment; use crate::store::AgentPlanState; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SyncStatus { NoItems, NoPullRequest, - Unchanged { number: u64 }, - Updated { number: u64 }, + Unchanged { + number: u64, + }, + Commented { + number: u64, + comment_id: u64, + items: usize, + }, } #[derive(Debug, Deserialize)] struct PullRequest { number: u64, - #[serde(default)] - body: Option, } -pub fn sync_state(context: &GitContext, state: &AgentPlanState) -> AppResult { - if !has_current_branch_items(state) { +#[derive(Debug, Deserialize)] +struct IssueComment { + id: u64, +} + +pub fn sync_state(context: &GitContext, state: &mut AgentPlanState) -> AppResult { + if !state.has_current_branch_items() { return Ok(SyncStatus::NoItems); } @@ -35,26 +43,31 @@ pub fn sync_state(context: &GitContext, state: &AgentPlanState) -> AppResult>(); + (render_plan_comment(state, &items), item_ids, items.len()) + }; - if body.trim_end() == updated_body.trim_end() { - return Ok(SyncStatus::Unchanged { - number: pull_request.number, - }); - } + let comment_id = create_issue_comment(context, pull_request.number, &comment_body)?; + state.mark_items_commented(pull_request.number, &item_ids, Some(comment_id)); - edit_pr_body(context, pull_request.number, &updated_body)?; - Ok(SyncStatus::Updated { + Ok(SyncStatus::Commented { number: pull_request.number, + comment_id, + items: item_count, }) } fn view_current_pr(repo_root: &Path) -> AppResult> { let output = Command::new("gh") .current_dir(repo_root) - .args(["pr", "view", "--json", "number,body"]) + .args(["pr", "view", "--json", "number"]) .output()?; if output.status.success() { @@ -69,19 +82,19 @@ fn view_current_pr(repo_root: &Path) -> AppResult> { Err(AppError::new(format!("gh pr view failed: {stderr}")).into()) } -fn edit_pr_body(context: &GitContext, number: u64, body: &str) -> AppResult<()> { +fn create_issue_comment(context: &GitContext, number: u64, body: &str) -> AppResult { let repo_slug = context .repo_slug .as_deref() - .ok_or_else(|| AppError::new("cannot sync PR body without a GitHub origin remote"))?; + .ok_or_else(|| AppError::new("cannot sync PR comments without a GitHub origin remote"))?; let request_file = temp_request_path(); let request = serde_json::json!({ "body": body }); fs::write(&request_file, serde_json::to_vec(&request)?)?; let output = Command::new("gh") .current_dir(&context.repo_root) - .args(["api", "--method", "PATCH"]) - .arg(format!("repos/{repo_slug}/pulls/{number}")) + .args(["api", "--method", "POST"]) + .arg(format!("repos/{repo_slug}/issues/{number}/comments")) .args(["--input"]) .arg(&request_file) .output(); @@ -93,11 +106,12 @@ fn edit_pr_body(context: &GitContext, number: u64, body: &str) -> AppResult<()> } if output.status.success() { - return Ok(()); + let comment: IssueComment = serde_json::from_slice(&output.stdout)?; + return Ok(comment.id); } let stderr = String::from_utf8_lossy(&output.stderr); - Err(AppError::new(format!("gh api PR update failed: {stderr}")).into()) + Err(AppError::new(format!("gh api PR comment failed: {stderr}")).into()) } fn temp_request_path() -> std::path::PathBuf { @@ -106,7 +120,7 @@ fn temp_request_path() -> std::path::PathBuf { .duration_since(UNIX_EPOCH) .map_or(0, |duration| duration.as_nanos()); path.push(format!( - "plan-to-git-pr-body-{}-{timestamp}.json", + "plan-to-git-pr-comment-{}-{timestamp}.json", std::process::id() )); path diff --git a/src/main.rs b/src/main.rs index 0b015f1..c4d75a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use plan_to_git::codex_history::{self, CodexHistoryImportOutcome}; use plan_to_git::error::{AppError, AppResult}; use plan_to_git::git; use plan_to_git::github::{self, SyncStatus}; -use plan_to_git::render::render_plan_block; +use plan_to_git::render::render_plan_comment; use plan_to_git::store::{load_state, save_state, STATE_FILE_NAME}; #[derive(Parser, Debug)] @@ -37,15 +37,15 @@ enum Commands { /// Scan and report what would be imported without writing or syncing. #[arg(long)] dry_run: bool, - /// Save imported plans locally without syncing the pull request body. + /// Save imported plans locally without posting a pull request comment. #[arg(long)] no_sync: bool, }, - /// Sync the local plan stack into the current branch pull request. + /// Post newly captured plan items to the current branch pull request. Sync, /// Print the local plan stack JSON. Show, - /// Render the pull request markdown block. + /// Render the local plan stack markdown. Render, /// Clear local plan stack state. Clear { @@ -109,7 +109,9 @@ fn run(command: &Commands) -> AppResult<()> { return Ok(()); } - print_sync_status(&github::sync_state(&context, &state)?); + let sync_status = github::sync_state(&context, &mut state)?; + save_state(&state_path, &state)?; + print_sync_status(&sync_status); Ok(()) } Commands::Sync => { @@ -120,8 +122,9 @@ fn run(command: &Commands) -> AppResult<()> { context.branch.clone(), context.head_sha.clone(), ); + let sync_status = github::sync_state(&context, &mut state)?; save_state(&state_path, &state)?; - print_sync_status(&github::sync_state(&context, &state)?); + print_sync_status(&sync_status); Ok(()) } Commands::Show => { @@ -133,7 +136,10 @@ fn run(command: &Commands) -> AppResult<()> { Commands::Render => { let (_, state_path) = state_context()?; let state = load_state(&state_path)?; - println!("{}", render_plan_block(&state)); + println!( + "{}", + render_plan_comment(&state, &state.unposted_items_for_pr(0)) + ); Ok(()) } Commands::Clear { yes } => { @@ -181,8 +187,16 @@ fn print_sync_status(status: &SyncStatus) { match status { SyncStatus::NoItems => println!("no captured plan items to sync"), SyncStatus::NoPullRequest => println!("no pull request found for the current branch"), - SyncStatus::Unchanged { number } => println!("pull request #{number} already up to date"), - SyncStatus::Updated { number } => println!("updated pull request #{number}"), + SyncStatus::Unchanged { number } => { + println!("no new plan items to comment on pull request #{number}"); + } + SyncStatus::Commented { + number, + comment_id, + items, + } => { + println!("posted {items} plan item(s) to pull request #{number} comment #{comment_id}"); + } } } diff --git a/src/render.rs b/src/render.rs index cb830d2..6dc1536 100644 --- a/src/render.rs +++ b/src/render.rs @@ -7,18 +7,7 @@ pub fn render_plan_block(state: &AgentPlanState) -> String { output.push_str(START_MARKER); output.push('\n'); output.push_str("## Agent Plan Stack\n\n"); - - if let Some(branch) = &state.branch { - output.push_str("_Branch: `"); - output.push_str(branch); - output.push('`'); - if let Some(head_sha) = &state.head_sha { - output.push_str(" at `"); - output.push_str(short_sha(head_sha)); - output.push('`'); - } - output.push_str("._\n\n"); - } + push_context_line(&mut output, state); let items = current_branch_items(state); @@ -45,6 +34,14 @@ pub fn render_plan_block(state: &AgentPlanState) -> String { output } +#[must_use] +pub fn render_plan_comment(state: &AgentPlanState, items: &[&PlanStackItem]) -> String { + let mut output = String::from("## Agent Plan Update\n\n"); + push_context_line(&mut output, state); + push_plan_items(&mut output, items); + output.trim_end().to_owned() +} + #[must_use] pub fn has_current_branch_items(state: &AgentPlanState) -> bool { state @@ -61,6 +58,37 @@ fn current_branch_items(state: &AgentPlanState) -> Vec<&PlanStackItem> { .collect() } +fn push_context_line(output: &mut String, state: &AgentPlanState) { + if let Some(branch) = &state.branch { + output.push_str("_Branch: `"); + output.push_str(branch); + output.push('`'); + if let Some(head_sha) = &state.head_sha { + output.push_str(" at `"); + output.push_str(short_sha(head_sha)); + output.push('`'); + } + output.push_str("._\n\n"); + } +} + +fn push_plan_items(output: &mut String, items: &[&PlanStackItem]) { + for (index, item) in items.iter().enumerate() { + output.push_str("### "); + output.push_str(&(index + 1).to_string()); + output.push_str(". "); + output.push_str(item_title(item.kind)); + output.push('\n'); + output.push_str("Source: "); + output.push_str(source_label(item.source)); + output.push_str(" - Captured: "); + output.push_str(&item.created_at); + output.push_str("\n\n"); + output.push_str(&escape_plan_markers(item.content.trim())); + output.push_str("\n\n"); + } +} + fn matches_current_branch(item: &PlanStackItem, current_branch: Option<&str>) -> bool { match (item.branch.as_deref(), current_branch) { (Some(item_branch), Some(branch)) => item_branch == branch, diff --git a/src/store.rs b/src/store.rs index be06bbd..e728b9d 100644 --- a/src/store.rs +++ b/src/store.rs @@ -20,6 +20,8 @@ pub struct AgentPlanState { pub items: Vec, #[serde(default)] pub pending_questions: Vec, + #[serde(default)] + pub posted_comments: Vec, } impl Default for AgentPlanState { @@ -31,6 +33,7 @@ impl Default for AgentPlanState { head_sha: None, items: Vec::new(), pending_questions: Vec::new(), + posted_comments: Vec::new(), } } } @@ -80,6 +83,15 @@ pub struct PendingQuestion { pub created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PostedPlanComment { + pub pr_number: u64, + pub item_id: String, + pub content_hash: String, + pub comment_id: Option, + pub posted_at: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewPlanItem { pub source: AgentSource, @@ -167,6 +179,63 @@ impl AgentPlanState { true } + #[must_use] + pub fn unposted_items_for_pr(&self, pr_number: u64) -> Vec<&PlanStackItem> { + self.items + .iter() + .filter(|item| self.matches_current_branch(item)) + .filter(|item| !self.is_posted_to_pr(pr_number, item)) + .collect() + } + + #[must_use] + pub fn has_current_branch_items(&self) -> bool { + self.items + .iter() + .any(|item| self.matches_current_branch(item)) + } + + pub fn mark_items_commented( + &mut self, + pr_number: u64, + item_ids: &[String], + comment_id: Option, + ) { + let posted_at = timestamp(); + let mut new_comments = Vec::new(); + for item_id in item_ids { + let Some(item) = self.items.iter().find(|item| &item.id == item_id) else { + continue; + }; + if self.is_posted_to_pr(pr_number, item) { + continue; + } + new_comments.push(PostedPlanComment { + pr_number, + item_id: item.id.clone(), + content_hash: item.content_hash.clone(), + comment_id, + posted_at: posted_at.clone(), + }); + } + self.posted_comments.extend(new_comments); + } + + fn matches_current_branch(&self, item: &PlanStackItem) -> bool { + match (item.branch.as_deref(), self.branch.as_deref()) { + (Some(item_branch), Some(branch)) => item_branch == branch, + (Some(_), None) | (None, _) => true, + } + } + + fn is_posted_to_pr(&self, pr_number: u64, item: &PlanStackItem) -> bool { + self.posted_comments.iter().any(|posted| { + posted.pr_number == pr_number + && posted.item_id == item.id + && posted.content_hash == item.content_hash + }) + } + pub fn add_pending_question(&mut self, new_question: NewPendingQuestion) -> bool { let questions: Vec = new_question .questions diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs index be2c602..ee15256 100644 --- a/tests/integration/cli.rs +++ b/tests/integration/cli.rs @@ -102,7 +102,7 @@ mod unix { } #[test] - fn hook_updates_open_pr_body_through_gh_api() { + fn hook_posts_open_pr_comment_through_gh_api() { let temp_dir = tempfile::tempdir().expect("temp dir"); let bin_dir = temp_dir.path().join("bin"); let repo_dir = temp_dir.path().join("repo"); @@ -121,16 +121,20 @@ mod unix { "cwd":"{}", "hook_event_name":"Stop", "turn_id":"turn", - "last_assistant_message":"\n# MVP\n\n- Update PR body\n" + "last_assistant_message":"\n# MVP\n\n- Post PR comment\n" }}"#, repo_dir.display() ), ); let request = fs::read_to_string(captured_request).expect("captured request"); - assert!(request.contains("Original PR body")); - assert!(request.contains("plan-to-git:start")); - assert!(request.contains("Update PR body")); + assert!(request.contains("Agent Plan Update")); + assert!(request.contains("Post PR comment")); + assert!(!request.contains("Original PR body")); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("\"posted_comments\"")); + assert!(state.contains("\"comment_id\": 12345")); } #[test] @@ -239,7 +243,7 @@ esac fn write_fake_gh_no_pr(bin_dir: &Path) { let script = r#"#!/usr/bin/env bash set -euo pipefail -if [[ "$*" == "pr view --json number,body" ]]; then +if [[ "$*" == "pr view --json number" ]]; then echo 'no pull requests found for branch "feature/test"' >&2 exit 1 fi @@ -253,12 +257,13 @@ exit 1 let script = format!( r#"#!/usr/bin/env bash set -euo pipefail -if [[ "$*" == "pr view --json number,body" ]]; then - printf '%s\n' '{{"number":17,"body":"Original PR body"}}' +if [[ "$*" == "pr view --json number" ]]; then + printf '%s\n' '{{"number":17}}' exit 0 fi -if [[ "$1 $2 $3" == "api --method PATCH" && "$4" == "repos/example/repo/pulls/17" && "$5" == "--input" ]]; then +if [[ "$1 $2 $3" == "api --method POST" && "$4" == "repos/example/repo/issues/17/comments" && "$5" == "--input" ]]; then cp "$6" "{}" + printf '%s\n' '{{"id":12345}}' exit 0 fi echo "unexpected gh args: $*" >&2 diff --git a/tests/unit/plan_capture.rs b/tests/unit/plan_capture.rs index db9e07d..53eb14c 100644 --- a/tests/unit/plan_capture.rs +++ b/tests/unit/plan_capture.rs @@ -1,7 +1,7 @@ use plan_to_git::normalize::{extract_marked_plans, extract_questions}; use plan_to_git::pr_body::{upsert_marker_block, END_MARKER, START_MARKER}; use plan_to_git::redact::redact; -use plan_to_git::render::{has_current_branch_items, render_plan_block}; +use plan_to_git::render::{has_current_branch_items, render_plan_block, render_plan_comment}; use plan_to_git::store::{ AgentPlanState, AgentSource, NewDecision, NewPendingQuestion, NewPlanItem, }; @@ -238,6 +238,37 @@ fn render_escapes_nested_plan_markers() { assert!(rendered.contains("<!-- plan-to-git:start -->")); } +#[test] +fn state_tracks_commented_items_per_pr() { + let mut state = AgentPlanState::default(); + state.set_context(None, Some("feature/current".to_owned()), None); + assert!(state.add_plan(NewPlanItem { + source: AgentSource::Codex, + title: Some("Commented".to_owned()), + content: "- Post once".to_owned(), + branch: Some("feature/current".to_owned()), + head_sha: None, + session_id: None, + turn_id: None, + created_at: None, + })); + + let item_ids = state + .unposted_items_for_pr(17) + .iter() + .map(|item| item.id.clone()) + .collect::>(); + let comment = render_plan_comment(&state, &state.unposted_items_for_pr(17)); + + assert!(comment.contains("Agent Plan Update")); + assert!(comment.contains("Post once")); + state.mark_items_commented(17, &item_ids, Some(12345)); + + assert!(state.unposted_items_for_pr(17).is_empty()); + assert_eq!(state.unposted_items_for_pr(18).len(), 1); + assert_eq!(state.posted_comments[0].comment_id, Some(12345)); +} + #[test] fn pr_body_appends_and_replaces_marker_block() { let original = "Existing body"; From ab2af390e64910a02d401764b548a25bc276cf9b Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 20:01:54 +0000 Subject: [PATCH 08/11] fix: avoid reposting plans to later PRs --- README.md | 4 ++-- src/store.rs | 13 ++++++------- tests/unit/plan_capture.rs | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 990cca0..05af6f4 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,10 @@ When `gh pr view` finds a PR for the current branch, `plan-to-git` creates a new ... ``` -The PR description is not edited. After a comment is created, `.agent-plan.json` records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, or `import-codex` runs do not post the same plan again. +The PR description is not edited. After a comment is created, `.agent-plan.json` records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, or `import-codex` runs do not post the same plan again, including on a later PR. ## Safety The hook path only uses stable hook payload fields and explicitly marked plan text. `import-codex` can backfill previous plans from `~/.codex/sessions`, but it only reads assistant message events from sessions that match the current repository and branch, and it still imports only explicit markers such as `...` or `## Accepted Plan`. -Captured content is redacted before local storage and PR sync. `.agent-plan.json` also acts as the sent-plan registry: content hashes prevent the same plan from being added and commented again for the same branch. +Captured content is redacted before local storage and PR sync. `.agent-plan.json` also acts as the sent-plan registry: content hashes prevent the same plan from being added and commented again. diff --git a/src/store.rs b/src/store.rs index e728b9d..4714d12 100644 --- a/src/store.rs +++ b/src/store.rs @@ -180,11 +180,11 @@ impl AgentPlanState { } #[must_use] - pub fn unposted_items_for_pr(&self, pr_number: u64) -> Vec<&PlanStackItem> { + pub fn unposted_items_for_pr(&self, _pr_number: u64) -> Vec<&PlanStackItem> { self.items .iter() .filter(|item| self.matches_current_branch(item)) - .filter(|item| !self.is_posted_to_pr(pr_number, item)) + .filter(|item| !self.is_posted(item)) .collect() } @@ -207,7 +207,7 @@ impl AgentPlanState { let Some(item) = self.items.iter().find(|item| &item.id == item_id) else { continue; }; - if self.is_posted_to_pr(pr_number, item) { + if self.is_posted(item) { continue; } new_comments.push(PostedPlanComment { @@ -228,11 +228,10 @@ impl AgentPlanState { } } - fn is_posted_to_pr(&self, pr_number: u64, item: &PlanStackItem) -> bool { + fn is_posted(&self, item: &PlanStackItem) -> bool { self.posted_comments.iter().any(|posted| { - posted.pr_number == pr_number - && posted.item_id == item.id - && posted.content_hash == item.content_hash + (posted.item_id.as_str(), posted.content_hash.as_str()) + == (item.id.as_str(), item.content_hash.as_str()) }) } diff --git a/tests/unit/plan_capture.rs b/tests/unit/plan_capture.rs index 53eb14c..cb693ac 100644 --- a/tests/unit/plan_capture.rs +++ b/tests/unit/plan_capture.rs @@ -265,7 +265,7 @@ fn state_tracks_commented_items_per_pr() { state.mark_items_commented(17, &item_ids, Some(12345)); assert!(state.unposted_items_for_pr(17).is_empty()); - assert_eq!(state.unposted_items_for_pr(18).len(), 1); + assert!(state.unposted_items_for_pr(18).is_empty()); assert_eq!(state.posted_comments[0].comment_id, Some(12345)); } From 85b5b9ecb0c03ac49a8109f9dcc07b34f819bd24 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 20:10:40 +0000 Subject: [PATCH 09/11] fix: align package version with main --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21d5e9d..4560f4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -439,7 +439,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "plan-to-git" -version = "0.16.0" +version = "0.17.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 3b062d3..5b3dc03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "plan-to-git" -version = "0.16.0" +version = "0.17.0" edition = "2021" description = "Capture agent plans and sync them to GitHub pull requests" readme = "README.md" From 0314192e7fb51e2fd22db72d4a6e44a85805ce5f Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 21:59:26 +0000 Subject: [PATCH 10/11] fix: make codex history tests windows-safe --- src/codex_history.rs | 159 +++++++++++++++++++++++++++++++------------ 1 file changed, 116 insertions(+), 43 deletions(-) diff --git a/src/codex_history.rs b/src/codex_history.rs index 6ca8414..da897c8 100644 --- a/src/codex_history.rs +++ b/src/codex_history.rs @@ -274,7 +274,8 @@ fn line_turn_id(path: &Path, line_number: usize) -> Option { #[cfg(test)] mod tests { - use std::fs; + use serde_json::json; + use std::{fs, path::Path}; use tempfile::tempdir; @@ -283,6 +284,67 @@ mod tests { use super::import_codex_history; + fn json_line(value: &serde_json::Value) -> String { + serde_json::to_string(value).expect("serialize jsonl event") + } + + fn session_meta_line(cwd: &Path, branch: &str) -> String { + json_line(&json!({ + "type": "session_meta", + "payload": { + "id": "session", + "cwd": cwd.to_string_lossy().into_owned(), + "git": { + "branch": branch, + "repository_url": "https://github.com/example/repo.git" + } + } + })) + } + + fn message_line(role: &str, content_type: &str, text: &str) -> String { + json_line(&json!({ + "type": "response_item", + "payload": { + "type": "message", + "role": role, + "content": [{ + "type": content_type, + "text": text + }] + } + })) + } + + fn task_complete_line(timestamp: &str, text: &str) -> String { + json_line(&json!({ + "timestamp": timestamp, + "type": "event_msg", + "payload": { + "type": "task_complete", + "last_agent_message": text + } + })) + } + + fn write_jsonl(path: &Path, lines: &[String]) { + fs::write(path, format!("{}\n", lines.join("\n"))).expect("write session"); + } + + #[test] + fn test_jsonl_helpers_escape_windows_paths() { + let line = session_meta_line(Path::new(r"C:\Users\dev\repo"), "feature/test"); + let event = serde_json::from_str::(&line).expect("parse session meta"); + + assert_eq!( + event + .get("payload") + .and_then(|payload| payload.get("cwd")) + .and_then(serde_json::Value::as_str), + Some(r"C:\Users\dev\repo") + ); + } + #[test] fn imports_marked_assistant_plans_and_skips_duplicates() { let temp_dir = tempdir().expect("temp dir"); @@ -293,19 +355,28 @@ mod tests { fs::create_dir_all(&session_dir).expect("session dir"); let session = session_dir.join("rollout-2026-05-31T00-00-00-session.jsonl"); - fs::write( + write_jsonl( &session, - format!( - r#"{{"type":"session_meta","payload":{{"id":"session","cwd":"{}","git":{{"branch":"feature/test","repository_url":"https://github.com/example/repo.git"}}}}}} -{{"type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"ignore user text"}}]}}}} -{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"not a marked plan"}}]}}}} -{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"\n# Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n"}}]}}}} -{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"\n# Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n"}}]}}}} -"#, - repo_root.display() - ), - ) - .expect("write session"); + &[ + session_meta_line(&repo_root, "feature/test"), + message_line( + "user", + "input_text", + "ignore user text", + ), + message_line("assistant", "output_text", "not a marked plan"), + message_line( + "assistant", + "output_text", + "\n# Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n", + ), + message_line( + "assistant", + "output_text", + "\n# Backfill\n\n- Import old plans\n- Keep api_key=secret-value private\n", + ), + ], + ); let context = GitContext { repo_root, @@ -341,16 +412,17 @@ mod tests { fs::create_dir_all(&repo_root).expect("repo root"); fs::create_dir_all(&session_dir).expect("session dir"); - fs::write( - session_dir.join("rollout-2026-05-31T00-00-00-other.jsonl"), - format!( - r#"{{"type":"session_meta","payload":{{"id":"session","cwd":"{}","git":{{"branch":"feature/other","repository_url":"https://github.com/example/repo.git"}}}}}} -{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"\n# Other\n\n- Wrong branch\n"}}]}}}} -"#, - repo_root.display() - ), - ) - .expect("write session"); + write_jsonl( + &session_dir.join("rollout-2026-05-31T00-00-00-other.jsonl"), + &[ + session_meta_line(&repo_root, "feature/other"), + message_line( + "assistant", + "output_text", + "\n# Other\n\n- Wrong branch\n", + ), + ], + ); let context = GitContext { repo_root, @@ -377,16 +449,16 @@ mod tests { fs::create_dir_all(&repo_root).expect("repo root"); fs::create_dir_all(&session_dir).expect("session dir"); - fs::write( - session_dir.join("rollout-2026-05-31T00-00-00-complete.jsonl"), - format!( - r#"{{"type":"session_meta","payload":{{"id":"session","cwd":"{}","git":{{"branch":"feature/test","repository_url":"https://github.com/example/repo.git"}}}}}} -{{"timestamp":"2026-05-31T12:34:56Z","type":"event_msg","payload":{{"type":"task_complete","last_agent_message":"\n# Complete\n\n- Import completion text\n"}}}} -"#, - repo_root.display() - ), - ) - .expect("write session"); + write_jsonl( + &session_dir.join("rollout-2026-05-31T00-00-00-complete.jsonl"), + &[ + session_meta_line(&repo_root, "feature/test"), + task_complete_line( + "2026-05-31T12:34:56Z", + "\n# Complete\n\n- Import completion text\n", + ), + ], + ); let context = GitContext { repo_root, @@ -443,16 +515,17 @@ mod tests { fs::create_dir_all(&repo_root).expect("repo root"); fs::create_dir_all(&session_dir).expect("session dir"); - fs::write( - session_dir.join("rollout-2026-05-31T00-00-00-rendered.jsonl"), - format!( - r#"{{"type":"session_meta","payload":{{"id":"session","cwd":"{}","git":{{"branch":"feature/test","repository_url":"https://github.com/example/repo.git"}}}}}} -{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"\n\n## Agent Plan Stack\n\n"}}]}}}} -"#, - repo_root.display() - ), - ) - .expect("write session"); + write_jsonl( + &session_dir.join("rollout-2026-05-31T00-00-00-rendered.jsonl"), + &[ + session_meta_line(&repo_root, "feature/test"), + message_line( + "assistant", + "output_text", + "\n\n## Agent Plan Stack\n\n", + ), + ], + ); let context = GitContext { repo_root, From 7dce5c55dfe1872ae06bbc504e58bf03e45bf8e9 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 31 May 2026 22:03:45 +0000 Subject: [PATCH 11/11] fix: keep cargo cache optional in ci --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bf4e07..f8323d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,6 +153,7 @@ jobs: run: cargo install rust-script - name: Cache cargo registry + continue-on-error: true uses: actions/cache@v5 with: path: | @@ -192,6 +193,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Cache cargo registry + continue-on-error: true uses: actions/cache@v5 with: path: | @@ -231,6 +233,7 @@ jobs: components: llvm-tools-preview - name: Cache cargo registry + continue-on-error: true uses: actions/cache@v5 with: path: | @@ -271,6 +274,7 @@ jobs: run: cargo install rust-script - name: Cache cargo registry + continue-on-error: true uses: actions/cache@v5 with: path: |