From 20cdd01921d6730fe6e4867c33c1f056717b38c4 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Sat, 18 Jul 2026 10:22:27 -0300 Subject: [PATCH 1/4] feat(github): GitHub CLI (gh) as an iii worker 30 typed github::pr/issue/repo/run/workflow/release/search functions plus github::exec (argv passthrough, outcome as data) and github::api (any REST endpoint). Bounded-capture exec core with process-group timeout kill (shell's run_to_completion mechanics), GH_TOKEN via the configuration worker with ambient gh-auth fallback, golden-tested wire schemas, and read-only functions allowed in iii-permissions (mutations and both escape hatches stay approval-gated). --- .github/workflows/create-tag.yml | 1 + .github/workflows/release.yml | 1 + README.md | 1 + github/Cargo.lock | 2264 +++++++++++++++++ github/Cargo.toml | 44 + github/README.md | 71 + github/config.yaml | 22 + github/iii-permissions.yaml | 34 + github/iii.worker.yaml | 7 + github/skills/SKILL.md | 61 + github/src/config.rs | 118 + github/src/configuration.rs | 164 ++ github/src/functions/actions.rs | 207 ++ github/src/functions/issue.rs | 281 ++ github/src/functions/mod.rs | 495 ++++ github/src/functions/passthrough.rs | 186 ++ github/src/functions/pr.rs | 422 +++ github/src/functions/release.rs | 118 + github/src/functions/repo.rs | 70 + github/src/functions/search.rs | 91 + github/src/gh.rs | 347 +++ github/src/lib.rs | 10 + github/src/main.rs | 114 + github/tests/contract.rs | 86 + github/tests/golden/schemas/github.api.json | 75 + github/tests/golden/schemas/github.exec.json | 87 + .../golden/schemas/github.issue.close.json | 68 + .../golden/schemas/github.issue.comment.json | 45 + .../golden/schemas/github.issue.create.json | 63 + .../golden/schemas/github.issue.edit.json | 84 + .../golden/schemas/github.issue.list.json | 94 + .../golden/schemas/github.issue.view.json | 39 + .../golden/schemas/github.pr.checks.json | 39 + .../golden/schemas/github.pr.comment.json | 45 + .../golden/schemas/github.pr.create.json | 64 + .../tests/golden/schemas/github.pr.diff.json | 45 + .../tests/golden/schemas/github.pr.edit.json | 81 + .../tests/golden/schemas/github.pr.list.json | 95 + .../tests/golden/schemas/github.pr.merge.json | 74 + .../golden/schemas/github.pr.review.json | 67 + .../tests/golden/schemas/github.pr.view.json | 39 + .../golden/schemas/github.release.create.json | 80 + .../golden/schemas/github.release.list.json | 41 + .../golden/schemas/github.release.view.json | 37 + .../golden/schemas/github.repo.list.json | 41 + .../golden/schemas/github.repo.view.json | 32 + .../golden/schemas/github.run.cancel.json | 40 + .../tests/golden/schemas/github.run.list.json | 62 + .../golden/schemas/github.run.rerun.json | 47 + .../tests/golden/schemas/github.run.view.json | 39 + .../golden/schemas/github.search.code.json | 41 + .../golden/schemas/github.search.issues.json | 41 + .../golden/schemas/github.search.prs.json | 41 + .../golden/schemas/github.search.repos.json | 41 + .../golden/schemas/github.workflow.list.json | 39 + .../golden/schemas/github.workflow.run.json | 55 + github/tests/schemas.rs | 131 + github/tests/support/mod.rs | 118 + iii-permissions.yaml | 25 + 59 files changed, 7270 insertions(+) create mode 100644 github/Cargo.lock create mode 100644 github/Cargo.toml create mode 100644 github/README.md create mode 100644 github/config.yaml create mode 100644 github/iii-permissions.yaml create mode 100644 github/iii.worker.yaml create mode 100644 github/skills/SKILL.md create mode 100644 github/src/config.rs create mode 100644 github/src/configuration.rs create mode 100644 github/src/functions/actions.rs create mode 100644 github/src/functions/issue.rs create mode 100644 github/src/functions/mod.rs create mode 100644 github/src/functions/passthrough.rs create mode 100644 github/src/functions/pr.rs create mode 100644 github/src/functions/release.rs create mode 100644 github/src/functions/repo.rs create mode 100644 github/src/functions/search.rs create mode 100644 github/src/gh.rs create mode 100644 github/src/lib.rs create mode 100644 github/src/main.rs create mode 100644 github/tests/contract.rs create mode 100644 github/tests/golden/schemas/github.api.json create mode 100644 github/tests/golden/schemas/github.exec.json create mode 100644 github/tests/golden/schemas/github.issue.close.json create mode 100644 github/tests/golden/schemas/github.issue.comment.json create mode 100644 github/tests/golden/schemas/github.issue.create.json create mode 100644 github/tests/golden/schemas/github.issue.edit.json create mode 100644 github/tests/golden/schemas/github.issue.list.json create mode 100644 github/tests/golden/schemas/github.issue.view.json create mode 100644 github/tests/golden/schemas/github.pr.checks.json create mode 100644 github/tests/golden/schemas/github.pr.comment.json create mode 100644 github/tests/golden/schemas/github.pr.create.json create mode 100644 github/tests/golden/schemas/github.pr.diff.json create mode 100644 github/tests/golden/schemas/github.pr.edit.json create mode 100644 github/tests/golden/schemas/github.pr.list.json create mode 100644 github/tests/golden/schemas/github.pr.merge.json create mode 100644 github/tests/golden/schemas/github.pr.review.json create mode 100644 github/tests/golden/schemas/github.pr.view.json create mode 100644 github/tests/golden/schemas/github.release.create.json create mode 100644 github/tests/golden/schemas/github.release.list.json create mode 100644 github/tests/golden/schemas/github.release.view.json create mode 100644 github/tests/golden/schemas/github.repo.list.json create mode 100644 github/tests/golden/schemas/github.repo.view.json create mode 100644 github/tests/golden/schemas/github.run.cancel.json create mode 100644 github/tests/golden/schemas/github.run.list.json create mode 100644 github/tests/golden/schemas/github.run.rerun.json create mode 100644 github/tests/golden/schemas/github.run.view.json create mode 100644 github/tests/golden/schemas/github.search.code.json create mode 100644 github/tests/golden/schemas/github.search.issues.json create mode 100644 github/tests/golden/schemas/github.search.prs.json create mode 100644 github/tests/golden/schemas/github.search.repos.json create mode 100644 github/tests/golden/schemas/github.workflow.list.json create mode 100644 github/tests/golden/schemas/github.workflow.run.json create mode 100644 github/tests/schemas.rs create mode 100644 github/tests/support/mod.rs diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 1f2f858c3..b8fc2bf40 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -31,6 +31,7 @@ on: - image-resize - llm-router - fp + - github - mcp - memory - memory-consolidate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a2004032..650d2451b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ on: - 'image-resize/v*' - 'llm-router/v*' - 'fp/v*' + - 'github/v*' - 'mcp/v*' - 'memory/v*' - 'memory-consolidate/v*' diff --git a/README.md b/README.md index a521b024b..67b2db4ed 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ npx skills add iii-hq/iii --all | [`scrapling`](scrapling/) | Python | [Scrapling](https://github.com/D4Vinci/Scrapling) as an iii worker — `scrapling::*` map three fetch tiers (HTTP / Camoufox stealth / Playwright), screenshots, and CSS/XPath/regex/adaptive extraction over the bus. | | [`browser`](browser/) | Rust | Interactive Chromium sessions over CDP with console/network capture, a11y-tree snapshots with actionable refs, viewable screenshots, and DevTools element picking for the console UI. | | [`worktree`](worktree/) | Rust | Git worktree lifecycle for parallel agents — `worktree::*` mint, claim, and track isolated worktrees per repo, emit six lifecycle trigger types, and land branches back through a per-repo FIFO queue (rebase, test gate, ff-only merge). | +| [`github`](github/) | Rust | GitHub CLI (`gh`) as an iii worker — typed `github::pr/issue/repo/run/workflow/release/search::*` functions plus `github::exec` argv passthrough and `github::api` for any GitHub REST endpoint. | ## SDK diff --git a/github/Cargo.lock b/github/Cargo.lock new file mode 100644 index 000000000..7df30ea79 --- /dev/null +++ b/github/Cargo.lock @@ -0,0 +1,2264 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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 = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[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 = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "github" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "iii-sdk", + "libc", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[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 = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-helpers" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d84d5c149ae4404365a79feca28aa66f6a7dbed56423b4b8c4e2421e0b5add" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "schemars", + "serde", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07dd060fddcc9153b0dd07c038a14cf172ce15ce1d4edb98155563ed55b2caba" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-helpers", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[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 = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[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 = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.5", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[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-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[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-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/github/Cargo.toml b/github/Cargo.toml new file mode 100644 index 000000000..5becc9fd4 --- /dev/null +++ b/github/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] + +[package] +name = "github" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "github" +path = "src/main.rs" + +# Library target so tests/ (required by validate_worker.py for source-changed +# workers) can exercise the public contract; the binary is a thin wiring shim. +[lib] +name = "github" +path = "src/lib.rs" + +[dependencies] +iii-sdk = "=0.21.6" +# Must stay on the same schemars major as iii-sdk so the derived schemas line up. +schemars = "0.8" +tokio = { version = "1", features = [ + "rt-multi-thread", + "macros", + "sync", + "signal", + "process", + "time", + "io-util", +] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive", "env"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/github/README.md b/github/README.md new file mode 100644 index 000000000..0fecee775 --- /dev/null +++ b/github/README.md @@ -0,0 +1,71 @@ +# github + +GitHub as iii functions, powered by the GitHub CLI. Typed `github::*` +functions cover pull requests, issues, repos, Actions runs and workflows, +releases, and search; `github::exec` runs any other gh command and +`github::api` reaches any GitHub REST endpoint. Agents get +schema-discoverable GitHub operations with read-vs-mutate permission gating +instead of raw shell. + +## Install + +```bash +iii worker add github +``` + +`iii worker add` fetches the binary, writes a config block into +`~/.iii/config.yaml`, and the engine starts the worker on the next +`iii start`. + +The worker shells out to the [GitHub CLI](https://cli.github.com) — install +a current `gh` (the typed field sets are validated against gh 2.94) and give +it credentials: either `gh auth login` on the host, or set `GH_TOKEN` for +the worker (see Configuration). + +## Quickstart + +```rust +use iii_sdk::{register_worker, InitOptions, TriggerRequest}; +use serde_json::json; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let iii = register_worker("ws://localhost:49134", InitOptions::default()); + + // Open PRs in a repo, typed and parsed: + let prs = iii + .trigger(TriggerRequest { + function_id: "github::pr::list".into(), + payload: json!({ "repo": "cli/cli", "state": "open", "limit": 5 }), + action: None, + timeout_ms: Some(60_000), + }) + .await?; + println!("{prs:#?}"); // { value: [{ number, title, state, url, … }] } + + // Anything else gh can do, verbatim: + let version = iii + .trigger(TriggerRequest { + function_id: "github::exec".into(), + payload: json!({ "args": ["--version"] }), + action: None, + timeout_ms: Some(60_000), + }) + .await?; + println!("{version:#?}"); // { stdout, stderr, exit_code, … } + + Ok(()) +} +``` + +## Configuration + +```yaml +gh_executable: "" # path to gh; empty = `gh` on PATH +token: "${GH_TOKEN}" # env-expanded; empty = ambient `gh auth login` +default_timeout_ms: 30000 # per-call timeout when timeout_ms is omitted +max_timeout_ms: 120000 # upper clamp for any per-call timeout_ms +max_output_bytes: 1048576 # per-stream capture cap (flags *_truncated) +``` + +Other keys (and their defaults) live in [`src/config.rs`](src/config.rs). diff --git a/github/config.yaml b/github/config.yaml new file mode 100644 index 000000000..a3e3dd8f7 --- /dev/null +++ b/github/config.yaml @@ -0,0 +1,22 @@ +# Seed installed as `initial_value` with the configuration worker on first +# registration; the live value is authoritative thereafter. The engine URL is +# bootstrap and lives on the --url flag, not here. + +# Path to the GitHub CLI binary. Empty = the `gh` binary on PATH. +gh_executable: "" + +# GitHub token injected as GH_TOKEN into every child gh process. Referenced as +# ${GH_TOKEN} so the configuration worker env-expands it; empty falls back to +# the worker's inherited environment (ambient `gh auth login` state or an +# already-exported GH_TOKEN). +token: "${GH_TOKEN}" + +# Timeout for a gh invocation when the caller omits timeout_ms, in ms. +default_timeout_ms: 30000 + +# Upper clamp for any per-call timeout_ms, in ms. +max_timeout_ms: 120000 + +# Per-stream capture cap (stdout and stderr each), in bytes. Output beyond it +# is dropped and flagged stdout_truncated / stderr_truncated. +max_output_bytes: 1048576 diff --git a/github/iii-permissions.yaml b/github/iii-permissions.yaml new file mode 100644 index 000000000..82fe991c7 --- /dev/null +++ b/github/iii-permissions.yaml @@ -0,0 +1,34 @@ +# Agent permissions for the github worker. +# Spec: docs/sops/new-worker.md § 7. First-match-wins. +# +# Read-only queries (list/view/diff/checks/search) are safe reads and allowed +# without approval. Mutations (create/edit/merge/comment/review/close/rerun/ +# cancel/workflow run/release create) and both escape hatches (github::exec +# runs arbitrary gh commands; github::api reaches any REST endpoint including +# writes) deliberately stay at the needs_approval default. +# github::on-config-change is the internal hot-reload hook and is denied. +# +# NOTE: the policy the checked-in harness actually loads is the repo-root +# iii-permissions.yaml, which carries these same rules. This per-worker copy +# documents the worker's intended posture for other deployments. +version: 1 + +rules: + - '!github::on-config-change' + - github::pr::list + - github::pr::view + - github::pr::diff + - github::pr::checks + - github::issue::list + - github::issue::view + - github::repo::view + - github::repo::list + - github::run::list + - github::run::view + - github::workflow::list + - github::release::list + - github::release::view + - github::search::repos + - github::search::issues + - github::search::prs + - github::search::code diff --git a/github/iii.worker.yaml b/github/iii.worker.yaml new file mode 100644 index 000000000..13f4be5a1 --- /dev/null +++ b/github/iii.worker.yaml @@ -0,0 +1,7 @@ +iii: v1 +name: github +language: rust +deploy: binary +manifest: Cargo.toml +bin: github +description: GitHub CLI (gh) as an iii worker — typed github::pr/issue/repo/run/workflow/release/search functions, github::exec argv passthrough, and github::api for any GitHub REST endpoint. diff --git a/github/skills/SKILL.md b/github/skills/SKILL.md new file mode 100644 index 000000000..a63b43f1a --- /dev/null +++ b/github/skills/SKILL.md @@ -0,0 +1,61 @@ +--- +name: github +description: >- + Operate GitHub through the gh CLI — typed github::* functions for pull + requests, issues, repos, Actions runs/workflows, releases, and search, + plus github::exec / github::api escape hatches for everything else. +--- + +# github + +The github worker wraps the GitHub CLI (`gh`). Thirty typed functions cover +the high-traffic surface (pr, issue, repo, run, workflow, release, search); +`github::exec` runs any other gh command verbatim, and `github::api` reaches +any GitHub REST endpoint. Auth comes from the worker's GH_TOKEN configuration +or the host's ambient `gh auth login` state. There is no local checkout: +every repo-scoped call takes an explicit `repo: "owner/name"`. + +## When to Use + +- Check a PR before landing: `github::pr::view`, `github::pr::checks` + (failing/pending checks are data, not errors), `github::pr::diff`. +- Open or shepherd a PR: `github::pr::create` / `review` / `merge`. +- File and triage issues: `github::issue::create` / `list` / `comment` / + `close`. +- Watch or poke CI: `github::run::list` / `view` / `rerun` / `cancel`; + dispatch with `github::workflow::run`. +- Cut or inspect releases: `github::release::create` / `list` / `view`. +- Find things org-wide: `github::search::repos` / `issues` / `prs` / `code` + (qualifiers like `repo:o/r is:open` go in the query string). +- Anything gh does that has no typed function → `github::exec { args: [...] }`. +- Any REST endpoint → `github::api { path: "repos/o/r/…", jq? }`. + +## Boundaries + +- Needs the gh CLI on the worker host; a missing binary errors at call time + with code `gh_not_found` (functions still register without it). +- Mutations (create/edit/merge/comment/review/close/rerun/cancel/workflow + run/release create) and both escape hatches are approval-gated by default; + the read-only surface is allowed (see iii-permissions.yaml). +- Curated functions error on a non-zero gh exit (the message carries gh's + stderr); `github::exec` returns exit_code/stderr/timed_out as data instead. +- Output is capped per stream (default 1 MiB) with `*_truncated` flags; + per-call `timeout_ms` clamps to `max_timeout_ms` (default 120 s; 30 s when + omitted). +- `github::api` paths take concrete owner/repo values — `{owner}`/`{repo}` + placeholders need a checkout the worker does not have. + +## Functions + +- `github::pr::*` — list, view, create, edit, merge, comment, review, diff, + checks. +- `github::issue::*` — list, view, create, edit, comment, close. +- `github::repo::*` — view, list. +- `github::run::*` — list, view, rerun, cancel (Actions runs). +- `github::workflow::*` — list, run (workflow_dispatch). +- `github::release::*` — list, view, create. +- `github::search::*` — repos, issues, prs, code. +- `github::exec` — `{ args, stdin?, timeout_ms? }` → the full outcome as data + (`stdout`, `stderr`, `exit_code`, `timed_out`, truncation flags). +- `github::api` — `{ path, method?, fields?, body?, jq?, paginate?, + timeout_ms? }` → parsed JSON. diff --git a/github/src/config.rs b/github/src/config.rs new file mode 100644 index 000000000..7f659b0cf --- /dev/null +++ b/github/src/config.rs @@ -0,0 +1,118 @@ +//! Runtime config managed by the `configuration` worker. `config.yaml` is the +//! seed installed as `initial_value` on first registration; the live value from +//! the configuration worker is authoritative thereafter and hot-reloads. +//! +//! `engine_url` is intentionally NOT here — it is bootstrap (you need it to +//! reach the configuration worker), so it stays on the `--url` CLI flag. +//! +//! The GitHub token is referenced in the seed as `${GH_TOKEN}`; the +//! configuration worker env-expands it before this worker ever sees the value, +//! so the secret never lives in the repo or on the wire in plaintext form. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct Config { + /// Path to the `gh` binary. Empty = resolve `gh` on PATH. + pub gh_executable: String, + /// GitHub token set as GH_TOKEN on every child gh process. Seed as + /// `${GH_TOKEN}` so the configuration worker env-expands it; empty = the + /// child inherits the worker's environment (ambient `gh auth login` state + /// or an already-exported GH_TOKEN). + pub token: String, + /// Timeout applied when a call does not pass `timeout_ms`, in ms. + pub default_timeout_ms: u64, + /// Upper clamp for any per-call `timeout_ms`, in ms. + pub max_timeout_ms: u64, + /// Per-stream (stdout/stderr) capture cap in bytes; output beyond it is + /// dropped and flagged `stdout_truncated` / `stderr_truncated`. + pub max_output_bytes: usize, +} + +impl Default for Config { + fn default() -> Self { + Self { + gh_executable: String::new(), + token: String::new(), + default_timeout_ms: 30_000, + max_timeout_ms: 120_000, + max_output_bytes: 1_048_576, + } + } +} + +impl Config { + /// Load the seed from a YAML file, expanding `${NAME}` against the process + /// environment first (an unset var expands to empty). Missing file yields + /// defaults; a parse error propagates so a typo fails the worker fast. + pub fn load(path: &str) -> anyhow::Result { + match std::fs::read_to_string(path) { + Ok(text) => Ok(serde_yaml::from_str(&expand_env(&text))?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()), + Err(e) => Err(e.into()), + } + } + + /// Resolve the gh binary: the configured path, or `gh` on PATH. + pub fn gh_bin(&self) -> String { + if self.gh_executable.is_empty() { + "gh".to_string() + } else { + self.gh_executable.clone() + } + } + + /// Per-call timeout: the requested value (or the default), clamped to + /// `max_timeout_ms`. + pub fn resolve_timeout(&self, requested: Option) -> u64 { + requested + .unwrap_or(self.default_timeout_ms) + .min(self.max_timeout_ms) + } + + pub fn json_schema() -> Value { + let root = schemars::gen::SchemaGenerator::default().into_root_schema_for::(); + serde_json::to_value(root).expect("config schema serializes") + } + + pub fn to_json(&self) -> Value { + serde_json::to_value(self).expect("config serializes") + } + + /// Parse a value fetched from the configuration worker (already env-expanded + /// by the worker; this does not re-expand). + pub fn from_json(value: &Value) -> anyhow::Result { + Ok(serde_json::from_value(value.clone())?) + } +} + +/// Expand `${NAME}` occurrences against the process environment. An unset +/// variable expands to empty (with a warning); a `${` without a closing `}` +/// is left verbatim. +fn expand_env(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + match after.find('}') { + Some(end) => { + let name = &after[..end]; + match std::env::var(name) { + Ok(v) => out.push_str(&v), + Err(_) => tracing::warn!(var = %name, "config references undefined env var"), + } + rest = &after[end + 1..]; + } + None => { + out.push_str("${"); + rest = after; + } + } + } + out.push_str(rest); + out +} diff --git a/github/src/configuration.rs b/github/src/configuration.rs new file mode 100644 index 000000000..ab1f1a379 --- /dev/null +++ b/github/src/configuration.rs @@ -0,0 +1,164 @@ +//! Integration with the `configuration` worker — register the github config +//! schema, fetch the live value, and hot-reload it on change. Every field is a +//! runtime tuning knob (gh binary path, GH_TOKEN credential, timeout/output +//! caps), so a change hot-swaps the whole snapshot. + +use std::sync::Arc; +use std::time::Duration; + +use iii_sdk::errors::Error; +use iii_sdk::protocol::{RegisterTriggerInput, TriggerRequest}; +use iii_sdk::{IIIClient, RegisterFunction}; +use serde_json::{json, Value}; +use tokio::sync::RwLock; + +use crate::config::Config; + +/// Hot-swappable config snapshot shared with every handler. A handler takes a +/// `read().await` and clones the inner `Arc` out (a cheap refcount bump) so it +/// never holds the lock across a turn; `apply_config` whole-snapshot replaces +/// the inner `Arc` under the write lock. +pub type ConfigCell = Arc>>; + +pub const CONFIG_ID: &str = "github"; +const CONFIG_FN_ID: &str = "github::on-config-change"; +const CONFIG_TIMEOUT_MS: u64 = 5_000; +const CONFIG_RETRIES: u32 = 3; + +/// Register the `github` configuration schema. When `seed` is present its value +/// is installed as `initial_value`; otherwise the built-in default is seeded +/// only when no stored value exists yet. +pub async fn register_config(iii: &IIIClient, seed: Option<&Config>) -> Result<(), String> { + let mut payload = json!({ + "id": CONFIG_ID, + "name": "GitHub", + "description": "GitHub worker: the gh binary path, the GH_TOKEN credential injected into child gh processes, and the timeout / output-capture limits applied to every gh invocation.", + "schema": Config::json_schema(), + }); + if let Some(seed) = seed { + payload["initial_value"] = seed.to_json(); + } else if should_seed_default(iii).await? { + payload["initial_value"] = Config::default().to_json(); + } + trigger_with_retry(iii, "configuration::register", payload).await?; + Ok(()) +} + +/// Read the live `github` configuration; built-in default when none stored. +pub async fn fetch_config(iii: &IIIClient) -> Result { + match try_get_value(iii).await? { + Some(v) if !v.is_null() => Config::from_json(&v).map_err(|e| e.to_string()), + _ => { + tracing::info!("no github configuration value found; using built-in defaults"); + Ok(Config::default()) + } + } +} + +async fn should_seed_default(iii: &IIIClient) -> Result { + Ok(matches!( + try_get_value(iii).await?, + None | Some(Value::Null) + )) +} + +/// `Ok(None)` when the entry does not exist (`NOT_FOUND`). +async fn try_get_value(iii: &IIIClient) -> Result, String> { + match trigger_with_retry(iii, "configuration::get", json!({ "id": CONFIG_ID })).await { + Ok(resp) => Ok(resp.get("value").cloned()), + Err(e) if e.contains("NOT_FOUND") => Ok(None), + Err(e) => Err(e), + } +} + +pub async fn apply_config(cell: &ConfigCell, cfg: Config) { + *cell.write().await = Arc::new(cfg); +} + +/// Register the internal config-change handler and bind the `configuration` +/// trigger that wakes it. +pub fn register_config_trigger(iii: &IIIClient, cell: ConfigCell) -> Result<(), Error> { + let engine = iii.clone(); + iii.register_function( + CONFIG_FN_ID, + RegisterFunction::new_async(move |_payload: Value| { + let cell = cell.clone(); + let engine = engine.clone(); + async move { + on_config_change(&engine, &cell).await; + Ok::(json!({ "ok": true })) + } + }) + .request_format(json!({ "type": "object", "properties": {} })) + .response_format(json!({ + "type": "object", + "properties": { "ok": { "type": "boolean" } }, + })) + .description("Internal: reload github configuration when it changes."), + ); + + iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: CONFIG_FN_ID.to_string(), + config: json!({ + "configuration_id": CONFIG_ID, + "event_types": ["configuration:updated"], + }), + metadata: None, + })?; + Ok(()) +} + +/// Re-fetch the authoritative value after trigger registration to close the +/// boot race (an update that landed between the initial fetch and the trigger +/// binding has no other listener). +pub async fn reconcile(iii: &IIIClient, cell: &ConfigCell) { + on_config_change(iii, cell).await; +} + +/// The trigger payload is intentionally ignored — `github::on-config-change` is +/// a discoverable bus function, so trusting `payload.new_value` would let any +/// caller inject config without updating persisted state. Re-fetch the stored +/// value instead. The previous snapshot is kept on any failure. +async fn on_config_change(iii: &IIIClient, cell: &ConfigCell) { + match fetch_config(iii).await { + Ok(cfg) => { + apply_config(cell, cfg).await; + tracing::info!("github configuration reloaded"); + } + Err(e) => { + tracing::error!(error = %e, "config-change: fetch failed; keeping previous config"); + } + } +} + +async fn trigger_with_retry( + iii: &IIIClient, + function_id: &str, + payload: Value, +) -> Result { + let mut last_err = String::new(); + for attempt in 1..=CONFIG_RETRIES { + match iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload: payload.clone(), + action: None, + timeout_ms: Some(CONFIG_TIMEOUT_MS), + }) + .await + { + Ok(v) => return Ok(v), + Err(e) => { + last_err = e.to_string(); + if attempt < CONFIG_RETRIES { + let backoff = 250u64 << (attempt - 1); + tokio::time::sleep(Duration::from_millis(backoff)).await; + } + } + } + } + Err(format!( + "{function_id} failed after {CONFIG_RETRIES} attempts: {last_err}" + )) +} diff --git a/github/src/functions/actions.rs b/github/src/functions/actions.rs new file mode 100644 index 000000000..4144d8478 --- /dev/null +++ b/github/src/functions/actions.rs @@ -0,0 +1,207 @@ +//! `github::run::*` and `github::workflow::*` — GitHub Actions wrappers +//! (`gh run …`, `gh workflow …`). + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::Deserialize; + +use super::{argv, push_bool, push_opt}; + +pub const RUN_LIST_ID: &str = "github::run::list"; +pub const RUN_LIST_DESC: &str = "List GitHub Actions workflow runs: { repo: \"owner/name\", workflow?, branch?, status?, limit? } -> { value: [{databaseId, displayTitle, workflowName, headBranch, event, status, conclusion, url, ...}] }."; +pub const RUN_JSON: &str = "databaseId,number,displayTitle,name,workflowName,headBranch,headSha,event,status,conclusion,attempt,createdAt,startedAt,updatedAt,url"; + +pub const RUN_VIEW_ID: &str = "github::run::view"; +pub const RUN_VIEW_DESC: &str = "View one workflow run including its jobs and their steps: { repo: \"owner/name\", run_id } -> { value }."; + +pub const RUN_RERUN_ID: &str = "github::run::rerun"; +pub const RUN_RERUN_DESC: &str = + "Rerun a workflow run: { repo, run_id, failed? } -> { output }. failed reruns only the failed jobs."; + +pub const RUN_CANCEL_ID: &str = "github::run::cancel"; +pub const RUN_CANCEL_DESC: &str = + "Cancel an in-progress workflow run: { repo, run_id } -> { output }."; + +pub const WORKFLOW_LIST_ID: &str = "github::workflow::list"; +pub const WORKFLOW_LIST_DESC: &str = "List workflows in a repo: { repo: \"owner/name\", all? } -> { value: [{id, name, path, state}] }. all includes disabled workflows."; +pub const WORKFLOW_JSON: &str = "id,name,path,state"; + +pub const WORKFLOW_RUN_ID: &str = "github::workflow::run"; +pub const WORKFLOW_RUN_DESC: &str = "Dispatch a workflow (workflow_dispatch): { repo, workflow: , ref?, inputs? } -> { output }. inputs become -f key=value pairs."; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RunListRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Filter by workflow (file name, e.g. "ci.yml"). + pub workflow: Option, + /// Filter by branch. + pub branch: Option, + /// Filter by status (e.g. completed, in_progress, queued, failure, success). + pub status: Option, + /// Maximum number of results (gh default: 20). + pub limit: Option, +} + +pub fn run_list_args(r: &RunListRequest) -> Vec { + let mut a = argv(["run", "list", "-R", r.repo.as_str(), "--json", RUN_JSON]); + push_opt(&mut a, "--workflow", r.workflow.as_deref()); + push_opt(&mut a, "--branch", r.branch.as_deref()); + push_opt(&mut a, "--status", r.status.as_deref()); + push_opt(&mut a, "--limit", r.limit); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RunViewRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Workflow run id (databaseId from github::run::list). + pub run_id: u64, +} + +pub fn run_view_args(r: &RunViewRequest) -> Vec { + let mut a = argv([ + "run", + "view", + r.run_id.to_string().as_str(), + "-R", + r.repo.as_str(), + "--json", + ]); + a.push(format!("{RUN_JSON},jobs")); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RunRerunRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Workflow run id. + pub run_id: u64, + /// Rerun only the failed jobs. + pub failed: Option, +} + +pub fn run_rerun_args(r: &RunRerunRequest) -> Vec { + let mut a = argv([ + "run", + "rerun", + r.run_id.to_string().as_str(), + "-R", + r.repo.as_str(), + ]); + push_bool(&mut a, "--failed", r.failed); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RunCancelRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Workflow run id. + pub run_id: u64, +} + +pub fn run_cancel_args(r: &RunCancelRequest) -> Vec { + argv([ + "run", + "cancel", + r.run_id.to_string().as_str(), + "-R", + r.repo.as_str(), + ]) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct WorkflowListRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Include disabled workflows. + pub all: Option, +} + +pub fn workflow_list_args(r: &WorkflowListRequest) -> Vec { + let mut a = argv([ + "workflow", + "list", + "-R", + r.repo.as_str(), + "--json", + WORKFLOW_JSON, + ]); + push_bool(&mut a, "--all", r.all); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct WorkflowRunRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Workflow file name (e.g. "release.yml") or numeric id. + pub workflow: String, + /// Branch or tag to run on (gh default: the repo default branch). + #[serde(rename = "ref")] + pub r#ref: Option, + /// workflow_dispatch inputs, each passed as `-f key=value`. + pub inputs: Option>, +} + +pub fn workflow_run_args(r: &WorkflowRunRequest) -> Vec { + let mut a = argv([ + "workflow", + "run", + r.workflow.as_str(), + "-R", + r.repo.as_str(), + ]); + push_opt(&mut a, "--ref", r.r#ref.as_deref()); + if let Some(inputs) = &r.inputs { + for (k, v) in inputs { + a.push("-f".to_string()); + a.push(format!("{k}={v}")); + } + } + a +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn run_view_requests_jobs_too() { + let r: RunViewRequest = + serde_json::from_value(json!({ "repo": "o/r", "run_id": 42 })).unwrap(); + let a = run_view_args(&r); + assert_eq!(a[..6], ["run", "view", "42", "-R", "o/r", "--json"]); + assert!(a[6].ends_with(",jobs")); + } + + #[test] + fn workflow_run_maps_ref_and_sorted_inputs() { + let r: WorkflowRunRequest = serde_json::from_value(json!({ + "repo": "o/r", "workflow": "release.yml", "ref": "main", + "inputs": { "worker": "github", "bump": "minor" } + })) + .unwrap(); + assert_eq!( + workflow_run_args(&r), + vec![ + "workflow", + "run", + "release.yml", + "-R", + "o/r", + "--ref", + "main", + "-f", + "bump=minor", + "-f", + "worker=github", + ] + ); + } +} diff --git a/github/src/functions/issue.rs b/github/src/functions/issue.rs new file mode 100644 index 000000000..cf4585992 --- /dev/null +++ b/github/src/functions/issue.rs @@ -0,0 +1,281 @@ +//! `github::issue::*` — typed wrappers over `gh issue …`. + +use schemars::JsonSchema; +use serde::Deserialize; + +use super::{argv, push_each, push_opt}; + +pub const LIST_ID: &str = "github::issue::list"; +pub const LIST_DESC: &str = "List issues: { repo: \"owner/name\", state?, limit?, author?, labels?, assignee?, search? } -> { value: [{number, title, state, url, author, labels, assignees, milestone, createdAt, updatedAt}] }."; +pub const LIST_JSON: &str = + "number,title,state,url,author,labels,assignees,milestone,createdAt,updatedAt"; + +pub const VIEW_ID: &str = "github::issue::view"; +pub const VIEW_DESC: &str = + "View one issue with body and comments: { repo: \"owner/name\", number } -> { value }."; +pub const VIEW_JSON: &str = "number,title,state,url,author,labels,assignees,milestone,createdAt,updatedAt,body,stateReason,comments,closedAt"; + +pub const CREATE_ID: &str = "github::issue::create"; +pub const CREATE_DESC: &str = "Open an issue: { repo: \"owner/name\", title, body, labels?, assignees? } -> { output: }."; + +pub const EDIT_ID: &str = "github::issue::edit"; +pub const EDIT_DESC: &str = "Edit an issue's title/body/labels/assignees: { repo, number, title?, body?, add_labels?, remove_labels?, add_assignees? } -> { output }."; + +pub const COMMENT_ID: &str = "github::issue::comment"; +pub const COMMENT_DESC: &str = + "Comment on an issue: { repo, number, body } -> { output: }."; + +pub const CLOSE_ID: &str = "github::issue::close"; +pub const CLOSE_DESC: &str = + "Close an issue: { repo, number, comment?, reason?: completed|not-planned } -> { output }."; + +/// Issue state filter. +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum IssueState { + Open, + Closed, + All, +} + +impl IssueState { + fn as_str(self) -> &'static str { + match self { + IssueState::Open => "open", + IssueState::Closed => "closed", + IssueState::All => "all", + } + } +} + +/// Close reason. The wire value is kebab-case; gh's flag value for +/// `not-planned` is the space-separated "not planned". +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum CloseReason { + Completed, + NotPlanned, +} + +impl CloseReason { + fn as_str(self) -> &'static str { + match self { + CloseReason::Completed => "completed", + CloseReason::NotPlanned => "not planned", + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Filter by state (gh default: open). + pub state: Option, + /// Maximum number of results (gh default: 30). + pub limit: Option, + /// Filter by author login. + pub author: Option, + /// Filter by labels (all must match). + pub labels: Option>, + /// Filter by assignee login. + pub assignee: Option, + /// Search query narrowing the list further (GitHub search syntax). + pub search: Option, +} + +pub fn list_args(r: &ListRequest) -> Vec { + let mut a = argv(["issue", "list", "-R", r.repo.as_str(), "--json", LIST_JSON]); + push_opt(&mut a, "--state", r.state.map(|s| s.as_str())); + push_opt(&mut a, "--limit", r.limit); + push_opt(&mut a, "--author", r.author.as_deref()); + push_each(&mut a, "--label", &r.labels); + push_opt(&mut a, "--assignee", r.assignee.as_deref()); + push_opt(&mut a, "--search", r.search.as_deref()); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ViewRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Issue number. + pub number: u64, +} + +pub fn view_args(r: &ViewRequest) -> Vec { + argv([ + "issue", + "view", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + "--json", + VIEW_JSON, + ]) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Issue title. + pub title: String, + /// Issue body (markdown). + pub body: String, + /// Labels to apply. + pub labels: Option>, + /// Assignee logins. + pub assignees: Option>, +} + +pub fn create_args(r: &CreateRequest) -> Vec { + let mut a = argv([ + "issue", + "create", + "-R", + r.repo.as_str(), + "--title", + r.title.as_str(), + "--body", + r.body.as_str(), + ]); + push_each(&mut a, "--label", &r.labels); + push_each(&mut a, "--assignee", &r.assignees); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct EditRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Issue number. + pub number: u64, + /// New title. + pub title: Option, + /// New body (markdown). + pub body: Option, + /// Labels to add. + pub add_labels: Option>, + /// Labels to remove. + pub remove_labels: Option>, + /// Assignee logins to add. + pub add_assignees: Option>, +} + +pub fn edit_args(r: &EditRequest) -> Vec { + let mut a = argv([ + "issue", + "edit", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + ]); + push_opt(&mut a, "--title", r.title.as_deref()); + push_opt(&mut a, "--body", r.body.as_deref()); + push_each(&mut a, "--add-label", &r.add_labels); + push_each(&mut a, "--remove-label", &r.remove_labels); + push_each(&mut a, "--add-assignee", &r.add_assignees); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CommentRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Issue number. + pub number: u64, + /// Comment body (markdown). + pub body: String, +} + +pub fn comment_args(r: &CommentRequest) -> Vec { + argv([ + "issue", + "comment", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + "--body", + r.body.as_str(), + ]) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CloseRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Issue number. + pub number: u64, + /// Closing comment. + pub comment: Option, + /// Close reason. + pub reason: Option, +} + +pub fn close_args(r: &CloseRequest) -> Vec { + let mut a = argv([ + "issue", + "close", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + ]); + push_opt(&mut a, "--comment", r.comment.as_deref()); + push_opt(&mut a, "--reason", r.reason.map(|x| x.as_str())); + a +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn close_maps_not_planned_to_the_spaced_flag_value() { + let r: CloseRequest = serde_json::from_value(json!({ + "repo": "o/r", "number": 3, "reason": "not-planned" + })) + .unwrap(); + assert_eq!( + close_args(&r), + vec![ + "issue", + "close", + "3", + "-R", + "o/r", + "--reason", + "not planned" + ] + ); + } + + #[test] + fn create_repeats_labels_and_assignees() { + let r: CreateRequest = serde_json::from_value(json!({ + "repo": "o/r", "title": "t", "body": "b", + "labels": ["bug"], "assignees": ["octocat", "hubot"] + })) + .unwrap(); + assert_eq!( + create_args(&r), + vec![ + "issue", + "create", + "-R", + "o/r", + "--title", + "t", + "--body", + "b", + "--label", + "bug", + "--assignee", + "octocat", + "--assignee", + "hubot", + ] + ); + } +} diff --git a/github/src/functions/mod.rs b/github/src/functions/mod.rs new file mode 100644 index 000000000..15e59eb6e --- /dev/null +++ b/github/src/functions/mod.rs @@ -0,0 +1,495 @@ +//! Registration: shared response types, the generic register helpers that +//! keep ~30 thin gh wrappers non-repetitive, and the wire-surface catalog +//! golden-tested in `tests/schemas.rs`. + +pub mod actions; +pub mod issue; +pub mod passthrough; +pub mod pr; +pub mod release; +pub mod repo; +pub mod search; + +use iii_sdk::errors::Error; +use iii_sdk::{IIIClient, RegisterFunction}; +use schemars::JsonSchema; +use serde::Serialize; +use serde_json::Value; + +use crate::configuration::ConfigCell; +use crate::gh::{self, GhError, GhOutcome}; + +/// Exit codes that mean "the gh operation succeeded" for most commands. +const OK: &[i32] = &[0]; +/// `gh pr checks` exit codes are data: 0 = all passed, 1 = some failed, +/// 8 = some pending. Only other codes are real failures. +const CHECKS_OK: &[i32] = &[0, 1, 8]; + +/// Parsed `--json` output of a gh command. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ValueResponse { + /// The JSON gh printed for the requested `--json` fields. + pub value: Value, +} + +/// Trimmed stdout of a mutating gh command (gh prints the created/updated +/// URL or a confirmation line; some commands print nothing). +#[derive(Debug, Serialize, JsonSchema)] +pub struct TextResponse { + /// gh's stdout, trimmed. + pub output: String, +} + +/// `github::pr::diff` response. +#[derive(Debug, Serialize, JsonSchema)] +pub struct DiffResponse { + /// The unified diff as printed by `gh pr diff`. + pub diff: String, + /// True when the diff exceeded the capture cap and was cut off. + pub truncated: bool, +} + +/// Lift a completed invocation into "the gh operation succeeded": a timeout +/// becomes `Error::Remote("timeout")`; an exit code outside `ok_codes` becomes +/// `Error::Handler` carrying gh's stderr (that's where gh explains auth +/// failures, 404s, and validation errors). +fn expect_ok(id: &str, out: GhOutcome, ok_codes: &[i32]) -> Result { + if out.timed_out { + return Err(GhError::timeout(out.duration_ms).into()); + } + match out.exit_code { + Some(code) if ok_codes.contains(&code) => Ok(out), + Some(code) => Err(Error::Handler(format!( + "{id}: gh exited {code}: {}", + out.stderr.trim() + ))), + None => Err(Error::Handler(format!( + "{id}: gh was killed before exiting: {}", + out.stderr.trim() + ))), + } +} + +/// Parse gh's stdout as JSON. Empty stdout parses to `null` (some commands +/// print nothing on success); truncated stdout is an error — a cut-off JSON +/// document would parse to garbage or fail confusingly. +fn parse_stdout(id: &str, out: &GhOutcome) -> Result { + if out.stdout_truncated { + return Err(Error::Handler(format!( + "{id}: gh output exceeded max_output_bytes and was truncated; \ + lower --limit, raise the cap, or use github::exec" + ))); + } + let trimmed = out.stdout.trim(); + if trimmed.is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(trimmed).map_err(|e| { + let head: String = trimmed.chars().take(200).collect(); + Error::Handler(format!("{id}: unexpected non-JSON gh output ({e}): {head}")) + }) +} + +/// Register a curated wrapper whose stdout is `--json` output: typed request +/// → pure argv builder → bounded gh run → parsed `ValueResponse`. +fn register_json( + iii: &IIIClient, + cell: &ConfigCell, + id: &'static str, + desc: &'static str, + ok_codes: &'static [i32], + build: fn(&Req) -> Vec, +) where + Req: serde::de::DeserializeOwned + JsonSchema + Send + 'static, +{ + let cell = cell.clone(); + iii.register_function( + id, + RegisterFunction::new_async(move |req: Req| { + let cell = cell.clone(); + async move { + let cfg = cell.read().await.clone(); + let out = gh::run(&cfg, &build(&req), None, None) + .await + .map_err(Error::from)?; + let out = expect_ok(id, out, ok_codes)?; + let value = parse_stdout(id, &out)?; + Ok::<_, Error>(ValueResponse { value }) + } + }) + .description(desc), + ); +} + +/// Register a curated wrapper for a mutating command: typed request → pure +/// argv builder → bounded gh run → trimmed stdout as `TextResponse`. +fn register_text( + iii: &IIIClient, + cell: &ConfigCell, + id: &'static str, + desc: &'static str, + build: fn(&Req) -> Vec, +) where + Req: serde::de::DeserializeOwned + JsonSchema + Send + 'static, +{ + let cell = cell.clone(); + iii.register_function( + id, + RegisterFunction::new_async(move |req: Req| { + let cell = cell.clone(); + async move { + let cfg = cell.read().await.clone(); + let out = gh::run(&cfg, &build(&req), None, None) + .await + .map_err(Error::from)?; + let out = expect_ok(id, out, OK)?; + Ok::<_, Error>(TextResponse { + output: out.stdout.trim().to_string(), + }) + } + }) + .description(desc), + ); +} + +/// `github::pr::diff` is hand-registered: its response is the raw diff, with +/// the truncation flag surfaced instead of erroring (a cut-off diff is still +/// useful, unlike cut-off JSON). +fn register_diff(iii: &IIIClient, cell: &ConfigCell) { + let cell = cell.clone(); + iii.register_function( + pr::DIFF_ID, + RegisterFunction::new_async(move |req: pr::DiffRequest| { + let cell = cell.clone(); + async move { + let cfg = cell.read().await.clone(); + let out = gh::run(&cfg, &pr::diff_args(&req), None, None) + .await + .map_err(Error::from)?; + let out = expect_ok(pr::DIFF_ID, out, OK)?; + Ok::<_, Error>(DiffResponse { + truncated: out.stdout_truncated, + diff: out.stdout, + }) + } + }) + .description(pr::DIFF_DESC), + ); +} + +/// Register every `github::*` function. Keep in lockstep with [`catalog`]. +pub fn register_all(iii: &IIIClient, cell: &ConfigCell) { + register_json(iii, cell, pr::LIST_ID, pr::LIST_DESC, OK, pr::list_args); + register_json(iii, cell, pr::VIEW_ID, pr::VIEW_DESC, OK, pr::view_args); + register_text(iii, cell, pr::CREATE_ID, pr::CREATE_DESC, pr::create_args); + register_text(iii, cell, pr::EDIT_ID, pr::EDIT_DESC, pr::edit_args); + register_text(iii, cell, pr::MERGE_ID, pr::MERGE_DESC, pr::merge_args); + register_text( + iii, + cell, + pr::COMMENT_ID, + pr::COMMENT_DESC, + pr::comment_args, + ); + register_text(iii, cell, pr::REVIEW_ID, pr::REVIEW_DESC, pr::review_args); + register_diff(iii, cell); + register_json( + iii, + cell, + pr::CHECKS_ID, + pr::CHECKS_DESC, + CHECKS_OK, + pr::checks_args, + ); + + register_json( + iii, + cell, + issue::LIST_ID, + issue::LIST_DESC, + OK, + issue::list_args, + ); + register_json( + iii, + cell, + issue::VIEW_ID, + issue::VIEW_DESC, + OK, + issue::view_args, + ); + register_text( + iii, + cell, + issue::CREATE_ID, + issue::CREATE_DESC, + issue::create_args, + ); + register_text( + iii, + cell, + issue::EDIT_ID, + issue::EDIT_DESC, + issue::edit_args, + ); + register_text( + iii, + cell, + issue::COMMENT_ID, + issue::COMMENT_DESC, + issue::comment_args, + ); + register_text( + iii, + cell, + issue::CLOSE_ID, + issue::CLOSE_DESC, + issue::close_args, + ); + + register_json( + iii, + cell, + repo::VIEW_ID, + repo::VIEW_DESC, + OK, + repo::view_args, + ); + register_json( + iii, + cell, + repo::LIST_ID, + repo::LIST_DESC, + OK, + repo::list_args, + ); + + register_json( + iii, + cell, + actions::RUN_LIST_ID, + actions::RUN_LIST_DESC, + OK, + actions::run_list_args, + ); + register_json( + iii, + cell, + actions::RUN_VIEW_ID, + actions::RUN_VIEW_DESC, + OK, + actions::run_view_args, + ); + register_text( + iii, + cell, + actions::RUN_RERUN_ID, + actions::RUN_RERUN_DESC, + actions::run_rerun_args, + ); + register_text( + iii, + cell, + actions::RUN_CANCEL_ID, + actions::RUN_CANCEL_DESC, + actions::run_cancel_args, + ); + register_json( + iii, + cell, + actions::WORKFLOW_LIST_ID, + actions::WORKFLOW_LIST_DESC, + OK, + actions::workflow_list_args, + ); + register_text( + iii, + cell, + actions::WORKFLOW_RUN_ID, + actions::WORKFLOW_RUN_DESC, + actions::workflow_run_args, + ); + + register_json( + iii, + cell, + release::LIST_ID, + release::LIST_DESC, + OK, + release::list_args, + ); + register_json( + iii, + cell, + release::VIEW_ID, + release::VIEW_DESC, + OK, + release::view_args, + ); + register_text( + iii, + cell, + release::CREATE_ID, + release::CREATE_DESC, + release::create_args, + ); + + register_json( + iii, + cell, + search::REPOS_ID, + search::REPOS_DESC, + OK, + search::repos_args, + ); + register_json( + iii, + cell, + search::ISSUES_ID, + search::ISSUES_DESC, + OK, + search::issues_args, + ); + register_json( + iii, + cell, + search::PRS_ID, + search::PRS_DESC, + OK, + search::prs_args, + ); + register_json( + iii, + cell, + search::CODE_ID, + search::CODE_DESC, + OK, + search::code_args, + ); + + passthrough::register(iii, cell); +} + +// ---- argv-building helpers (pure; shared by the group modules) ---- + +/// Start an argv from static parts. +pub(crate) fn argv(parts: [&str; N]) -> Vec { + parts.iter().map(|s| s.to_string()).collect() +} + +/// Push `flag value` when the value is present. +pub(crate) fn push_opt(a: &mut Vec, flag: &str, v: Option) { + if let Some(v) = v { + a.push(flag.to_string()); + a.push(v.to_string()); + } +} + +/// Push a bare `flag` when the option is `Some(true)`. +pub(crate) fn push_bool(a: &mut Vec, flag: &str, v: Option) { + if v == Some(true) { + a.push(flag.to_string()); + } +} + +/// Push `flag value` once per element. +pub(crate) fn push_each(a: &mut Vec, flag: &str, vs: &Option>) { + if let Some(vs) = vs { + for v in vs { + a.push(flag.to_string()); + a.push(v.clone()); + } + } +} + +// ---- wire-surface catalog ---- + +/// One function's complete agent-facing wire surface: id, registration +/// description, and the schemars-derived request/response schemas. +pub struct FunctionSpec { + pub function_id: &'static str, + pub description: &'static str, + pub request_schema: schemars::schema::RootSchema, + pub response_schema: schemars::schema::RootSchema, +} + +/// Schema generation MUST mirror iii-sdk's internal `json_schema_for` +/// (`SchemaSettings::draft07()` on the handler's request/response types): +/// `RegisterFunction::new_async` auto-extracts schemas from the SAME structs +/// referenced here, with the same schemars 0.8 generator settings, so a +/// catalog snapshot pins exactly what registration emits. +fn schema_of() -> schemars::schema::RootSchema { + schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::() +} + +fn spec( + function_id: &'static str, + description: &'static str, +) -> FunctionSpec { + FunctionSpec { + function_id, + description, + request_schema: schema_of::(), + response_schema: schema_of::(), + } +} + +/// The full wire-surface catalog, in registration order. Golden-tested in +/// `tests/schemas.rs`; keep in lockstep with [`register_all`]. +/// `github::on-config-change` is internal and deliberately not listed. +pub fn catalog() -> Vec { + vec![ + spec::(pr::LIST_ID, pr::LIST_DESC), + spec::(pr::VIEW_ID, pr::VIEW_DESC), + spec::(pr::CREATE_ID, pr::CREATE_DESC), + spec::(pr::EDIT_ID, pr::EDIT_DESC), + spec::(pr::MERGE_ID, pr::MERGE_DESC), + spec::(pr::COMMENT_ID, pr::COMMENT_DESC), + spec::(pr::REVIEW_ID, pr::REVIEW_DESC), + spec::(pr::DIFF_ID, pr::DIFF_DESC), + spec::(pr::CHECKS_ID, pr::CHECKS_DESC), + spec::(issue::LIST_ID, issue::LIST_DESC), + spec::(issue::VIEW_ID, issue::VIEW_DESC), + spec::(issue::CREATE_ID, issue::CREATE_DESC), + spec::(issue::EDIT_ID, issue::EDIT_DESC), + spec::(issue::COMMENT_ID, issue::COMMENT_DESC), + spec::(issue::CLOSE_ID, issue::CLOSE_DESC), + spec::(repo::VIEW_ID, repo::VIEW_DESC), + spec::(repo::LIST_ID, repo::LIST_DESC), + spec::( + actions::RUN_LIST_ID, + actions::RUN_LIST_DESC, + ), + spec::( + actions::RUN_VIEW_ID, + actions::RUN_VIEW_DESC, + ), + spec::( + actions::RUN_RERUN_ID, + actions::RUN_RERUN_DESC, + ), + spec::( + actions::RUN_CANCEL_ID, + actions::RUN_CANCEL_DESC, + ), + spec::( + actions::WORKFLOW_LIST_ID, + actions::WORKFLOW_LIST_DESC, + ), + spec::( + actions::WORKFLOW_RUN_ID, + actions::WORKFLOW_RUN_DESC, + ), + spec::(release::LIST_ID, release::LIST_DESC), + spec::(release::VIEW_ID, release::VIEW_DESC), + spec::(release::CREATE_ID, release::CREATE_DESC), + spec::(search::REPOS_ID, search::REPOS_DESC), + spec::(search::ISSUES_ID, search::ISSUES_DESC), + spec::(search::PRS_ID, search::PRS_DESC), + spec::(search::CODE_ID, search::CODE_DESC), + spec::(passthrough::EXEC_ID, passthrough::EXEC_DESC), + spec::(passthrough::API_ID, passthrough::API_DESC), + ] +} diff --git a/github/src/functions/passthrough.rs b/github/src/functions/passthrough.rs new file mode 100644 index 000000000..1ea171c76 --- /dev/null +++ b/github/src/functions/passthrough.rs @@ -0,0 +1,186 @@ +//! The escape hatches: `github::exec` (any gh command, outcome as data) and +//! `github::api` (any GitHub REST endpoint via `gh api`). + +use iii_sdk::errors::Error; +use iii_sdk::{IIIClient, RegisterFunction}; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::Value; + +use super::{argv, expect_ok, push_opt, ValueResponse, OK}; +use crate::configuration::ConfigCell; +use crate::gh; + +pub const EXEC_ID: &str = "github::exec"; +pub const EXEC_DESC: &str = "Run any gh command: { args: [\"pr\", \"list\", \"-R\", \"owner/name\"], stdin?, timeout_ms? } -> { stdout, stderr, exit_code, duration_ms, timed_out, stdout_truncated, stderr_truncated }. A non-zero exit or a timeout is DATA here, not an error. The escape hatch for everything without a typed github::* function."; + +pub const API_ID: &str = "github::api"; +pub const API_DESC: &str = "Call any GitHub REST endpoint via gh api: { path: \"repos/OWNER/REPO/issues\", method?, fields?, body?, jq?, paginate?, timeout_ms? } -> { value: }. fields become -f key=value form fields (default method flips to POST); body is sent verbatim as the JSON request body. Non-2xx responses are errors carrying gh's stderr."; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ExecRequest { + /// gh argv, without the leading `gh` (e.g. ["pr", "status", "-R", "o/r"]). + pub args: Vec, + /// Bytes piped to gh's stdin (for flags like `--body-file -`). + pub stdin: Option, + /// Per-call timeout in ms, clamped to the configured max_timeout_ms. + pub timeout_ms: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ApiRequest { + /// Endpoint path, with concrete owner/repo values (e.g. "rate_limit" or + /// "repos/cli/cli/issues") — placeholder expansion needs a checkout the + /// worker does not have. + pub path: String, + /// HTTP method. gh defaults to GET, or POST when fields/body are present. + pub method: Option, + /// Form fields, each passed as `-f key=value` (string-typed on the wire). + pub fields: Option>, + /// Raw JSON request body, piped to gh via `--input -`. Mutually exclusive + /// with fields (gh rejects the combination). + pub body: Option, + /// jq expression gh applies to the response (`--jq`); keeps large + /// responses small. + pub jq: Option, + /// Follow pagination to the end (`--paginate`); combine with jq. + pub paginate: Option, + /// Per-call timeout in ms, clamped to the configured max_timeout_ms. + pub timeout_ms: Option, +} + +pub fn api_args(r: &ApiRequest) -> Vec { + let mut a = argv(["api", r.path.as_str()]); + push_opt(&mut a, "-X", r.method.as_deref()); + if let Some(fields) = &r.fields { + for (k, v) in fields { + a.push("-f".to_string()); + a.push(format!("{k}={v}")); + } + } + if r.body.is_some() { + a.push("--input".to_string()); + a.push("-".to_string()); + } + push_opt(&mut a, "--jq", r.jq.as_deref()); + if r.paginate == Some(true) { + a.push("--paginate".to_string()); + } + a +} + +pub(crate) fn register(iii: &IIIClient, cell: &ConfigCell) { + let c = cell.clone(); + iii.register_function( + EXEC_ID, + RegisterFunction::new_async(move |req: ExecRequest| { + let cell = c.clone(); + async move { + let cfg = cell.read().await.clone(); + gh::run(&cfg, &req.args, req.stdin, req.timeout_ms) + .await + .map_err(Error::from) + } + }) + .description(EXEC_DESC), + ); + + let c = cell.clone(); + iii.register_function( + API_ID, + RegisterFunction::new_async(move |req: ApiRequest| { + let cell = c.clone(); + async move { + let cfg = cell.read().await.clone(); + let stdin = match &req.body { + Some(v) => Some(serde_json::to_string(v).map_err(|e| { + Error::Handler(format!("github::api: body does not serialize: {e}")) + })?), + None => None, + }; + let out = gh::run(&cfg, &api_args(&req), stdin, req.timeout_ms) + .await + .map_err(Error::from)?; + let out = expect_ok(API_ID, out, OK)?; + if out.stdout_truncated { + return Err(Error::Handler( + "github::api: response exceeded max_output_bytes and was truncated; \ + narrow it with jq, paginate less, or raise the cap" + .to_string(), + )); + } + Ok::<_, Error>(ValueResponse { + value: parse_api_stdout(&out.stdout), + }) + } + }) + .description(API_DESC), + ); +} + +/// gh api output: empty (204s) → null; JSON → parsed; anything else (a --jq +/// raw-string result) → the raw text as a JSON string. +fn parse_api_stdout(stdout: &str) -> Value { + let trimmed = stdout.trim(); + if trimmed.is_empty() { + return Value::Null; + } + serde_json::from_str(trimmed).unwrap_or_else(|_| Value::String(trimmed.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn api_maps_method_fields_body_jq_paginate() { + let r: ApiRequest = serde_json::from_value(json!({ + "path": "repos/o/r/issues", + "method": "POST", + "fields": { "title": "t", "body": "b" }, + "jq": ".url", + "paginate": true + })) + .unwrap(); + assert_eq!( + api_args(&r), + vec![ + "api", + "repos/o/r/issues", + "-X", + "POST", + "-f", + "body=b", + "-f", + "title=t", + "--jq", + ".url", + "--paginate", + ] + ); + } + + #[test] + fn api_body_pipes_via_input_dash() { + let r: ApiRequest = serde_json::from_value(json!({ + "path": "repos/o/r/labels", + "body": { "name": "bug" } + })) + .unwrap(); + assert_eq!( + api_args(&r), + vec!["api", "repos/o/r/labels", "--input", "-"] + ); + } + + #[test] + fn api_stdout_parsing_covers_empty_json_and_raw() { + assert_eq!(parse_api_stdout(""), Value::Null); + assert_eq!(parse_api_stdout("{\"a\":1}"), json!({"a": 1})); + assert_eq!( + parse_api_stdout("plain jq line\n"), + Value::String("plain jq line".to_string()) + ); + } +} diff --git a/github/src/functions/pr.rs b/github/src/functions/pr.rs new file mode 100644 index 000000000..da6655587 --- /dev/null +++ b/github/src/functions/pr.rs @@ -0,0 +1,422 @@ +//! `github::pr::*` — typed wrappers over `gh pr …`. Builders are pure +//! (request → argv) so tests assert them without spawning anything. + +use schemars::JsonSchema; +use serde::Deserialize; + +use super::{argv, push_bool, push_each, push_opt}; + +pub const LIST_ID: &str = "github::pr::list"; +pub const LIST_DESC: &str = "List pull requests: { repo: \"owner/name\", state?, limit?, author?, labels?, base?, search? } -> { value: [{number, title, state, url, author, headRefName, baseRefName, isDraft, labels, createdAt, updatedAt}] }."; +pub const LIST_JSON: &str = + "number,title,state,url,author,headRefName,baseRefName,isDraft,labels,createdAt,updatedAt"; + +pub const VIEW_ID: &str = "github::pr::view"; +pub const VIEW_DESC: &str = "View one pull request with body, mergeability, review decision, and diff stats: { repo: \"owner/name\", number } -> { value }."; +pub const VIEW_JSON: &str = "number,title,state,url,author,headRefName,baseRefName,isDraft,labels,createdAt,updatedAt,body,mergeable,mergeStateStatus,reviewDecision,additions,deletions,changedFiles,assignees,milestone,mergedAt,closedAt"; + +pub const CREATE_ID: &str = "github::pr::create"; +pub const CREATE_DESC: &str = "Open a pull request: { repo: \"owner/name\", title, body, base?, head?, draft? } -> { output: }. head defaults to the repo's current branch on the gh side — pass it explicitly."; + +pub const EDIT_ID: &str = "github::pr::edit"; +pub const EDIT_DESC: &str = "Edit a pull request's title/body/base/labels: { repo, number, title?, body?, base?, add_labels?, remove_labels? } -> { output }."; + +pub const MERGE_ID: &str = "github::pr::merge"; +pub const MERGE_DESC: &str = "Merge a pull request: { repo, number, method: merge|squash|rebase, delete_branch?, auto? } -> { output }. auto enables auto-merge once requirements pass."; + +pub const COMMENT_ID: &str = "github::pr::comment"; +pub const COMMENT_DESC: &str = + "Comment on a pull request: { repo, number, body } -> { output: }."; + +pub const REVIEW_ID: &str = "github::pr::review"; +pub const REVIEW_DESC: &str = "Review a pull request: { repo, number, event: approve|request-changes|comment, body? } -> { output }. gh requires body for request-changes and comment reviews."; + +pub const DIFF_ID: &str = "github::pr::diff"; +pub const DIFF_DESC: &str = "Unified diff of a pull request: { repo, number } -> { diff, truncated }. truncated is true when the diff exceeded the capture cap."; + +pub const CHECKS_ID: &str = "github::pr::checks"; +pub const CHECKS_DESC: &str = "CI check rollup for a pull request: { repo, number } -> { value: [{bucket, name, state, link, workflow, description, startedAt, completedAt}] }. Failing or pending checks are data, not errors."; +pub const CHECKS_JSON: &str = "bucket,name,state,link,workflow,description,startedAt,completedAt"; + +/// PR state filter. +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum PrState { + Open, + Closed, + Merged, + All, +} + +impl PrState { + fn as_str(self) -> &'static str { + match self { + PrState::Open => "open", + PrState::Closed => "closed", + PrState::Merged => "merged", + PrState::All => "all", + } + } +} + +/// Merge method. +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum MergeMethod { + Merge, + Squash, + Rebase, +} + +impl MergeMethod { + fn as_flag(self) -> &'static str { + match self { + MergeMethod::Merge => "--merge", + MergeMethod::Squash => "--squash", + MergeMethod::Rebase => "--rebase", + } + } +} + +/// Review event. +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ReviewEvent { + Approve, + RequestChanges, + Comment, +} + +impl ReviewEvent { + fn as_flag(self) -> &'static str { + match self { + ReviewEvent::Approve => "--approve", + ReviewEvent::RequestChanges => "--request-changes", + ReviewEvent::Comment => "--comment", + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Filter by state (gh default: open). + pub state: Option, + /// Maximum number of results (gh default: 30). + pub limit: Option, + /// Filter by author login. + pub author: Option, + /// Filter by labels (all must match). + pub labels: Option>, + /// Filter by base branch. + pub base: Option, + /// Search query narrowing the list further (GitHub search syntax). + pub search: Option, +} + +pub fn list_args(r: &ListRequest) -> Vec { + let mut a = argv(["pr", "list", "-R", r.repo.as_str(), "--json", LIST_JSON]); + push_opt(&mut a, "--state", r.state.map(|s| s.as_str())); + push_opt(&mut a, "--limit", r.limit); + push_opt(&mut a, "--author", r.author.as_deref()); + push_each(&mut a, "--label", &r.labels); + push_opt(&mut a, "--base", r.base.as_deref()); + push_opt(&mut a, "--search", r.search.as_deref()); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ViewRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Pull request number. + pub number: u64, +} + +pub fn view_args(r: &ViewRequest) -> Vec { + argv([ + "pr", + "view", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + "--json", + VIEW_JSON, + ]) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Pull request title. + pub title: String, + /// Pull request body (markdown). + pub body: String, + /// Base branch to merge into (gh default: the repo default branch). + pub base: Option, + /// Head branch the PR ships (pass explicitly; the worker has no checkout). + pub head: Option, + /// Open as draft. + pub draft: Option, +} + +pub fn create_args(r: &CreateRequest) -> Vec { + let mut a = argv([ + "pr", + "create", + "-R", + r.repo.as_str(), + "--title", + r.title.as_str(), + "--body", + r.body.as_str(), + ]); + push_opt(&mut a, "--base", r.base.as_deref()); + push_opt(&mut a, "--head", r.head.as_deref()); + push_bool(&mut a, "--draft", r.draft); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct EditRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Pull request number. + pub number: u64, + /// New title. + pub title: Option, + /// New body (markdown). + pub body: Option, + /// New base branch. + pub base: Option, + /// Labels to add. + pub add_labels: Option>, + /// Labels to remove. + pub remove_labels: Option>, +} + +pub fn edit_args(r: &EditRequest) -> Vec { + let mut a = argv([ + "pr", + "edit", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + ]); + push_opt(&mut a, "--title", r.title.as_deref()); + push_opt(&mut a, "--body", r.body.as_deref()); + push_opt(&mut a, "--base", r.base.as_deref()); + push_each(&mut a, "--add-label", &r.add_labels); + push_each(&mut a, "--remove-label", &r.remove_labels); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MergeRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Pull request number. + pub number: u64, + /// Merge method. + pub method: MergeMethod, + /// Delete the head branch after merging. + pub delete_branch: Option, + /// Enable auto-merge: merge automatically once requirements pass. + pub auto: Option, +} + +pub fn merge_args(r: &MergeRequest) -> Vec { + let mut a = argv([ + "pr", + "merge", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + r.method.as_flag(), + ]); + push_bool(&mut a, "--delete-branch", r.delete_branch); + push_bool(&mut a, "--auto", r.auto); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CommentRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Pull request number. + pub number: u64, + /// Comment body (markdown). + pub body: String, +} + +pub fn comment_args(r: &CommentRequest) -> Vec { + argv([ + "pr", + "comment", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + "--body", + r.body.as_str(), + ]) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ReviewRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Pull request number. + pub number: u64, + /// Review event. + pub event: ReviewEvent, + /// Review body (gh requires it for request-changes and comment). + pub body: Option, +} + +pub fn review_args(r: &ReviewRequest) -> Vec { + let mut a = argv([ + "pr", + "review", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + r.event.as_flag(), + ]); + push_opt(&mut a, "--body", r.body.as_deref()); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DiffRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Pull request number. + pub number: u64, +} + +pub fn diff_args(r: &DiffRequest) -> Vec { + argv([ + "pr", + "diff", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + ]) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ChecksRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Pull request number. + pub number: u64, +} + +pub fn checks_args(r: &ChecksRequest) -> Vec { + argv([ + "pr", + "checks", + r.number.to_string().as_str(), + "-R", + r.repo.as_str(), + "--json", + CHECKS_JSON, + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn list_maps_every_filter() { + let r: ListRequest = serde_json::from_value(json!({ + "repo": "o/r", + "state": "merged", + "limit": 5, + "author": "octocat", + "labels": ["bug", "p1"], + "base": "main", + "search": "review:required" + })) + .unwrap(); + assert_eq!( + list_args(&r), + vec![ + "pr", + "list", + "-R", + "o/r", + "--json", + LIST_JSON, + "--state", + "merged", + "--limit", + "5", + "--author", + "octocat", + "--label", + "bug", + "--label", + "p1", + "--base", + "main", + "--search", + "review:required", + ] + ); + } + + #[test] + fn minimal_list_is_just_the_json_fields() { + let r: ListRequest = serde_json::from_value(json!({ "repo": "o/r" })).unwrap(); + assert_eq!( + list_args(&r), + vec!["pr", "list", "-R", "o/r", "--json", LIST_JSON] + ); + } + + #[test] + fn merge_maps_method_and_flags() { + let r: MergeRequest = serde_json::from_value(json!({ + "repo": "o/r", "number": 7, "method": "squash", "delete_branch": true + })) + .unwrap(); + assert_eq!( + merge_args(&r), + vec![ + "pr", + "merge", + "7", + "-R", + "o/r", + "--squash", + "--delete-branch" + ] + ); + } + + #[test] + fn review_kebab_case_event() { + let r: ReviewRequest = serde_json::from_value(json!({ + "repo": "o/r", "number": 7, "event": "request-changes", "body": "nit" + })) + .unwrap(); + assert_eq!( + review_args(&r), + vec![ + "pr", + "review", + "7", + "-R", + "o/r", + "--request-changes", + "--body", + "nit" + ] + ); + } +} diff --git a/github/src/functions/release.rs b/github/src/functions/release.rs new file mode 100644 index 000000000..d0f596d81 --- /dev/null +++ b/github/src/functions/release.rs @@ -0,0 +1,118 @@ +//! `github::release::*` — typed wrappers over `gh release …`. + +use schemars::JsonSchema; +use serde::Deserialize; + +use super::{argv, push_bool, push_opt}; + +pub const LIST_ID: &str = "github::release::list"; +pub const LIST_DESC: &str = "List releases: { repo: \"owner/name\", limit? } -> { value: [{tagName, name, isDraft, isLatest, isPrerelease, createdAt, publishedAt}] }."; +pub const LIST_JSON: &str = "tagName,name,isDraft,isLatest,isPrerelease,createdAt,publishedAt"; + +pub const VIEW_ID: &str = "github::release::view"; +pub const VIEW_DESC: &str = + "View one release including body and assets: { repo: \"owner/name\", tag } -> { value }."; +pub const VIEW_JSON: &str = + "tagName,name,body,isDraft,isPrerelease,author,assets,targetCommitish,url,createdAt,publishedAt"; + +pub const CREATE_ID: &str = "github::release::create"; +pub const CREATE_DESC: &str = "Create a release: { repo, tag, title?, notes?, generate_notes?, draft?, prerelease?, target? } -> { output: }. generate_notes autogenerates from merged PRs."; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Maximum number of results (gh default: 30). + pub limit: Option, +} + +pub fn list_args(r: &ListRequest) -> Vec { + let mut a = argv([ + "release", + "list", + "-R", + r.repo.as_str(), + "--json", + LIST_JSON, + ]); + push_opt(&mut a, "--limit", r.limit); + a +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ViewRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Release tag (e.g. "v1.2.0"). + pub tag: String, +} + +pub fn view_args(r: &ViewRequest) -> Vec { + argv([ + "release", + "view", + r.tag.as_str(), + "-R", + r.repo.as_str(), + "--json", + VIEW_JSON, + ]) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CreateRequest { + /// Target repository, "owner/name". + pub repo: String, + /// Tag to release (created at target if it does not exist). + pub tag: String, + /// Release title. + pub title: Option, + /// Release notes body (markdown). + pub notes: Option, + /// Autogenerate notes from merged PRs since the last release. + pub generate_notes: Option, + /// Create as draft. + pub draft: Option, + /// Mark as prerelease. + pub prerelease: Option, + /// Target branch or commit SHA for the tag (gh default: the default branch). + pub target: Option, +} + +pub fn create_args(r: &CreateRequest) -> Vec { + let mut a = argv(["release", "create", r.tag.as_str(), "-R", r.repo.as_str()]); + push_opt(&mut a, "--title", r.title.as_deref()); + push_opt(&mut a, "--notes", r.notes.as_deref()); + push_bool(&mut a, "--generate-notes", r.generate_notes); + push_bool(&mut a, "--draft", r.draft); + push_bool(&mut a, "--prerelease", r.prerelease); + push_opt(&mut a, "--target", r.target.as_deref()); + a +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn create_maps_generate_notes_and_target() { + let r: CreateRequest = serde_json::from_value(json!({ + "repo": "o/r", "tag": "v0.1.0", "generate_notes": true, "target": "main" + })) + .unwrap(); + assert_eq!( + create_args(&r), + vec![ + "release", + "create", + "v0.1.0", + "-R", + "o/r", + "--generate-notes", + "--target", + "main", + ] + ); + } +} diff --git a/github/src/functions/repo.rs b/github/src/functions/repo.rs new file mode 100644 index 000000000..fd9fd05cc --- /dev/null +++ b/github/src/functions/repo.rs @@ -0,0 +1,70 @@ +//! `github::repo::*` — typed wrappers over `gh repo …`. Unlike the other +//! groups, `gh repo view`/`list` take the repo/owner as a POSITIONAL argument +//! (`-R` is not a flag these subcommands accept). + +use schemars::JsonSchema; +use serde::Deserialize; + +use super::{argv, push_opt}; + +pub const VIEW_ID: &str = "github::repo::view"; +pub const VIEW_DESC: &str = "View a repository's metadata (description, default branch, visibility, stars, language, topics, latest release): { repo: \"owner/name\" } -> { value }."; +pub const VIEW_JSON: &str = "name,nameWithOwner,description,url,defaultBranchRef,visibility,isPrivate,isFork,isArchived,stargazerCount,forkCount,primaryLanguage,repositoryTopics,latestRelease,pushedAt,updatedAt,homepageUrl"; + +pub const LIST_ID: &str = "github::repo::list"; +pub const LIST_DESC: &str = "List repositories of an owner: { owner?, limit? } -> { value: [{name, nameWithOwner, description, url, visibility, ...}] }. owner defaults to the authenticated user."; +pub const LIST_JSON: &str = "name,nameWithOwner,description,url,visibility,isPrivate,isFork,isArchived,stargazerCount,primaryLanguage,pushedAt"; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ViewRequest { + /// Target repository, "owner/name". + pub repo: String, +} + +pub fn view_args(r: &ViewRequest) -> Vec { + argv(["repo", "view", r.repo.as_str(), "--json", VIEW_JSON]) +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ListRequest { + /// User or organization login (default: the authenticated user). + pub owner: Option, + /// Maximum number of results (gh default: 30). + pub limit: Option, +} + +pub fn list_args(r: &ListRequest) -> Vec { + let mut a = argv(["repo", "list"]); + if let Some(owner) = &r.owner { + a.push(owner.clone()); + } + a.push("--json".to_string()); + a.push(LIST_JSON.to_string()); + push_opt(&mut a, "--limit", r.limit); + a +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn view_is_positional_no_dash_r() { + let r: ViewRequest = serde_json::from_value(json!({ "repo": "cli/cli" })).unwrap(); + assert_eq!( + view_args(&r), + vec!["repo", "view", "cli/cli", "--json", VIEW_JSON] + ); + } + + #[test] + fn list_owner_precedes_flags() { + let r: ListRequest = + serde_json::from_value(json!({ "owner": "iii-hq", "limit": 10 })).unwrap(); + assert_eq!( + list_args(&r), + vec!["repo", "list", "iii-hq", "--json", LIST_JSON, "--limit", "10"] + ); + } +} diff --git a/github/src/functions/search.rs b/github/src/functions/search.rs new file mode 100644 index 000000000..5bb5a632d --- /dev/null +++ b/github/src/functions/search.rs @@ -0,0 +1,91 @@ +//! `github::search::*` — typed wrappers over `gh search …`. No `-R` flag: +//! repo scoping lives in the query itself (e.g. "repo:owner/name fix"). + +use schemars::JsonSchema; +use serde::Deserialize; + +use super::{argv, push_opt}; + +pub const REPOS_ID: &str = "github::search::repos"; +pub const REPOS_DESC: &str = "Search repositories: { query, limit? } -> { value: [{fullName, description, url, visibility, stargazersCount, language, updatedAt, ...}] }. GitHub search syntax (e.g. \"language:rust stars:>100 topic:cli\")."; +pub const REPOS_JSON: &str = + "fullName,description,url,visibility,isArchived,isFork,stargazersCount,forksCount,language,updatedAt"; + +pub const ISSUES_ID: &str = "github::search::issues"; +pub const ISSUES_DESC: &str = "Search issues across repositories: { query, limit? } -> { value: [{number, title, state, url, repository, author, labels, commentsCount, ...}] }. Use qualifiers like \"repo:o/r is:open label:bug\"."; +pub const ISSUES_JSON: &str = + "number,title,state,url,repository,author,labels,commentsCount,createdAt,updatedAt"; + +pub const PRS_ID: &str = "github::search::prs"; +pub const PRS_DESC: &str = "Search pull requests across repositories: { query, limit? } -> { value }. Use qualifiers like \"repo:o/r is:open review:required\"."; +pub const PRS_JSON: &str = + "number,title,state,url,repository,author,labels,commentsCount,createdAt,updatedAt,isDraft"; + +pub const CODE_ID: &str = "github::search::code"; +pub const CODE_DESC: &str = "Search code: { query, limit? } -> { value: [{path, repository, sha, url, textMatches}] }. Use qualifiers like \"repo:o/r language:rust symbol\"."; +pub const CODE_JSON: &str = "path,repository,sha,url,textMatches"; + +macro_rules! search_request { + ($name:ident) => { + #[derive(Debug, Deserialize, JsonSchema)] + pub struct $name { + /// Search query (GitHub search syntax, qualifiers included). + pub query: String, + /// Maximum number of results (gh default: 30). + pub limit: Option, + } + }; +} + +search_request!(ReposRequest); +search_request!(IssuesRequest); +search_request!(PrsRequest); +search_request!(CodeRequest); + +fn search_args(kind: &str, json: &str, query: &str, limit: Option) -> Vec { + let mut a = argv(["search", kind, query, "--json", json]); + push_opt(&mut a, "--limit", limit); + a +} + +pub fn repos_args(r: &ReposRequest) -> Vec { + search_args("repos", REPOS_JSON, &r.query, r.limit) +} + +pub fn issues_args(r: &IssuesRequest) -> Vec { + search_args("issues", ISSUES_JSON, &r.query, r.limit) +} + +pub fn prs_args(r: &PrsRequest) -> Vec { + search_args("prs", PRS_JSON, &r.query, r.limit) +} + +pub fn code_args(r: &CodeRequest) -> Vec { + search_args("code", CODE_JSON, &r.query, r.limit) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn code_search_maps_query_and_limit() { + let r: CodeRequest = serde_json::from_value(json!({ + "query": "repo:cli/cli read_bounded", "limit": 5 + })) + .unwrap(); + assert_eq!( + code_args(&r), + vec![ + "search", + "code", + "repo:cli/cli read_bounded", + "--json", + CODE_JSON, + "--limit", + "5", + ] + ); + } +} diff --git a/github/src/gh.rs b/github/src/gh.rs new file mode 100644 index 000000000..e77eda597 --- /dev/null +++ b/github/src/gh.rs @@ -0,0 +1,347 @@ +//! Process core for the `gh` CLI: spawn with bounded capture, a timeout +//! watchdog, and process-group kill. The bounded-read / group-kill / stdin +//! mechanics are lifted from `shell/src/exec/host.rs` (see the comments there +//! for the full rationale); this file trims the policy jail, env scrubbing, +//! and background-job machinery a gh wrapper doesn't need. + +use std::process::Stdio; +use std::time::Duration; + +use schemars::JsonSchema; +use serde::Serialize; +use tokio::io::AsyncReadExt; +use tokio::process::Command; + +use crate::config::Config; + +/// A completed `gh` invocation. This is also the `github::exec` response: a +/// non-zero exit or a timeout is DATA here — only failure to run the process +/// at all is a `GhError`. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct GhOutcome { + /// Captured stdout (UTF-8 lossy), capped at `max_output_bytes`. + pub stdout: String, + /// Captured stderr (UTF-8 lossy), capped at `max_output_bytes`. + pub stderr: String, + /// Process exit code; null when the process was killed (timeout). + pub exit_code: Option, + /// Wall-clock duration of the invocation, in ms. + pub duration_ms: u64, + /// True when the timeout expired and the process group was killed. + pub timed_out: bool, + /// True when stdout exceeded `max_output_bytes` and was truncated. + pub stdout_truncated: bool, + /// True when stderr exceeded `max_output_bytes` and was truncated. + pub stderr_truncated: bool, +} + +/// Machine-branchable failure to run gh at all. Lifted to +/// `Error::Remote { code, message }` so a caller can branch on `error.code` +/// (`gh_not_found`, `spawn_failed`, `timeout`) instead of parsing messages — +/// any other `Error` variant collapses to `code: "invocation_failed"` on the +/// wire (see shell's `ExecError` for the precedent). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GhError { + pub code: &'static str, + pub message: String, +} + +impl GhError { + pub fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + pub fn timeout(ms: u64) -> Self { + Self::new("timeout", format!("gh timed out after {ms} ms")) + } +} + +impl From for iii_sdk::errors::Error { + fn from(err: GhError) -> Self { + iii_sdk::errors::Error::Remote { + code: err.code.to_string(), + message: err.message, + stacktrace: None, + } + } +} + +/// Run `gh` with `args`, bounded capture, and a timeout watchdog. `timeout_ms` +/// is clamped via [`Config::resolve_timeout`]. A timeout is returned as +/// `timed_out: true` (with the whole process group killed), not as an error — +/// `github::exec` exposes it as data; the typed wrappers lift it to +/// `GhError::timeout`. +pub async fn run( + cfg: &Config, + args: &[String], + stdin: Option, + timeout_ms: Option, +) -> Result { + let started = std::time::Instant::now(); + let mut cmd = build_command(cfg, args, stdin.is_some()); + let mut child = cmd.spawn().map_err(|e| spawn_error(&cfg.gh_bin(), &e))?; + pump_stdin(&mut child, &stdin); + + let mut stdout_reader = child + .stdout + .take() + .ok_or_else(|| GhError::new("spawn_failed", "no stdout pipe"))?; + let mut stderr_reader = child + .stderr + .take() + .ok_or_else(|| GhError::new("spawn_failed", "no stderr pipe"))?; + + let limit = cfg.max_output_bytes; + let timeout = Duration::from_millis(cfg.resolve_timeout(timeout_ms)); + + let stdout_task = tokio::spawn(async move { read_bounded(&mut stdout_reader, limit).await }); + let stderr_task = tokio::spawn(async move { read_bounded(&mut stderr_reader, limit).await }); + + let wait_res = tokio::time::timeout(timeout, child.wait()).await; + + let (exit_code, timed_out) = match wait_res { + Ok(Ok(status)) => (status.code(), false), + Ok(Err(e)) => return Err(GhError::new("spawn_failed", format!("wait: {e}"))), + Err(_) => { + // Hard timeout: kill the whole process group (grandchildren too, + // e.g. the git/credential helpers gh spawns) while we still own + // the un-reaped child, then reap. start_kill is the non-unix + // fallback (kill_process_group is a no-op there). + kill_process_group(child.id()); + let _ = child.start_kill(); + let _ = child.wait().await; + (None, true) + } + }; + + let (stdout_bytes, stdout_truncated) = stdout_task + .await + .map_err(|e| GhError::new("spawn_failed", format!("stdout: {e}")))?; + let (stderr_bytes, stderr_truncated) = stderr_task + .await + .map_err(|e| GhError::new("spawn_failed", format!("stderr: {e}")))?; + + Ok(GhOutcome { + stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(), + stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(), + exit_code, + duration_ms: started.elapsed().as_millis() as u64, + timed_out, + stdout_truncated, + stderr_truncated, + }) +} + +fn spawn_error(bin: &str, e: &std::io::Error) -> GhError { + if e.kind() == std::io::ErrorKind::NotFound { + GhError::new( + "gh_not_found", + format!( + "gh executable '{bin}' not found; install the GitHub CLI \ + (https://cli.github.com) or set gh_executable in the github configuration" + ), + ) + } else { + GhError::new("spawn_failed", format!("spawn {bin}: {e}")) + } +} + +fn build_command(cfg: &Config, args: &[String], stdin: bool) -> Command { + let mut cmd = Command::new(cfg.gh_bin()); + cmd.args(args); + // Never let gh block on an interactive prompt or chat about updates. + cmd.env("GH_PROMPT_DISABLED", "1"); + cmd.env("GH_NO_UPDATE_NOTIFIER", "1"); + // A non-empty configured token wins; empty inherits the worker's env + // (ambient `gh auth login` state or an already-exported GH_TOKEN). + if !cfg.token.is_empty() { + cmd.env("GH_TOKEN", &cfg.token); + } + // stdin is piped when the caller supplied bytes (the spawn site writes them + // then closes the pipe so the child sees EOF); otherwise /dev/null so a + // command that reads stdin doesn't block forever. + cmd.stdin(if stdin { Stdio::piped() } else { Stdio::null() }); + cmd.stdout(Stdio::piped()) + .stderr(Stdio::piped()) + // Tokio's Command does NOT kill children on drop by default; without + // this a worker shutdown would orphan a still-running gh. + .kill_on_drop(true); + // Put the child in its own process group (it becomes the group leader, so + // pgid == pid). Termination then signals the WHOLE group via `-pid`, + // killing any grandchildren too — not just the direct child, which + // `start_kill`/`kill_on_drop` alone would leave orphaned. + #[cfg(unix)] + cmd.process_group(0); + cmd +} + +/// SIGKILL an entire process group by its leader pid. The caller MUST still own +/// the un-reaped `Child` for `pid` when calling this, so the pid (and therefore +/// the pgid, since children are spawned with `process_group(0)`) cannot have +/// been reused by the OS — this is what makes signalling by number safe. The +/// negated pid targets the whole group, terminating grandchildren too. No-op on +/// non-unix; a vanished process (ESRCH) is ignored. +#[cfg(unix)] +fn kill_process_group(pid: Option) { + if let Some(pid) = pid { + // SAFETY: see the doc above — `pid` is an un-reaped child we own, and + // the negative value signals its process group. + unsafe { + libc::kill(-(pid as libc::pid_t), libc::SIGKILL); + } + } +} + +#[cfg(not(unix))] +fn kill_process_group(_pid: Option) {} + +/// Feed `stdin` (if any) to the child's stdin pipe, then close it so the child +/// sees EOF. Runs in a detached task so a child that interleaves reading stdin +/// with writing stdout cannot deadlock against our stdout/stderr drains +/// (each side would otherwise block on a full pipe). Best-effort: a child that +/// exited before consuming its input just makes the write fail, which we ignore. +fn pump_stdin(child: &mut tokio::process::Child, stdin: &Option) { + if let Some(data) = stdin { + if let Some(mut sin) = child.stdin.take() { + let bytes = data.clone().into_bytes(); + tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + let _ = sin.write_all(&bytes).await; + let _ = sin.shutdown().await; + }); + } + } +} + +async fn read_bounded(reader: &mut R, limit: usize) -> (Vec, bool) { + let mut buf = Vec::with_capacity(limit.min(8192)); + let mut chunk = [0u8; 8192]; + let mut truncated = false; + loop { + match reader.read(&mut chunk).await { + Ok(0) => break, + Ok(n) => { + if !truncated { + if buf.len() + n > limit { + let take = limit.saturating_sub(buf.len()); + buf.extend_from_slice(&chunk[..take]); + truncated = true; + // Fall through and continue draining; do NOT break. + // Dropping the reader here closes the pipe, and the + // child's next write() raises SIGPIPE — making + // ExitStatus::code() report None on what should have + // been a normal-exit-with-truncated-output run. + } else { + buf.extend_from_slice(&chunk[..n]); + } + } + // truncated == true: discard everything past `limit`. + } + Err(_) => break, + } + } + (buf, truncated) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Point `gh_executable` at a plain binary so the mechanics are testable + /// without gh installed (CI runners don't have it). + fn cfg_for(bin: &str) -> Config { + Config { + gh_executable: bin.to_string(), + ..Config::default() + } + } + + #[tokio::test] + async fn echo_roundtrip() { + let out = run(&cfg_for("echo"), &["hi".into()], None, None) + .await + .unwrap(); + assert_eq!(out.exit_code, Some(0)); + assert_eq!(out.stdout.trim(), "hi"); + assert!(!out.timed_out); + assert!(!out.stdout_truncated); + } + + #[tokio::test] + async fn missing_binary_is_gh_not_found() { + let err = run(&cfg_for("/nonexistent/gh-not-here"), &[], None, None) + .await + .unwrap_err(); + assert_eq!(err.code, "gh_not_found"); + assert!(err.message.contains("cli.github.com")); + } + + #[tokio::test] + async fn timeout_kills_and_reports_as_data() { + let out = run(&cfg_for("sleep"), &["5".into()], None, Some(200)) + .await + .unwrap(); + assert!(out.timed_out); + assert_eq!(out.exit_code, None); + } + + #[tokio::test] + async fn truncation_keeps_the_real_exit_code() { + let mut cfg = cfg_for("sh"); + cfg.max_output_bytes = 16; + let out = run( + &cfg, + &["-c".into(), "printf 'x%.0s' $(seq 1 100)".into()], + None, + None, + ) + .await + .unwrap(); + assert!(out.stdout_truncated); + assert_eq!(out.stdout.len(), 16); + // drain-after-cap: the child still exits cleanly (no SIGPIPE kill) + assert_eq!(out.exit_code, Some(0)); + } + + #[tokio::test] + async fn stdin_reaches_the_child() { + let out = run(&cfg_for("cat"), &[], Some("ping".into()), None) + .await + .unwrap(); + assert_eq!(out.stdout, "ping"); + assert_eq!(out.exit_code, Some(0)); + } + + #[tokio::test] + async fn token_is_injected_into_the_child_env() { + let mut cfg = cfg_for("sh"); + cfg.token = "tkn-123".into(); + let out = run( + &cfg, + &["-c".into(), "printf %s \"$GH_TOKEN\"".into()], + None, + None, + ) + .await + .unwrap(); + assert_eq!(out.stdout, "tkn-123"); + } + + #[tokio::test] + async fn nonzero_exit_is_data() { + let out = run( + &cfg_for("sh"), + &["-c".into(), "echo boom >&2; exit 3".into()], + None, + None, + ) + .await + .unwrap(); + assert_eq!(out.exit_code, Some(3)); + assert_eq!(out.stderr.trim(), "boom"); + assert!(!out.timed_out); + } +} diff --git a/github/src/lib.rs b/github/src/lib.rs new file mode 100644 index 000000000..384b3ae7a --- /dev/null +++ b/github/src/lib.rs @@ -0,0 +1,10 @@ +//! GitHub CLI (`gh`) as an iii worker: typed `github::*` functions for the +//! high-traffic pr/issue/repo/run/workflow/release/search surface, plus +//! `github::exec` (argv passthrough) and `github::api` (any REST endpoint) +//! escape hatches. The binary is a thin wiring shim; all logic lives here so +//! `tests/` can exercise the contract. + +pub mod config; +pub mod configuration; +pub mod functions; +pub mod gh; diff --git a/github/src/main.rs b/github/src/main.rs new file mode 100644 index 000000000..cfe098ef9 --- /dev/null +++ b/github/src/main.rs @@ -0,0 +1,114 @@ +//! `github` binary entry: connect, seed + fetch config, bind the +//! configuration-change trigger, register the `github::*` surface, then wait +//! for shutdown. No gh probe at boot — a missing gh binary surfaces at call +//! time as `gh_not_found`, so interface collection works on bare runners. + +use std::sync::Arc; + +use anyhow::Result; +use clap::Parser; +use iii_sdk::runtime::WorkerMetadata; +use iii_sdk::{register_worker, InitOptions}; +use tokio::sync::RwLock; + +use github::config::Config; +use github::configuration; +use github::functions::register_all; + +#[derive(Parser, Debug)] +#[command(name = "github", about = "GitHub CLI (gh) as an iii worker")] +struct Cli { + /// Seed config registered as `initial_value` with the `configuration` + /// worker on first registration. Defaults to ./config.yaml. The live value + /// from the configuration worker is authoritative once an entry exists. + #[arg(long, default_value = "./config.yaml")] + config: String, + + #[arg(long, env = "III_URL", default_value = "ws://127.0.0.1:49134")] + url: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + tracing::info!(url = %cli.url, seed_config = %cli.config, "connecting to III engine"); + + let iii = register_worker( + &cli.url, + InitOptions { + metadata: Some(WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "github".to_string(), + os: std::env::consts::OS.to_string(), + description: Some("GitHub CLI (gh) as an iii worker.".to_string()), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + }), + ..InitOptions::default() + }, + ); + + // Seed from config.yaml when present; a missing file means defaults. + let seed = match Config::load(&cli.config) { + Ok(cfg) => Some(cfg), + Err(e) => { + tracing::warn!(path = %cli.config, error = %e, "failed to load seed config; relying on the configuration worker"); + None + } + }; + + // Config registration is best-effort: if the configuration worker is + // unreachable (or absent, as in interface collection on a bare engine) the + // worker still serves with the seed / built-in defaults. Registering the + // github::* surface must not depend on the configuration worker being up. + if let Err(e) = configuration::register_config(&iii, seed.as_ref()).await { + tracing::warn!(error = %e, "configuration::register failed; continuing with the seed"); + } + let cfg = match configuration::fetch_config(&iii).await { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!(error = %e, "configuration fetch failed; using seed/default config"); + seed.clone().unwrap_or_default() + } + }; + + let cell: configuration::ConfigCell = Arc::new(RwLock::new(Arc::new(cfg))); + + if let Err(e) = configuration::register_config_trigger(&iii, cell.clone()) { + tracing::warn!(error = %e, "configuration change trigger registration failed"); + } + configuration::reconcile(&iii, &cell).await; + + register_all(&iii, &cell); + tracing::info!("github worker ready: 30 typed functions + github::exec + github::api"); + + wait_for_shutdown_signal().await?; + tracing::info!("github worker shutting down"); + iii.shutdown_async().await; + Ok(()) +} + +async fn wait_for_shutdown_signal() -> std::io::Result<()> { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = signal(SignalKind::terminate())?; + tokio::select! { + r = tokio::signal::ctrl_c() => r, + _ = sigterm.recv() => Ok(()), + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await + } +} diff --git a/github/tests/contract.rs b/github/tests/contract.rs new file mode 100644 index 000000000..0e59c17f9 --- /dev/null +++ b/github/tests/contract.rs @@ -0,0 +1,86 @@ +//! Public-contract tests. CI's validate_worker.py requires a non-empty +//! tests/ suite for source-changed workers; the deep per-builder coverage +//! lives in the unit tests under src/functions/, and the process mechanics +//! in src/gh.rs. + +use github::config::Config; +use github::functions::{catalog, passthrough, pr}; +use serde_json::json; + +/// The engine injects `_caller_worker_id` into every payload; typed requests +/// must tolerate it (serde's default unknown-field behavior — pinned here so +/// nobody adds `deny_unknown_fields` and breaks every live call). +#[test] +fn requests_tolerate_engine_injected_metadata() { + let r: pr::ListRequest = serde_json::from_value(json!({ + "repo": "o/r", + "_caller_worker_id": "harness", + })) + .expect("live shape parses"); + assert_eq!(pr::list_args(&r)[..4], ["pr", "list", "-R", "o/r"]); + + let r: passthrough::ExecRequest = serde_json::from_value(json!({ + "args": ["--version"], + "_caller_worker_id": "harness", + })) + .expect("live shape parses"); + assert_eq!(r.args, vec!["--version"]); +} + +#[test] +fn config_defaults_are_the_documented_ones() { + let c = Config::default(); + assert_eq!(c.gh_bin(), "gh"); + assert!(c.token.is_empty()); + assert_eq!(c.default_timeout_ms, 30_000); + assert_eq!(c.max_timeout_ms, 120_000); + assert_eq!(c.max_output_bytes, 1_048_576); +} + +#[test] +fn resolve_timeout_clamps_to_the_configured_max() { + let c = Config::default(); + assert_eq!(c.resolve_timeout(None), 30_000); + assert_eq!(c.resolve_timeout(Some(5)), 5); + assert_eq!(c.resolve_timeout(Some(999_999)), 120_000); +} + +/// `${NAME}` in the seed expands from the process env; untouched fields keep +/// their defaults; a missing file yields the full defaults. +#[test] +fn seed_loading_expands_env_and_defaults_the_rest() { + // Unique var name: no other test reads or writes it, so the process-env + // mutation cannot race the parallel test runner. + std::env::set_var("GH_CONTRACT_TOKEN_92AF", "sekrit"); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + std::fs::write( + &path, + "token: \"${GH_CONTRACT_TOKEN_92AF}\"\ndefault_timeout_ms: 1000\n", + ) + .unwrap(); + let c = Config::load(path.to_str().unwrap()).unwrap(); + std::env::remove_var("GH_CONTRACT_TOKEN_92AF"); + assert_eq!(c.token, "sekrit"); + assert_eq!(c.default_timeout_ms, 1000); + assert_eq!(c.max_timeout_ms, 120_000); + + let c = Config::load(dir.path().join("nope.yaml").to_str().unwrap()).unwrap(); + assert_eq!(c.default_timeout_ms, 30_000); +} + +#[test] +fn gh_bin_prefers_the_configured_path() { + let c = Config { + gh_executable: "/opt/homebrew/bin/gh".to_string(), + ..Config::default() + }; + assert_eq!(c.gh_bin(), "/opt/homebrew/bin/gh"); +} + +/// 30 curated functions + exec + api. The exact ids and order are pinned in +/// tests/schemas.rs; this is the cheap headcount. +#[test] +fn catalog_covers_the_full_surface() { + assert_eq!(catalog().len(), 32); +} diff --git a/github/tests/golden/schemas/github.api.json b/github/tests/golden/schemas/github.api.json new file mode 100644 index 000000000..02ba34294 --- /dev/null +++ b/github/tests/golden/schemas/github.api.json @@ -0,0 +1,75 @@ +{ + "description": "Call any GitHub REST endpoint via gh api: { path: \"repos/OWNER/REPO/issues\", method?, fields?, body?, jq?, paginate?, timeout_ms? } -> { value: }. fields become -f key=value form fields (default method flips to POST); body is sent verbatim as the JSON request body. Non-2xx responses are errors carrying gh's stderr.", + "function_id": "github::api", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "body": { + "description": "Raw JSON request body, piped to gh via `--input -`. Mutually exclusive with fields (gh rejects the combination)." + }, + "fields": { + "additionalProperties": { + "type": "string" + }, + "description": "Form fields, each passed as `-f key=value` (string-typed on the wire).", + "type": [ + "object", + "null" + ] + }, + "jq": { + "description": "jq expression gh applies to the response (`--jq`); keeps large responses small.", + "type": [ + "string", + "null" + ] + }, + "method": { + "description": "HTTP method. gh defaults to GET, or POST when fields/body are present.", + "type": [ + "string", + "null" + ] + }, + "paginate": { + "description": "Follow pagination to the end (`--paginate`); combine with jq.", + "type": [ + "boolean", + "null" + ] + }, + "path": { + "description": "Endpoint path, with concrete owner/repo values (e.g. \"rate_limit\" or \"repos/cli/cli/issues\") — placeholder expansion needs a checkout the worker does not have.", + "type": "string" + }, + "timeout_ms": { + "description": "Per-call timeout in ms, clamped to the configured max_timeout_ms.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "path" + ], + "title": "ApiRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.exec.json b/github/tests/golden/schemas/github.exec.json new file mode 100644 index 000000000..b84a032d4 --- /dev/null +++ b/github/tests/golden/schemas/github.exec.json @@ -0,0 +1,87 @@ +{ + "description": "Run any gh command: { args: [\"pr\", \"list\", \"-R\", \"owner/name\"], stdin?, timeout_ms? } -> { stdout, stderr, exit_code, duration_ms, timed_out, stdout_truncated, stderr_truncated }. A non-zero exit or a timeout is DATA here, not an error. The escape hatch for everything without a typed github::* function.", + "function_id": "github::exec", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "args": { + "description": "gh argv, without the leading `gh` (e.g. [\"pr\", \"status\", \"-R\", \"o/r\"]).", + "items": { + "type": "string" + }, + "type": "array" + }, + "stdin": { + "description": "Bytes piped to gh's stdin (for flags like `--body-file -`).", + "type": [ + "string", + "null" + ] + }, + "timeout_ms": { + "description": "Per-call timeout in ms, clamped to the configured max_timeout_ms.", + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "args" + ], + "title": "ExecRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "A completed `gh` invocation. This is also the `github::exec` response: a non-zero exit or a timeout is DATA here — only failure to run the process at all is a `GhError`.", + "properties": { + "duration_ms": { + "description": "Wall-clock duration of the invocation, in ms.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "exit_code": { + "description": "Process exit code; null when the process was killed (timeout).", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "stderr": { + "description": "Captured stderr (UTF-8 lossy), capped at `max_output_bytes`.", + "type": "string" + }, + "stderr_truncated": { + "description": "True when stderr exceeded `max_output_bytes` and was truncated.", + "type": "boolean" + }, + "stdout": { + "description": "Captured stdout (UTF-8 lossy), capped at `max_output_bytes`.", + "type": "string" + }, + "stdout_truncated": { + "description": "True when stdout exceeded `max_output_bytes` and was truncated.", + "type": "boolean" + }, + "timed_out": { + "description": "True when the timeout expired and the process group was killed.", + "type": "boolean" + } + }, + "required": [ + "duration_ms", + "stderr", + "stderr_truncated", + "stdout", + "stdout_truncated", + "timed_out" + ], + "title": "GhOutcome", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.issue.close.json b/github/tests/golden/schemas/github.issue.close.json new file mode 100644 index 000000000..30ad2f39d --- /dev/null +++ b/github/tests/golden/schemas/github.issue.close.json @@ -0,0 +1,68 @@ +{ + "description": "Close an issue: { repo, number, comment?, reason?: completed|not-planned } -> { output }.", + "function_id": "github::issue::close", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "CloseReason": { + "description": "Close reason. The wire value is kebab-case; gh's flag value for `not-planned` is the space-separated \"not planned\".", + "enum": [ + "completed", + "not-planned" + ], + "type": "string" + } + }, + "properties": { + "comment": { + "description": "Closing comment.", + "type": [ + "string", + "null" + ] + }, + "number": { + "description": "Issue number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/CloseReason" + }, + { + "type": "null" + } + ], + "description": "Close reason." + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "number", + "repo" + ], + "title": "CloseRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.issue.comment.json b/github/tests/golden/schemas/github.issue.comment.json new file mode 100644 index 000000000..11ae73e04 --- /dev/null +++ b/github/tests/golden/schemas/github.issue.comment.json @@ -0,0 +1,45 @@ +{ + "description": "Comment on an issue: { repo, number, body } -> { output: }.", + "function_id": "github::issue::comment", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "body": { + "description": "Comment body (markdown).", + "type": "string" + }, + "number": { + "description": "Issue number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "body", + "number", + "repo" + ], + "title": "CommentRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.issue.create.json b/github/tests/golden/schemas/github.issue.create.json new file mode 100644 index 000000000..60ca5e39c --- /dev/null +++ b/github/tests/golden/schemas/github.issue.create.json @@ -0,0 +1,63 @@ +{ + "description": "Open an issue: { repo: \"owner/name\", title, body, labels?, assignees? } -> { output: }.", + "function_id": "github::issue::create", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "assignees": { + "description": "Assignee logins.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "body": { + "description": "Issue body (markdown).", + "type": "string" + }, + "labels": { + "description": "Labels to apply.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "title": { + "description": "Issue title.", + "type": "string" + } + }, + "required": [ + "body", + "repo", + "title" + ], + "title": "CreateRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.issue.edit.json b/github/tests/golden/schemas/github.issue.edit.json new file mode 100644 index 000000000..a677df329 --- /dev/null +++ b/github/tests/golden/schemas/github.issue.edit.json @@ -0,0 +1,84 @@ +{ + "description": "Edit an issue's title/body/labels/assignees: { repo, number, title?, body?, add_labels?, remove_labels?, add_assignees? } -> { output }.", + "function_id": "github::issue::edit", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "add_assignees": { + "description": "Assignee logins to add.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "add_labels": { + "description": "Labels to add.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "body": { + "description": "New body (markdown).", + "type": [ + "string", + "null" + ] + }, + "number": { + "description": "Issue number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "remove_labels": { + "description": "Labels to remove.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "title": { + "description": "New title.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "number", + "repo" + ], + "title": "EditRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.issue.list.json b/github/tests/golden/schemas/github.issue.list.json new file mode 100644 index 000000000..a5a53515d --- /dev/null +++ b/github/tests/golden/schemas/github.issue.list.json @@ -0,0 +1,94 @@ +{ + "description": "List issues: { repo: \"owner/name\", state?, limit?, author?, labels?, assignee?, search? } -> { value: [{number, title, state, url, author, labels, assignees, milestone, createdAt, updatedAt}] }.", + "function_id": "github::issue::list", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "IssueState": { + "description": "Issue state filter.", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + "properties": { + "assignee": { + "description": "Filter by assignee login.", + "type": [ + "string", + "null" + ] + }, + "author": { + "description": "Filter by author login.", + "type": [ + "string", + "null" + ] + }, + "labels": { + "description": "Filter by labels (all must match).", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "limit": { + "description": "Maximum number of results (gh default: 30).", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "search": { + "description": "Search query narrowing the list further (GitHub search syntax).", + "type": [ + "string", + "null" + ] + }, + "state": { + "anyOf": [ + { + "$ref": "#/definitions/IssueState" + }, + { + "type": "null" + } + ], + "description": "Filter by state (gh default: open)." + } + }, + "required": [ + "repo" + ], + "title": "ListRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.issue.view.json b/github/tests/golden/schemas/github.issue.view.json new file mode 100644 index 000000000..5b9e56527 --- /dev/null +++ b/github/tests/golden/schemas/github.issue.view.json @@ -0,0 +1,39 @@ +{ + "description": "View one issue with body and comments: { repo: \"owner/name\", number } -> { value }.", + "function_id": "github::issue::view", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "number": { + "description": "Issue number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "number", + "repo" + ], + "title": "ViewRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.pr.checks.json b/github/tests/golden/schemas/github.pr.checks.json new file mode 100644 index 000000000..c016c0cd0 --- /dev/null +++ b/github/tests/golden/schemas/github.pr.checks.json @@ -0,0 +1,39 @@ +{ + "description": "CI check rollup for a pull request: { repo, number } -> { value: [{bucket, name, state, link, workflow, description, startedAt, completedAt}] }. Failing or pending checks are data, not errors.", + "function_id": "github::pr::checks", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "number": { + "description": "Pull request number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "number", + "repo" + ], + "title": "ChecksRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.pr.comment.json b/github/tests/golden/schemas/github.pr.comment.json new file mode 100644 index 000000000..d8aa41812 --- /dev/null +++ b/github/tests/golden/schemas/github.pr.comment.json @@ -0,0 +1,45 @@ +{ + "description": "Comment on a pull request: { repo, number, body } -> { output: }.", + "function_id": "github::pr::comment", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "body": { + "description": "Comment body (markdown).", + "type": "string" + }, + "number": { + "description": "Pull request number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "body", + "number", + "repo" + ], + "title": "CommentRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.pr.create.json b/github/tests/golden/schemas/github.pr.create.json new file mode 100644 index 000000000..9585f2ec8 --- /dev/null +++ b/github/tests/golden/schemas/github.pr.create.json @@ -0,0 +1,64 @@ +{ + "description": "Open a pull request: { repo: \"owner/name\", title, body, base?, head?, draft? } -> { output: }. head defaults to the repo's current branch on the gh side — pass it explicitly.", + "function_id": "github::pr::create", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "base": { + "description": "Base branch to merge into (gh default: the repo default branch).", + "type": [ + "string", + "null" + ] + }, + "body": { + "description": "Pull request body (markdown).", + "type": "string" + }, + "draft": { + "description": "Open as draft.", + "type": [ + "boolean", + "null" + ] + }, + "head": { + "description": "Head branch the PR ships (pass explicitly; the worker has no checkout).", + "type": [ + "string", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "title": { + "description": "Pull request title.", + "type": "string" + } + }, + "required": [ + "body", + "repo", + "title" + ], + "title": "CreateRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.pr.diff.json b/github/tests/golden/schemas/github.pr.diff.json new file mode 100644 index 000000000..4a88d6ab5 --- /dev/null +++ b/github/tests/golden/schemas/github.pr.diff.json @@ -0,0 +1,45 @@ +{ + "description": "Unified diff of a pull request: { repo, number } -> { diff, truncated }. truncated is true when the diff exceeded the capture cap.", + "function_id": "github::pr::diff", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "number": { + "description": "Pull request number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "number", + "repo" + ], + "title": "DiffRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "`github::pr::diff` response.", + "properties": { + "diff": { + "description": "The unified diff as printed by `gh pr diff`.", + "type": "string" + }, + "truncated": { + "description": "True when the diff exceeded the capture cap and was cut off.", + "type": "boolean" + } + }, + "required": [ + "diff", + "truncated" + ], + "title": "DiffResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.pr.edit.json b/github/tests/golden/schemas/github.pr.edit.json new file mode 100644 index 000000000..0b60327a4 --- /dev/null +++ b/github/tests/golden/schemas/github.pr.edit.json @@ -0,0 +1,81 @@ +{ + "description": "Edit a pull request's title/body/base/labels: { repo, number, title?, body?, base?, add_labels?, remove_labels? } -> { output }.", + "function_id": "github::pr::edit", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "add_labels": { + "description": "Labels to add.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "base": { + "description": "New base branch.", + "type": [ + "string", + "null" + ] + }, + "body": { + "description": "New body (markdown).", + "type": [ + "string", + "null" + ] + }, + "number": { + "description": "Pull request number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "remove_labels": { + "description": "Labels to remove.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "title": { + "description": "New title.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "number", + "repo" + ], + "title": "EditRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.pr.list.json b/github/tests/golden/schemas/github.pr.list.json new file mode 100644 index 000000000..88c0a1e09 --- /dev/null +++ b/github/tests/golden/schemas/github.pr.list.json @@ -0,0 +1,95 @@ +{ + "description": "List pull requests: { repo: \"owner/name\", state?, limit?, author?, labels?, base?, search? } -> { value: [{number, title, state, url, author, headRefName, baseRefName, isDraft, labels, createdAt, updatedAt}] }.", + "function_id": "github::pr::list", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PrState": { + "description": "PR state filter.", + "enum": [ + "open", + "closed", + "merged", + "all" + ], + "type": "string" + } + }, + "properties": { + "author": { + "description": "Filter by author login.", + "type": [ + "string", + "null" + ] + }, + "base": { + "description": "Filter by base branch.", + "type": [ + "string", + "null" + ] + }, + "labels": { + "description": "Filter by labels (all must match).", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "limit": { + "description": "Maximum number of results (gh default: 30).", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "search": { + "description": "Search query narrowing the list further (GitHub search syntax).", + "type": [ + "string", + "null" + ] + }, + "state": { + "anyOf": [ + { + "$ref": "#/definitions/PrState" + }, + { + "type": "null" + } + ], + "description": "Filter by state (gh default: open)." + } + }, + "required": [ + "repo" + ], + "title": "ListRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.pr.merge.json b/github/tests/golden/schemas/github.pr.merge.json new file mode 100644 index 000000000..0487cb131 --- /dev/null +++ b/github/tests/golden/schemas/github.pr.merge.json @@ -0,0 +1,74 @@ +{ + "description": "Merge a pull request: { repo, number, method: merge|squash|rebase, delete_branch?, auto? } -> { output }. auto enables auto-merge once requirements pass.", + "function_id": "github::pr::merge", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "MergeMethod": { + "description": "Merge method.", + "enum": [ + "merge", + "squash", + "rebase" + ], + "type": "string" + } + }, + "properties": { + "auto": { + "description": "Enable auto-merge: merge automatically once requirements pass.", + "type": [ + "boolean", + "null" + ] + }, + "delete_branch": { + "description": "Delete the head branch after merging.", + "type": [ + "boolean", + "null" + ] + }, + "method": { + "allOf": [ + { + "$ref": "#/definitions/MergeMethod" + } + ], + "description": "Merge method." + }, + "number": { + "description": "Pull request number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "method", + "number", + "repo" + ], + "title": "MergeRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.pr.review.json b/github/tests/golden/schemas/github.pr.review.json new file mode 100644 index 000000000..661f21304 --- /dev/null +++ b/github/tests/golden/schemas/github.pr.review.json @@ -0,0 +1,67 @@ +{ + "description": "Review a pull request: { repo, number, event: approve|request-changes|comment, body? } -> { output }. gh requires body for request-changes and comment reviews.", + "function_id": "github::pr::review", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ReviewEvent": { + "description": "Review event.", + "enum": [ + "approve", + "request-changes", + "comment" + ], + "type": "string" + } + }, + "properties": { + "body": { + "description": "Review body (gh requires it for request-changes and comment).", + "type": [ + "string", + "null" + ] + }, + "event": { + "allOf": [ + { + "$ref": "#/definitions/ReviewEvent" + } + ], + "description": "Review event." + }, + "number": { + "description": "Pull request number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "event", + "number", + "repo" + ], + "title": "ReviewRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.pr.view.json b/github/tests/golden/schemas/github.pr.view.json new file mode 100644 index 000000000..6952cdbc5 --- /dev/null +++ b/github/tests/golden/schemas/github.pr.view.json @@ -0,0 +1,39 @@ +{ + "description": "View one pull request with body, mergeability, review decision, and diff stats: { repo: \"owner/name\", number } -> { value }.", + "function_id": "github::pr::view", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "number": { + "description": "Pull request number.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "number", + "repo" + ], + "title": "ViewRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.release.create.json b/github/tests/golden/schemas/github.release.create.json new file mode 100644 index 000000000..caa0946de --- /dev/null +++ b/github/tests/golden/schemas/github.release.create.json @@ -0,0 +1,80 @@ +{ + "description": "Create a release: { repo, tag, title?, notes?, generate_notes?, draft?, prerelease?, target? } -> { output: }. generate_notes autogenerates from merged PRs.", + "function_id": "github::release::create", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "draft": { + "description": "Create as draft.", + "type": [ + "boolean", + "null" + ] + }, + "generate_notes": { + "description": "Autogenerate notes from merged PRs since the last release.", + "type": [ + "boolean", + "null" + ] + }, + "notes": { + "description": "Release notes body (markdown).", + "type": [ + "string", + "null" + ] + }, + "prerelease": { + "description": "Mark as prerelease.", + "type": [ + "boolean", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "tag": { + "description": "Tag to release (created at target if it does not exist).", + "type": "string" + }, + "target": { + "description": "Target branch or commit SHA for the tag (gh default: the default branch).", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "Release title.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "repo", + "tag" + ], + "title": "CreateRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.release.list.json b/github/tests/golden/schemas/github.release.list.json new file mode 100644 index 000000000..6a3393916 --- /dev/null +++ b/github/tests/golden/schemas/github.release.list.json @@ -0,0 +1,41 @@ +{ + "description": "List releases: { repo: \"owner/name\", limit? } -> { value: [{tagName, name, isDraft, isLatest, isPrerelease, createdAt, publishedAt}] }.", + "function_id": "github::release::list", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "limit": { + "description": "Maximum number of results (gh default: 30).", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "repo" + ], + "title": "ListRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.release.view.json b/github/tests/golden/schemas/github.release.view.json new file mode 100644 index 000000000..3d43d1cc1 --- /dev/null +++ b/github/tests/golden/schemas/github.release.view.json @@ -0,0 +1,37 @@ +{ + "description": "View one release including body and assets: { repo: \"owner/name\", tag } -> { value }.", + "function_id": "github::release::view", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "tag": { + "description": "Release tag (e.g. \"v1.2.0\").", + "type": "string" + } + }, + "required": [ + "repo", + "tag" + ], + "title": "ViewRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.repo.list.json b/github/tests/golden/schemas/github.repo.list.json new file mode 100644 index 000000000..8c26ebb36 --- /dev/null +++ b/github/tests/golden/schemas/github.repo.list.json @@ -0,0 +1,41 @@ +{ + "description": "List repositories of an owner: { owner?, limit? } -> { value: [{name, nameWithOwner, description, url, visibility, ...}] }. owner defaults to the authenticated user.", + "function_id": "github::repo::list", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "limit": { + "description": "Maximum number of results (gh default: 30).", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "owner": { + "description": "User or organization login (default: the authenticated user).", + "type": [ + "string", + "null" + ] + } + }, + "title": "ListRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.repo.view.json b/github/tests/golden/schemas/github.repo.view.json new file mode 100644 index 000000000..6c9ce81d5 --- /dev/null +++ b/github/tests/golden/schemas/github.repo.view.json @@ -0,0 +1,32 @@ +{ + "description": "View a repository's metadata (description, default branch, visibility, stars, language, topics, latest release): { repo: \"owner/name\" } -> { value }.", + "function_id": "github::repo::view", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "repo" + ], + "title": "ViewRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.run.cancel.json b/github/tests/golden/schemas/github.run.cancel.json new file mode 100644 index 000000000..4e55e17f7 --- /dev/null +++ b/github/tests/golden/schemas/github.run.cancel.json @@ -0,0 +1,40 @@ +{ + "description": "Cancel an in-progress workflow run: { repo, run_id } -> { output }.", + "function_id": "github::run::cancel", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "run_id": { + "description": "Workflow run id.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "repo", + "run_id" + ], + "title": "RunCancelRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.run.list.json b/github/tests/golden/schemas/github.run.list.json new file mode 100644 index 000000000..a498d85be --- /dev/null +++ b/github/tests/golden/schemas/github.run.list.json @@ -0,0 +1,62 @@ +{ + "description": "List GitHub Actions workflow runs: { repo: \"owner/name\", workflow?, branch?, status?, limit? } -> { value: [{databaseId, displayTitle, workflowName, headBranch, event, status, conclusion, url, ...}] }.", + "function_id": "github::run::list", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "branch": { + "description": "Filter by branch.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Maximum number of results (gh default: 20).", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "status": { + "description": "Filter by status (e.g. completed, in_progress, queued, failure, success).", + "type": [ + "string", + "null" + ] + }, + "workflow": { + "description": "Filter by workflow (file name, e.g. \"ci.yml\").", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "repo" + ], + "title": "RunListRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.run.rerun.json b/github/tests/golden/schemas/github.run.rerun.json new file mode 100644 index 000000000..cac45081a --- /dev/null +++ b/github/tests/golden/schemas/github.run.rerun.json @@ -0,0 +1,47 @@ +{ + "description": "Rerun a workflow run: { repo, run_id, failed? } -> { output }. failed reruns only the failed jobs.", + "function_id": "github::run::rerun", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "failed": { + "description": "Rerun only the failed jobs.", + "type": [ + "boolean", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "run_id": { + "description": "Workflow run id.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "repo", + "run_id" + ], + "title": "RunRerunRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.run.view.json b/github/tests/golden/schemas/github.run.view.json new file mode 100644 index 000000000..808068bb8 --- /dev/null +++ b/github/tests/golden/schemas/github.run.view.json @@ -0,0 +1,39 @@ +{ + "description": "View one workflow run including its jobs and their steps: { repo: \"owner/name\", run_id } -> { value }.", + "function_id": "github::run::view", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "run_id": { + "description": "Workflow run id (databaseId from github::run::list).", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "repo", + "run_id" + ], + "title": "RunViewRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.search.code.json b/github/tests/golden/schemas/github.search.code.json new file mode 100644 index 000000000..40478682d --- /dev/null +++ b/github/tests/golden/schemas/github.search.code.json @@ -0,0 +1,41 @@ +{ + "description": "Search code: { query, limit? } -> { value: [{path, repository, sha, url, textMatches}] }. Use qualifiers like \"repo:o/r language:rust symbol\".", + "function_id": "github::search::code", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "limit": { + "description": "Maximum number of results (gh default: 30).", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "query": { + "description": "Search query (GitHub search syntax, qualifiers included).", + "type": "string" + } + }, + "required": [ + "query" + ], + "title": "CodeRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.search.issues.json b/github/tests/golden/schemas/github.search.issues.json new file mode 100644 index 000000000..20fbae3fc --- /dev/null +++ b/github/tests/golden/schemas/github.search.issues.json @@ -0,0 +1,41 @@ +{ + "description": "Search issues across repositories: { query, limit? } -> { value: [{number, title, state, url, repository, author, labels, commentsCount, ...}] }. Use qualifiers like \"repo:o/r is:open label:bug\".", + "function_id": "github::search::issues", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "limit": { + "description": "Maximum number of results (gh default: 30).", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "query": { + "description": "Search query (GitHub search syntax, qualifiers included).", + "type": "string" + } + }, + "required": [ + "query" + ], + "title": "IssuesRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.search.prs.json b/github/tests/golden/schemas/github.search.prs.json new file mode 100644 index 000000000..21731c48b --- /dev/null +++ b/github/tests/golden/schemas/github.search.prs.json @@ -0,0 +1,41 @@ +{ + "description": "Search pull requests across repositories: { query, limit? } -> { value }. Use qualifiers like \"repo:o/r is:open review:required\".", + "function_id": "github::search::prs", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "limit": { + "description": "Maximum number of results (gh default: 30).", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "query": { + "description": "Search query (GitHub search syntax, qualifiers included).", + "type": "string" + } + }, + "required": [ + "query" + ], + "title": "PrsRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.search.repos.json b/github/tests/golden/schemas/github.search.repos.json new file mode 100644 index 000000000..1afc44ae2 --- /dev/null +++ b/github/tests/golden/schemas/github.search.repos.json @@ -0,0 +1,41 @@ +{ + "description": "Search repositories: { query, limit? } -> { value: [{fullName, description, url, visibility, stargazersCount, language, updatedAt, ...}] }. GitHub search syntax (e.g. \"language:rust stars:>100 topic:cli\").", + "function_id": "github::search::repos", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "limit": { + "description": "Maximum number of results (gh default: 30).", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "query": { + "description": "Search query (GitHub search syntax, qualifiers included).", + "type": "string" + } + }, + "required": [ + "query" + ], + "title": "ReposRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.workflow.list.json b/github/tests/golden/schemas/github.workflow.list.json new file mode 100644 index 000000000..ff27d4884 --- /dev/null +++ b/github/tests/golden/schemas/github.workflow.list.json @@ -0,0 +1,39 @@ +{ + "description": "List workflows in a repo: { repo: \"owner/name\", all? } -> { value: [{id, name, path, state}] }. all includes disabled workflows.", + "function_id": "github::workflow::list", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "all": { + "description": "Include disabled workflows.", + "type": [ + "boolean", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + } + }, + "required": [ + "repo" + ], + "title": "WorkflowListRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Parsed `--json` output of a gh command.", + "properties": { + "value": { + "description": "The JSON gh printed for the requested `--json` fields." + } + }, + "required": [ + "value" + ], + "title": "ValueResponse", + "type": "object" + } +} diff --git a/github/tests/golden/schemas/github.workflow.run.json b/github/tests/golden/schemas/github.workflow.run.json new file mode 100644 index 000000000..31fd4523d --- /dev/null +++ b/github/tests/golden/schemas/github.workflow.run.json @@ -0,0 +1,55 @@ +{ + "description": "Dispatch a workflow (workflow_dispatch): { repo, workflow: , ref?, inputs? } -> { output }. inputs become -f key=value pairs.", + "function_id": "github::workflow::run", + "request_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "inputs": { + "additionalProperties": { + "type": "string" + }, + "description": "workflow_dispatch inputs, each passed as `-f key=value`.", + "type": [ + "object", + "null" + ] + }, + "ref": { + "description": "Branch or tag to run on (gh default: the repo default branch).", + "type": [ + "string", + "null" + ] + }, + "repo": { + "description": "Target repository, \"owner/name\".", + "type": "string" + }, + "workflow": { + "description": "Workflow file name (e.g. \"release.yml\") or numeric id.", + "type": "string" + } + }, + "required": [ + "repo", + "workflow" + ], + "title": "WorkflowRunRequest", + "type": "object" + }, + "response_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Trimmed stdout of a mutating gh command (gh prints the created/updated URL or a confirmation line; some commands print nothing).", + "properties": { + "output": { + "description": "gh's stdout, trimmed.", + "type": "string" + } + }, + "required": [ + "output" + ], + "title": "TextResponse", + "type": "object" + } +} diff --git a/github/tests/schemas.rs b/github/tests/schemas.rs new file mode 100644 index 000000000..28ed4d349 --- /dev/null +++ b/github/tests/schemas.rs @@ -0,0 +1,131 @@ +//! Wire-schema snapshots for the `github::*` functions. +//! +//! `github::functions::catalog()` is the single source of truth for each +//! function's id, registration description, and schemars-derived +//! request/response schemas (generated with the same +//! `SchemaSettings::draft07()` construction iii-sdk uses at registration, +//! from the same input/output structs). Each entry is serialized to pretty +//! JSON and compared against `tests/golden/schemas/.json` (`::` maps to +//! `.` in filenames). +//! +//! These snapshots ARE the product surface consumed by callers and agents — +//! any schema or description change must land as an explicit golden diff. +//! Regenerate with `UPDATE_GOLDENS=1 cargo test`. + +mod support; + +use github::functions::{catalog, FunctionSpec}; + +fn golden_file_name(function_id: &str) -> String { + format!("schemas/{}.json", function_id.replace("::", ".")) +} + +fn spec_to_pretty_json(spec: &FunctionSpec) -> String { + let value = serde_json::json!({ + "function_id": spec.function_id, + "description": spec.description, + "request_schema": spec.request_schema, + "response_schema": spec.response_schema, + }); + let mut pretty = serde_json::to_string_pretty(&value).expect("spec serializes"); + pretty.push('\n'); + pretty +} + +/// The catalog must cover exactly the registered functions, in registration +/// order (kept in lockstep with `register_all`; `github::on-config-change` +/// is internal and excluded). +#[test] +fn catalog_lists_all_functions_in_registration_order() { + let ids: Vec<&str> = catalog().iter().map(|s| s.function_id).collect(); + assert_eq!( + ids, + vec![ + "github::pr::list", + "github::pr::view", + "github::pr::create", + "github::pr::edit", + "github::pr::merge", + "github::pr::comment", + "github::pr::review", + "github::pr::diff", + "github::pr::checks", + "github::issue::list", + "github::issue::view", + "github::issue::create", + "github::issue::edit", + "github::issue::comment", + "github::issue::close", + "github::repo::view", + "github::repo::list", + "github::run::list", + "github::run::view", + "github::run::rerun", + "github::run::cancel", + "github::workflow::list", + "github::workflow::run", + "github::release::list", + "github::release::view", + "github::release::create", + "github::search::repos", + "github::search::issues", + "github::search::prs", + "github::search::code", + "github::exec", + "github::api", + ] + ); +} + +/// Every catalog entry matches its committed golden. Mismatches are collected +/// across ALL functions before failing so one run shows the full drift, not +/// just the first file. +#[test] +fn wire_schema_snapshots_match_goldens() { + let mut failures = Vec::new(); + for spec in catalog() { + let rel = golden_file_name(spec.function_id); + let actual = spec_to_pretty_json(&spec); + if let Err(msg) = support::check_golden(&rel, &actual) { + failures.push(msg); + } + } + assert!( + failures.is_empty(), + "{} wire-schema golden(s) drifted:\n\n{}", + failures.len(), + failures.join("\n") + ); +} + +/// No function may ship the permissive `AnyValue` schema — the deploy-time +/// "unknown" request/response schema this convention exists to prevent. Every +/// request and response schema must be a typed struct. +#[test] +fn every_function_has_typed_request_and_response_schemas() { + for spec in catalog() { + support::assert_typed_schema( + &format!("{} request_schema", spec.function_id), + &spec.request_schema, + ); + support::assert_typed_schema( + &format!("{} response_schema", spec.function_id), + &spec.response_schema, + ); + } +} + +/// The field docs callers rely on must appear in the schemas' doc-comment +/// descriptions (a rename here is a breaking API change even if types still +/// compile). +#[test] +fn schemas_carry_field_descriptions() { + for spec in catalog() { + let rendered = serde_json::to_string(&spec.request_schema).expect("schema serializes"); + assert!( + rendered.contains("description"), + "{}: request schema lost its field descriptions", + spec.function_id + ); + } +} diff --git a/github/tests/support/mod.rs b/github/tests/support/mod.rs new file mode 100644 index 000000000..440e3bf0e --- /dev/null +++ b/github/tests/support/mod.rs @@ -0,0 +1,118 @@ +//! Hand-rolled golden-file harness (deliberately no `insta`/snapshot +//! dependency). Goldens live under `tests/golden/` and are committed; +//! any wire-surface change must show up as an explicit, reviewed diff. +//! +//! Workflow: +//! - `cargo test` compares actual output against the committed goldens. +//! - `UPDATE_GOLDENS=1 cargo test` regenerates the files; review the git +//! diff, then commit the new goldens alongside the change that caused +//! them. + +#![allow(dead_code)] + +use std::fs; +use std::path::PathBuf; + +/// Root of the committed golden files. +pub fn golden_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden") +} + +fn update_mode() -> bool { + std::env::var("UPDATE_GOLDENS") + .map(|v| v == "1") + .unwrap_or(false) +} + +/// Compare `actual` against the golden file at `tests/golden/`. +/// Returns `Err(readable diff hint)` on mismatch or missing golden; +/// with `UPDATE_GOLDENS=1` the file is (re)written and the check passes. +pub fn check_golden(rel: &str, actual: &str) -> Result<(), String> { + let path = golden_root().join(rel); + if update_mode() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + fs::write(&path, actual).map_err(|e| format!("write {}: {e}", path.display()))?; + return Ok(()); + } + let expected = fs::read_to_string(&path).map_err(|e| { + format!( + "golden file {} unreadable ({e}).\n\ + Run `UPDATE_GOLDENS=1 cargo test` to (re)generate, then review \ + and commit the diff.", + path.display() + ) + })?; + if expected == actual { + return Ok(()); + } + Err(diff_hint(rel, &expected, actual)) +} + +/// Readable first-divergence diff hint: line number, expected vs actual +/// around the mismatch, and the regeneration instructions. +fn diff_hint(rel: &str, expected: &str, actual: &str) -> String { + let exp_lines: Vec<&str> = expected.lines().collect(); + let act_lines: Vec<&str> = actual.lines().collect(); + let first_diff = exp_lines + .iter() + .zip(act_lines.iter()) + .position(|(e, a)| e != a) + .unwrap_or_else(|| exp_lines.len().min(act_lines.len())); + + const CONTEXT: usize = 3; + let lo = first_diff.saturating_sub(CONTEXT); + let hi = (first_diff + CONTEXT + 1).max(first_diff + 1); + + let mut out = format!( + "golden mismatch: tests/golden/{rel}\n\ + first divergence at line {} (expected {} lines, actual {} lines)\n", + first_diff + 1, + exp_lines.len(), + act_lines.len() + ); + out.push_str("--- expected (golden) ---\n"); + for (i, line) in exp_lines.iter().enumerate().skip(lo).take(hi - lo) { + let marker = if i == first_diff { ">" } else { " " }; + out.push_str(&format!("{marker} {:>4} | {line}\n", i + 1)); + } + out.push_str("--- actual ---\n"); + for (i, line) in act_lines.iter().enumerate().skip(lo).take(hi - lo) { + let marker = if i == first_diff { ">" } else { " " }; + out.push_str(&format!("{marker} {:>4} | {line}\n", i + 1)); + } + out.push_str( + "If this change is intentional, run `UPDATE_GOLDENS=1 cargo test`, \ + review the git diff, and commit the updated goldens.\n", + ); + out +} + +/// Assert a schemars-derived request/response schema is a *real* schema and +/// not the permissive `AnyValue` schema a `Value` handler emits (the "unknown" +/// schema this whole convention exists to prevent). A real schema carries at +/// least one schema-defining keyword. +pub fn assert_typed_schema(label: &str, schema: &schemars::schema::RootSchema) { + let value = serde_json::to_value(schema).expect("schema serializes"); + let obj = value + .as_object() + .unwrap_or_else(|| panic!("{label}: schema is not a JSON object")); + const DEFINING: [&str; 8] = [ + "type", + "properties", + "$ref", + "allOf", + "anyOf", + "oneOf", + "enum", + "items", + ]; + let has_defining = DEFINING.iter().any(|k| obj.contains_key(*k)); + assert!( + has_defining, + "{label}: schema is the permissive AnyValue/empty schema (no type/properties/$ref/…). \ + The handler is registered with `Value` — give it a typed struct deriving JsonSchema. \ + Got: {value}" + ); +} diff --git a/iii-permissions.yaml b/iii-permissions.yaml index 0560d886d..4d96f71f5 100644 --- a/iii-permissions.yaml +++ b/iii-permissions.yaml @@ -152,6 +152,9 @@ rules: - '!slack::on-turn-completed' - '!slack::on-pending-created' - '!slack::on-pending-resolved' + # github: internal hot-reload hook — invoked only by the engine's + # configuration:updated trigger dispatch, never agent-callable. + - '!github::on-config-change' # Read-only / introspection (extend below for your tools). - state::get @@ -218,6 +221,28 @@ rules: - fp::flatten - fp::sortBy - fp::reverse + # github: read-only GitHub queries (list/view/diff/checks/search) are safe + # reads. Mutations (create/edit/merge/comment/review/close/rerun/cancel/ + # workflow run/release create) and the escape hatches (github::exec — + # arbitrary gh commands; github::api — any REST endpoint including writes) + # stay at the needs_approval default. + - github::pr::list + - github::pr::view + - github::pr::diff + - github::pr::checks + - github::issue::list + - github::issue::view + - github::repo::view + - github::repo::list + - github::run::list + - github::run::view + - github::workflow::list + - github::release::list + - github::release::view + - github::search::repos + - github::search::issues + - github::search::prs + - github::search::code # web::fetch is load-bearing for the system prompt's SDK-reference gate and # HTTP-trigger verification; it enforces size/timeout caps and server-side # SSRF protection, so it is allowed by default. From 750650c7cb584166c5577aa594d5be659c6bff90 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Sat, 18 Jul 2026 10:57:23 -0300 Subject: [PATCH 2/4] =?UTF-8?q?(MOT-4106)=20feat(console):=20github=20work?= =?UTF-8?q?er=20view=20=E2=80=94=20presence-gated=20#/github=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five read-only panels over the github worker: pull requests (state filter + inline CI checks rollup via github::pr::checks), issues, Actions runs, releases, and org-wide search (repos/issues/prs/code). Repo input persists in localStorage; data loads on demand (no polling — gh rate limits). Nav entry gated on worker presence like worktrees/browser/memory; mutations deliberately stay in agent flows behind the approval gate. Console bumped to 1.6.11. --- console/Cargo.lock | 2 +- console/Cargo.toml | 2 +- console/web/src/App.tsx | 12 +- .../web/src/hooks/use-github-status.test.ts | 19 + console/web/src/hooks/use-github-status.ts | 43 +++ console/web/src/hooks/use-hash-route.ts | 6 + console/web/src/lib/conversations-context.tsx | 11 + console/web/src/lib/github.test.ts | 194 ++++++++++ console/web/src/lib/github.ts | 335 ++++++++++++++++++ console/web/src/lib/nav-options.test.ts | 47 ++- console/web/src/lib/nav-options.ts | 4 + .../pages/Github/components/IssuesPanel.tsx | 93 +++++ .../pages/Github/components/PanelShell.tsx | 57 +++ .../src/pages/Github/components/PrsPanel.tsx | 177 +++++++++ .../pages/Github/components/ReleasesPanel.tsx | 55 +++ .../src/pages/Github/components/RunsPanel.tsx | 86 +++++ .../pages/Github/components/SearchPanel.tsx | 186 ++++++++++ .../src/pages/Github/hooks/useGithubQuery.ts | 58 +++ console/web/src/pages/Github/index.tsx | 148 ++++++++ 19 files changed, 1505 insertions(+), 30 deletions(-) create mode 100644 console/web/src/hooks/use-github-status.test.ts create mode 100644 console/web/src/hooks/use-github-status.ts create mode 100644 console/web/src/lib/github.test.ts create mode 100644 console/web/src/lib/github.ts create mode 100644 console/web/src/pages/Github/components/IssuesPanel.tsx create mode 100644 console/web/src/pages/Github/components/PanelShell.tsx create mode 100644 console/web/src/pages/Github/components/PrsPanel.tsx create mode 100644 console/web/src/pages/Github/components/ReleasesPanel.tsx create mode 100644 console/web/src/pages/Github/components/RunsPanel.tsx create mode 100644 console/web/src/pages/Github/components/SearchPanel.tsx create mode 100644 console/web/src/pages/Github/hooks/useGithubQuery.ts create mode 100644 console/web/src/pages/Github/index.tsx diff --git a/console/Cargo.lock b/console/Cargo.lock index 82e99d909..b0233b101 100644 --- a/console/Cargo.lock +++ b/console/Cargo.lock @@ -251,7 +251,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "console" -version = "1.6.10" +version = "1.6.11" dependencies = [ "anyhow", "async-trait", diff --git a/console/Cargo.toml b/console/Cargo.toml index 9ae08b117..71799fea9 100644 --- a/console/Cargo.toml +++ b/console/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "console" -version = "1.6.10" +version = "1.6.11" edition = "2021" publish = false diff --git a/console/web/src/App.tsx b/console/web/src/App.tsx index 5a6077b7b..d1c88e6f6 100644 --- a/console/web/src/App.tsx +++ b/console/web/src/App.tsx @@ -22,6 +22,7 @@ import { buildViewOptions } from '@/lib/nav-options' import { cn } from '@/lib/utils' import { Browser } from '@/pages/Browser' import { Configuration } from '@/pages/Configuration' +import { Github } from '@/pages/Github' import { Memory } from '@/pages/Memory' import { TracesV2 } from '@/pages/TracesV2' import { Workers } from '@/pages/Workers' @@ -100,6 +101,8 @@ export function App() { ) : view === 'memory' ? ( + ) : view === 'github' ? ( + ) : ( )} @@ -129,12 +132,17 @@ function Header({ // Optional-worker entries appear only while their worker is present; a // direct #/worktrees or #/browser hit still lands on that page's install // notice. - const { worktreeAvailable, browserAvailable, memoryAvailable } = - useConversationsCtx() + const { + worktreeAvailable, + browserAvailable, + memoryAvailable, + githubAvailable, + } = useConversationsCtx() const viewOptions = buildViewOptions( worktreeAvailable, browserAvailable, memoryAvailable, + githubAvailable, ) const onConsoleSettings = view === 'configuration' return ( diff --git a/console/web/src/hooks/use-github-status.test.ts b/console/web/src/hooks/use-github-status.test.ts new file mode 100644 index 000000000..4c285cbe3 --- /dev/null +++ b/console/web/src/hooks/use-github-status.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { + GITHUB_WATCH_FN, + GITHUB_WORKER_NAME, + isGithubAvailable, +} from './use-github-status' + +describe('github presence probe wiring', () => { + it('probes the github worker with its own watch handler id', () => { + expect(GITHUB_WORKER_NAME).toBe('github') + expect(GITHUB_WATCH_FN).toBe('console::github-watch') + }) + + it('gates on both presence and the initial probe settling', () => { + expect(isGithubAvailable({ present: true, loading: false })).toBe(true) + expect(isGithubAvailable({ present: true, loading: true })).toBe(false) + expect(isGithubAvailable({ present: false, loading: false })).toBe(false) + }) +}) diff --git a/console/web/src/hooks/use-github-status.ts b/console/web/src/hooks/use-github-status.ts new file mode 100644 index 000000000..b9c013b0c --- /dev/null +++ b/console/web/src/hooks/use-github-status.ts @@ -0,0 +1,43 @@ +import { + isWorkerPresent, + useWorkerPresence, + type WorkerPresence, +} from './use-worker-presence' + +/** + * Presence probe for the `github` worker. It wraps the GitHub CLI (`gh`) as + * typed `github::*` functions (pull requests, issues, Actions runs, releases, + * search). It is OPTIONAL, so the console gates the Github page on its + * presence rather than rendering controls that would call functions that + * don't exist. Thin wrapper over the generic worker-presence probe. + */ + +/** Engine worker name for the github worker. */ +const GITHUB_WORKER_NAME = 'github' +/** Base id for the browser-local handler bound to the `worker` trigger. */ +const GITHUB_WATCH_FN = 'console::github-watch' + +export type GithubStatus = WorkerPresence + +/** + * @param enabled - only run against the real backend; pass `false` for the + * mock/Storybook backend (treats github as present so its UI shows in + * isolation). + */ +export function useGithubStatus(enabled: boolean): GithubStatus { + return useWorkerPresence({ + workerName: GITHUB_WORKER_NAME, + watchFnId: GITHUB_WATCH_FN, + enabled, + }) +} + +/** + * Whether the github worker's functions are registered and safe to trigger. + * False during the initial presence probe and while the worker is absent. + */ +export function isGithubAvailable(status: GithubStatus): boolean { + return isWorkerPresent(status) +} + +export { GITHUB_WATCH_FN, GITHUB_WORKER_NAME } diff --git a/console/web/src/hooks/use-hash-route.ts b/console/web/src/hooks/use-hash-route.ts index eb81fe7be..07c57fdbd 100644 --- a/console/web/src/hooks/use-hash-route.ts +++ b/console/web/src/hooks/use-hash-route.ts @@ -12,6 +12,7 @@ export type View = | 'worktrees' | 'browser' | 'memory' + | 'github' export interface WorkersConfigurationRoute { configurationId: string | null @@ -97,6 +98,9 @@ function routeFromHash(hash: string): View | null { if (hash === '#/memory') { return 'memory' } + if (hash === '#/github') { + return 'github' + } if (hash === '#/browser' || hash.startsWith('#/browser/')) { return 'browser' } @@ -130,6 +134,8 @@ function hashFor(view: View): string { return '#/browser' case 'memory': return '#/memory' + case 'github': + return '#/github' case 'configuration': return '#/configuration' } diff --git a/console/web/src/lib/conversations-context.tsx b/console/web/src/lib/conversations-context.tsx index e805a96a9..1e3f722ae 100644 --- a/console/web/src/lib/conversations-context.tsx +++ b/console/web/src/lib/conversations-context.tsx @@ -17,6 +17,7 @@ import { type ConversationsApi, useConversations, } from '@/hooks/use-conversations' +import { isGithubAvailable, useGithubStatus } from '@/hooks/use-github-status' import { type HarnessStatus, isHarnessAvailable, @@ -92,6 +93,12 @@ interface ConversationsContextValue extends ConversationsApi { * backend. */ memoryAvailable: boolean + /** + * Whether the optional `github` worker is connected — gates the Github + * page nav entry and its `github::*` RPC. Only meaningful on the real + * backend. + */ + githubAvailable: boolean } const ConversationsContext = createContext( @@ -126,6 +133,9 @@ export function ConversationsProvider({ const memoryAvailable = isMemoryAvailable( useMemoryStatus(backend.id === 'real'), ) + const githubAvailable = isGithubAvailable( + useGithubStatus(backend.id === 'real'), + ) const { modelOptions, catalogKeys, @@ -175,6 +185,7 @@ export function ConversationsProvider({ worktreeAvailable, browserAvailable, memoryAvailable, + githubAvailable, } return ( diff --git a/console/web/src/lib/github.test.ts b/console/web/src/lib/github.test.ts new file mode 100644 index 000000000..c7384134a --- /dev/null +++ b/console/web/src/lib/github.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import { + parseIssues, + parsePrChecks, + parsePrs, + parseReleases, + parseRuns, + parseSearchItems, + releaseUrl, + timeAgoIso, + unwrapValue, +} from './github' + +/** + * Parser contract against the github worker's wire shapes. The payloads + * mirror the worker's `--json` field sets (github/tests/golden/schemas/*) + * and its live output; a worker-side field-set change should surface here. + */ + +const PR = { + number: 529, + title: '(MOT-4106) feat(github): GitHub CLI (gh) as an iii worker', + state: 'OPEN', + url: 'https://github.com/iii-hq/workers/pull/529', + author: { id: 'U_x', is_bot: false, login: 'andersonleal', name: '' }, + headRefName: 'feat/gh-worker', + baseRefName: 'main', + isDraft: false, + labels: [ + { color: 'D6393F', description: '', id: 'LA_x', name: 'needs-triage' }, + ], + createdAt: '2026-07-18T08:18:49Z', + updatedAt: '2026-07-18T09:00:00Z', +} + +describe('unwrapValue', () => { + it('unwraps the ValueResponse envelope and tolerates junk', () => { + expect(unwrapValue({ value: [1, 2] })).toEqual([1, 2]) + expect(unwrapValue({ value: null })).toBeNull() + expect(unwrapValue('nope')).toBeNull() + expect(unwrapValue(undefined)).toBeNull() + }) +}) + +describe('parsePrs', () => { + it('parses the pr list wire shape, extra fields ignored', () => { + const prs = parsePrs([PR]) + expect(prs).toHaveLength(1) + expect(prs[0]?.number).toBe(529) + expect(prs[0]?.author?.login).toBe('andersonleal') + expect(prs[0]?.labels?.[0]?.name).toBe('needs-triage') + }) + + it('drops invalid rows instead of failing the whole list', () => { + expect(parsePrs([PR, { number: 'not-a-number' }, null])).toHaveLength(1) + }) + + it('returns empty for a non-array (null value, error payloads)', () => { + expect(parsePrs(null)).toEqual([]) + expect(parsePrs({})).toEqual([]) + }) +}) + +describe('parsePrChecks', () => { + it('parses the checks rollup rows', () => { + const checks = parsePrChecks([ + { + bucket: 'skipping', + completedAt: '2026-07-18T08:18:53Z', + description: '', + link: 'https://github.com/iii-hq/workers/actions/runs/1/job/2', + name: 'close-no-help-wanted', + startedAt: '2026-07-18T08:19:01Z', + state: 'SKIPPED', + workflow: 'PR Triaging', + }, + ]) + expect(checks).toHaveLength(1) + expect(checks[0]?.bucket).toBe('skipping') + expect(checks[0]?.workflow).toBe('PR Triaging') + }) +}) + +describe('parseIssues / parseRuns / parseReleases', () => { + it('parses an issue row', () => { + const issues = parseIssues([ + { + number: 7, + title: 'bug', + state: 'OPEN', + url: 'https://github.com/o/r/issues/7', + author: { login: 'octocat' }, + labels: [], + assignees: [], + milestone: null, + createdAt: '2026-07-01T00:00:00Z', + updatedAt: '2026-07-02T00:00:00Z', + }, + ]) + expect(issues[0]?.number).toBe(7) + }) + + it('parses a run row (nullable conclusion while in progress)', () => { + const runs = parseRuns([ + { + databaseId: 29646046993, + number: 12, + displayTitle: '(MOT-4106) feat(github): ...', + name: 'CI', + workflowName: 'CI', + headBranch: 'feat/gh-worker', + headSha: 'abc123', + event: 'pull_request', + status: 'in_progress', + conclusion: null, + attempt: 1, + createdAt: '2026-07-18T13:20:00Z', + startedAt: '2026-07-18T13:20:05Z', + updatedAt: '2026-07-18T13:21:00Z', + url: 'https://github.com/iii-hq/workers/actions/runs/29646046993', + }, + ]) + expect(runs[0]?.databaseId).toBe(29646046993) + expect(runs[0]?.conclusion).toBeNull() + }) + + it('parses a release row and builds its url', () => { + const releases = parseReleases([ + { + tagName: 'fp/v0.2.0', + name: 'fp 0.2.0', + isDraft: false, + isLatest: true, + isPrerelease: false, + createdAt: '2026-07-16T00:00:00Z', + publishedAt: '2026-07-16T00:05:00Z', + }, + ]) + expect(releases[0]?.tagName).toBe('fp/v0.2.0') + expect(releaseUrl('iii-hq/workers', 'fp/v0.2.0')).toBe( + 'https://github.com/iii-hq/workers/releases/tag/fp%2Fv0.2.0', + ) + }) +}) + +describe('parseSearchItems', () => { + it('parses each kind with its own row shape', () => { + const repos = parseSearchItems('repos', [ + { + fullName: 'cli/cli', + url: 'https://github.com/cli/cli', + language: 'Go', + }, + ]) + expect(repos.kind).toBe('repos') + expect(repos.items).toHaveLength(1) + + const prs = parseSearchItems('prs', [ + { + number: 1, + title: 't', + state: 'OPEN', + url: 'u', + repository: { name: 'workers', nameWithOwner: 'iii-hq/workers' }, + isDraft: true, + }, + ]) + expect(prs.kind).toBe('prs') + expect(prs.items).toHaveLength(1) + + const code = parseSearchItems('code', [ + { path: 'src/gh.rs', repository: { nameWithOwner: 'iii-hq/workers' } }, + ]) + expect(code.kind).toBe('code') + // discriminant narrowing: `items` is only GithubSearchCode[] inside the guard + if (code.kind !== 'code') throw new Error('unreachable') + expect(code.items[0]?.path).toBe('src/gh.rs') + }) +}) + +describe('timeAgoIso', () => { + const now = Date.parse('2026-07-18T12:00:00Z') + it('formats compact relative times', () => { + expect(timeAgoIso('2026-07-18T11:59:30Z', now)).toBe('30s ago') + expect(timeAgoIso('2026-07-18T11:30:00Z', now)).toBe('30m ago') + expect(timeAgoIso('2026-07-18T09:00:00Z', now)).toBe('3h ago') + expect(timeAgoIso('2026-07-10T12:00:00Z', now)).toBe('8d ago') + }) + it('is empty for missing or malformed timestamps', () => { + expect(timeAgoIso(undefined, now)).toBe('') + expect(timeAgoIso(null, now)).toBe('') + expect(timeAgoIso('not-a-date', now)).toBe('') + }) +}) diff --git a/console/web/src/lib/github.ts b/console/web/src/lib/github.ts new file mode 100644 index 000000000..975e7571d --- /dev/null +++ b/console/web/src/lib/github.ts @@ -0,0 +1,335 @@ +import { z } from 'zod' +import { getIiiClient } from '@/lib/iii-client' + +/** + * Typed wrappers over the optional `github` worker (the gh CLI as + * `github::*` functions): the read-only list/search surface the Github page + * renders. Every wrapper unwraps the worker's `{ value }` envelope and + * parses rows with a tolerant zod schema (unknown fields ignored, invalid + * rows dropped). Callers gate on presence (`use-github-status`). + * + * Mutating `github::*` functions are deliberately NOT wrapped here — writes + * stay in agent flows where the approval gate reviews them. + */ + +export const GITHUB_WORKER_NAME = 'github' + +export const GITHUB_PR_LIST_FN = 'github::pr::list' +export const GITHUB_PR_CHECKS_FN = 'github::pr::checks' +export const GITHUB_ISSUE_LIST_FN = 'github::issue::list' +export const GITHUB_RUN_LIST_FN = 'github::run::list' +export const GITHUB_RELEASE_LIST_FN = 'github::release::list' + +export const GITHUB_SEARCH_FNS = { + repos: 'github::search::repos', + issues: 'github::search::issues', + prs: 'github::search::prs', + code: 'github::search::code', +} as const +export type SearchKind = keyof typeof GITHUB_SEARCH_FNS + +export const PR_STATE_FILTERS = ['open', 'merged', 'closed', 'all'] as const +export type PrStateFilter = (typeof PR_STATE_FILTERS)[number] + +export const ISSUE_STATE_FILTERS = ['open', 'closed', 'all'] as const +export type IssueStateFilter = (typeof ISSUE_STATE_FILTERS)[number] + +// ---- wire schemas (loose: gh adds fields freely; rows must not vanish +// because an optional field changed shape) ---- + +const authorSchema = z + .object({ login: z.string().optional() }) + .nullable() + .optional() + +const labelSchema = z.object({ + name: z.string(), + color: z.string().optional(), +}) + +const prSchema = z.object({ + number: z.number(), + title: z.string(), + state: z.string(), + url: z.string(), + author: authorSchema, + headRefName: z.string().optional(), + baseRefName: z.string().optional(), + isDraft: z.boolean().optional(), + labels: z.array(labelSchema).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}) +export type GithubPr = z.infer + +const prCheckSchema = z.object({ + bucket: z.string(), + name: z.string(), + state: z.string().optional(), + link: z.string().nullable().optional(), + workflow: z.string().nullable().optional(), + description: z.string().nullable().optional(), + startedAt: z.string().nullable().optional(), + completedAt: z.string().nullable().optional(), +}) +export type GithubPrCheck = z.infer + +const issueSchema = z.object({ + number: z.number(), + title: z.string(), + state: z.string(), + url: z.string(), + author: authorSchema, + labels: z.array(labelSchema).optional(), + assignees: z.array(z.object({ login: z.string().optional() })).optional(), + milestone: z.object({ title: z.string().optional() }).nullable().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}) +export type GithubIssue = z.infer + +const runSchema = z.object({ + databaseId: z.number(), + number: z.number().optional(), + displayTitle: z.string().optional(), + name: z.string().optional(), + workflowName: z.string().optional(), + headBranch: z.string().nullable().optional(), + headSha: z.string().optional(), + event: z.string().optional(), + status: z.string().optional(), + conclusion: z.string().nullable().optional(), + attempt: z.number().optional(), + createdAt: z.string().optional(), + startedAt: z.string().optional(), + updatedAt: z.string().optional(), + url: z.string().optional(), +}) +export type GithubRun = z.infer + +const releaseSchema = z.object({ + tagName: z.string(), + name: z.string().nullable().optional(), + isDraft: z.boolean().optional(), + isLatest: z.boolean().optional(), + isPrerelease: z.boolean().optional(), + createdAt: z.string().optional(), + publishedAt: z.string().nullable().optional(), +}) +export type GithubRelease = z.infer + +const searchRepoSchema = z.object({ + fullName: z.string(), + description: z.string().nullable().optional(), + url: z.string(), + visibility: z.string().optional(), + isArchived: z.boolean().optional(), + isFork: z.boolean().optional(), + stargazersCount: z.number().optional(), + forksCount: z.number().optional(), + language: z.string().nullable().optional(), + updatedAt: z.string().optional(), +}) +export type GithubSearchRepo = z.infer + +const searchRepositoryRefSchema = z + .object({ + nameWithOwner: z.string().optional(), + name: z.string().optional(), + }) + .optional() + +const searchIssueSchema = z.object({ + number: z.number(), + title: z.string(), + state: z.string(), + url: z.string(), + repository: searchRepositoryRefSchema, + author: authorSchema, + labels: z.array(labelSchema).optional(), + commentsCount: z.number().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + isDraft: z.boolean().optional(), +}) +export type GithubSearchIssue = z.infer + +const searchCodeSchema = z.object({ + path: z.string(), + repository: searchRepositoryRefSchema, + sha: z.string().optional(), + url: z.string().optional(), + textMatches: z.array(z.unknown()).optional(), +}) +export type GithubSearchCode = z.infer + +// ---- parsing (exported for tests; wrappers below are thin) ---- + +const valueEnvelopeSchema = z.object({ value: z.unknown() }) + +/** Unwrap the worker's `ValueResponse { value }` envelope. */ +export function unwrapValue(res: unknown): unknown { + const parsed = valueEnvelopeSchema.safeParse(res) + return parsed.success ? parsed.data.value : null +} + +function parseArray(value: unknown, schema: z.ZodType): T[] { + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + const parsed = schema.safeParse(item) + return parsed.success ? [parsed.data] : [] + }) +} + +export function parsePrs(value: unknown): GithubPr[] { + return parseArray(value, prSchema) +} +export function parsePrChecks(value: unknown): GithubPrCheck[] { + return parseArray(value, prCheckSchema) +} +export function parseIssues(value: unknown): GithubIssue[] { + return parseArray(value, issueSchema) +} +export function parseRuns(value: unknown): GithubRun[] { + return parseArray(value, runSchema) +} +export function parseReleases(value: unknown): GithubRelease[] { + return parseArray(value, releaseSchema) +} + +export type GithubSearchItems = + | { kind: 'repos'; items: GithubSearchRepo[] } + | { kind: 'issues'; items: GithubSearchIssue[] } + | { kind: 'prs'; items: GithubSearchIssue[] } + | { kind: 'code'; items: GithubSearchCode[] } + +export function parseSearchItems( + kind: SearchKind, + value: unknown, +): GithubSearchItems { + switch (kind) { + case 'repos': + return { kind, items: parseArray(value, searchRepoSchema) } + case 'issues': + return { kind, items: parseArray(value, searchIssueSchema) } + case 'prs': + return { kind, items: parseArray(value, searchIssueSchema) } + case 'code': + return { kind, items: parseArray(value, searchCodeSchema) } + } +} + +// ---- RPC wrappers ---- + +const LIST_LIMIT = 30 + +async function triggerValue( + fnId: string, + payload: Record, +): Promise { + const client = await getIiiClient() + const res = await client.trigger(fnId, payload) + return unwrapValue(res) +} + +export async function listPrs( + repo: string, + state: PrStateFilter, +): Promise { + return parsePrs( + await triggerValue(GITHUB_PR_LIST_FN, { repo, state, limit: LIST_LIMIT }), + ) +} + +export async function listPrChecks( + repo: string, + number: number, +): Promise { + return parsePrChecks( + await triggerValue(GITHUB_PR_CHECKS_FN, { repo, number }), + ) +} + +export async function listIssues( + repo: string, + state: IssueStateFilter, +): Promise { + return parseIssues( + await triggerValue(GITHUB_ISSUE_LIST_FN, { + repo, + state, + limit: LIST_LIMIT, + }), + ) +} + +export async function listRuns(repo: string): Promise { + return parseRuns( + await triggerValue(GITHUB_RUN_LIST_FN, { repo, limit: LIST_LIMIT }), + ) +} + +export async function listReleases(repo: string): Promise { + return parseReleases( + await triggerValue(GITHUB_RELEASE_LIST_FN, { repo, limit: LIST_LIMIT }), + ) +} + +export async function searchGithub( + kind: SearchKind, + query: string, +): Promise { + return parseSearchItems( + kind, + await triggerValue(GITHUB_SEARCH_FNS[kind], { query, limit: LIST_LIMIT }), + ) +} + +// ---- small view helpers ---- + +/** The releases list payload carries no url; build the canonical one. */ +export function releaseUrl(repo: string, tagName: string): string { + return `https://github.com/${repo}/releases/tag/${encodeURIComponent(tagName)}` +} + +/** Compact relative time for gh's ISO timestamps ("3h ago"). */ +export function timeAgoIso( + iso: string | null | undefined, + now = Date.now(), +): string { + if (!iso) return '' + const t = Date.parse(iso) + if (Number.isNaN(t)) return '' + const s = Math.max(0, Math.floor((now - t) / 1000)) + if (s < 60) return `${s}s ago` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.floor(h / 24) + if (d < 30) return `${d}d ago` + const mo = Math.floor(d / 30) + if (mo < 12) return `${mo}mo ago` + return `${Math.floor(mo / 12)}y ago` +} + +// ---- repo persistence (UI affordance only) ---- + +const GITHUB_REPO_KEY = 'iii-github-repo' + +export function loadGithubRepo(): string { + try { + return localStorage.getItem(GITHUB_REPO_KEY) ?? '' + } catch { + return '' + } +} + +export function saveGithubRepo(repo: string): void { + try { + if (repo) localStorage.setItem(GITHUB_REPO_KEY, repo) + else localStorage.removeItem(GITHUB_REPO_KEY) + } catch { + // storage unavailable; the field just won't persist + } +} diff --git a/console/web/src/lib/nav-options.test.ts b/console/web/src/lib/nav-options.test.ts index 19052c17e..cbe8430a2 100644 --- a/console/web/src/lib/nav-options.test.ts +++ b/console/web/src/lib/nav-options.test.ts @@ -3,43 +3,38 @@ import { buildViewOptions } from './nav-options' describe('buildViewOptions', () => { it('hides the optional-worker entries while their workers are absent', () => { - expect(buildViewOptions(false, false, false).map((o) => o.value)).toEqual([ - 'traces', - 'workers', - ]) + expect( + buildViewOptions(false, false, false, false).map((o) => o.value), + ).toEqual(['traces', 'workers']) }) it('appends the worktrees entry when the worker is present', () => { - expect(buildViewOptions(true, false, false).map((o) => o.value)).toEqual([ - 'traces', - 'workers', - 'worktrees', - ]) + expect( + buildViewOptions(true, false, false, false).map((o) => o.value), + ).toEqual(['traces', 'workers', 'worktrees']) }) it('appends the browser entry when the worker is present', () => { - expect(buildViewOptions(false, true, false).map((o) => o.value)).toEqual([ - 'traces', - 'workers', - 'browser', - ]) + expect( + buildViewOptions(false, true, false, false).map((o) => o.value), + ).toEqual(['traces', 'workers', 'browser']) }) it('appends the memory entry when the worker is present', () => { - expect(buildViewOptions(false, false, true).map((o) => o.value)).toEqual([ - 'traces', - 'workers', - 'memory', - ]) + expect( + buildViewOptions(false, false, true, false).map((o) => o.value), + ).toEqual(['traces', 'workers', 'memory']) + }) + + it('appends the github entry when the worker is present', () => { + expect( + buildViewOptions(false, false, false, true).map((o) => o.value), + ).toEqual(['traces', 'workers', 'github']) }) it('appends every entry when all optional workers are present', () => { - expect(buildViewOptions(true, true, true).map((o) => o.value)).toEqual([ - 'traces', - 'workers', - 'worktrees', - 'browser', - 'memory', - ]) + expect( + buildViewOptions(true, true, true, true).map((o) => o.value), + ).toEqual(['traces', 'workers', 'worktrees', 'browser', 'memory', 'github']) }) }) diff --git a/console/web/src/lib/nav-options.ts b/console/web/src/lib/nav-options.ts index 63c369e0b..fac2aecf2 100644 --- a/console/web/src/lib/nav-options.ts +++ b/console/web/src/lib/nav-options.ts @@ -10,6 +10,7 @@ export function buildViewOptions( worktreeAvailable: boolean, browserAvailable: boolean, memoryAvailable: boolean, + githubAvailable: boolean, ): { value: View; label: string }[] { const options: { value: View; label: string }[] = [ { value: 'traces', label: 'traces' }, @@ -24,5 +25,8 @@ export function buildViewOptions( if (memoryAvailable) { options.push({ value: 'memory', label: 'memory' }) } + if (githubAvailable) { + options.push({ value: 'github', label: 'github' }) + } return options } diff --git a/console/web/src/pages/Github/components/IssuesPanel.tsx b/console/web/src/pages/Github/components/IssuesPanel.tsx new file mode 100644 index 000000000..c576e2c87 --- /dev/null +++ b/console/web/src/pages/Github/components/IssuesPanel.tsx @@ -0,0 +1,93 @@ +import { CircleDot } from 'lucide-react' +import { useCallback, useState } from 'react' +import { Badge } from '@/components/ui/Badge' +import { ModeToggle } from '@/components/ui/ModeToggle' +import { + type GithubIssue, + ISSUE_STATE_FILTERS, + type IssueStateFilter, + listIssues, + timeAgoIso, +} from '@/lib/github' +import { useGithubQuery } from '../hooks/useGithubQuery' +import { PanelShell } from './PanelShell' + +const STATE_OPTIONS: { value: IssueStateFilter; label: string }[] = + ISSUE_STATE_FILTERS.map((value) => ({ value, label: value })) + +function issueBadge(issue: GithubIssue): { + label: string + variant: 'accent' | 'default' +} { + return issue.state.toLowerCase() === 'open' + ? { label: 'open', variant: 'accent' } + : { label: 'closed', variant: 'default' } +} + +interface IssuesPanelProps { + repo: string + enabled: boolean +} + +export function IssuesPanel({ repo, enabled }: IssuesPanelProps) { + const [state, setState] = useState('open') + const fetcher = useCallback(() => listIssues(repo, state), [repo, state]) + const { data, loading, error } = useGithubQuery(enabled, fetcher) + const issues = data ?? [] + + return ( +
+
+ + value={state} + onChange={setState} + options={STATE_OPTIONS} + /> +
+ +
    + {issues.map((issue) => { + const badge = issueBadge(issue) + return ( +
  • + + #{issue.number} + + + {issue.title} + + + {issue.labels?.length ? ( + + {issue.labels.map((l) => l.name).join(', ')} + + ) : null} + + {issue.author?.login ?? ''} + {issue.updatedAt ? ` · ${timeAgoIso(issue.updatedAt)}` : ''} + + + {badge.label} +
  • + ) + })} +
+
+
+ ) +} diff --git a/console/web/src/pages/Github/components/PanelShell.tsx b/console/web/src/pages/Github/components/PanelShell.tsx new file mode 100644 index 000000000..d3a7aa391 --- /dev/null +++ b/console/web/src/pages/Github/components/PanelShell.tsx @@ -0,0 +1,57 @@ +import { AlertCircle, type LucideIcon } from 'lucide-react' +import type { ReactNode } from 'react' +import { EmptyState } from '@/components/ui/EmptyState' +import { StatusPanel } from '@/components/ui/StatusPanel' + +interface PanelShellProps { + loading: boolean + error: string | null + empty: boolean + emptyIcon: LucideIcon + emptyTitle: string + emptyDescription: string + children: ReactNode +} + +/** + * Shared error / first-load / empty scaffolding for the Github panels. + * Worker errors carry gh's own stderr (auth failures, 404s), so the alert + * detail is already the actionable message. + */ +export function PanelShell({ + loading, + error, + empty, + emptyIcon, + emptyTitle, + emptyDescription, + children, +}: PanelShellProps) { + if (error) { + return ( + } + headline="github call failed" + detail={error} + /> + ) + } + if (loading && empty) { + return ( +

+ loading... +

+ ) + } + if (empty) { + return ( + + ) + } + return <>{children} +} diff --git a/console/web/src/pages/Github/components/PrsPanel.tsx b/console/web/src/pages/Github/components/PrsPanel.tsx new file mode 100644 index 000000000..81616c245 --- /dev/null +++ b/console/web/src/pages/Github/components/PrsPanel.tsx @@ -0,0 +1,177 @@ +import { GitPullRequest } from 'lucide-react' +import { useCallback, useState } from 'react' +import { Badge } from '@/components/ui/Badge' +import { ModeToggle } from '@/components/ui/ModeToggle' +import { + type GithubPr, + type GithubPrCheck, + listPrChecks, + listPrs, + PR_STATE_FILTERS, + type PrStateFilter, + timeAgoIso, +} from '@/lib/github' +import { useGithubQuery } from '../hooks/useGithubQuery' +import { PanelShell } from './PanelShell' + +const STATE_OPTIONS: { value: PrStateFilter; label: string }[] = + PR_STATE_FILTERS.map((value) => ({ value, label: value })) + +type BadgeVariant = 'default' | 'warn' | 'alert' | 'accent' + +function prBadge(pr: GithubPr): { label: string; variant: BadgeVariant } { + const state = pr.state.toLowerCase() + if (pr.isDraft && state === 'open') + return { label: 'draft', variant: 'default' } + if (state === 'open') return { label: 'open', variant: 'accent' } + if (state === 'merged') return { label: 'merged', variant: 'default' } + if (state === 'closed') return { label: 'closed', variant: 'alert' } + return { label: state, variant: 'default' } +} + +function checkVariant(bucket: string): BadgeVariant { + switch (bucket) { + case 'pass': + return 'accent' + case 'fail': + return 'alert' + case 'pending': + return 'warn' + default: + return 'default' + } +} + +interface PrsPanelProps { + repo: string + enabled: boolean +} + +/** Pull requests for the selected repo; a row expands its CI check rollup. */ +export function PrsPanel({ repo, enabled }: PrsPanelProps) { + const [state, setState] = useState('open') + const [expanded, setExpanded] = useState(null) + const fetcher = useCallback(() => listPrs(repo, state), [repo, state]) + const { data, loading, error } = useGithubQuery(enabled, fetcher) + const prs = data ?? [] + + return ( +
+
+ + value={state} + onChange={setState} + options={STATE_OPTIONS} + /> +
+ +
    + {prs.map((pr) => { + const badge = prBadge(pr) + const isExpanded = expanded === pr.number + return ( +
  • +
    + + + {pr.title} + + + {pr.author?.login ?? ''} + {pr.updatedAt ? ` · ${timeAgoIso(pr.updatedAt)}` : ''} + + {badge.label} +
    + {isExpanded ? ( + + ) : null} +
  • + ) + })} +
+
+
+ ) +} + +function ChecksInline({ repo, number }: { repo: string; number: number }) { + const fetcher = useCallback(() => listPrChecks(repo, number), [repo, number]) + const { data, loading, error } = useGithubQuery(true, fetcher) + const checks = data ?? [] + + return ( +
+ {error ? ( +

{error}

+ ) : loading && checks.length === 0 ? ( +

+ loading checks... +

+ ) : checks.length === 0 ? ( +

+ no checks reported +

+ ) : ( +
    + {checks.map((check) => ( +
  • + {check.bucket} + {check.link ? ( + + {checkLabel(check)} + + ) : ( + + {checkLabel(check)} + + )} +
  • + ))} +
+ )} +
+ ) +} + +function checkLabel(check: GithubPrCheck): string { + return check.workflow ? `${check.workflow} / ${check.name}` : check.name +} + +function checkKey(check: GithubPrCheck): string { + return `${check.workflow ?? ''}/${check.name}/${check.startedAt ?? ''}` +} diff --git a/console/web/src/pages/Github/components/ReleasesPanel.tsx b/console/web/src/pages/Github/components/ReleasesPanel.tsx new file mode 100644 index 000000000..906973b8c --- /dev/null +++ b/console/web/src/pages/Github/components/ReleasesPanel.tsx @@ -0,0 +1,55 @@ +import { Tag } from 'lucide-react' +import { useCallback } from 'react' +import { Badge } from '@/components/ui/Badge' +import { listReleases, releaseUrl, timeAgoIso } from '@/lib/github' +import { useGithubQuery } from '../hooks/useGithubQuery' +import { PanelShell } from './PanelShell' + +interface ReleasesPanelProps { + repo: string + enabled: boolean +} + +export function ReleasesPanel({ repo, enabled }: ReleasesPanelProps) { + const fetcher = useCallback(() => listReleases(repo), [repo]) + const { data, loading, error } = useGithubQuery(enabled, fetcher) + const releases = data ?? [] + + return ( + +
    + {releases.map((release) => ( +
  • + + {release.tagName} + + + {release.name ?? ''} + + + {timeAgoIso(release.publishedAt ?? release.createdAt)} + + {release.isLatest ? latest : null} + {release.isDraft ? draft : null} + {release.isPrerelease ? prerelease : null} +
  • + ))} +
+
+ ) +} diff --git a/console/web/src/pages/Github/components/RunsPanel.tsx b/console/web/src/pages/Github/components/RunsPanel.tsx new file mode 100644 index 000000000..11545f084 --- /dev/null +++ b/console/web/src/pages/Github/components/RunsPanel.tsx @@ -0,0 +1,86 @@ +import { Workflow } from 'lucide-react' +import { useCallback } from 'react' +import { Badge } from '@/components/ui/Badge' +import { type GithubRun, listRuns, timeAgoIso } from '@/lib/github' +import { useGithubQuery } from '../hooks/useGithubQuery' +import { PanelShell } from './PanelShell' + +type BadgeVariant = 'default' | 'warn' | 'alert' | 'accent' + +function runBadge(run: GithubRun): { label: string; variant: BadgeVariant } { + const status = (run.status ?? '').toLowerCase() + if (status && status !== 'completed') { + return { label: status.replace(/_/g, ' '), variant: 'warn' } + } + const conclusion = (run.conclusion ?? '').toLowerCase() + if (conclusion === 'success') return { label: 'success', variant: 'accent' } + if ( + conclusion === 'failure' || + conclusion === 'timed_out' || + conclusion === 'startup_failure' + ) { + return { label: conclusion.replace(/_/g, ' '), variant: 'alert' } + } + return { label: conclusion || 'completed', variant: 'default' } +} + +interface RunsPanelProps { + repo: string + enabled: boolean +} + +/** GitHub Actions runs, newest first (worker default order from gh). */ +export function RunsPanel({ repo, enabled }: RunsPanelProps) { + const fetcher = useCallback(() => listRuns(repo), [repo]) + const { data, loading, error } = useGithubQuery(enabled, fetcher) + const runs = data ?? [] + + return ( + +
    + {runs.map((run) => { + const badge = runBadge(run) + const meta = [ + run.workflowName, + run.headBranch ?? undefined, + timeAgoIso(run.updatedAt ?? run.createdAt) || undefined, + ] + .filter(Boolean) + .join(' · ') + return ( +
  • + {run.url ? ( + + {run.displayTitle ?? run.name ?? String(run.databaseId)} + + ) : ( + + {run.displayTitle ?? run.name ?? String(run.databaseId)} + + )} + + {meta} + + {badge.label} +
  • + ) + })} +
+
+ ) +} diff --git a/console/web/src/pages/Github/components/SearchPanel.tsx b/console/web/src/pages/Github/components/SearchPanel.tsx new file mode 100644 index 000000000..66769536a --- /dev/null +++ b/console/web/src/pages/Github/components/SearchPanel.tsx @@ -0,0 +1,186 @@ +import { Search } from 'lucide-react' +import { useCallback, useState } from 'react' +import { Badge } from '@/components/ui/Badge' +import { Input } from '@/components/ui/Input' +import { ModeToggle } from '@/components/ui/ModeToggle' +import { + type GithubSearchItems, + type SearchKind, + searchGithub, + timeAgoIso, +} from '@/lib/github' +import { useGithubQuery } from '../hooks/useGithubQuery' +import { PanelShell } from './PanelShell' + +const KIND_OPTIONS: { value: SearchKind; label: string }[] = [ + { value: 'repos', label: 'repos' }, + { value: 'issues', label: 'issues' }, + { value: 'prs', label: 'prs' }, + { value: 'code', label: 'code' }, +] + +interface SearchPanelProps { + enabled: boolean +} + +/** + * Org-wide GitHub search. Repo scoping happens inside the query itself + * (`repo:owner/name ...`), so this panel ignores the page's repo field. + */ +export function SearchPanel({ enabled }: SearchPanelProps) { + const [kind, setKind] = useState('repos') + const [queryInput, setQueryInput] = useState('') + const [query, setQuery] = useState('') + + const fetcher = useCallback(() => searchGithub(kind, query), [kind, query]) + const active = enabled && query !== '' + const { data, loading, error } = useGithubQuery(active, fetcher) + + return ( +
+
+ { + if (e.key === 'Enter') setQuery(queryInput.trim()) + }} + placeholder='search query, e.g. "repo:iii-hq/workers is:open"' + preserveCase + aria-label="search query" + className="w-80 max-w-full" + /> + + value={kind} + onChange={setKind} + options={KIND_OPTIONS} + /> +
+ {query === '' ? ( +

+ type a query and press enter — github search syntax, qualifiers + included +

+ ) : ( + + {data ? : null} + + )} +
+ ) +} + +function isEmpty(results: GithubSearchItems | null): boolean { + return !results || results.items.length === 0 +} + +function SearchResults({ results }: { results: GithubSearchItems }) { + if (results.kind === 'repos') { + return ( +
    + {results.items.map((repo) => ( +
  • + + {repo.fullName} + + + {repo.description ?? ''} + + + {[ + repo.language ?? undefined, + repo.stargazersCount != null + ? `${repo.stargazersCount}★` + : undefined, + timeAgoIso(repo.updatedAt) || undefined, + ] + .filter(Boolean) + .join(' · ')} + +
  • + ))} +
+ ) + } + if (results.kind === 'code') { + return ( +
    + {results.items.map((hit) => ( +
  • + {hit.url ? ( + + {hit.path} + + ) : ( + + {hit.path} + + )} + + {hit.repository?.nameWithOwner ?? ''} + +
  • + ))} +
+ ) + } + return ( +
    + {results.items.map((item) => ( +
  • + + #{item.number} + + + {item.title} + + + {[ + item.repository?.nameWithOwner ?? undefined, + item.author?.login ?? undefined, + timeAgoIso(item.updatedAt) || undefined, + ] + .filter(Boolean) + .join(' · ')} + + + {item.state.toLowerCase()} + +
  • + ))} +
+ ) +} diff --git a/console/web/src/pages/Github/hooks/useGithubQuery.ts b/console/web/src/pages/Github/hooks/useGithubQuery.ts new file mode 100644 index 000000000..f6c34f10e --- /dev/null +++ b/console/web/src/pages/Github/hooks/useGithubQuery.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useState } from 'react' + +/** + * One in-flight github read: run `fetcher` while `enabled`, re-run when the + * fetcher identity changes (callers memo it on repo/filter deps) or on + * `refresh`. No polling and no live trigger bindings on purpose — the data + * changes on GitHub's side, not the engine's, so the page refreshes on + * demand instead of hammering the gh rate limit. + */ + +export interface GithubQuery { + data: T | null + loading: boolean + error: string | null + refresh: () => void +} + +export function useGithubQuery( + enabled: boolean, + fetcher: () => Promise, +): GithubQuery { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(enabled) + const [error, setError] = useState(null) + const [token, setToken] = useState(0) + + const refresh = useCallback(() => setToken((t) => t + 1), []) + + // biome-ignore lint/correctness/useExhaustiveDependencies: token is a re-run token (bumped by manual refresh), not read by the effect body + useEffect(() => { + if (!enabled) { + setData(null) + setLoading(false) + setError(null) + return + } + setLoading(true) + let cancelled = false + void (async () => { + try { + const next = await fetcher() + if (cancelled) return + setData(next) + setError(null) + } catch (err) { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [enabled, fetcher, token]) + + return { data, loading, error, refresh } +} diff --git a/console/web/src/pages/Github/index.tsx b/console/web/src/pages/Github/index.tsx new file mode 100644 index 000000000..08d380a28 --- /dev/null +++ b/console/web/src/pages/Github/index.tsx @@ -0,0 +1,148 @@ +import { AlertCircle, FolderGit2, RefreshCw } from 'lucide-react' +import { useState } from 'react' +import { Button } from '@/components/ui/Button' +import { EmptyState } from '@/components/ui/EmptyState' +import { Input } from '@/components/ui/Input' +import { ModeToggle } from '@/components/ui/ModeToggle' +import { StatusPanel } from '@/components/ui/StatusPanel' +import { isGithubAvailable, useGithubStatus } from '@/hooks/use-github-status' +import { useConversationsCtx } from '@/lib/conversations-context' +import { loadGithubRepo, saveGithubRepo } from '@/lib/github' +import { IssuesPanel } from './components/IssuesPanel' +import { PrsPanel } from './components/PrsPanel' +import { ReleasesPanel } from './components/ReleasesPanel' +import { RunsPanel } from './components/RunsPanel' +import { SearchPanel } from './components/SearchPanel' + +/** + * The github worker's read surface: pull requests (with CI check rollups), + * issues, Actions runs, and releases for a chosen repo, plus org-wide + * search. Data loads on demand (repo commit / panel switch / refresh) — + * no polling, GitHub-side state isn't engine-event-driven and gh rate + * limits are real. The nav entry is gated on worker presence; a direct + * #/github hit with the worker absent lands on the install notice below. + * Read-only on purpose: mutations stay in agent flows behind the + * approval gate. + */ + +type Panel = 'prs' | 'issues' | 'runs' | 'releases' | 'search' + +const PANEL_OPTIONS: { value: Panel; label: string }[] = [ + { value: 'prs', label: 'pull requests' }, + { value: 'issues', label: 'issues' }, + { value: 'runs', label: 'runs' }, + { value: 'releases', label: 'releases' }, + { value: 'search', label: 'search' }, +] + +export function Github() { + const { backend } = useConversationsCtx() + const status = useGithubStatus(backend.id === 'real') + const available = isGithubAvailable(status) + + const [panel, setPanel] = useState('prs') + const [repoInput, setRepoInput] = useState(loadGithubRepo) + const [repo, setRepo] = useState(loadGithubRepo) + const [bump, setBump] = useState(0) + + const commitRepo = () => { + const next = repoInput.trim() + setRepo(next) + saveGithubRepo(next) + } + + const needsRepo = panel !== 'search' + + return ( +
+
+
+

+ github +

+

+ {available + ? repo || 'no repository selected' + : 'worker not connected'} +

+
+ {available ? ( + + ) : null} +
+ + {available ? ( +
+ {needsRepo ? ( + { + if (e.key === 'Enter') commitRepo() + }} + placeholder="owner/name" + preserveCase + aria-label="repository" + className="w-64 max-w-full" + /> + ) : null} + + value={panel} + onChange={setPanel} + options={PANEL_OPTIONS} + /> +
+ ) : null} + +
+ {!available ? ( + status.loading ? ( +

+ checking for the github worker... +

+ ) : ( + } + headline="github worker not installed" + detail="this page needs the optional github worker (and the gh CLI on its host). run: iii worker add github" + /> + ) + ) : needsRepo && repo === '' ? ( + + ) : ( + // Remount on repo/panel/refresh so every fetch hook restarts clean. +
+ {panel === 'prs' ? ( + + ) : panel === 'issues' ? ( + + ) : panel === 'runs' ? ( + + ) : panel === 'releases' ? ( + + ) : ( + + )} +
+ )} +
+
+ ) +} From b0f1ff8af59eef5b168aa216fa8bcff194c0c4fc Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Mon, 20 Jul 2026 15:15:01 -0300 Subject: [PATCH 3/4] (MOT-4106) fix(github): required pr::create head, `--` guard for search queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: `github::pr::create` now requires `head` (the worker runs outside any checkout, so gh cannot infer a branch — schema-required beats a runtime gh error), and `gh search` argv puts flags before `--` with the query positional after it, so leading negative qualifiers ("-label:bug") stop being swallowed as flags (verified against gh 2.94). --- github/src/functions/pr.rs | 25 ++++++++++++++--- github/src/functions/search.rs | 28 +++++++++++++++++-- .../golden/schemas/github.pr.create.json | 10 +++---- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/github/src/functions/pr.rs b/github/src/functions/pr.rs index da6655587..cc71e6cc4 100644 --- a/github/src/functions/pr.rs +++ b/github/src/functions/pr.rs @@ -16,7 +16,7 @@ pub const VIEW_DESC: &str = "View one pull request with body, mergeability, revi pub const VIEW_JSON: &str = "number,title,state,url,author,headRefName,baseRefName,isDraft,labels,createdAt,updatedAt,body,mergeable,mergeStateStatus,reviewDecision,additions,deletions,changedFiles,assignees,milestone,mergedAt,closedAt"; pub const CREATE_ID: &str = "github::pr::create"; -pub const CREATE_DESC: &str = "Open a pull request: { repo: \"owner/name\", title, body, base?, head?, draft? } -> { output: }. head defaults to the repo's current branch on the gh side — pass it explicitly."; +pub const CREATE_DESC: &str = "Open a pull request: { repo: \"owner/name\", title, body, head, base?, draft? } -> { output: }. head is required: the worker runs outside any checkout, so gh cannot infer a current branch."; pub const EDIT_ID: &str = "github::pr::edit"; pub const EDIT_DESC: &str = "Edit a pull request's title/body/base/labels: { repo, number, title?, body?, base?, add_labels?, remove_labels? } -> { output }."; @@ -154,10 +154,11 @@ pub struct CreateRequest { pub title: String, /// Pull request body (markdown). pub body: String, + /// Head branch the PR ships. Required: the worker runs outside any + /// checkout, so gh cannot infer a current branch. + pub head: String, /// Base branch to merge into (gh default: the repo default branch). pub base: Option, - /// Head branch the PR ships (pass explicitly; the worker has no checkout). - pub head: Option, /// Open as draft. pub draft: Option, } @@ -172,9 +173,10 @@ pub fn create_args(r: &CreateRequest) -> Vec { r.title.as_str(), "--body", r.body.as_str(), + "--head", + r.head.as_str(), ]); push_opt(&mut a, "--base", r.base.as_deref()); - push_opt(&mut a, "--head", r.head.as_deref()); push_bool(&mut a, "--draft", r.draft); a } @@ -379,6 +381,21 @@ mod tests { ); } + #[test] + fn create_always_passes_head() { + let r: CreateRequest = serde_json::from_value(json!({ + "repo": "o/r", "title": "t", "body": "b", "head": "feat/x", "draft": true + })) + .unwrap(); + assert_eq!( + create_args(&r), + vec![ + "pr", "create", "-R", "o/r", "--title", "t", "--body", "b", "--head", "feat/x", + "--draft", + ] + ); + } + #[test] fn merge_maps_method_and_flags() { let r: MergeRequest = serde_json::from_value(json!({ diff --git a/github/src/functions/search.rs b/github/src/functions/search.rs index 5bb5a632d..9edf38c06 100644 --- a/github/src/functions/search.rs +++ b/github/src/functions/search.rs @@ -43,8 +43,12 @@ search_request!(PrsRequest); search_request!(CodeRequest); fn search_args(kind: &str, json: &str, query: &str, limit: Option) -> Vec { - let mut a = argv(["search", kind, query, "--json", json]); + let mut a = argv(["search", kind, "--json", json]); push_opt(&mut a, "--limit", limit); + // `--` ends option parsing: a query starting with a negative qualifier + // (e.g. "-label:bug") would otherwise be swallowed as a flag by gh. + a.push("--".to_string()); + a.push(query.to_string()); a } @@ -80,11 +84,31 @@ mod tests { vec![ "search", "code", - "repo:cli/cli read_bounded", "--json", CODE_JSON, "--limit", "5", + "--", + "repo:cli/cli read_bounded", + ] + ); + } + + #[test] + fn leading_negative_qualifier_stays_positional() { + let r: ReposRequest = serde_json::from_value(json!({ + "query": "-topic:python cli" + })) + .unwrap(); + assert_eq!( + repos_args(&r), + vec![ + "search", + "repos", + "--json", + REPOS_JSON, + "--", + "-topic:python cli" ] ); } diff --git a/github/tests/golden/schemas/github.pr.create.json b/github/tests/golden/schemas/github.pr.create.json index 9585f2ec8..77a4f4d40 100644 --- a/github/tests/golden/schemas/github.pr.create.json +++ b/github/tests/golden/schemas/github.pr.create.json @@ -1,5 +1,5 @@ { - "description": "Open a pull request: { repo: \"owner/name\", title, body, base?, head?, draft? } -> { output: }. head defaults to the repo's current branch on the gh side — pass it explicitly.", + "description": "Open a pull request: { repo: \"owner/name\", title, body, head, base?, draft? } -> { output: }. head is required: the worker runs outside any checkout, so gh cannot infer a current branch.", "function_id": "github::pr::create", "request_schema": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -23,11 +23,8 @@ ] }, "head": { - "description": "Head branch the PR ships (pass explicitly; the worker has no checkout).", - "type": [ - "string", - "null" - ] + "description": "Head branch the PR ships. Required: the worker runs outside any checkout, so gh cannot infer a current branch.", + "type": "string" }, "repo": { "description": "Target repository, \"owner/name\".", @@ -40,6 +37,7 @@ }, "required": [ "body", + "head", "repo", "title" ], From 66b24a625de7dec7c523175b8f725b8652e936bf Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Mon, 20 Jul 2026 15:31:47 -0300 Subject: [PATCH 4/4] (MOT-4106) chore(github): add registry discovery tags to iii.worker.yaml --- github/iii.worker.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/github/iii.worker.yaml b/github/iii.worker.yaml index 13f4be5a1..09a06b31b 100644 --- a/github/iii.worker.yaml +++ b/github/iii.worker.yaml @@ -4,4 +4,5 @@ language: rust deploy: binary manifest: Cargo.toml bin: github +tags: [github, gh, git, pull-requests, issues, actions, releases] description: GitHub CLI (gh) as an iii worker — typed github::pr/issue/repo/run/workflow/release/search functions, github::exec argv passthrough, and github::api for any GitHub REST endpoint.