diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1869798..200d0cd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ on: - slophammer.yml - fixtures/** - captures/** + - plugins/** - scripts/** - src/** - tests/** @@ -23,6 +24,7 @@ on: - slophammer.yml - fixtures/** - captures/** + - plugins/** - scripts/** - src/** - tests/** diff --git a/Cargo.lock b/Cargo.lock index 4330f39e..24179536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,7 +640,7 @@ dependencies = [ [[package]] name = "ghzinga" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 67cfdec7..85a85750 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ghzinga" -version = "0.3.0" +version = "0.4.0" edition = "2021" rust-version = "1.88" description = "Terminal UI for monitoring GitHub pull requests and issues." diff --git a/README.md b/README.md index 829226a8..95d266ad 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,29 @@ that session, including while another resource is still loading. If the target session is not running, `gzg open` updates the saved session so the resources appear on the next restore. +## Herdr Plugin + +`ghzinga` includes a Herdr plugin for opening GitHub issue and pull request links +from Herdr panes. + +Install it with: + +```sh +cargo install ghzinga --locked --force +herdr plugin install dutifuldev/ghzinga/plugins/herdr --yes +``` + +Then Ctrl-click a GitHub issue or pull request URL inside Herdr. The plugin opens +or reuses a right-side ghzinga pane next to the clicked pane. Links followed from +inside that ghzinga pane update the same pane. + +The Herdr plugin requires `ghzinga` 0.4.0 or newer on `PATH`. + +The plugin is intentionally thin: Herdr calls `gzg herdr-plugin open/viewer`, +and ghzinga owns the URL parsing, pane reuse, and session control in Rust. See +[`plugins/herdr/README.md`](plugins/herdr/README.md) for the install and +architecture details. + ## Refresh `ghzinga` refreshes automatically every 300 seconds by default. Use diff --git a/captures/ghzinga-issue-88499/large/manifest.json b/captures/ghzinga-issue-88499/large/manifest.json index 1103795e..4eeaab5c 100644 --- a/captures/ghzinga-issue-88499/large/manifest.json +++ b/captures/ghzinga-issue-88499/large/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, @@ -113,5 +113,5 @@ } ], "actual_tmux_size": "160x50", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-issue-88499/manifest.json b/captures/ghzinga-issue-88499/manifest.json index 7757f9fa..b4e9a2db 100644 --- a/captures/ghzinga-issue-88499/manifest.json +++ b/captures/ghzinga-issue-88499/manifest.json @@ -3,7 +3,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "offline_fixture": "fixtures/issue-88499.json", "offline_resource_fixtures": [], @@ -24,5 +24,5 @@ "rows": 50 } ], - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-issue-88499/medium/manifest.json b/captures/ghzinga-issue-88499/medium/manifest.json index cbb9c0a8..d6942f10 100644 --- a/captures/ghzinga-issue-88499/medium/manifest.json +++ b/captures/ghzinga-issue-88499/medium/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, @@ -113,5 +113,5 @@ } ], "actual_tmux_size": "120x36", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json index 4a5d69fc..df007a44 100644 --- a/captures/ghzinga-issue-88499/mouse-smoke/manifest.json +++ b/captures/ghzinga-issue-88499/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-issue-88499/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-issue-88499/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-issue-88499/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-issue-88499/mouse-smoke/capture-copy-url.sh ./target/debug/gzg https://github.com/openclaw/openclaw/issues/88499 --offline-fixture ./captures/ghzinga-issue-88499/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", @@ -120,5 +120,5 @@ "ansi": "70_mouse_quit_confirm.ansi" } ], - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-issue-88499/narrow/manifest.json b/captures/ghzinga-issue-88499/narrow/manifest.json index a27a0fa3..eab5b541 100644 --- a/captures/ghzinga-issue-88499/narrow/manifest.json +++ b/captures/ghzinga-issue-88499/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "openai-responses provider: 404 on previous_response_id when store=false (default)", "mode": "issue", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-issue-88499/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, @@ -113,5 +113,5 @@ } ], "actual_tmux_size": "80x24", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-pr-81834/large/manifest.json b/captures/ghzinga-pr-81834/large/manifest.json index c26d8bea..833601b6 100644 --- a/captures/ghzinga-pr-81834/large/manifest.json +++ b/captures/ghzinga-pr-81834/large/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 160, "requested_rows": 50, @@ -183,5 +183,5 @@ } ], "actual_tmux_size": "160x50", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-pr-81834/manifest.json b/captures/ghzinga-pr-81834/manifest.json index cda0becc..c0101750 100644 --- a/captures/ghzinga-pr-81834/manifest.json +++ b/captures/ghzinga-pr-81834/manifest.json @@ -3,7 +3,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "offline_fixture": null, "offline_resource_fixtures": [], @@ -24,5 +24,5 @@ "rows": 50 } ], - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-pr-81834/medium/manifest.json b/captures/ghzinga-pr-81834/medium/manifest.json index 93ea88ef..9414e0d5 100644 --- a/captures/ghzinga-pr-81834/medium/manifest.json +++ b/captures/ghzinga-pr-81834/medium/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 120, "requested_rows": 36, @@ -183,5 +183,5 @@ } ], "actual_tmux_size": "120x36", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json index 31eff387..55170071 100644 --- a/captures/ghzinga-pr-81834/mouse-smoke/manifest.json +++ b/captures/ghzinga-pr-81834/mouse-smoke/manifest.json @@ -5,7 +5,7 @@ "fixtures/issue-66943.json" ], "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml", "command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache BROWSER=./captures/ghzinga-pr-81834/mouse-smoke/capture-open-url.sh GZG_COPY_COMMAND=./captures/ghzinga-pr-81834/mouse-smoke/capture-copy-url.sh ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/navigation-fixture.json --offline-resource-fixture ./fixtures/issue-66943.json --no-restore --refresh-seconds 0", "actual_tmux_size": "120x36", @@ -240,5 +240,5 @@ ], "load_full_fixture": "captures/ghzinga-pr-81834/mouse-smoke/load-full-fixture.json", "load_full_command": "cd . && TERM=xterm-256color GZG_CONFIG_PATH=./captures/ghzinga-pr-81834/mouse-smoke/capture-empty-config.toml GZG_STATE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-state GZG_CACHE_HOME=./captures/ghzinga-pr-81834/mouse-smoke/.capture-cache ./target/debug/gzg 'openclaw/openclaw#81834' --offline-fixture ./captures/ghzinga-pr-81834/mouse-smoke/load-full-fixture.json --no-restore --refresh-seconds 0", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/captures/ghzinga-pr-81834/narrow/manifest.json b/captures/ghzinga-pr-81834/narrow/manifest.json index 139b183a..eaebe346 100644 --- a/captures/ghzinga-pr-81834/narrow/manifest.json +++ b/captures/ghzinga-pr-81834/narrow/manifest.json @@ -4,7 +4,7 @@ "title": "feat(senseaudio): add SenseAudio TTS provider", "mode": "pr", "binary": "target/debug/gzg", - "git_commit": "b673d451bc0e78e5f0f6e5e5ace46288288c47e3", + "git_commit": "50adb66554e3eb8b6a53e751631532d968f2588b", "config_path": "captures/ghzinga-pr-81834/capture-empty-config.toml", "requested_columns": 80, "requested_rows": 24, @@ -183,5 +183,5 @@ } ], "actual_tmux_size": "80x24", - "app_tree_hash": "4d92003b824c94729ccd42745e5bac8e9f73559f666d40d96b7c4149575de0b6" + "app_tree_hash": "085282e9659a0f7b7da99bfc219f12ca4ccc7ab59b379dea63ce601cda4239e3" } diff --git a/plugins/herdr/README.md b/plugins/herdr/README.md new file mode 100644 index 00000000..d2d0704e --- /dev/null +++ b/plugins/herdr/README.md @@ -0,0 +1,124 @@ +# Ghzinga for Herdr + +This Herdr plugin opens GitHub issue and pull request links in a ghzinga side +pane. + +## Install + +Install `ghzinga` 0.4.0 or newer first: + +```sh +cargo install ghzinga --locked --force +``` + +Then install the Herdr plugin: + +```sh +herdr plugin install dutifuldev/ghzinga/plugins/herdr --yes +``` + +For local development: + +```sh +herdr plugin link /path/to/ghzinga/plugins/herdr +``` + +## Usage + +Inside Herdr, Ctrl-click a GitHub issue or pull request URL: + +```text +https://github.com/dutifuldev/ghzinga/pull/29 +https://github.com/dutifuldev/ghzinga/issues/32 +``` + +The plugin opens a right-side ghzinga pane next to the pane that contained the +link. Later Ctrl-clicks from the same source pane in the same Herdr session +reuse that side pane by running `gzg open --session ...`. + +Ctrl-clicks from inside that ghzinga side pane also stay in the same ghzinga +pane, so following related issue/PR links does not create nested ghzinga panes. +When a new ghzinga side pane is created, it starts from the clicked resource +rather than restoring tabs from a previous closed plugin pane. + +Herdr currently routes plugin link handlers through Ctrl-click. Plain left-click +link handling would need Herdr itself to expose that behavior to plugins. + +## How it works + +The plugin manifest is only wiring. It registers a GitHub issue/PR link handler +and points Herdr at two ghzinga-owned entrypoints: + +```text +herdr-plugin.toml -> gzg herdr-plugin open +herdr-plugin.toml -> gzg herdr-plugin viewer +``` + +`gzg herdr-plugin open` runs as the Herdr link action. It reads Herdr's clicked +URL and source pane environment, normalizes the GitHub issue or PR URL with +ghzinga's Rust parser, and opens a right-side Herdr plugin pane beside the source +pane. + +`gzg herdr-plugin viewer` runs inside that side pane. It starts the normal +ghzinga TUI for the selected issue or PR with `--new --session`, so the pane has +a fresh initial resource while still exposing a stable session for later +`gzg open --session ...` updates. + +The Rust entrypoint also keeps pane reuse state scoped by Herdr session/socket +and source pane. On later Ctrl-clicks from the same source pane, it focuses the +existing ghzinga pane if it is still alive and still belongs to this plugin, then +sends `gzg open --session ... ` into that ghzinga session. If the stored +pane is gone or belongs to something else, it opens a fresh side pane. + +When the source pane is already a ghzinga plugin viewer, the entrypoint uses the +viewer-pane-to-session state written at pane creation time and opens the link in +that same ghzinga session. If that reverse state is missing, for example for a +pane opened by an older plugin build, it falls back to ghzinga's Herdr pane +context resolution and still updates the current viewer. + +## Requirements + +- Herdr 0.7.0 or newer. +- `ghzinga` 0.4.0 or newer, with `gzg` installed on `PATH`. The Herdr entrypoints call + `gzg herdr-plugin open` and `gzg herdr-plugin viewer`. +- GitHub credentials through `gh auth token`, `GH_TOKEN`, or `GITHUB_TOKEN` for + private repositories. + +Set `GHZINGA_BIN` before launching Herdr if you need to use a non-default +ghzinga binary path for the viewer process. Normal installs do not need this. + +## Troubleshooting + +Check that Herdr can find a new enough ghzinga binary: + +```sh +gzg --version +``` + +Check that Herdr has the plugin installed and enabled: + +```sh +herdr plugin list --json +``` + +Inspect plugin command failures: + +```sh +herdr plugin log list --plugin dutifuldev.ghzinga +``` + +If Herdr still runs an older `gzg`, reinstall ghzinga and restart Herdr so its +plugin environment sees the updated `PATH`. + +## Verification + +The production path has three layers of coverage: + +- Rust unit tests for URL normalization, pane identity validation, session + scoping, self-pane reuse, and the `gzg herdr-plugin open` entrypoint. +- Fake Herdr shell tests for open, reuse, self-pane reuse, stale-pane, and viewer + launch behavior. +- `scripts/herdr-plugin-live-smoke.sh`, which starts a disposable Herdr session, + links this plugin, opens a real right-side pane, verifies ghzinga rendered + fixture content there, and verifies links from the ghzinga pane reuse that + pane. diff --git a/plugins/herdr/herdr-plugin.toml b/plugins/herdr/herdr-plugin.toml new file mode 100644 index 00000000..c1004444 --- /dev/null +++ b/plugins/herdr/herdr-plugin.toml @@ -0,0 +1,24 @@ +id = "dutifuldev.ghzinga" +name = "Ghzinga for Herdr" +version = "0.1.0" +min_herdr_version = "0.7.0" +description = "Open GitHub issue and pull request links in a ghzinga side pane from Herdr." +platforms = ["linux", "macos"] + +[[actions]] +id = "open" +title = "Open in ghzinga" +contexts = ["pane"] +command = ["gzg", "herdr-plugin", "open"] + +[[panes]] +id = "viewer" +title = "ghzinga" +placement = "split" +command = ["gzg", "herdr-plugin", "viewer"] + +[[link_handlers]] +id = "github-issue-pr" +title = "Open GitHub issue or PR in ghzinga" +pattern = "^https://github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/(issues|pull)/[0-9]+([/?#].*)?$" +action = "open" diff --git a/plugins/herdr/test/fake-gzg.sh b/plugins/herdr/test/fake-gzg.sh new file mode 100755 index 00000000..2a762a10 --- /dev/null +++ b/plugins/herdr/test/fake-gzg.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +set -eu + +log=${GZG_FAKE_LOG:?GZG_FAKE_LOG is required} +printf '%s\n' "$*" >>"$log" +printf 'fake gzg: %s\n' "$*" diff --git a/plugins/herdr/test/fake-herdr.sh b/plugins/herdr/test/fake-herdr.sh new file mode 100755 index 00000000..21c64b59 --- /dev/null +++ b/plugins/herdr/test/fake-herdr.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env sh +set -eu + +log=${HERDR_FAKE_LOG:?HERDR_FAKE_LOG is required} +printf '%s\n' "$*" >>"$log" + +if [ "$#" -ge 3 ] && [ "$1" = "pane" ] && [ "$2" = "get" ]; then + if [ "${HERDR_FAKE_EXISTING_PANE:-}" = "$3" ]; then + printf '{"id":"fake","result":{"type":"pane_info","pane":{"pane_id":"%s"}}}\n' "$3" + exit 0 + fi + printf 'pane not found\n' >&2 + exit 1 +fi + +if [ "$#" -ge 4 ] && [ "$1" = "plugin" ] && [ "$2" = "pane" ] && [ "$3" = "focus" ]; then + if [ "${HERDR_FAKE_PLUGIN_PANE:-}" = "$4" ]; then + printf '{"id":"fake","result":{"type":"plugin_pane_focused","plugin_pane":{"plugin_id":"%s","entrypoint":"%s","pane":{"pane_id":"%s"}}}}\n' "${HERDR_FAKE_PLUGIN_ID:-dutifuldev.ghzinga}" "${HERDR_FAKE_PLUGIN_ENTRYPOINT:-viewer}" "$4" + exit 0 + fi + printf 'plugin pane not found\n' >&2 + exit 1 +fi + +if [ "$#" -ge 4 ] && [ "$1" = "plugin" ] && [ "$2" = "pane" ] && [ "$3" = "open" ]; then + pane=${HERDR_FAKE_OPENED_PANE:-w1:p9} + printf '{"id":"fake","result":{"type":"plugin_pane_opened","plugin_pane":{"plugin_id":"dutifuldev.ghzinga","entrypoint":"viewer","pane":{"pane_id":"%s"}}}}\n' "$pane" + exit 0 +fi + +printf '{"id":"fake","result":{"type":"ok"}}\n' diff --git a/plugins/herdr/test/test-open.sh b/plugins/herdr/test/test-open.sh new file mode 100755 index 00000000..95ba8de3 --- /dev/null +++ b/plugins/herdr/test/test-open.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +plugin_dir=$(CDPATH= cd -- "${script_dir}/.." && pwd) +repo_root=$(CDPATH= cd -- "${plugin_dir}/../.." && pwd) +gzg_bin=${GHZINGA_TEST_BIN:-${repo_root}/target/debug/gzg} +work_dir=$(mktemp -d) + +cleanup() { + rm -rf "$work_dir" +} +trap cleanup EXIT INT TERM + +herdr_socket="${work_dir}/herdr-a.sock" + +cargo build --manifest-path "${repo_root}/Cargo.toml" --bin gzg >/dev/null + +assert_contains() { + file=$1 + expected=$2 + if ! grep -Fq -- "$expected" "$file"; then + printf 'expected to find: %s\n' "$expected" >&2 + printf '%s\n' '--- file ---' >&2 + cat "$file" >&2 + exit 1 + fi +} + +assert_not_contains() { + file=$1 + unexpected=$2 + [ -f "$file" ] || return 0 + if grep -Fq -- "$unexpected" "$file"; then + printf 'expected not to find: %s\n' "$unexpected" >&2 + printf '%s\n' '--- file ---' >&2 + cat "$file" >&2 + exit 1 + fi +} + +first_state_file() { + find "$1" -name '*.pane' -type f -print | sort | sed -n '1p' +} + +session_from_herdr_log() { + sed -n 's/.*--env GHZINGA_SESSION=\([^ ]*\).*/\1/p' "$1" | tail -n 1 +} + +assert_different() { + left=$1 + right=$2 + if [ "$left" = "$right" ]; then + printf 'expected values to differ: %s\n' "$left" >&2 + exit 1 + fi +} + +run_open() { + url=$1 + state=$2 + herdr_log=$3 + gzg_log=$4 + shift 4 + env \ + HERDR_PLUGIN_CLICKED_URL="$url" \ + HERDR_PANE_ID="w1:p1" \ + HERDR_PLUGIN_ID="dutifuldev.ghzinga" \ + HERDR_PLUGIN_STATE_DIR="$state" \ + HERDR_SOCKET_PATH="$herdr_socket" \ + HERDR_BIN_PATH="${script_dir}/fake-herdr.sh" \ + HERDR_FAKE_LOG="$herdr_log" \ + GZG_FAKE_LOG="$gzg_log" \ + GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ + "$@" \ + "$gzg_bin" herdr-plugin open >/dev/null +} + +first_state="${work_dir}/first-state" +mkdir -p "$first_state" +first_herdr="${work_dir}/first-herdr.log" +first_gzg="${work_dir}/first-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/29" "$first_state" "$first_herdr" "$first_gzg" +herdr_session=$(session_from_herdr_log "$first_herdr") +assert_contains "$first_herdr" "plugin pane open --plugin dutifuldev.ghzinga --entrypoint viewer --placement split --target-pane w1:p1 --direction right" +assert_contains "$first_herdr" "--env GHZINGA_TARGET=https://github.com/dutifuldev/ghzinga/pull/29" +assert_contains "$first_herdr" "--env GHZINGA_SESSION=$herdr_session" +assert_contains "$first_herdr" "--env GHZINGA_BIN=${script_dir}/fake-gzg.sh" +assert_contains "$(first_state_file "$first_state")" "w1:p9" + +second_socket="${work_dir}/herdr-b.sock" +shared_state="${work_dir}/shared-state" +mkdir -p "$shared_state" +shared_first_herdr="${work_dir}/shared-first-herdr.log" +shared_first_gzg="${work_dir}/shared-first-gzg.log" +shared_second_herdr="${work_dir}/shared-second-herdr.log" +shared_second_gzg="${work_dir}/shared-second-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/30" "$shared_state" "$shared_first_herdr" "$shared_first_gzg" \ + HERDR_FAKE_OPENED_PANE=w1:p9 +run_open "https://github.com/dutifuldev/ghzinga/pull/31" "$shared_state" "$shared_second_herdr" "$shared_second_gzg" \ + HERDR_SOCKET_PATH="$second_socket" \ + HERDR_FAKE_OPENED_PANE=w1:p8 +shared_first_session=$(session_from_herdr_log "$shared_first_herdr") +shared_second_session=$(session_from_herdr_log "$shared_second_herdr") +assert_different "$shared_first_session" "$shared_second_session" +assert_contains "$shared_second_herdr" "--env GHZINGA_SESSION=$shared_second_session" +if [ "$(find "$shared_state" -name '*.pane' -type f | wc -l | tr -d ' ')" != "2" ]; then + printf 'expected two scoped state files\n' >&2 + find "$shared_state" -name '*.pane' -type f -print >&2 + exit 1 +fi +grep -R -Fq 'w1:p9' "$shared_state" +grep -R -Fq 'w1:p8' "$shared_state" + +issue_state="${work_dir}/issue-state" +mkdir -p "$issue_state" +issue_herdr="${work_dir}/issue-herdr.log" +issue_gzg="${work_dir}/issue-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/issues/32/?utm_source=test#note" "$issue_state" "$issue_herdr" "$issue_gzg" +assert_contains "$issue_herdr" "--env GHZINGA_TARGET=https://github.com/dutifuldev/ghzinga/issues/32" + +legacy_viewer_state="${work_dir}/legacy-viewer-state" +mkdir -p "$legacy_viewer_state" +legacy_viewer_herdr="${work_dir}/legacy-viewer-herdr.log" +legacy_viewer_gzg="${work_dir}/legacy-viewer-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/38" "$legacy_viewer_state" "$legacy_viewer_herdr" "$legacy_viewer_gzg" \ + HERDR_PANE_ID=w1:p9 \ + HERDR_FAKE_PLUGIN_PANE=w1:p9 +assert_contains "$legacy_viewer_herdr" "plugin pane focus w1:p9" +assert_not_contains "$legacy_viewer_herdr" "plugin pane open" +assert_contains "$legacy_viewer_gzg" "open https://github.com/dutifuldev/ghzinga/pull/38" +assert_not_contains "$legacy_viewer_gzg" "--session" + +reuse_state="${work_dir}/reuse-state" +mkdir -p "$reuse_state" +reuse_initial_herdr="${work_dir}/reuse-initial-herdr.log" +reuse_initial_gzg="${work_dir}/reuse-initial-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/32" "$reuse_state" "$reuse_initial_herdr" "$reuse_initial_gzg" \ + HERDR_FAKE_OPENED_PANE=w1:p9 +reuse_session=$(session_from_herdr_log "$reuse_initial_herdr") +self_herdr="${work_dir}/self-herdr.log" +self_gzg="${work_dir}/self-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/issues/37" "$reuse_state" "$self_herdr" "$self_gzg" \ + HERDR_PANE_ID=w1:p9 \ + HERDR_FAKE_PLUGIN_PANE=w1:p9 +assert_contains "$self_herdr" "plugin pane focus w1:p9" +assert_not_contains "$self_herdr" "plugin pane open" +assert_contains "$self_gzg" "open --session $reuse_session https://github.com/dutifuldev/ghzinga/issues/37" +reuse_herdr="${work_dir}/reuse-herdr.log" +reuse_gzg="${work_dir}/reuse-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/33" "$reuse_state" "$reuse_herdr" "$reuse_gzg" \ + HERDR_FAKE_EXISTING_PANE=w1:p9 \ + HERDR_FAKE_PLUGIN_PANE=w1:p9 +assert_contains "$reuse_herdr" "pane get w1:p9" +assert_contains "$reuse_herdr" "plugin pane focus w1:p9" +assert_not_contains "$reuse_herdr" "plugin pane open" +assert_contains "$reuse_gzg" "open --session $reuse_session https://github.com/dutifuldev/ghzinga/pull/33" + +fallback_bin="${work_dir}/fallback-bin" +mkdir -p "$fallback_bin" +ln -s "${script_dir}/fake-gzg.sh" "${fallback_bin}/ghzinga" +fallback_state="${work_dir}/fallback-state" +mkdir -p "$fallback_state" +fallback_initial_herdr="${work_dir}/fallback-initial-herdr.log" +fallback_initial_gzg="${work_dir}/fallback-initial-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/36" "$fallback_state" "$fallback_initial_herdr" "$fallback_initial_gzg" \ + HERDR_FAKE_OPENED_PANE=w1:p6 +fallback_session=$(session_from_herdr_log "$fallback_initial_herdr") +fallback_herdr="${work_dir}/fallback-herdr.log" +fallback_gzg="${work_dir}/fallback-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/36" "$fallback_state" "$fallback_herdr" "$fallback_gzg" \ + PATH="$fallback_bin:/usr/bin:/bin" \ + GHZINGA_BIN= \ + HERDR_FAKE_EXISTING_PANE=w1:p6 \ + HERDR_FAKE_PLUGIN_PANE=w1:p6 +assert_contains "$fallback_gzg" "open --session $fallback_session https://github.com/dutifuldev/ghzinga/pull/36" + +stale_state="${work_dir}/stale-state" +mkdir -p "$stale_state" +stale_initial_herdr="${work_dir}/stale-initial-herdr.log" +stale_initial_gzg="${work_dir}/stale-initial-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/issues/34" "$stale_state" "$stale_initial_herdr" "$stale_initial_gzg" \ + HERDR_FAKE_OPENED_PANE=w1:p8 +stale_herdr="${work_dir}/stale-herdr.log" +stale_gzg="${work_dir}/stale-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/issues/34" "$stale_state" "$stale_herdr" "$stale_gzg" \ + HERDR_FAKE_EXISTING_PANE=w1:p8 +assert_contains "$stale_herdr" "pane get w1:p8" +assert_contains "$stale_herdr" "plugin pane focus w1:p8" +assert_contains "$stale_herdr" "plugin pane open --plugin dutifuldev.ghzinga" +assert_not_contains "$stale_gzg" "open --session" + +other_plugin_state="${work_dir}/other-plugin-state" +mkdir -p "$other_plugin_state" +other_plugin_initial_herdr="${work_dir}/other-plugin-initial-herdr.log" +other_plugin_initial_gzg="${work_dir}/other-plugin-initial-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/35" "$other_plugin_state" "$other_plugin_initial_herdr" "$other_plugin_initial_gzg" \ + HERDR_FAKE_OPENED_PANE=w1:p7 +other_plugin_herdr="${work_dir}/other-plugin-herdr.log" +other_plugin_gzg="${work_dir}/other-plugin-gzg.log" +run_open "https://github.com/dutifuldev/ghzinga/pull/35" "$other_plugin_state" "$other_plugin_herdr" "$other_plugin_gzg" \ + HERDR_FAKE_EXISTING_PANE=w1:p7 \ + HERDR_FAKE_PLUGIN_PANE=w1:p7 \ + HERDR_FAKE_PLUGIN_ID=example.other +assert_contains "$other_plugin_herdr" "pane get w1:p7" +assert_contains "$other_plugin_herdr" "plugin pane focus w1:p7" +assert_contains "$other_plugin_herdr" "plugin pane open --plugin dutifuldev.ghzinga" +assert_not_contains "$other_plugin_gzg" "open --session" + +invalid_err="${work_dir}/invalid.err" +if env \ + HERDR_PLUGIN_CLICKED_URL="https://github.com/dutifuldev/ghzinga/tree/main" \ + HERDR_PANE_ID="w1:p1" \ + HERDR_PLUGIN_STATE_DIR="${work_dir}/invalid-state" \ + HERDR_BIN_PATH="${script_dir}/fake-herdr.sh" \ + HERDR_FAKE_LOG="${work_dir}/invalid-herdr.log" \ + GZG_FAKE_LOG="${work_dir}/invalid-gzg.log" \ + "$gzg_bin" herdr-plugin open 2>"$invalid_err"; then + printf 'expected invalid URL to fail\n' >&2 + exit 1 +fi +assert_contains "$invalid_err" "unsupported GitHub issue/PR URL" + +missing_err="${work_dir}/missing.err" +if env \ + HERDR_PANE_ID="w1:p1" \ + HERDR_PLUGIN_STATE_DIR="${work_dir}/missing-state" \ + HERDR_BIN_PATH="${script_dir}/fake-herdr.sh" \ + HERDR_FAKE_LOG="${work_dir}/missing-herdr.log" \ + GZG_FAKE_LOG="${work_dir}/missing-gzg.log" \ + "$gzg_bin" herdr-plugin open 2>"$missing_err"; then + printf 'expected missing URL to fail\n' >&2 + exit 1 +fi +assert_contains "$missing_err" "HERDR_PLUGIN_CLICKED_URL is not set" + +printf 'OK: herdr plugin open tests passed.\n' diff --git a/plugins/herdr/test/test-viewer.sh b/plugins/herdr/test/test-viewer.sh new file mode 100755 index 00000000..c911e4e8 --- /dev/null +++ b/plugins/herdr/test/test-viewer.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +plugin_dir=$(CDPATH= cd -- "${script_dir}/.." && pwd) +repo_root=$(CDPATH= cd -- "${plugin_dir}/../.." && pwd) +gzg_bin=${GHZINGA_TEST_BIN:-${repo_root}/target/debug/gzg} +work_dir=$(mktemp -d) + +cleanup() { + rm -rf "$work_dir" +} +trap cleanup EXIT INT TERM + +cargo build --manifest-path "${repo_root}/Cargo.toml" --bin gzg >/dev/null + +assert_contains() { + file=$1 + expected=$2 + if ! grep -Fq -- "$expected" "$file"; then + printf 'expected to find: %s\n' "$expected" >&2 + printf '%s\n' '--- file ---' >&2 + cat "$file" >&2 + exit 1 + fi +} + +gzg_log="${work_dir}/gzg.log" +env \ + GHZINGA_TARGET="https://github.com/dutifuldev/ghzinga/pull/29" \ + GHZINGA_SESSION="herdr-ghzinga-w1_p1" \ + GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ + GZG_FAKE_LOG="$gzg_log" \ + "$gzg_bin" herdr-plugin viewer >/dev/null +assert_contains "$gzg_log" "--new --session herdr-ghzinga-w1_p1 https://github.com/dutifuldev/ghzinga/pull/29" + +missing_err="${work_dir}/missing.err" +if env \ + GHZINGA_BIN="${script_dir}/fake-gzg.sh" \ + GZG_FAKE_LOG="${work_dir}/missing-gzg.log" \ + "$gzg_bin" herdr-plugin viewer 2>"$missing_err"; then + printf 'expected missing target to fail\n' >&2 + exit 1 +fi +assert_contains "$missing_err" "GHZINGA_TARGET is not set" + +printf 'OK: herdr plugin viewer tests passed.\n' diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index ca0891dc..46e48a3a 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -17,6 +17,13 @@ slophammer-rs check . --format json scripts/verify-install.sh sh -n scripts/live-smoke.sh GZG_LIVE_SELF_TEST=1 scripts/live-smoke.sh +sh -n scripts/herdr-plugin-live-smoke.sh +HERDR_PLUGIN_LIVE_SELF_TEST=1 scripts/herdr-plugin-live-smoke.sh +for script in plugins/herdr/test/*.sh; do + sh -n "$script" +done +plugins/herdr/test/test-open.sh +plugins/herdr/test/test-viewer.sh npx -y @simpledoc/simpledoc check scripts/verify-no-png-captures.sh diff --git a/scripts/herdr-plugin-live-smoke.sh b/scripts/herdr-plugin-live-smoke.sh new file mode 100755 index 00000000..be2920e3 --- /dev/null +++ b/scripts/herdr-plugin-live-smoke.sh @@ -0,0 +1,325 @@ +#!/usr/bin/env sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +session=${HERDR_PLUGIN_LIVE_SESSION:-gzgplug} +target_url=${HERDR_PLUGIN_LIVE_URL:-https://github.com/openclaw/openclaw/pull/81834} +internal_url=${HERDR_PLUGIN_LIVE_INTERNAL_URL:-https://github.com/openclaw/openclaw/issues/88499} + +if [ "${HERDR_PLUGIN_LIVE_SELF_TEST:-0}" = "1" ]; then + printf 'OK: herdr plugin live smoke self-test passed.\n' + exit 0 +fi + +command -v herdr >/dev/null 2>&1 || { + printf 'herdr is required for the live Herdr smoke test.\n' >&2 + exit 1 +} +command -v python3 >/dev/null 2>&1 || { + printf 'python3 is required for the live Herdr smoke test.\n' >&2 + exit 1 +} + +cargo build --manifest-path "${repo_root}/Cargo.toml" --bin gzg >/dev/null + +HERDR_PLUGIN_LIVE_REPO_ROOT=$repo_root \ +HERDR_PLUGIN_LIVE_SESSION_NAME=$session \ +HERDR_PLUGIN_LIVE_TARGET_URL=$target_url \ +HERDR_PLUGIN_LIVE_INTERNAL_URL=$internal_url \ +python3 - <<'PY' +import json +import os +import pty +import select +import shutil +import signal +import subprocess +import tempfile +import time +from pathlib import Path + +import errno +import fcntl +import struct +import termios + + +REPO = Path(os.environ["HERDR_PLUGIN_LIVE_REPO_ROOT"]) +SESSION = os.environ["HERDR_PLUGIN_LIVE_SESSION_NAME"] +TARGET_URL = os.environ["HERDR_PLUGIN_LIVE_TARGET_URL"] +INTERNAL_URL = os.environ["HERDR_PLUGIN_LIVE_INTERNAL_URL"] +ROWS = 40 +COLS = 140 +ISOLATED_ENV = {} + + +def clean_env(extra=None): + env = os.environ.copy() + for key in ( + "HERDR_ENV", + "HERDR_SOCKET_PATH", + "HERDR_PANE_ID", + "HERDR_TAB_ID", + "HERDR_WORKSPACE_ID", + "HERDR_SESSION", + "HERDR_CONFIG_PATH", + ): + env.pop(key, None) + env.setdefault("TERM", "xterm-256color") + env["PATH"] = f"{REPO / 'target' / 'debug'}:{env.get('PATH', '')}" + env.update(ISOLATED_ENV) + if extra: + env.update(extra) + return env + + +def run_herdr(args, *, check=True, timeout=20): + result = subprocess.run( + ["herdr", "--session", SESSION, *args], + cwd=REPO, + env=clean_env(), + text=True, + capture_output=True, + timeout=timeout, + ) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"herdr {' '.join(args)} failed: {detail}") + return result + + +def herdr_socket_path(): + status = run_herdr(["status", "server"]).stdout + for line in status.splitlines(): + if line.startswith("socket: "): + return line.removeprefix("socket: ").strip() + raise RuntimeError(f"could not find Herdr socket path in status output: {status}") + + +def stop_session(): + subprocess.run( + ["herdr", "session", "stop", SESSION, "--json"], + cwd=REPO, + env=clean_env(), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + subprocess.run( + ["herdr", "session", "delete", SESSION, "--json"], + cwd=REPO, + env=clean_env(), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + + +def drain_pty(fd): + while True: + ready, _, _ = select.select([fd], [], [], 0) + if not ready: + return + try: + data = os.read(fd, 8192) + except OSError as exc: + if exc.errno in (errno.EAGAIN, errno.EIO): + return + raise + if not data: + return + + +def parse_json(stdout): + return json.loads(stdout)["result"] + + +def wait_for_panes(fd, *, count=1, timeout=15): + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + drain_pty(fd) + result = run_herdr(["pane", "list"], check=False) + last = result.stderr or result.stdout + if result.returncode == 0: + panes = parse_json(result.stdout)["panes"] + if len(panes) >= count: + return panes + time.sleep(0.25) + raise RuntimeError(f"expected at least {count} Herdr panes. Last output:\n{last}") + + +def wait_for_visible(pane_id, needles, *, timeout=45): + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + result = run_herdr( + ["pane", "read", pane_id, "--source", "visible", "--lines", "80"], + check=False, + ) + if result.returncode == 0: + last = result.stdout + if all(needle in last for needle in needles): + return last + time.sleep(0.5) + raise RuntimeError( + f"pane {pane_id} did not render {needles!r}. Last visible output:\n{last}" + ) + + +def write_executable(path, contents): + path.write_text(contents) + path.chmod(0o755) + + +def main(): + global ISOLATED_ENV + tmp_parent = Path(os.environ.get("HERDR_PLUGIN_LIVE_TMPDIR", "/tmp")) + tmp = Path(tempfile.mkdtemp(prefix="gzg-hp-", dir=tmp_parent)) + child_pid = None + try: + xdg_config = tmp / "xdg-config" + xdg_state = tmp / "xdg-state" + xdg_cache = tmp / "xdg-cache" + xdg_config.mkdir() + xdg_state.mkdir() + xdg_cache.mkdir() + ISOLATED_ENV = { + "XDG_CONFIG_HOME": str(xdg_config), + "XDG_STATE_HOME": str(xdg_state), + "XDG_CACHE_HOME": str(xdg_cache), + } + + stop_session() + + herdr_wrapper = tmp / "herdr-session.sh" + gzg_wrapper = tmp / "gzg-fixture.sh" + state_dir = tmp / "state" + state_dir.mkdir() + + write_executable( + herdr_wrapper, + "#!/bin/sh\n" + "exec herdr --session \"$HERDR_PLUGIN_LIVE_SESSION_NAME\" \"$@\"\n", + ) + write_executable( + gzg_wrapper, + "#!/bin/sh\n" + "if [ \"${1:-}\" = open ]; then\n" + f" exec {str(REPO / 'target' / 'debug' / 'gzg')!r} \"$@\"\n" + "fi\n" + f"exec {str(REPO / 'target' / 'debug' / 'gzg')!r} \"$@\" " + f"--offline-fixture {str(REPO / 'fixtures' / 'pr-81834.json')!r} " + f"--offline-resource-fixture {str(REPO / 'fixtures' / 'issue-88499.json')!r} " + "--refresh-seconds 0\n", + ) + + child_pid, fd = pty.fork() + if child_pid == 0: + os.chdir(REPO) + env = clean_env({"TERM": "xterm-256color"}) + os.execvpe("herdr", ["herdr", "--session", SESSION], env) + + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0)) + fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK) + + panes = wait_for_panes(fd, count=1, timeout=20) + source_pane = panes[0]["pane_id"] + + run_herdr(["plugin", "unlink", "dutifuldev.ghzinga"], check=False) + link = parse_json(run_herdr(["plugin", "link", str(REPO / "plugins" / "herdr")]).stdout) + handlers = link["plugin"].get("link_handlers", []) + if not any(handler.get("id") == "github-issue-pr" for handler in handlers): + raise RuntimeError(f"linked plugin did not expose github-issue-pr handler: {handlers}") + + action_env = clean_env( + { + "HERDR_PLUGIN_LIVE_SESSION_NAME": SESSION, + "HERDR_PLUGIN_CLICKED_URL": TARGET_URL, + "HERDR_PANE_ID": source_pane, + "HERDR_SOCKET_PATH": herdr_socket_path(), + "HERDR_PLUGIN_ID": "dutifuldev.ghzinga", + "HERDR_PLUGIN_STATE_DIR": str(state_dir), + "HERDR_BIN_PATH": str(herdr_wrapper), + "GHZINGA_BIN": str(gzg_wrapper), + } + ) + opened = subprocess.run( + [str(REPO / "target" / "debug" / "gzg"), "herdr-plugin", "open"], + cwd=REPO, + env=action_env, + text=True, + capture_output=True, + timeout=30, + ) + if opened.returncode != 0: + detail = opened.stderr.strip() or opened.stdout.strip() + raise RuntimeError(f"plugin open entrypoint failed: {detail}") + + panes = wait_for_panes(fd, count=2, timeout=20) + neighbor = run_herdr( + ["pane", "neighbor", "--direction", "right", "--pane", source_pane], + check=True, + ) + neighbor_result = parse_json(neighbor.stdout)["neighbor"] + neighbor_pane = neighbor_result["neighbor_pane_id"] + if neighbor_pane == source_pane: + raise RuntimeError("right neighbor resolved to the source pane") + + visible = wait_for_visible(neighbor_pane, ["Overview", "Activity", "Files"]) + if "openclaw" not in visible.lower(): + raise RuntimeError(f"ghzinga pane did not show the expected fixture:\n{visible}") + + pane_count = len(panes) + self_env = clean_env( + { + "HERDR_PLUGIN_LIVE_SESSION_NAME": SESSION, + "HERDR_PLUGIN_CLICKED_URL": INTERNAL_URL, + "HERDR_PANE_ID": neighbor_pane, + "HERDR_SOCKET_PATH": herdr_socket_path(), + "HERDR_PLUGIN_ID": "dutifuldev.ghzinga", + "HERDR_PLUGIN_STATE_DIR": str(state_dir), + "HERDR_BIN_PATH": str(herdr_wrapper), + "GHZINGA_BIN": str(gzg_wrapper), + } + ) + self_opened = subprocess.run( + [str(REPO / "target" / "debug" / "gzg"), "herdr-plugin", "open"], + cwd=REPO, + env=self_env, + text=True, + capture_output=True, + timeout=30, + ) + if self_opened.returncode != 0: + detail = self_opened.stderr.strip() or self_opened.stdout.strip() + raise RuntimeError(f"plugin self-open entrypoint failed: {detail}") + panes_after_self_open = wait_for_panes(fd, count=pane_count, timeout=10) + if len(panes_after_self_open) != pane_count: + raise RuntimeError( + "ghzinga viewer link opened a nested plugin pane; " + f"before={pane_count}, after={len(panes_after_self_open)}" + ) + wait_for_visible( + neighbor_pane, + ["openai-responses provider", "Bug Description"], + timeout=15, + ) + + print(f"OK: linked Herdr plugin {link['plugin']['plugin_id']}") + print(f"OK: source pane {source_pane} opened right-side ghzinga pane {neighbor_pane}") + print(f"OK: ghzinga pane {neighbor_pane} reused itself for {INTERNAL_URL}") + print(f"OK: ghzinga rendered fixture content for {TARGET_URL}") + finally: + if child_pid is not None: + try: + os.kill(child_pid, signal.SIGTERM) + except ProcessLookupError: + pass + stop_session() + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + main() +PY diff --git a/src/herdr_plugin.rs b/src/herdr_plugin.rs new file mode 100644 index 00000000..997c0477 --- /dev/null +++ b/src/herdr_plugin.rs @@ -0,0 +1,710 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command as StdCommand, +}; + +use anyhow::Context; +use serde_json::Value; + +use crate::domain::ResourceId; + +const DEFAULT_PLUGIN_ID: &str = "dutifuldev.ghzinga"; +const VIEWER_ENTRYPOINT: &str = "viewer"; + +pub fn run_command(args: &[String]) -> anyhow::Result { + let Some(command) = args.first().map(String::as_str) else { + print_usage_to_stderr(); + return Ok(2); + }; + + match command { + "open" => run_open_entrypoint(), + "viewer" => run_viewer_entrypoint(), + "help" | "--help" | "-h" => { + print_usage(); + Ok(0) + } + _ => { + print_usage_to_stderr(); + Ok(2) + } + } +} + +fn run_open_entrypoint() -> anyhow::Result { + let clicked_url = required_env("HERDR_PLUGIN_CLICKED_URL")?; + let source_pane = required_env("HERDR_PANE_ID")?; + let target = normalize_github_issue_or_pr_url(&clicked_url)?; + let herdr = env::var_os("HERDR_BIN_PATH") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("herdr")); + let plugin_id = env::var("HERDR_PLUGIN_ID").unwrap_or_else(|_| DEFAULT_PLUGIN_ID.into()); + let state_dir = plugin_state_dir(); + fs::create_dir_all(&state_dir) + .with_context(|| format!("failed to create {}", state_dir.display()))?; + + if let Some(session) = read_stored_line(&viewer_session_file(&state_dir, &source_pane))? { + if focus_is_our_viewer(&herdr, &source_pane, &plugin_id)? { + return run_ghzinga_open(&session, &target); + } + } else if focus_is_our_viewer(&herdr, &source_pane, &plugin_id)? { + return run_ghzinga_open_for_current_context(&target); + } + + let source_key = herdr_source_key(&source_pane); + let session = format!("herdr-ghzinga-{source_key}"); + let state_file = state_dir.join(format!("{source_key}.pane")); + + if let Some(stored_pane) = read_stored_line(&state_file)? { + if herdr_command_succeeds(&herdr, ["pane", "get", stored_pane.as_str()]) + && focus_is_our_viewer(&herdr, &stored_pane, &plugin_id)? + { + return run_ghzinga_open(&session, &target); + } + } + + let viewer_bin = ghzinga_viewer_bin(); + let response = run_herdr_capture( + &herdr, + vec![ + "plugin".into(), + "pane".into(), + "open".into(), + "--plugin".into(), + plugin_id, + "--entrypoint".into(), + VIEWER_ENTRYPOINT.into(), + "--placement".into(), + "split".into(), + "--target-pane".into(), + source_pane, + "--direction".into(), + "right".into(), + "--env".into(), + format!("GHZINGA_TARGET={target}"), + "--env".into(), + format!("GHZINGA_SESSION={session}"), + "--env".into(), + format!("GHZINGA_BIN={}", viewer_bin.display()), + "--focus".into(), + ], + )?; + print!("{response}"); + if !response.ends_with('\n') { + println!(); + } + + if let Some(opened_pane) = plugin_pane_id_from_response(&response) { + fs::write(&state_file, format!("{opened_pane}\n")) + .with_context(|| format!("failed to write {}", state_file.display()))?; + let session_file = viewer_session_file(&state_dir, &opened_pane); + fs::write(&session_file, format!("{session}\n")) + .with_context(|| format!("failed to write {}", session_file.display()))?; + } else { + eprintln!("ghzinga-herdr: warning: could not find opened pane id in Herdr response"); + } + + Ok(0) +} + +fn run_viewer_entrypoint() -> anyhow::Result { + let target = required_env("GHZINGA_TARGET")?; + let session = env::var("GHZINGA_SESSION").unwrap_or_else(|_| "herdr-ghzinga".into()); + let bin = ghzinga_viewer_bin(); + let mut command = StdCommand::new(&bin); + command + .arg("--new") + .arg("--session") + .arg(&session) + .arg(&target); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let error = command.exec(); + Err(error).with_context(|| format!("failed to exec {}", bin.display())) + } + + #[cfg(not(unix))] + { + let status = command + .status() + .with_context(|| format!("failed to run {}", bin.display()))?; + Ok(status.code().unwrap_or(1)) + } +} + +fn run_ghzinga_open(session: &str, target: &str) -> anyhow::Result { + let bin = ghzinga_control_bin(); + let status = StdCommand::new(&bin) + .arg("open") + .arg("--session") + .arg(session) + .arg(target) + .status() + .with_context(|| format!("failed to run {}", bin.display()))?; + Ok(status.code().unwrap_or(1)) +} + +fn run_ghzinga_open_for_current_context(target: &str) -> anyhow::Result { + let bin = ghzinga_control_bin(); + let status = StdCommand::new(&bin) + .arg("open") + .arg(target) + .status() + .with_context(|| format!("failed to run {}", bin.display()))?; + Ok(status.code().unwrap_or(1)) +} + +fn required_env(name: &str) -> anyhow::Result { + let value = env::var(name).with_context(|| format!("{name} is not set"))?; + if value.is_empty() { + anyhow::bail!("{name} is not set"); + } + Ok(value) +} + +fn normalize_github_issue_or_pr_url(input: &str) -> anyhow::Result { + let trimmed = input.trim(); + if !trimmed.starts_with("https://github.com/") { + anyhow::bail!("unsupported GitHub issue/PR URL: {input}"); + } + let id = ResourceId::parse(trimmed) + .map_err(|_| anyhow::anyhow!("unsupported GitHub issue/PR URL: {input}"))?; + let kind = id + .kind_hint + .ok_or_else(|| anyhow::anyhow!("unsupported GitHub issue/PR URL: {input}"))?; + Ok(id.web_url_for_kind(kind)) +} + +fn plugin_state_dir() -> PathBuf { + env::var_os("HERDR_PLUGIN_STATE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| env::temp_dir().join("ghzinga-herdr-plugin")) +} + +fn viewer_session_file(state_dir: &Path, pane_id: &str) -> PathBuf { + state_dir.join(format!("{}.session", herdr_source_key(pane_id))) +} + +fn herdr_source_key(source_pane: &str) -> String { + format!("{}_{}", herdr_scope_key(), state_key_for_pane(source_pane)) +} + +fn herdr_scope_key() -> String { + if let Ok(socket) = env::var("HERDR_SOCKET_PATH") { + if !socket.is_empty() { + return format!("socket_{}", stable_key_for_text(&socket)); + } + } + if let Ok(session) = env::var("HERDR_SESSION") { + if !session.is_empty() { + return format!("session_{}", stable_key_for_text(&session)); + } + } + "default".into() +} + +fn state_key_for_pane(pane_id: &str) -> String { + pane_id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect() +} + +fn stable_key_for_text(input: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in input.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +fn read_stored_line(path: &Path) -> anyhow::Result> { + match fs::read_to_string(path) { + Ok(raw) => Ok(raw + .lines() + .next() + .map(str::trim) + .filter(|pane| !pane.is_empty()) + .map(str::to_string)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())), + } +} + +fn focus_is_our_viewer(herdr: &Path, pane_id: &str, plugin_id: &str) -> anyhow::Result { + let output = StdCommand::new(herdr) + .arg("plugin") + .arg("pane") + .arg("focus") + .arg(pane_id) + .output() + .with_context(|| format!("failed to run {}", herdr.display()))?; + if !output.status.success() { + return Ok(false); + } + let response = String::from_utf8_lossy(&output.stdout); + Ok(plugin_focuses_viewer(&response, plugin_id)) +} + +fn herdr_command_succeeds<'a>(herdr: &Path, args: impl IntoIterator) -> bool { + StdCommand::new(herdr) + .args(args) + .status() + .is_ok_and(|status| status.success()) +} + +fn run_herdr_capture(herdr: &Path, args: Vec) -> anyhow::Result { + let output = StdCommand::new(herdr) + .args(&args) + .output() + .with_context(|| format!("failed to run {}", herdr.display()))?; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + if output.status.success() { + return Ok(stdout); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + stderr.trim().to_string() + }; + anyhow::bail!( + "herdr {} failed: {}", + args.join(" "), + if detail.is_empty() { + "no output" + } else { + &detail + } + ); +} + +fn plugin_focuses_viewer(response: &str, plugin_id: &str) -> bool { + let Ok(value) = serde_json::from_str::(response) else { + return false; + }; + value + .pointer("/result/plugin_pane/plugin_id") + .and_then(Value::as_str) + == Some(plugin_id) + && value + .pointer("/result/plugin_pane/entrypoint") + .and_then(Value::as_str) + == Some(VIEWER_ENTRYPOINT) +} + +fn plugin_pane_id_from_response(response: &str) -> Option { + let value = serde_json::from_str::(response).ok()?; + value + .pointer("/result/plugin_pane/pane/pane_id") + .or_else(|| value.pointer("/result/plugin_pane/pane_id")) + .and_then(Value::as_str) + .map(str::to_string) +} + +fn ghzinga_viewer_bin() -> PathBuf { + env_path("GHZINGA_BIN") + .or_else(|| env::current_exe().ok()) + .or_else(|| command_on_path("gzg")) + .or_else(|| command_on_path("ghzinga")) + .unwrap_or_else(|| PathBuf::from("gzg")) +} + +fn ghzinga_control_bin() -> PathBuf { + env_path("GHZINGA_BIN") + .or_else(|| command_on_path("gzg")) + .or_else(|| command_on_path("ghzinga")) + .or_else(|| env::current_exe().ok()) + .unwrap_or_else(|| PathBuf::from("gzg")) +} + +fn env_path(name: &str) -> Option { + env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn command_on_path(command: &str) -> Option { + let paths = env::var_os("PATH")?; + env::split_paths(&paths) + .map(|path| path.join(command)) + .find(|candidate| candidate.is_file()) +} + +fn print_usage() { + println!("usage: gzg herdr-plugin "); +} + +fn print_usage_to_stderr() { + eprintln!("usage: gzg herdr-plugin "); +} + +#[cfg(test)] +mod tests { + use std::{env, ffi::OsString, fs, path::PathBuf, sync::Mutex}; + + use super::{ + herdr_source_key, normalize_github_issue_or_pr_url, plugin_focuses_viewer, + plugin_pane_id_from_response, run_open_entrypoint, stable_key_for_text, state_key_for_pane, + }; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + const PLUGIN_ENV_KEYS: &[&str] = &[ + "GZG_FAKE_LOG", + "GHZINGA_BIN", + "HERDR_BIN_PATH", + "HERDR_FAKE_EXISTING_PANE", + "HERDR_FAKE_LOG", + "HERDR_FAKE_OPENED_PANE", + "HERDR_FAKE_PLUGIN_ENTRYPOINT", + "HERDR_FAKE_PLUGIN_ID", + "HERDR_FAKE_PLUGIN_PANE", + "HERDR_PANE_ID", + "HERDR_PLUGIN_CLICKED_URL", + "HERDR_PLUGIN_ID", + "HERDR_PLUGIN_STATE_DIR", + "HERDR_SESSION", + "HERDR_SOCKET_PATH", + ]; + + fn first_file_with_extension(dir: &std::path::Path, extension: &str) -> PathBuf { + let mut files = fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some(extension)) + .collect::>(); + files.sort(); + files.into_iter().next().unwrap() + } + + struct EnvRestore { + values: Vec<(&'static str, Option)>, + } + + impl EnvRestore { + fn clear() -> Self { + let values = PLUGIN_ENV_KEYS + .iter() + .map(|key| { + let value = env::var_os(key); + env::remove_var(key); + (*key, value) + }) + .collect(); + Self { values } + } + } + + impl Drop for EnvRestore { + fn drop(&mut self) { + for (key, value) in &self.values { + if let Some(value) = value { + env::set_var(key, value); + } else { + env::remove_var(key); + } + } + } + } + + #[test] + fn normalize_github_issue_or_pr_url_strips_suffixes_and_preserves_kind() { + assert_eq!( + normalize_github_issue_or_pr_url( + "https://github.com/dutifuldev/ghzinga/pull/29/files#diff" + ) + .unwrap(), + "https://github.com/dutifuldev/ghzinga/pull/29" + ); + assert_eq!( + normalize_github_issue_or_pr_url( + "https://github.com/dutifuldev/ghzinga/issues/32/?utm_source=test#note" + ) + .unwrap(), + "https://github.com/dutifuldev/ghzinga/issues/32" + ); + } + + #[test] + fn normalize_github_issue_or_pr_url_rejects_other_github_paths() { + let error = + normalize_github_issue_or_pr_url("https://github.com/dutifuldev/ghzinga/tree/main") + .unwrap_err(); + assert!(error + .to_string() + .contains("unsupported GitHub issue/PR URL")); + } + + #[test] + fn pane_and_scope_keys_are_stable_for_state_files() { + assert_eq!(state_key_for_pane("w1:p1"), "w1_p1"); + assert_eq!(state_key_for_pane("tab/pane:3"), "tab_pane_3"); + assert_eq!( + stable_key_for_text("/tmp/herdr-a.sock"), + stable_key_for_text("/tmp/herdr-a.sock") + ); + assert_ne!( + stable_key_for_text("/tmp/herdr-a.sock"), + stable_key_for_text("/tmp/herdr-b.sock") + ); + } + + #[test] + fn plugin_focus_validation_requires_matching_plugin_and_viewer() { + let response = r#"{"result":{"plugin_pane":{"plugin_id":"dutifuldev.ghzinga","entrypoint":"viewer","pane":{"pane_id":"w1:p9"}}}}"#; + assert!(plugin_focuses_viewer(response, "dutifuldev.ghzinga")); + assert!(!plugin_focuses_viewer(response, "example.other")); + + let wrong_entrypoint = r#"{"result":{"plugin_pane":{"plugin_id":"dutifuldev.ghzinga","entrypoint":"other","pane":{"pane_id":"w1:p9"}}}}"#; + assert!(!plugin_focuses_viewer( + wrong_entrypoint, + "dutifuldev.ghzinga" + )); + } + + #[test] + fn plugin_pane_id_is_read_from_herdr_response() { + let response = r#"{"result":{"plugin_pane":{"pane":{"pane_id":"w1:p9"}}}}"#; + assert_eq!( + plugin_pane_id_from_response(response).as_deref(), + Some("w1:p9") + ); + } + + #[test] + fn open_entrypoint_opens_new_plugin_pane_and_records_state() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvRestore::clear(); + let temp = tempfile::tempdir().unwrap(); + let state_dir = temp.path().join("state"); + let herdr_log = temp.path().join("herdr.log"); + let gzg_log = temp.path().join("gzg.log"); + let socket = temp.path().join("herdr.sock"); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/pull/29/files#diff", + ); + env::set_var("HERDR_PANE_ID", "w1:p1"); + env::set_var("HERDR_PLUGIN_STATE_DIR", &state_dir); + env::set_var("HERDR_SOCKET_PATH", &socket); + env::set_var( + "HERDR_BIN_PATH", + repo_file("plugins/herdr/test/fake-herdr.sh"), + ); + env::set_var("HERDR_FAKE_LOG", &herdr_log); + env::set_var("GHZINGA_BIN", repo_file("plugins/herdr/test/fake-gzg.sh")); + env::set_var("GZG_FAKE_LOG", &gzg_log); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + let herdr_log = fs::read_to_string(herdr_log).unwrap(); + assert!(herdr_log.contains( + "plugin pane open --plugin dutifuldev.ghzinga --entrypoint viewer --placement split" + )); + assert!(herdr_log + .contains("--env GHZINGA_TARGET=https://github.com/dutifuldev/ghzinga/pull/29")); + assert!(herdr_log.contains("--env GHZINGA_SESSION=herdr-ghzinga-socket_")); + assert!(herdr_log.contains("_w1_p1")); + + assert_eq!( + fs::read_to_string(first_file_with_extension(&state_dir, "pane")).unwrap(), + "w1:p9\n" + ); + assert!( + fs::read_to_string(first_file_with_extension(&state_dir, "session")) + .unwrap() + .starts_with("herdr-ghzinga-socket_") + ); + assert!(!gzg_log.exists()); + } + + #[test] + fn open_entrypoint_reuses_existing_matching_viewer_pane() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvRestore::clear(); + let temp = tempfile::tempdir().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let herdr_log = temp.path().join("herdr.log"); + let gzg_log = temp.path().join("gzg.log"); + let socket = temp.path().join("herdr.sock"); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/issues/32/?utm_source=test#note", + ); + env::set_var("HERDR_PANE_ID", "w1:p1"); + env::set_var("HERDR_PLUGIN_STATE_DIR", &state_dir); + env::set_var("HERDR_SOCKET_PATH", &socket); + env::set_var( + "HERDR_BIN_PATH", + repo_file("plugins/herdr/test/fake-herdr.sh"), + ); + env::set_var("HERDR_FAKE_LOG", &herdr_log); + env::set_var("HERDR_FAKE_EXISTING_PANE", "w1:p9"); + env::set_var("HERDR_FAKE_PLUGIN_PANE", "w1:p9"); + env::set_var("GHZINGA_BIN", repo_file("plugins/herdr/test/fake-gzg.sh")); + env::set_var("GZG_FAKE_LOG", &gzg_log); + + let source_key = herdr_source_key("w1:p1"); + fs::write(state_dir.join(format!("{source_key}.pane")), "w1:p9\n").unwrap(); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + let herdr_log = fs::read_to_string(herdr_log).unwrap(); + assert!(herdr_log.contains("pane get w1:p9")); + assert!(herdr_log.contains("plugin pane focus w1:p9")); + assert!(!herdr_log.contains("plugin pane open")); + + let gzg_log = fs::read_to_string(gzg_log).unwrap(); + assert!(gzg_log.contains(&format!( + "open --session herdr-ghzinga-{source_key} https://github.com/dutifuldev/ghzinga/issues/32" + ))); + } + + #[test] + fn open_entrypoint_reuses_current_viewer_when_link_originates_inside_ghzinga() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvRestore::clear(); + let temp = tempfile::tempdir().unwrap(); + let state_dir = temp.path().join("state"); + let initial_herdr_log = temp.path().join("initial-herdr.log"); + let initial_gzg_log = temp.path().join("initial-gzg.log"); + let self_herdr_log = temp.path().join("self-herdr.log"); + let self_gzg_log = temp.path().join("self-gzg.log"); + let socket = temp.path().join("herdr.sock"); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/pull/29", + ); + env::set_var("HERDR_PANE_ID", "w1:p1"); + env::set_var("HERDR_PLUGIN_STATE_DIR", &state_dir); + env::set_var("HERDR_SOCKET_PATH", &socket); + env::set_var( + "HERDR_BIN_PATH", + repo_file("plugins/herdr/test/fake-herdr.sh"), + ); + env::set_var("HERDR_FAKE_LOG", &initial_herdr_log); + env::set_var("HERDR_FAKE_OPENED_PANE", "w1:p9"); + env::set_var("GHZINGA_BIN", repo_file("plugins/herdr/test/fake-gzg.sh")); + env::set_var("GZG_FAKE_LOG", &initial_gzg_log); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/issues/32", + ); + env::set_var("HERDR_PANE_ID", "w1:p9"); + env::set_var("HERDR_FAKE_LOG", &self_herdr_log); + env::set_var("HERDR_FAKE_PLUGIN_PANE", "w1:p9"); + env::set_var("GZG_FAKE_LOG", &self_gzg_log); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + let source_key = herdr_source_key("w1:p1"); + let self_herdr_log = fs::read_to_string(self_herdr_log).unwrap(); + assert!(self_herdr_log.contains("plugin pane focus w1:p9")); + assert!(!self_herdr_log.contains("plugin pane open")); + + let self_gzg_log = fs::read_to_string(self_gzg_log).unwrap(); + assert!(self_gzg_log.contains(&format!( + "open --session herdr-ghzinga-{source_key} https://github.com/dutifuldev/ghzinga/issues/32" + ))); + } + + #[test] + fn open_entrypoint_uses_current_viewer_context_when_reverse_state_is_missing() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvRestore::clear(); + let temp = tempfile::tempdir().unwrap(); + let state_dir = temp.path().join("state"); + let herdr_log = temp.path().join("herdr.log"); + let gzg_log = temp.path().join("gzg.log"); + let socket = temp.path().join("herdr.sock"); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/pull/38", + ); + env::set_var("HERDR_PANE_ID", "w1:p9"); + env::set_var("HERDR_PLUGIN_STATE_DIR", &state_dir); + env::set_var("HERDR_SOCKET_PATH", &socket); + env::set_var( + "HERDR_BIN_PATH", + repo_file("plugins/herdr/test/fake-herdr.sh"), + ); + env::set_var("HERDR_FAKE_LOG", &herdr_log); + env::set_var("HERDR_FAKE_PLUGIN_PANE", "w1:p9"); + env::set_var("GHZINGA_BIN", repo_file("plugins/herdr/test/fake-gzg.sh")); + env::set_var("GZG_FAKE_LOG", &gzg_log); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + let herdr_log = fs::read_to_string(herdr_log).unwrap(); + assert!(herdr_log.contains("plugin pane focus w1:p9")); + assert!(!herdr_log.contains("plugin pane open")); + + let gzg_log = fs::read_to_string(gzg_log).unwrap(); + assert!(gzg_log.contains("open https://github.com/dutifuldev/ghzinga/pull/38")); + assert!(!gzg_log.contains("--session")); + } + + #[test] + fn open_entrypoint_reopens_when_stored_pane_is_not_our_viewer() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvRestore::clear(); + let temp = tempfile::tempdir().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let herdr_log = temp.path().join("herdr.log"); + let gzg_log = temp.path().join("gzg.log"); + let socket = temp.path().join("herdr.sock"); + + env::set_var( + "HERDR_PLUGIN_CLICKED_URL", + "https://github.com/dutifuldev/ghzinga/pull/35", + ); + env::set_var("HERDR_PANE_ID", "w1:p1"); + env::set_var("HERDR_PLUGIN_STATE_DIR", &state_dir); + env::set_var("HERDR_SOCKET_PATH", &socket); + env::set_var( + "HERDR_BIN_PATH", + repo_file("plugins/herdr/test/fake-herdr.sh"), + ); + env::set_var("HERDR_FAKE_LOG", &herdr_log); + env::set_var("HERDR_FAKE_EXISTING_PANE", "w1:p7"); + env::set_var("HERDR_FAKE_PLUGIN_PANE", "w1:p7"); + env::set_var("HERDR_FAKE_PLUGIN_ID", "example.other"); + env::set_var("GHZINGA_BIN", repo_file("plugins/herdr/test/fake-gzg.sh")); + env::set_var("GZG_FAKE_LOG", &gzg_log); + + let source_key = herdr_source_key("w1:p1"); + fs::write(state_dir.join(format!("{source_key}.pane")), "w1:p7\n").unwrap(); + + assert_eq!(run_open_entrypoint().unwrap(), 0); + + let herdr_log = fs::read_to_string(herdr_log).unwrap(); + assert!(herdr_log.contains("pane get w1:p7")); + assert!(herdr_log.contains("plugin pane focus w1:p7")); + assert!(herdr_log.contains("plugin pane open --plugin dutifuldev.ghzinga")); + assert!(!gzg_log.exists()); + } + + fn repo_file(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path) + } +} diff --git a/src/lib.rs b/src/lib.rs index b05be1c7..ac50fe7c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod control; pub mod domain; pub mod fetch; pub mod github; +mod herdr_plugin; pub mod input; pub mod render; pub mod runner; diff --git a/src/runner.rs b/src/runner.rs index 0bc8f1e3..062b9229 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -48,6 +48,7 @@ async fn maybe_run_session_command(args: &[String]) -> anyhow::Result run_session_subcommand(&args[2..]).map(Some), + "herdr-plugin" => crate::herdr_plugin::run_command(&args[2..]).map(Some), "open" => run_open_command(args, &args[2..]).await.map(Some), "set" => run_set_command(args, &args[2..]).await.map(Some), _ => Ok(None), diff --git a/tests/architecture.rs b/tests/architecture.rs index c4758888..1d7873b7 100644 --- a/tests/architecture.rs +++ b/tests/architecture.rs @@ -232,6 +232,24 @@ fn ci_workflow_delegates_to_full_local_gate() { assert!(workflow.contains("workflow_dispatch:")); assert!(workflow.contains("scripts/ci-local.sh")); + for expected_path in [ + ".github/workflows/ci.yml", + "Cargo.lock", + "Cargo.toml", + "slophammer.yml", + "fixtures/**", + "captures/**", + "plugins/**", + "scripts/**", + "src/**", + "tests/**", + ] { + assert!( + workflow.contains(expected_path), + "CI workflow path filters are missing `{expected_path}`" + ); + } + for expected_check in [ "cargo fmt --check", "cargo check",