From a8952122370a57da0b7c1a08b43a9dc21a092cec Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Sat, 10 Oct 2026 14:05:25 +0200 Subject: [PATCH 1/2] chore(build): bundle JSX CLI entry points with OpenTUI's Solid transform The build, dev, and install-local tasks compiled only src/index.ts and src/tui.ts, so a CLI entry point that renders JSX couldn't ship. Add .mise/bundle.ts, which the three tasks share. It also compiles src/tui.tsx through @opentui/solid's Bun plugin and keeps solid-js and @opentui/* external, because OpenCode provides them at runtime. Existing packages build to the same output as before. Also: - Raise @opencode/plugin to 2.0.26, add the OpenTUI and Solid dev dependencies for type checks, and pin Prettier to the locked 3.8.1. - Make the dev task executable. mise skipped it, so `mise run dev` failed with "no task dev found". - Add a markdownlint config with the repository's 100-column width, and wrap the AGENTS.md lines that exceeded it. - Disable shellcheck's SC2154 for usage_package, which mise sets from the #USAGE lines. --- .markdownlint-cli2.jsonc | 6 + .mise/bundle.ts | 22 +++ .mise/tasks/build | 6 +- .mise/tasks/dev | 7 +- .mise/tasks/install-local | 7 +- .mise/tasks/publish | 1 + AGENTS.md | 18 ++- bun.lock | 299 +++++++++++++++++++++++++++++++++++--- package.json | 7 +- 9 files changed, 332 insertions(+), 41 deletions(-) create mode 100644 .markdownlint-cli2.jsonc create mode 100644 .mise/bundle.ts mode change 100644 => 100755 .mise/tasks/dev diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..0820e5f --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,6 @@ +{ + "config": { + "MD013": { "line_length": 100, "tables": false, "code_blocks": false }, + }, + "ignores": ["**/node_modules/**", "**/CHANGELOG.md"], +} diff --git a/.mise/bundle.ts b/.mise/bundle.ts new file mode 100644 index 0000000..145a097 --- /dev/null +++ b/.mise/bundle.ts @@ -0,0 +1,22 @@ +import solidTransformPlugin from '@opentui/solid/bun-plugin'; + +const usage = 'Usage: bun .mise/bundle.ts [--sourcemap]'; +const [pkg, outdir, ...flags] = Bun.argv.slice(2); +if (!pkg || !outdir) throw new Error(usage); + +for (const file of ['index.ts', 'tui.ts', 'tui.tsx']) { + const entry = `${pkg}/src/${file}`; + if (!(await Bun.file(entry).exists())) continue; + const result = await Bun.build({ + entrypoints: [entry], + outdir, + target: 'bun', + sourcemap: flags.includes('--sourcemap') ? 'inline' : 'none', + external: ['@opencode/plugin', '@opencode/plugin/*', 'solid-js', 'solid-js/*', '@opentui/*'], + plugins: [solidTransformPlugin], + }); + if (!result.success) { + for (const log of result.logs) process.stderr.write(`${log}\n`); + process.exit(1); + } +} diff --git a/.mise/tasks/build b/.mise/tasks/build index 4fc3752..0fd994a 100755 --- a/.mise/tasks/build +++ b/.mise/tasks/build @@ -16,9 +16,5 @@ fi for pkg in "${pkgs[@]}"; do pkg=${pkg%/} echo "Building $(basename "$pkg")..." - for entry in index tui; do - [ -f "$pkg/src/$entry.ts" ] || continue - bun build "$pkg/src/$entry.ts" --outdir "$pkg/dist" --target bun \ - --external '@opencode/plugin' --external '@opencode/plugin/*' - done + bun .mise/bundle.ts "$pkg" "$pkg/dist" done diff --git a/.mise/tasks/dev b/.mise/tasks/dev old mode 100644 new mode 100755 index 8ce7d49..e51ddb3 --- a/.mise/tasks/dev +++ b/.mise/tasks/dev @@ -2,13 +2,10 @@ #MISE description="Build with sourcemaps for development" #USAGE arg "" help="Package to build (e.g. terminal-progress)" set -euo pipefail +# shellcheck disable=SC2154 # mise sets usage_* from the #USAGE lines pkg="packages/${usage_package}" if [ ! -d "$pkg" ]; then echo "Package not found: $pkg" >&2 exit 1 fi -for entry in index tui; do - [ -f "$pkg/src/$entry.ts" ] || continue - bun build "$pkg/src/$entry.ts" --outdir "$pkg/dist" --target bun --sourcemap=inline \ - --external '@opencode/plugin' --external '@opencode/plugin/*' -done +bun .mise/bundle.ts "$pkg" "$pkg/dist" --sourcemap diff --git a/.mise/tasks/install-local b/.mise/tasks/install-local index a305735..2c822a2 100755 --- a/.mise/tasks/install-local +++ b/.mise/tasks/install-local @@ -2,6 +2,7 @@ #MISE description="Build a plugin into the local OpenCode plugins directory" #USAGE arg "" help="Package to install (e.g. terminal-progress)" set -euo pipefail +# shellcheck disable=SC2154 # mise sets usage_* from the #USAGE lines pkg="packages/${usage_package}" if [ ! -d "$pkg" ]; then echo "Package not found: $pkg" >&2 @@ -9,8 +10,4 @@ if [ ! -d "$pkg" ]; then fi destination="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/${usage_package}" mkdir -p "$destination" -for entry in index tui; do - [ -f "$pkg/src/$entry.ts" ] || continue - bun build "$pkg/src/$entry.ts" --outfile "$destination/$entry.js" --target bun \ - --external '@opencode/plugin' --external '@opencode/plugin/*' -done +bun .mise/bundle.ts "$pkg" "$destination" diff --git a/.mise/tasks/publish b/.mise/tasks/publish index 91db7ad..3a88040 100755 --- a/.mise/tasks/publish +++ b/.mise/tasks/publish @@ -3,6 +3,7 @@ #USAGE arg "" help="Package to publish (e.g. terminal-progress)" #USAGE arg "[tag]" help="npm dist-tag" default="latest" set -euo pipefail +# shellcheck disable=SC2154 # mise sets usage_* from the #USAGE lines pkg="packages/${usage_package}" if [ ! -d "$pkg" ]; then echo "Package not found: $pkg" >&2 diff --git a/AGENTS.md b/AGENTS.md index 442b359..b39159d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,8 +29,13 @@ There are no npm scripts. Do not add a `"scripts"` key to any `package.json`. Plugins target the OpenCode 2 plugin API (`@opencode/plugin`). See . -- Server plugins live in `src/index.ts`; CLI (TUI) plugins live in `src/tui.ts`. The build compiles - whichever exist, and packages export them as `.` and `./tui`. +- Server plugins live in `src/index.ts`; CLI (TUI) plugins live in `src/tui.ts`, or `src/tui.tsx` + when they render JSX. `.mise/bundle.ts` compiles whichever exist, and packages export them as `.` + and `./tui`. +- JSX goes through OpenTUI's Solid transform. The bundle keeps `solid-js` and `@opentui/*` external, + because OpenCode provides them at runtime. A package that renders JSX lists `@opentui/core`, + `@opentui/solid`, and `solid-js` as peer dependencies, and sets `"jsx": "preserve"` and + `"jsxImportSource": "@opentui/solid"` in its `tsconfig.json`. - Import `@opencode/plugin` and `@opencode/plugin/tui` with `import type` only, and default-export `{ id, setup } satisfies Plugin.Plugin` (or `Plugin.Definition` for TUI). `Plugin.define` is an identity function, so no runtime dependency is needed. The build marks the package as external as @@ -46,7 +51,8 @@ Plugins target the OpenCode 2 plugin API (`@opencode/plugin`). See - Each package has its own `package.json` with name, version, description, and a peerDependency on `@opencode/plugin` - Each package extends `../../tsconfig.base.json` via its own `tsconfig.json` -- Internal shared code lives in `packages/_shared/` without a `package.json`; it is bundled into each plugin +- Internal shared code lives in `packages/_shared/` without a `package.json`; it is bundled into + each plugin ## Adding a new plugin @@ -60,9 +66,11 @@ Verify by running `mise run test`, `mise run build`, and `mise run typecheck` su ## Releasing -Releases are fully automated via [release-please](https://github.com/googleapis/release-please) and GitHub Actions. **Do NOT manually bump versions.** +Releases are fully automated via [release-please](https://github.com/googleapis/release-please) +and GitHub Actions. **Do NOT manually bump versions.** -1. Push commits to `main` using [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix:`, `feat:`, `chore:`). +1. Push commits to `main` using [Conventional Commits](https://www.conventionalcommits.org/) + (e.g. `fix:`, `feat:`, `chore:`). 2. Release-please creates separate release PRs per package. 3. Merge the release PR — this creates a GitHub Release and triggers npm publish. diff --git a/bun.lock b/bun.lock index 57dca25..6b69d53 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,16 @@ "": { "devDependencies": { "@eslint/js": "^9.39.1", - "@opencode/plugin": "^2.0.16", + "@opencode/plugin": "^2.0.26", + "@opentui/core": "^0.5.17", + "@opentui/solid": "^0.5.17", "@types/node": "^20.11.5", "bun-types": "1.3.10", "eslint": "^9.39.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-prettier": "^5.1.3", - "prettier": "^3.2.4", + "prettier": "3.8.1", + "solid-js": "^1.9.9", "typescript": "^5.7.0", "typescript-eslint": "^8.47.0", }, @@ -25,35 +28,35 @@ }, "packages/atuin-history": { "name": "opencode-atuin-history", - "version": "0.2.1", + "version": "1.0.1", "peerDependencies": { "@opencode/plugin": "^2.0.0", }, }, "packages/forge-session-title": { "name": "opencode-forge-session-title", - "version": "0.3.0", + "version": "1.3.0", "peerDependencies": { "@opencode/plugin": "^2.0.0", }, }, "packages/homeassistant": { "name": "opencode-homeassistant", - "version": "0.7.8", + "version": "1.0.2", "peerDependencies": { "@opencode/plugin": "^2.0.0", }, }, "packages/terminal-progress": { "name": "opencode-terminal-progress", - "version": "0.5.1", + "version": "1.0.2", "peerDependencies": { "@opencode/plugin": "^2.0.0", }, }, "packages/tmux-indicator": { "name": "opencode-tmux-indicator", - "version": "0.5.0", + "version": "1.0.2", "peerDependencies": { "@opencode/plugin": "^2.0.0", }, @@ -110,6 +113,62 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.10", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.9", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.10", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="], @@ -150,6 +209,16 @@ "@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], @@ -192,17 +261,17 @@ "@npmcli/run-script": ["@npmcli/run-script@10.0.4", "", { "dependencies": { "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", "proc-log": "^6.0.0" } }, "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg=="], - "@opencode/ai": ["@opencode/ai@2.0.16", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.0.16", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.112", "google-auth-library": "10.5.0" } }, "sha512-QNOut+LUDNqMm2gqzr5xiOkj2xxD3JQX/DG3Z358aBJMgYZkDHY+ui20J7Jl7XdudQb4Jot2hIQ7t6q17BpOFw=="], + "@opencode/ai": ["@opencode/ai@2.0.26", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.0.26", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.112", "google-auth-library": "10.5.0" } }, "sha512-CB+k3Ed6mtat2Or/6YZiXSPrI8q02NfECEKKO9HNeYLKFG3UrbIxrhnaRLbA9YMQp9mKOA1qWIWXJDnkxdVjMQ=="], - "@opencode/client": ["@opencode/client@2.0.16", "", { "dependencies": { "@opencode/protocol": "2.0.16", "@opencode/schema": "2.0.16" }, "peerDependencies": { "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-lEZM5NY8cKVwnlIJVHiERIaIMJfWfKTlQvKKkIz7NcNEwUXlrTXOA79kS5n17HtjYer1u1XiQIIggYtEYJXHRw=="], + "@opencode/client": ["@opencode/client@2.0.26", "", { "dependencies": { "@opencode/protocol": "2.0.26", "@opencode/schema": "2.0.26" }, "peerDependencies": { "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-0uqhXq6DJYjas3mc4roTyWAmhJpyO2gJaMcenoiA22RXJSOSIwPFho5K4qGmYDJGd5Fvvswh++Nr/wfjaHAYyg=="], - "@opencode/plugin": ["@opencode/plugin@2.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.0.16", "@opencode/client": "2.0.16", "@opencode/protocol": "2.0.16", "@opencode/schema": "2.0.16", "@opencode/util": "2.0.16", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { "@opencode/theme": "2.0.16", "@opentui/core": ">=0.5.10", "@opentui/solid": ">=0.5.10", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-Ir+7fJNUqo81dfzLAcQ8cZVLREbUxx/jbbiPQFR6wCjKWdJXLtMWJIAnYnYxBLf4Im7GcQQfdnlsG5xT9NYNrQ=="], + "@opencode/plugin": ["@opencode/plugin@2.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.0.26", "@opencode/client": "2.0.26", "@opencode/protocol": "2.0.26", "@opencode/schema": "2.0.26", "@opencode/util": "2.0.26", "effect": "4.0.0-rc.112" }, "peerDependencies": { "@opencode/theme": "2.0.26", "@opentui/core": ">=0.5.17", "@opentui/solid": ">=0.5.17", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-8fu/WokR5hxGjclLQJKL0PAgsMEfh81MHnUBsJuhcnYUiTJ2ZQPLqSYAhzoYEQu/Vj4M65D3Xx7QS0Q9b9Z4fw=="], - "@opencode/protocol": ["@opencode/protocol@2.0.16", "", { "dependencies": { "@opencode/schema": "2.0.16", "effect": "4.0.0-rc.112" } }, "sha512-TQQOXLngSH4D9Z/B6DkyioFsHyApaczjU9EdD5BJ0UtwihKs3iT5mLQlicctKzcWomNoCqIM45XEjBv+WrSynA=="], + "@opencode/protocol": ["@opencode/protocol@2.0.26", "", { "dependencies": { "@opencode/schema": "2.0.26", "effect": "4.0.0-rc.112" } }, "sha512-Q+kbu0zTneh0RC4jhiuB19WeP8et9dWXGJVjb5EbUdJ8E1j5Eh0lAyqLXlM2tzTxfMfK3gos9ss6mxMVsvDFIg=="], - "@opencode/schema": ["@opencode/schema@2.0.16", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112" } }, "sha512-MXENqPxsEzv77AAQDZsyYM4kSqUSApGEKD3xQ6kDDDvm5f5SHMffcNQ+uJXbh7rFo7Fm7nzdvoDHeZwiXm2UvQ=="], + "@opencode/schema": ["@opencode/schema@2.0.26", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112" } }, "sha512-DZ09xXCTkTI3Y9VzuQHsm5AMGh67EqkUM13lC0LGKvD12RAYaVmRX5lDv5AnD3OkmUmPPWLYWjicT18CDloy6Q=="], - "@opencode/util": ["@opencode/util@2.0.16", "", { "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", "@effect/platform-node-shared": "4.0.0-rc.112", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "cross-spawn": "7.0.6", "effect": "4.0.0-rc.112", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "pacote": "21.5.1" } }, "sha512-VfY65xppJgSQSXT3x5hH3jM6e5Z08pYEaRA3sKv1K7I8jIFNma8IGZXSoYWaSXPnDt1svxd1sZ9b7tA1RRhOGw=="], + "@opencode/util": ["@opencode/util@2.0.26", "", { "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", "@effect/platform-node-shared": "4.0.0-rc.112", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "cross-spawn": "7.0.6", "effect": "4.0.0-rc.112", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "open": "11.0.4", "pacote": "21.5.1" } }, "sha512-RebqfdFEEby7IFA7agajYvCpeW4VoaPkKu/ZMhBXsEYRnpz2NMyoI7zapFvvDJdYl4B0/kPnmt5vUVuqRWm0lQ=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -230,6 +299,26 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@opentui/core": ["@opentui/core@0.5.17", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.17", "@opentui/core-darwin-x64": "0.5.17", "@opentui/core-linux-arm64": "0.5.17", "@opentui/core-linux-arm64-musl": "0.5.17", "@opentui/core-linux-x64": "0.5.17", "@opentui/core-linux-x64-musl": "0.5.17", "@opentui/core-win32-arm64": "0.5.17", "@opentui/core-win32-x64": "0.5.17" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-75vMaKPKc6tnnUWEultPxiiSU0kqNSdAjbcsFGQecjCF4Z1Jrr4NZBiRo3SoMciai/nq24JCiYcVqPjaKtl/6Q=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ptobzW+1zM29nHvadwy+8uW9jvV8NJr63vcQXWe2rQNtpwN/6k7lEXWiS5LndeBs9AMV5tTsXMEMhttgts4BDQ=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-6yLbnElIZPxhZSrZSyS4mB5+k0YFlpRO0Q8uX4sLFTmvOy8tIeDPFcTGRQqZQ1Rk938tiAhf7iyAX3P1wL4Liw=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HO3dYgzQBGJhe58uJauVClIJg3vToYl/LZa7ZzqxQ+3vZ//cxaJ7tgXhHWR2uJE02550yI69e5nqBpSOTT9E8Q=="], + + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-QMRdDbmR0LwpmX1HXVbN+V3A44/QsbWzMsF3V7cEz7MaDq6RNBs3CUFqfkPorKgPtI75T0AxM7FdJnjt9C4d0Q=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.17", "", { "os": "linux", "cpu": "x64" }, "sha512-lNmtQlcwsio4IVo2trc0QoMyun31UsN0mzmSc2BtYMOeOC5e0PyWaEAZWJb/g6AinOs9xwJGL02JAN5J3QPWsw=="], + + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M2RatQaeET/werHFSH+cg7OkQk/Wn0VoVXZLLEL7DiCDTuhZyMfi3uFDz0+R1IGiiUITqHWpZdZ1LdBY8NCjwQ=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-MyVVpZ0CyGDQ+g4eM0zURqEd/9jDrBIPcZdLyZOmWbdoVu36PLM9KAkqwl/n5ASn/rOrQd+d14jUAsZJvFTqzw=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.17", "", { "os": "win32", "cpu": "x64" }, "sha512-etkuzyfclRX9wel4ivCOk90dUdZNtjK7U6vr7ZHmwkZyOik34ec3Gse0B/jyk192Xdx0l50Tl2xgC1h68XRiEQ=="], + + "@opentui/solid": ["@opentui/solid@0.5.17", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.17", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-YShxRqJYyg3ysh4qR2NpCDhr+I1OpIfmcn8nQch7EsBpmUOZh8INqThQI722tXBULOAYhOQWqzr5APjJxYDFbQ=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], @@ -330,10 +419,18 @@ "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.11", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-k7TkOv7uVYnrq/bYyVvG5cWOC9IObAGfmyW3aMkG8l2sq2qt/5THkUBja69PMLEGGi8G+fXB2Fh7eS4OwZRBoQ=="], + + "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], + + "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], @@ -342,14 +439,22 @@ "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "bundle-name": ["bundle-name@4.1.1", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA=="], + "cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001815", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -370,28 +475,48 @@ "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], - "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.452", "", {}, "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], @@ -434,6 +559,8 @@ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -446,10 +573,18 @@ "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], "gcp-metadata": ["gcp-metadata@8.1.4", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], + "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -466,8 +601,12 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], @@ -488,18 +627,32 @@ "ip-address": ["ip-address@10.7.2", "", {}, "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w=="], + "is-core-module": ["is-core-module@2.17.0", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -514,6 +667,8 @@ "json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], "just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="], @@ -536,6 +691,8 @@ "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], @@ -576,6 +733,8 @@ "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "node-releases": ["node-releases@2.0.58", "", {}, "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], "npm-bundled": ["npm-bundled@5.0.0", "", { "dependencies": { "npm-normalize-package-bin": "^5.0.0" } }, "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw=="], @@ -592,6 +751,8 @@ "npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="], + "open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="], + "opencode-PLUGIN-NAME": ["opencode-PLUGIN-NAME@workspace:packages/_template"], "opencode-atuin-history": ["opencode-atuin-history@workspace:packages/atuin-history"], @@ -612,6 +773,8 @@ "p-map": ["p-map@7.0.8", "", {}, "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="], @@ -620,16 +783,26 @@ "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], @@ -652,15 +825,27 @@ "redis": ["redis@6.2.1", "", { "dependencies": { "@redis/bloom": "6.2.1", "@redis/client": "6.2.1", "@redis/json": "6.2.1", "@redis/search": "6.2.1", "@redis/time-series": "6.2.1" } }, "sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg=="], + "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], + + "resolve": ["resolve@1.22.13", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.17.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "seroval": ["seroval@1.6.9", "", {}, "sha512-UeEzHwAK+yl0YLbsvROV1t5yMYEwZZ0CfKYxDvUgS2fO9Vxdf8djqVIB+OuVpuawRNPqw68LE1Du/GMsW2ZPqA=="], + + "seroval-plugins": ["seroval-plugins@1.6.9", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-kiDK/Tb8IWZ61WEU8pA2PDmAuw37gKph9Y77OZlD56mg0Y2g0Bg7gZ1HeHhs5d40w+nRoUq/wj08V26ilbez9g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -676,6 +861,8 @@ "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "solid-js": ["solid-js@1.9.17", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.6.8", "seroval-plugins": "~1.6.8" } }, "sha512-4GAmLOWPfNV8H9Jfn2LSFTpNEG6jiOKPiwd5lnLGQtehHaX6l3TOLzJqQ7mqwB/3k1Yy3zyumFVopmc/tm34ow=="], + "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], @@ -684,11 +871,11 @@ "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], - "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -696,6 +883,8 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], @@ -720,6 +909,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -730,6 +921,8 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], @@ -742,24 +935,42 @@ "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "wsl-utils": ["wsl-utils@1.0.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], - "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "@npmcli/arborist/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@npmcli/arborist/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@npmcli/config/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@npmcli/fs/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@npmcli/git/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/git/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], "@npmcli/map-workspaces/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@npmcli/metavuln-calculator/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@npmcli/package-json/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@npmcli/promise-spawn/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], "@opencode/util/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -770,8 +981,14 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], @@ -784,12 +1001,26 @@ "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "node-gyp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "node-gyp/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + "npm-install-checks/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "npm-package-arg/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "npm-pick-manifest/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -798,6 +1029,10 @@ "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -806,6 +1041,10 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@npmcli/arborist/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], @@ -820,6 +1059,12 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + + "babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + + "babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "ignore-walk/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], @@ -830,6 +1075,8 @@ "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + "rimraf/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], @@ -840,6 +1087,8 @@ "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "@npmcli/arborist/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -850,12 +1099,24 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "babel-plugin-module-resolver/glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], + + "babel-plugin-module-resolver/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "babel-plugin-module-resolver/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "ignore-walk/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + + "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], } } diff --git a/package.json b/package.json index 78c9c8d..93e6e7d 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,16 @@ ], "devDependencies": { "@eslint/js": "^9.39.1", - "@opencode/plugin": "^2.0.16", + "@opencode/plugin": "^2.0.26", + "@opentui/core": "^0.5.17", + "@opentui/solid": "^0.5.17", "@types/node": "^20.11.5", "bun-types": "1.3.10", "eslint": "^9.39.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-prettier": "^5.1.3", - "prettier": "^3.2.4", + "prettier": "3.8.1", + "solid-js": "^1.9.9", "typescript": "^5.7.0", "typescript-eslint": "^8.47.0" } From 74e57927fa5e67d941306277f37e0a30df032b4e Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Sat, 10 Oct 2026 14:05:48 +0200 Subject: [PATCH 2/2] feat(forge-session): add opencode-forge-session Add a plugin that tracks each session's GitHub and GitLab issues and PRs/MRs. It replaces opencode-forge-session-title and keeps its plugin and RPC IDs, so stored targets and RPC callers carry over. - The server keeps the title prefix, accepts up to 50 targets through set_session_target, and relates them to a shared issue_url. - A CLI entry point shows the PR/MR status in the prompt footer, with /forge-status and /forge-open commands. - The server polls the PRs/MRs of the sessions that CLIs show, and tells the agent when a GitLab Duo review finishes with feedback or people leave new review comments. reviewStatus: false keeps only the title behavior. Release-As: 1.0.0 --- .github/workflows/release.yml | 5 +- .release-please-manifest.json | 1 + README.md | 1 + packages/forge-session/README.md | 387 ++++++ packages/forge-session/package.json | 49 + .../src/automated-review-watch.test.ts | 303 +++++ .../src/automated-review-watch.ts | 205 +++ packages/forge-session/src/exec.ts | 13 + packages/forge-session/src/forge.ts | 238 ++++ packages/forge-session/src/forges.test.ts | 104 ++ packages/forge-session/src/forges.ts | 102 ++ packages/forge-session/src/format.test.ts | 312 +++++ packages/forge-session/src/format.ts | 240 ++++ packages/forge-session/src/git.test.ts | 139 ++ packages/forge-session/src/git.ts | 170 +++ packages/forge-session/src/github.test.ts | 309 +++++ packages/forge-session/src/github.ts | 379 ++++++ .../forge-session/src/gitlab-feedback.test.ts | 127 ++ packages/forge-session/src/gitlab-feedback.ts | 97 ++ packages/forge-session/src/gitlab.test.ts | 460 +++++++ packages/forge-session/src/gitlab.ts | 383 ++++++ .../src/human-review-watch.test.ts | 418 ++++++ .../forge-session/src/human-review-watch.ts | 220 +++ packages/forge-session/src/index.ts | 39 + packages/forge-session/src/limit.test.ts | 79 ++ packages/forge-session/src/limit.ts | 56 + packages/forge-session/src/locate.test.ts | 333 +++++ packages/forge-session/src/locate.ts | 137 ++ packages/forge-session/src/lookups.test.ts | 127 ++ packages/forge-session/src/lookups.ts | 67 + packages/forge-session/src/options.test.ts | 43 + packages/forge-session/src/options.ts | 25 + packages/forge-session/src/rpc.ts | 141 ++ packages/forge-session/src/server.test.ts | 1212 +++++++++++++++++ packages/forge-session/src/server.ts | 338 +++++ .../forge-session/src/status-client.test.ts | 165 +++ packages/forge-session/src/status-client.ts | 189 +++ packages/forge-session/src/status-server.ts | 347 +++++ .../forge-session/src/status-service.test.ts | 232 ++++ packages/forge-session/src/status-service.ts | 232 ++++ packages/forge-session/src/store.test.ts | 351 +++++ packages/forge-session/src/store.ts | 225 +++ packages/forge-session/src/target.test.ts | 357 +++++ packages/forge-session/src/target.ts | 263 ++++ packages/forge-session/src/title.test.ts | 80 ++ packages/forge-session/src/title.ts | 54 + packages/forge-session/src/traits.test.ts | 106 ++ packages/forge-session/src/tui.tsx | 410 ++++++ packages/forge-session/tsconfig.json | 8 + release-please-config.json | 3 + 50 files changed, 10280 insertions(+), 1 deletion(-) create mode 100644 packages/forge-session/README.md create mode 100644 packages/forge-session/package.json create mode 100644 packages/forge-session/src/automated-review-watch.test.ts create mode 100644 packages/forge-session/src/automated-review-watch.ts create mode 100644 packages/forge-session/src/exec.ts create mode 100644 packages/forge-session/src/forge.ts create mode 100644 packages/forge-session/src/forges.test.ts create mode 100644 packages/forge-session/src/forges.ts create mode 100644 packages/forge-session/src/format.test.ts create mode 100644 packages/forge-session/src/format.ts create mode 100644 packages/forge-session/src/git.test.ts create mode 100644 packages/forge-session/src/git.ts create mode 100644 packages/forge-session/src/github.test.ts create mode 100644 packages/forge-session/src/github.ts create mode 100644 packages/forge-session/src/gitlab-feedback.test.ts create mode 100644 packages/forge-session/src/gitlab-feedback.ts create mode 100644 packages/forge-session/src/gitlab.test.ts create mode 100644 packages/forge-session/src/gitlab.ts create mode 100644 packages/forge-session/src/human-review-watch.test.ts create mode 100644 packages/forge-session/src/human-review-watch.ts create mode 100644 packages/forge-session/src/index.ts create mode 100644 packages/forge-session/src/limit.test.ts create mode 100644 packages/forge-session/src/limit.ts create mode 100644 packages/forge-session/src/locate.test.ts create mode 100644 packages/forge-session/src/locate.ts create mode 100644 packages/forge-session/src/lookups.test.ts create mode 100644 packages/forge-session/src/lookups.ts create mode 100644 packages/forge-session/src/options.test.ts create mode 100644 packages/forge-session/src/options.ts create mode 100644 packages/forge-session/src/rpc.ts create mode 100644 packages/forge-session/src/server.test.ts create mode 100644 packages/forge-session/src/server.ts create mode 100644 packages/forge-session/src/status-client.test.ts create mode 100644 packages/forge-session/src/status-client.ts create mode 100644 packages/forge-session/src/status-server.ts create mode 100644 packages/forge-session/src/status-service.test.ts create mode 100644 packages/forge-session/src/status-service.ts create mode 100644 packages/forge-session/src/store.test.ts create mode 100644 packages/forge-session/src/store.ts create mode 100644 packages/forge-session/src/target.test.ts create mode 100644 packages/forge-session/src/target.ts create mode 100644 packages/forge-session/src/title.test.ts create mode 100644 packages/forge-session/src/title.ts create mode 100644 packages/forge-session/src/traits.test.ts create mode 100644 packages/forge-session/src/tui.tsx create mode 100644 packages/forge-session/tsconfig.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index feefd55..ba953e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ name: Release -on: +'on': push: branches: - main @@ -20,6 +20,7 @@ jobs: terminal-progress--release_created: ${{ steps.release.outputs['packages/terminal-progress--release_created'] }} homeassistant--release_created: ${{ steps.release.outputs['packages/homeassistant--release_created'] }} forge-session-title--release_created: ${{ steps.release.outputs['packages/forge-session-title--release_created'] }} + forge-session--release_created: ${{ steps.release.outputs['packages/forge-session--release_created'] }} atuin-history--release_created: ${{ steps.release.outputs['packages/atuin-history--release_created'] }} tmux-indicator--release_created: ${{ steps.release.outputs['packages/tmux-indicator--release_created'] }} steps: @@ -42,6 +43,8 @@ jobs: enabled: ${{ needs.release-please.outputs['homeassistant--release_created'] }} - package: forge-session-title enabled: ${{ needs.release-please.outputs['forge-session-title--release_created'] }} + - package: forge-session + enabled: ${{ needs.release-please.outputs['forge-session--release_created'] }} - package: atuin-history enabled: ${{ needs.release-please.outputs['atuin-history--release_created'] }} - package: tmux-indicator diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 38c7d0c..0917fb6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,6 +2,7 @@ "packages/terminal-progress": "1.0.2", "packages/homeassistant": "1.0.2", "packages/forge-session-title": "1.3.0", + "packages/forge-session": "0.0.0", "packages/atuin-history": "1.0.1", "packages/tmux-indicator": "1.0.2" } diff --git a/README.md b/README.md index 623ad60..f363390 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The plugins target OpenCode 2. The last OpenCode 1 versions live on the | [terminal-progress](packages/terminal-progress/) | Shows agent progress in terminal tabs (Ghostty, iTerm2, WezTerm, Windows Terminal) | [![npm](https://img.shields.io/npm/v/opencode-terminal-progress)](https://www.npmjs.com/package/opencode-terminal-progress) | | [homeassistant](packages/homeassistant/) | Sends agent status to Home Assistant via webhooks | [![npm](https://img.shields.io/npm/v/opencode-homeassistant)](https://www.npmjs.com/package/opencode-homeassistant) | | [forge-session-title](packages/forge-session-title/) | Prefixes session titles with forge issue/PR/MR references | [![npm](https://img.shields.io/npm/v/opencode-forge-session-title)](https://www.npmjs.com/package/opencode-forge-session-title) | +| [forge-session](packages/forge-session/) | Tracks each session's issues and PRs/MRs: title prefixes, a PR/MR status footer, and review notifications | [![npm](https://img.shields.io/npm/v/opencode-forge-session)](https://www.npmjs.com/package/opencode-forge-session) | | [atuin-history](packages/atuin-history/) | Records agent shell commands in Atuin shell history | [![npm](https://img.shields.io/npm/v/opencode-atuin-history)](https://www.npmjs.com/package/opencode-atuin-history) | | [tmux-indicator](packages/tmux-indicator/) | Sets a tmux window option when the agent is waiting for input | [![npm](https://img.shields.io/npm/v/opencode-tmux-indicator)](https://www.npmjs.com/package/opencode-tmux-indicator) | diff --git a/packages/forge-session/README.md b/packages/forge-session/README.md new file mode 100644 index 0000000..3eaaca9 --- /dev/null +++ b/packages/forge-session/README.md @@ -0,0 +1,387 @@ +# opencode-forge-session + +An [OpenCode](https://opencode.ai) plugin that tracks the issues or PRs/MRs a session works on. +It prefixes the session title with their references, shows the PR/MR status in the prompt +footer, and tells the agent about new review feedback. It supports GitLab and GitHub. The status +and review notifications are optional; see [Keep only session titles](#keep-only-session-titles). + +Requires OpenCode 2. It replaces +[`opencode-forge-session-title`](https://www.npmjs.com/package/opencode-forge-session-title); see +[Migrating from opencode-forge-session-title](#migrating-from-opencode-forge-session-title). + +## Installation + +```bash +opencode plugin add opencode-forge-session +``` + +This adds the plugin to your `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugins": ["opencode-forge-session"] +} +``` + +The package has a server entry point and a CLI entry point, which OpenCode loads automatically. +The server owns session targets, title prefixes, forge lookups, polling, and notification +delivery. The CLI holds leases on the sessions it has shown, their latest snapshots, and each +directory's settings. It renders the status that the server serves and shows its notices as +toasts. + +### Requirements + +- [`glab`](https://gitlab.com/gitlab-org/cli) for GitLab and [`gh`](https://cli.github.com/) + for GitHub, on the `PATH` of the OpenCode server. Each is only needed for its forge. +- Each CLI logged in to every host it's used for: `glab auth login --hostname ` and + `gh auth login --hostname `. The plugin uses their credentials and has none of its + own. +- For self-managed GitLab hosts not named `gitlab.*`, and for GitHub Enterprise, the + `hosts` and `githubHosts` [options](#options). + +Without a forge CLI, titles still follow targets and branches, and `/forge-status` reports +what's missing, such as `glab is not installed`. + +## Session targets + +The server registers the `set_session_target` tool and adds instructions to the agent's +context. When you establish or change the primary issue, PR, or MR, the agent sets its full +URL as the session target. Background references, comparisons, and dependencies keep the +current target. After the agent creates a PR/MR for the current task, it sets the new PR/MR +as the target, passing the current issue as `issue_url`. + +For example, if the title starts with `[#123, !45]` and you ask the agent to review MR `!456`, +the prefix becomes `[!456]`. Without `issue_url`, the server reads the PR/MR's source branch +and takes the issue number from its name, so reviewing an MR from `321-fix-timeout` produces +`[#321, !456]`. + +Targets persist per session across plugin reloads. Calling `set_session_target` with +`target: "branch"` returns to branch-based naming. Child sessions and untitled sessions are +skipped. + +### Several targets + +A session can work on up to 50 issues and PRs/MRs at once, such as a set of related MRs +under review. Pass their full URLs in `targets` instead of `target`, and choose how they +change the current targets with `operation`: + +| `operation` | Effect | +| ------------------- | ------------------------------------------------------------------------------ | +| `replace` (default) | Sets exactly the given targets. | +| `add` | Appends the given targets and keeps the current ones. | +| `remove` | Drops the given targets. Removing the last one returns to branch-based naming. | + +```json +{ + "targets": [ + "https://gitlab.com/group/project/-/merge_requests/101", + "https://gitlab.com/group/other/-/merge_requests/102" + ], + "operation": "add" +} +``` + +The server normalizes and deduplicates the URLs, which can come from different projects. + +With `targets`, `issue_url` relates every listed target to the same issue, such as a set of +MRs for one issue. Every target must then be a PR/MR from the issue's forge. With +`operation: "add"`, only the added targets get the issue. + +The agent guidance is generic. When you ask the agent to work on several issues or PRs/MRs, +it sets all of them as targets. If you describe them instead of linking them, the agent +finds them first, then sets them. After it creates a PR/MR for a task with several targets, +it adds the new one instead of replacing the others. + +With several targets, the title prefix lists their own references, such as `[!101, !102]`. +When every target has the same related issue, from `issue_url` or from its source branch +name, the issue comes first, such as `[#12, !101, !102]`. More than four targets list the +first three and count the rest, such as `[!101, !102, !103, +3]`. + +Target URLs can name a GitLab MR, issue, or work item on any host, or a GitHub PR or issue. +GitHub URLs are accepted on any host that isn't recognizably GitLab, so GitHub Enterprise +targets work. + +## Branch-based naming + +Without an explicit target, the title follows the checked-out branch: + +1. The prefix starts with the issue number from the branch name, or the branch name + without one. +2. It adds the newest open PR/MR from the branch, or `!N/A` (GitLab) or `#N/A` (GitHub) + until one exists. + +The title is reconciled after target changes, title changes, and successful agent runs. +Text you add to the title, including your own bracketed prefixes, is kept. + +Branch names longer than 40 characters are shortened with an ellipsis in the prefix. Titles +stay within 100 characters by shortening the text after the prefix, never the prefix +itself, so the plugin can still find and replace its prefix later. + +The default branch and branches named `main`, `master`, `develop`, or `HEAD` get no prefix. + +The branch's remote, pushed branch name, and forge come from the same repository discovery +as the status footer. That discovery uses the branch's push or upstream remote, recognizes +forks, and classifies hosts with the `hosts` and `githubHosts` options. + +| Pattern | Example branch | Issue | +| --------------------------------- | ----------------------- | ----- | +| `/-` | `feature/123-add-login` | `123` | +| `-` | `123-fix-typo` | `123` | +| `-` | `fix-typo-123` | `123` | +| `//` | `user/123/some-work` | `123` | +| `issue-`, `gh-` | `gh-42-improve-perf` | `42` | +| `fix-`, `feat-` | `fix-99` | `99` | + +## Status footer + +The server picks each session's PRs/MRs in this order: + +1. The explicit targets. +1. A PR/MR number in a title prefix that you wrote, such as `!456` in `[!456] Review`. +1. The checked-out branch's open PRs/MRs. + +A prefix that the plugin wrote from the branch doesn't count as a title reference, so the +footer follows the branch when its PR/MR closes and another opens. The title prefix itself +reuses a branch's PR/MR number for up to 10 minutes. + +Explicit targets that aren't PRs/MRs, such as issues, show no status. Unresolved explicit +PRs/MRs never fall back to the branch. + +The server runs at most four `glab` or `gh` commands at once for status and review +feedback, across all sessions. When some target lookups fail, the footer shows the others +and counts the failures as `N unavailable`. A target whose lookup failed with an error +keeps its previous status until a later poll succeeds. A GitLab response that reports an +error counts as a failed lookup, never as a missing MR, so it's retried with backoff. + +When the CLI can't reach the server, such as while the server plugin reloads, the footer +keeps the last status and marks it `stale`. The CLI never looks PRs/MRs up itself, so it +can't show another project's PR/MR with the same number as a target. + +With several PR/MR targets, the footer shows counts instead of one PR/MR's status, such as +`5 MRs · 🤖 2 reviewing · 1 CI failed · 1 conflict · 1 merged`. Clicking it opens the +status dialog, which lists each PR/MR. + +The footer shows checks or the pipeline, unresolved threads, conflicts, approval, and a +running GitLab Duo review. GitHub also shows a requested change. Unknown mergeability stays +unknown rather than appearing conflict-free. + +The footer shows `approved` only when GitLab reports that approval requirements are met, +at least one person approved, and every human reviewer approved. Bot approvals, such as +Duo's, don't count. While human reviewers haven't approved, it shows `awaiting @username`, +or `awaiting N reviewers` for more than two. + +Clicking the PR/MR number or pipeline opens it in the browser. Clicking any other indicator +opens the status dialog. These commands are also available from the command palette: + +- `/forge-status` opens a dialog with full status and a refresh action. Aliases: + `/mr-status` and `/pr-status`. +- `/forge-open` opens the PR/MR in the browser. Aliases: `/mr-open` and `/pr-open`. + +The server caches each session's status and polls every 2 minutes while a CLI shows an open +PR/MR, or every 30 seconds while an automated review runs. Each change reaches the CLIs as +an RPC event. + +### Which sessions the server watches + +The server watches only sessions that a CLI has shown since it started. The CLI renews a +lease on each of them every minute, and a lease that isn't renewed for 3 minutes ends, such +as after the CLI exits. A session keeps polling while it's on screen in any CLI. A hidden +session with a lease keeps polling while it has a running automated review, human feedback +to watch, or a notification to retry. + +A hidden session that the server hasn't looked up yet, such as after the server plugin +reloads, is looked up once to find out whether it has reviews to watch. A target change +also looks up a hidden session that isn't polling. + +Each checkout's server plugin instance watches only the sessions in that checkout. When a +session moves to another worktree, the CLI moves its lease to that checkout's instance. The +old instance stops watching the session, even if another CLI still leases it there. Before +each notification, the server reads the session again, so a session that moved while a +lookup or fetch ran is left to its new checkout. A review that finishes during the move can +go unannounced, because the new instance never saw it running. If the session can't be +read, the notification is retried later instead of being dropped. + +After that read, the server checks the targets again and writes the message for the +PRs/MRs that are still targets. A target removed in the meantime isn't mentioned. + +When the server plugin stops, such as on a reload, no new lookup, fetch, notification, +title update, or target change starts, and queued `glab` and `gh` commands don't run. A +title update that is still looking up its branch doesn't rename the session or store +anything, because the replacement instance owns the session's state by then. Notifications +that were already being sent can finish and store their outcome, so the replacement instance +doesn't send them again. Cleanup waits up to 5 seconds for them. One that takes longer may be +sent again. + +## Review notifications + +For each explicit target that is an open PR/MR, the server tells the agent about reviews. +A target that merges or closes gets no more notifications, and a notification waiting for a +retry is dropped. +Each notification is a queued synthetic message, followed by a toast in the CLIs that have +shown the session. The message resumes the session unless `resumeSession` is `false`. +Messages include the full URL of each PR/MR. PRs/MRs from the title or the branch don't get +notifications. + +Before it sends a notification, the server checks that the PR/MR is still one of the +session's targets. A lookup that started before the targets changed is discarded, so a +removed target never triggers a notification. + +| Notification | GitLab | GitHub | +| ------------------------------------------ | ----------- | ------------- | +| An automated review finished with feedback | GitLab Duo | Not supported | +| New human review feedback | MR comments | Not supported | + +- **Automated reviews**: Duo's final state of `REVIEWED` or `REQUESTED_CHANGES` asks the + agent to read its comments. Other final states, such as `APPROVED`, send nothing. The + server notifies only when it sees a review go from running to finished, so a review that + finished before the server first saw it running isn't announced. Reviews that finish in + the same poll share one message. +- **Human reviews**: new comments and replies from people other than you and bots are + announced once none of their unresolved comments has changed for 5 minutes. Expect one + message 5 to 7 minutes after the review goes quiet. Resolved threads are skipped. + +The first look at a PR/MR records its newest comment as a baseline, so older comments never +count as new. Announced comments are stored per session and PR/MR, so a restart replays +nothing but still catches comments posted in the meantime. + +A failed send shows an error toast and is retried after 10 minutes. A failed automated +review notification stays pending in the server's storage, so it's retried even after a +restart, as long as the review still has feedback and the PR/MR is still an open target. + +Every checkout's server plugin instance shares the plugin's storage, so each baseline and +each pending notification has its own storage key. Instances never overwrite each other's +records. A failed storage write shows an error toast once. + +On GitLab, the server reads the newest 2,000 comments. On busier MRs, edits to older +comments don't count as activity. + +## Options + +Set options with the object form of the `plugins` entry in `opencode.json`: + +```json +{ + "plugins": [{ "package": "opencode-forge-session", "options": { "notifyHumanReviews": false } }] +} +``` + +| Option | Default | Description | +| ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `reviewStatus` | `true` | Show the PR/MR status and notify the agent about reviews. Set to `false` to keep only session targets and titles. | +| `hosts` | `["gitlab.com"]` | GitLab hosts whose remotes are recognized, besides hosts named `gitlab.*`. | +| `githubHosts` | `["github.com"]` | GitHub hosts, including GitHub Enterprise hosts. | +| `pollSeconds` | `120` | Normal polling interval. Running automated reviews poll at 30 seconds or this value, whichever is lower. | +| `notifyAutomatedReviews` | `true` | Tell the agent when an automated review finishes with feedback. The former name, `notifyDuoReview`, still works. | +| `notifyHumanReviews` | `true` | Tell the agent about new human review feedback. | +| `resumeSession` | `true` | Resume the session when telling the agent about a review. Set to `false` to queue the message for the session's next turn instead. | + +The server entry point reads every option, so changing one takes effect when the server +plugin reloads. The CLI has no options of its own. Each checkout's server reads the +options for its location, so the CLI can show status in one project and not in another. + +### Keep only session titles + +To keep the behavior of `opencode-forge-session-title` alone, turn off review status: + +```json +{ + "plugins": [{ "package": "opencode-forge-session", "options": { "reviewStatus": false } }] +} +``` + +The server still registers `set_session_target`, adds the agent guidance, and maintains the +title prefix. It doesn't poll or send review notifications, and the CLI shows no footer. +`/forge-status` and `/forge-open` explain that the status is off. + +## RPC + +The server registers an [RPC](https://opencode.ai/v2/docs/build/plugins/rpc) that the CLI +uses, defined in `src/rpc.ts`: + +- `target({ sessionID })` returns `{ url, issueUrl?, targets }` for explicit targets, and + `{}` in branch mode. `targets` lists every target as `{ url, issueUrl? }`. `url` and + `issueUrl` describe the first target, for callers that expect a single target. +- `watch({ clientID, keys, visible? })` renews the caller's leases on `keys` and returns + `{ enabled, statuses }`. A key is a session ID, or `""` for the checkout outside a + session. `visible` is the key on screen. +- `release({ clientID })` drops the caller's leases. +- `refresh({ key })` looks the key up now and returns `{ enabled, snapshot }`. +- `targetChanged` fires after `set_session_target` runs, with the same fields as `target` + and the `sessionID`. It has only the `sessionID` in branch mode. +- `status` fires with `{ directory, key, snapshot }` when a key's status changes. +- `notice` fires with `{ sessionID?, title?, message, variant }` for the CLIs to show as a + toast. + +Keys are relative to the location of the call or event, because each checkout's server +plugin instance watches its own sessions. + +## Migrating from opencode-forge-session-title + +Replace `opencode-forge-session-title` with `opencode-forge-session` in `opencode.json`. Never +load both: the server keeps `opencode-forge-session-title`'s plugin and RPC IDs, so the two +would collide. + +| Entry point | Plugin ID | Stored state | +| ----------- | ------------------------------ | ---------------------------------------------------------------------------------------- | +| Server | `opencode-forge-session-title` | Session targets, owned title prefixes, human-review baselines, and pending notifications | +| CLI | `pedropombeiro.forge-session` | None | + +Because the IDs match, stored targets carry over, and callers of the `target` RPC keep working. +The server reads a session's single stored `target` as a list of one, and writes `targets` from +the next change on. + +With default options, the new footer and review notifications start right away, and +notifications resume the session. Set `reviewStatus` to `false` to keep only titles, or +`resumeSession` to `false` to queue notifications for the next turn. + +## Code layout + +- `src/index.ts` wires the server: the forge catalogs from the options, with a shared + concurrency limit for status, and title lookups. +- `src/server.ts` registers the tool, the agent guidance, title reconciliation, and the RPC. +- `src/status-server.ts` connects the status service to sessions, storage, notifications, + and RPC events. `src/status-service.ts` holds the leases and decides which keys poll. +- `src/target.ts` parses, normalizes, changes, and formats session targets, and matches + forge URLs against them. +- `src/limit.ts` bounds how many forge requests run at once. +- `src/title.ts` extracts branch issue numbers and reconciles title prefixes. +- `src/options.ts` reads the plugin's status options. +- `src/lookups.ts` answers the title's forge questions: the branch's newest PR/MR number + and a PR/MR's source branch. +- `src/status-client.ts` holds the CLI's leases, snapshots, and per-directory settings. + `src/tui.tsx` connects it to the RPC and renders the footer, dialog, commands, and notices. +- `src/forge.ts` defines the forge-neutral types, the `Forge` adapter interface, and + `ForgeTraits` with its optional `automatedReview` and `feedback` capabilities. +- `src/gitlab.ts` and `src/github.ts` provide each forge's adapter and traits. Adapters own + API calls, pagination, error classification, and status normalization. Traits cover + everything else: hosts, URL parsing, reference syntax, vocabulary, and forge-specific + footer and dialog content. +- `src/forges.ts` registers each forge's traits, classifies hosts, opens adapters, and + parses URLs. +- `src/git.ts` resolves a checkout's remotes, branch, and pushed branch name. +- `src/locate.ts` picks each key's PRs/MRs. `src/store.ts` caches lookups, polls, backs off + after failures, and discards lookups that started before a change. `src/format.ts` + renders the footer and dialog. +- `src/automated-review-watch.ts`, `src/human-review-watch.ts`, and + `src/gitlab-feedback.ts` decide when to notify the agent. + +Shared code never compares a forge against a specific name. It asks the forge's traits, or +checks for a capability, instead. To add a forge, add its kind to `ForgeKind` and +`ReviewRequest`, implement `Forge` and `ForgeTraits`, and register the traits in +`src/forges.ts`. + +## Development + +From the repository root: + +```bash +mise run test +mise run typecheck +mise run lint +mise run build forge-session +``` + +## License + +[MIT](../../LICENSE) diff --git a/packages/forge-session/package.json b/packages/forge-session/package.json new file mode 100644 index 0000000..602cebf --- /dev/null +++ b/packages/forge-session/package.json @@ -0,0 +1,49 @@ +{ + "name": "opencode-forge-session", + "version": "0.0.0", + "description": "OpenCode plugin that tracks each session's GitHub and GitLab issues and PRs/MRs: title prefixes, a PR/MR status footer, and review notifications", + "author": { + "name": "Pedro Pombeiro", + "email": "pedropombeiro@users.noreply.github.com" + }, + "type": "module", + "main": "./dist/index.js", + "exports": { + ".": { + "default": "./dist/index.js" + }, + "./tui": { + "default": "./dist/tui.js" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/pedropombeiro/opencode-plugins.git", + "directory": "packages/forge-session" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "keywords": [ + "opencode", + "opencode-plugin", + "session", + "title", + "github", + "gitlab", + "forge", + "merge-request", + "pull-request", + "code-review" + ], + "license": "MIT", + "peerDependencies": { + "@opencode/plugin": "^2.0.26", + "@opentui/core": ">=0.5.8", + "@opentui/solid": ">=0.5.8", + "solid-js": ">=1.9.0" + } +} diff --git a/packages/forge-session/src/automated-review-watch.test.ts b/packages/forge-session/src/automated-review-watch.test.ts new file mode 100644 index 0000000..f5b86e4 --- /dev/null +++ b/packages/forge-session/src/automated-review-watch.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, test } from 'bun:test'; +import { + automatedReview, + createAutomatedReviewWatcher, + finishedReviews, + isReviewRunning, + notificationKey, + reviewMessage, + reviewOutcome, + reviewTitle, + reviewToast, + RETRY_DELAY, + type PendingDeliveries, + type PendingDelivery, +} from './automated-review-watch'; +import type { GitHubPullRequest, GitLabMergeRequest, ReviewRequest } from './forge'; +import type { Lookup } from './store'; + +const mr = (duoReviewState?: string, iid = '4281', state = 'opened'): GitLabMergeRequest => + ({ + forge: 'gitlab', + iid, + url: `https://gitlab.com/group/project/-/merge_requests/${iid}`, + state, + duoReviewState, + }) as GitLabMergeRequest; + +const found = (...requests: ReviewRequest[]): Lookup => ({ + kind: 'found', + explicitTarget: true, + requests, +}); + +describe('isReviewRunning', () => { + test('detects a running review', () => { + expect(isReviewRunning(found(mr('REVIEWED'), mr('REVIEW_STARTED', '2')))).toBe(true); + }); + + test.each([['REVIEWED'], ['APPROVED'], [undefined]])('ignores %s', (state) => { + expect(isReviewRunning(found(mr(state)))).toBe(false); + }); + + test('handles missing lookups', () => { + expect(isReviewRunning(undefined)).toBe(false); + expect(isReviewRunning({ kind: 'none', reason: 'Detached HEAD' })).toBe(false); + }); +}); + +describe('finishedReviews', () => { + test.each(['REVIEWED', 'REQUESTED_CHANGES'])('reports a review that finished as %s', (state) => { + expect(finishedReviews(found(mr('REVIEW_STARTED')), found(mr(state)))).toEqual([mr(state)]); + }); + + test.each(['APPROVED', 'UNREVIEWED', 'UNAPPROVED', 'REVIEW_STARTED', undefined])( + 'ignores a review that ended as %s', + (state) => { + expect(finishedReviews(found(mr('REVIEW_STARTED')), found(mr(state)))).toEqual([]); + }, + ); + + test('ignores a review that was already finished on the first load', () => { + expect(finishedReviews(undefined, found(mr('REVIEWED')))).toEqual([]); + expect(finishedReviews(found(mr('REVIEWED')), found(mr('REVIEWED')))).toEqual([]); + }); + + test('matches MRs by URL', () => { + expect(finishedReviews(found(mr('REVIEW_STARTED', '1')), found(mr('REVIEWED', '2')))).toEqual( + [], + ); + }); +}); + +describe('reviewMessage', () => { + test('names the reviewer, the MR, and its final state', () => { + const message = reviewMessage([mr('REQUESTED_CHANGES')]); + expect(message).toStartWith('GitLab Duo finished reviewing !4281'); + expect(message).toContain("GitLab Duo's new comments and discussion threads on the MR"); + expect(reviewTitle([mr('REVIEWED')])).toBe('GitLab Duo finished reviewing !4281'); + expect(message).toContain('!4281 (https://gitlab.com/group/project/-/merge_requests/4281)'); + expect(message).toContain('"requested changes"'); + }); + + test('lists every review that finished together, with its URL and state', () => { + const requests = [mr('REVIEWED', '1'), mr('REQUESTED_CHANGES', '2')]; + expect(reviewTitle(requests)).toBe('GitLab Duo finished reviewing 2 MRs'); + expect(reviewMessage(requests)).toBe( + [ + 'GitLab Duo finished reviewing 2 MRs:', + '- !1 (https://gitlab.com/group/project/-/merge_requests/1) with the state "reviewed"', + '- !2 (https://gitlab.com/group/project/-/merge_requests/2) with the state "requested changes"', + "For each one, fetch the reviewer's new comments and discussion threads, then work through its feedback.", + ].join('\n'), + ); + expect(reviewToast(requests)).toEqual({ + title: 'GitLab Duo finished reviewing 2 MRs', + message: '!1: reviewed, !2: requested changes. Sent to the agent.', + }); + expect(reviewToast([mr('REVIEWED')]).message).toBe('Reviewed. Sent to the agent.'); + }); +}); + +describe('reviewOutcome', () => { + test.each([ + ['REVIEWED', 'Reviewed'], + ['REQUESTED_CHANGES', 'Requested changes'], + ])('formats %s', (state, outcome) => { + expect(reviewOutcome(mr(state))).toBe(outcome); + }); +}); + +describe('createAutomatedReviewWatcher', () => { + interface Options { + fail?: boolean; + targets?: (url: string) => boolean; + now?: () => number; + pending?: PendingDeliveries; + } + + function harness({ + fail = false, + targets = () => true, + now = () => 1_000, + pending, + }: Options = {}) { + const sent: Array<[string, string[]]> = []; + // Stored deliveries, by notification key. + const stored = new Map(Object.entries(pending ?? {})); + const errors: string[] = []; + const state = { fail }; + const watcher = createAutomatedReviewWatcher({ + isTarget: (_sessionID, url) => targets(url), + async send(sessionID, requests) { + if (state.fail) throw new Error('server busy'); + sent.push([ + sessionID, + requests.map((request) => `${request.iid} ${automatedReview(request)?.label}`), + ]); + }, + onSendError: (error) => errors.push((error as Error).message), + read: async (sessionID) => + Object.fromEntries([...stored].filter(([, delivery]) => delivery.sessionID === sessionID)), + write: (sessionID, url, delivery) => { + const key = notificationKey(sessionID, { url }); + if (delivery) stored.set(key, delivery); + else stored.delete(key); + }, + now, + }); + return { watcher, sent, stored, errors, state }; + } + + test('notifies about a review that finished with feedback', async () => { + const { watcher, sent } = harness(); + await watcher.observe('ses_1', found(mr('REVIEW_STARTED')), found(mr('REQUESTED_CHANGES'))); + expect(sent).toEqual([['ses_1', ['4281 requested changes']]]); + // The same feedback without a new transition sends nothing. + await watcher.observe('ses_1', found(mr('REQUESTED_CHANGES')), found(mr('REQUESTED_CHANGES'))); + expect(sent).toHaveLength(1); + }); + + test('sends reviews of several targets that finished in one poll together', async () => { + const { watcher, sent } = harness(); + const before = found( + mr('REVIEW_STARTED', '1'), + mr('REVIEW_STARTED', '2'), + mr('REVIEW_STARTED', '3'), + ); + const after = found( + mr('REVIEWED', '1'), + mr('REVIEW_STARTED', '2'), + mr('REQUESTED_CHANGES', '3'), + ); + await watcher.observe('ses_1', before, after); + expect(sent).toEqual([['ses_1', ['1 reviewed', '3 requested changes']]]); + await watcher.observe('ses_1', after, found(mr('REVIEWED', '1'), mr('REVIEWED', '2'))); + expect(sent.at(-1)).toEqual(['ses_1', ['2 reviewed']]); + }); + + test('skips PRs/MRs that are no longer targets', async () => { + const { watcher, sent } = harness({ targets: (url) => !url.endsWith('/2') }); + await watcher.observe( + 'ses_1', + found(mr('REVIEW_STARTED', '1'), mr('REVIEW_STARTED', '2')), + found(mr('REVIEWED', '1'), mr('REVIEWED', '2')), + ); + expect(sent).toEqual([['ses_1', ['1 reviewed']]]); + }); + + test('ignores approvals and running reviews', async () => { + const { watcher, sent } = harness(); + await watcher.observe('ses_1', found(mr('REVIEW_STARTED')), found(mr('APPROVED'))); + await watcher.observe('ses_1', undefined, found(mr('REVIEW_STARTED'))); + expect(sent).toEqual([]); + }); + + test('retries a failed send while the review still has feedback', async () => { + let clock = 1_000; + const { watcher, sent, errors, stored, state } = harness({ fail: true, now: () => clock }); + await watcher.observe('ses_1', found(mr('REVIEW_STARTED')), found(mr('REVIEWED'))); + expect(errors).toEqual(['server busy']); + expect(watcher.hasPending('ses_1')).toBe(true); + expect(stored.get(notificationKey('ses_1', mr()))).toMatchObject({ + retryAt: 1_000 + RETRY_DELAY, + }); + + state.fail = false; + clock += RETRY_DELAY - 1; + await watcher.observe('ses_1', found(mr('REVIEWED')), found(mr('REVIEWED'))); + expect(sent).toEqual([]); + clock += 1; + await watcher.observe('ses_1', found(mr('REVIEWED')), found(mr('REVIEWED'))); + expect(sent).toEqual([['ses_1', ['4281 reviewed']]]); + expect(watcher.hasPending('ses_1')).toBe(false); + expect(stored.size).toBe(0); + }); + + test('drops a pending send once a new review starts or the PR/MR stops being a target', async () => { + const key = notificationKey('ses_1', mr()); + const pending: PendingDeliveries = { + [key]: { sessionID: 'ses_1', url: mr().url, since: 1_000, retryAt: 0 }, + }; + const restarted = harness({ pending }); + await restarted.watcher.observe('ses_1', found(mr('REVIEWED')), found(mr('REVIEW_STARTED'))); + expect(restarted.watcher.hasPending('ses_1')).toBe(false); + expect(restarted.sent).toEqual([]); + + const removed = harness({ pending, targets: () => false }); + await removed.watcher.observe('ses_1', found(mr('REVIEWED')), found(mr('REVIEWED'))); + expect(removed.watcher.hasPending('ses_1')).toBe(false); + expect(removed.sent).toEqual([]); + }); + + test('ignores reviews of merged or closed PRs/MRs, and drops their pending sends', async () => { + const { watcher, sent } = harness(); + await watcher.observe( + 'ses_1', + found(mr('REVIEW_STARTED')), + found(mr('REVIEWED', '4281', 'merged')), + ); + expect(sent).toEqual([]); + + const key = notificationKey('ses_1', mr()); + const closed = harness({ + pending: { [key]: { sessionID: 'ses_1', url: mr().url, since: 1_000, retryAt: 0 } }, + }); + await closed.watcher.observe('ses_1', undefined, found(mr('REVIEWED', '4281', 'closed'))); + expect(closed.sent).toEqual([]); + expect(closed.stored.size).toBe(0); + }); + + test("reads a session's stored deliveries once, so a removal still queued isn't undone", async () => { + let reads = 0; + const watcher = createAutomatedReviewWatcher({ + isTarget: () => true, + send: async () => {}, + read: async () => { + reads++; + return {}; + }, + now: () => 1_000, + }); + await watcher.observe('ses_1', undefined, found(mr('REVIEWED'))); + await watcher.observe('ses_1', undefined, found(mr('REVIEWED'))); + expect(reads).toBe(1); + // A session that moved away is read again if it comes back. + watcher.forget('ses_1'); + await watcher.observe('ses_1', undefined, found(mr('REVIEWED'))); + expect(reads).toBe(2); + }); + + test('sends a delivery that was pending before a restart', async () => { + const key = notificationKey('ses_1', mr()); + const { watcher, sent } = harness({ + pending: { [key]: { sessionID: 'ses_1', url: mr().url, since: 1_000, retryAt: 0 } }, + }); + await watcher.observe('ses_1', undefined, found(mr('REQUESTED_CHANGES'))); + expect(sent).toEqual([['ses_1', ['4281 requested changes']]]); + }); +}); + +describe('automated review capability', () => { + test('maps GitLab Duo states', () => { + expect(automatedReview(mr('REVIEW_STARTED'))).toEqual({ + name: 'GitLab Duo', + state: 'running', + label: 'review started', + }); + expect(automatedReview(mr('REQUESTED_CHANGES'))?.state).toBe('feedback'); + expect(automatedReview(mr('APPROVED'))?.state).toBe('settled'); + expect(automatedReview(mr())).toBeUndefined(); + }); + + test('ignores forges without the capability', () => { + const pull = { + forge: 'github', + iid: '1', + url: 'https://github.com/o/r/pull/1', + } as GitHubPullRequest; + expect(automatedReview(pull)).toBeUndefined(); + expect(isReviewRunning(found(pull))).toBe(false); + expect(finishedReviews(found(pull), found(pull))).toEqual([]); + }); +}); diff --git a/packages/forge-session/src/automated-review-watch.ts b/packages/forge-session/src/automated-review-watch.ts new file mode 100644 index 0000000..3ca3b20 --- /dev/null +++ b/packages/forge-session/src/automated-review-watch.ts @@ -0,0 +1,205 @@ +import type { ReviewRequest } from './forge'; +import { reference, traitsOf } from './forges'; +import type { Lookup } from './store'; + +const requests = (lookup: Lookup | undefined) => (lookup?.kind === 'found' ? lookup.requests : []); + +// The request's automated review, from its forge's `automatedReview` capability. +export function automatedReview(request: ReviewRequest) { + const reviewer = traitsOf(request).automatedReview; + const status = reviewer?.status(request); + return reviewer && status ? { name: reviewer.name, ...status } : undefined; +} + +export const isReviewRunning = (lookup: Lookup | undefined) => + requests(lookup).some((request) => automatedReview(request)?.state === 'running'); + +// Requests whose automated review was running in `previous` and finished with +// feedback in `next`. +export function finishedReviews(previous: Lookup | undefined, next: Lookup): ReviewRequest[] { + const running = new Set( + requests(previous) + .filter((request) => automatedReview(request)?.state === 'running') + .map((request) => request.url), + ); + return requests(next).filter( + (request) => running.has(request.url) && automatedReview(request)?.state === 'feedback', + ); +} + +const reviewer = (request: ReviewRequest) => + automatedReview(request)?.name ?? 'The automated reviewer'; + +// The one value that every request shares, or `fallback` when they differ. +const shared = ( + requests: ReviewRequest[], + value: (request: ReviewRequest) => string, + fallback: string, +) => { + const values = new Set(requests.map(value)); + return values.size === 1 ? [...values][0] : fallback; +}; + +const reviewers = (requests: ReviewRequest[]) => shared(requests, reviewer, 'Automated reviewers'); +const nouns = (requests: ReviewRequest[]) => + shared(requests, (request) => `${traitsOf(request).noun}s`, 'PRs/MRs'); + +// A short sentence-case outcome for notifications, such as "Requested changes". +export function reviewOutcome(request: ReviewRequest): string { + const label = automatedReview(request)?.label ?? ''; + return label.charAt(0).toUpperCase() + label.slice(1); +} + +export function reviewTitle(requests: ReviewRequest[]): string { + if (requests.length === 1) + return `${reviewer(requests[0])} finished reviewing ${reference(requests[0])}`; + return `${reviewers(requests)} finished reviewing ${requests.length} ${nouns(requests)}`; +} + +// Reviews that finish in the same poll share one message, so the agent +// works through them in one turn. +export function reviewMessage(requests: ReviewRequest[]): string { + const state = (request: ReviewRequest) => `the state "${automatedReview(request)?.label ?? ''}"`; + if (requests.length === 1) { + const [request] = requests; + const name = reviewer(request); + return [ + `${name} finished reviewing ${reference(request)} (${request.url}) with ${state(request)}.`, + `Fetch ${name}'s new comments and discussion threads on the ${traitsOf(request).noun}, then work through its feedback.`, + ].join(' '); + } + const name = reviewers(requests); + return [ + `${name} finished reviewing ${requests.length} ${nouns(requests)}:`, + ...requests.map((request) => `- ${reference(request)} (${request.url}) with ${state(request)}`), + "For each one, fetch the reviewer's new comments and discussion threads, then work through its feedback.", + ].join('\n'); +} + +export function reviewToast(requests: ReviewRequest[]) { + const outcome = + requests.length === 1 + ? reviewOutcome(requests[0]) + : requests + .map((request) => `${reference(request)}: ${automatedReview(request)?.label ?? ''}`) + .join(', '); + return { title: reviewTitle(requests), message: `${outcome}. Sent to the agent.` }; +} + +// A failed send is retried after this long, while the review still has feedback. +export const RETRY_DELAY = 10 * 60_000; +// A delivery that keeps failing is dropped after this long. +const PENDING_RETENTION = 24 * 60 * 60_000; + +export const notificationKey = (sessionID: string, request: { url: string }) => + `${sessionID} ${request.url}`; + +// A review that finished with feedback but couldn't be sent yet. +export interface PendingDelivery { + sessionID: string; + url: string; + since: number; + retryAt: number; +} + +// Pending deliveries keyed by `notificationKey`. +export type PendingDeliveries = Record; + +export interface AutomatedReviewWatcherOptions { + // Whether the PR/MR is one of the session's targets right now. + isTarget: (sessionID: string, url: string) => boolean; + // Rejects when the session couldn't be told. + send: (sessionID: string, requests: ReviewRequest[]) => Promise; + onSendError?: (error: unknown, sessionID: string, requests: ReviewRequest[]) => void; + // The session's stored pending deliveries, read the first time this + // instance observes the session, which may have moved here from another + // checkout. From then on this instance is their only writer. + read?: (sessionID: string) => Promise; + // Stores one delivery, or removes it when `delivery` is undefined. Each + // delivery is its own record, so instances that share storage never + // overwrite each other's. + write?: (sessionID: string, url: string, delivery: PendingDelivery | undefined) => void; + now?: () => number; +} + +export type AutomatedReviewWatcher = ReturnType; + +const isOpen = (request: ReviewRequest) => request.state === 'opened'; + +// Notifies a session when automated reviews of its open targets finish with +// feedback. Reviews that finish in the same poll share one message. A failed +// send stays pending and is retried while the review still has feedback, +// because the review won't finish again to trigger another notification. +export function createAutomatedReviewWatcher(options: AutomatedReviewWatcherOptions) { + const now = options.now ?? Date.now; + // The pending deliveries of each session this instance has observed. + const known = new Map(); + const sending = new Set(); + + const write = (sessionID: string, url: string, delivery?: PendingDelivery) => + options.write?.(sessionID, url, delivery); + + return { + async observe(sessionID: string, previous: Lookup | undefined, next: Lookup): Promise { + const pending = known.get(sessionID) ?? { ...(await options.read?.(sessionID)) }; + known.set(sessionID, pending); + const at = now(); + // Merged or closed PRs/MRs need no more work, even with feedback. + const targets = requests(next).filter( + (request) => isOpen(request) && options.isTarget(sessionID, request.url), + ); + + // A pending delivery ends once its PR/MR is no longer an open target, + // its review no longer has feedback, such as when a new review starts, + // or it has failed for too long. + for (const [key, entry] of Object.entries(pending)) { + const request = targets.find((target) => target.url === entry.url); + if ( + request && + automatedReview(request)?.state === 'feedback' && + at - entry.since < PENDING_RETENTION + ) + continue; + if (sending.has(key)) continue; + delete pending[key]; + write(sessionID, entry.url); + } + + const finished = finishedReviews(previous, next).filter( + (request) => isOpen(request) && options.isTarget(sessionID, request.url), + ); + const due = targets.filter( + (request) => (pending[notificationKey(sessionID, request)]?.retryAt ?? Infinity) <= at, + ); + const batch = [ + ...new Map([...finished, ...due].map((request) => [request.url, request])).values(), + ].filter((request) => !sending.has(notificationKey(sessionID, request))); + if (batch.length === 0) return; + + const keys = batch.map((request) => notificationKey(sessionID, request)); + for (const key of keys) sending.add(key); + try { + await options.send(sessionID, batch); + batch.forEach((request, index) => { + delete pending[keys[index]]; + write(sessionID, request.url); + }); + } catch (error) { + const retryAt = now() + RETRY_DELAY; + batch.forEach((request, index) => { + const key = keys[index]; + pending[key] = { sessionID, url: request.url, since: pending[key]?.since ?? at, retryAt }; + write(sessionID, request.url, pending[key]); + }); + options.onSendError?.(error, sessionID, batch); + } finally { + for (const key of keys) sending.delete(key); + } + }, + // Whether the session has a delivery to retry, so its status keeps polling. + hasPending: (sessionID: string) => Object.keys(known.get(sessionID) ?? {}).length > 0, + // Drops what this instance knows about a session that moved away, so its + // deliveries are read again if it comes back. + forget: (sessionID: string) => void known.delete(sessionID), + }; +} diff --git a/packages/forge-session/src/exec.ts b/packages/forge-session/src/exec.ts new file mode 100644 index 0000000..b88e019 --- /dev/null +++ b/packages/forge-session/src/exec.ts @@ -0,0 +1,13 @@ +import { exec as run, type ExecResult } from '../../_shared/src/index.ts'; + +export type { ExecResult }; + +export type Exec = (file: string, args: string[], cwd: string) => Promise; + +// Runs a command without a shell, so arguments are never reinterpreted. +export const execWithTimeout = + (timeout: number): Exec => + (file, args, cwd) => + run(file, args, { cwd, timeout }); + +export const exec = execWithTimeout(30_000); diff --git a/packages/forge-session/src/forge.ts b/packages/forge-session/src/forge.ts new file mode 100644 index 0000000..d2bdb8d --- /dev/null +++ b/packages/forge-session/src/forge.ts @@ -0,0 +1,238 @@ +import type { Exec } from './exec'; +import type { RemoteProject, Repository } from './git'; + +// Forge-neutral review requests: GitLab merge requests and GitHub pull +// requests. The store, footer, and commands work with these types; each forge +// adapter owns its API calls and normalization. +export type ForgeKind = 'gitlab' | 'github'; + +export interface Pipeline { + status: string; + label: string; + url?: string; +} + +interface Common { + iid: string; + title: string; + url: string; + // opened, merged, closed, or locked. + state: string; + draft: boolean; + conflicts: boolean; + // False while the forge hasn't computed mergeability yet. + conflictsKnown: boolean; + // Approved, with no human reviewer still pending. + approved: boolean; + // Human reviewers who haven't approved yet. + awaitingReviewers: string[]; + // False when more reviewers exist than the adapter fetches. + reviewersComplete: boolean; + targetProject: string; + targetBranch: string; + headSha?: string; + updatedAt?: string; + pipeline?: Pipeline; + unresolvedThreads: number; + // False when more thread pages exist than the adapter fetches. + threadsComplete: boolean; +} + +export interface GitLabMergeRequest extends Common { + forge: 'gitlab'; + // Approvals from people; bot approvals such as Duo's don't count. + hasApprovals: boolean; + approvalRequirementsSatisfied: boolean | null; + mergeStatus?: string; + duoReviewState?: string; +} + +export interface GitHubPullRequest extends Common { + forge: 'github'; + // GitHub's review decision. It doesn't cover every branch protection rule. + reviewDecision?: string; +} + +export type ReviewRequest = GitLabMergeRequest | GitHubPullRequest; + +// A PR/MR identified by URL, independent of the checked-out branch. +export interface ReviewRef { + forge: ForgeKind; + host: string; + project: string; + iid: string; +} + +export type Tone = 'muted' | 'success' | 'warning' | 'error'; + +// A footer segment. `compact` replaces the text when space runs short, and +// essential segments are the last to be dropped. +export interface Indicator { + text: string; + tone: Tone; + url?: string; + compact?: string; + essential?: boolean; +} + +// Everything that differs between forges, apart from API access: identity, +// host detection, URLs, vocabulary, and how the forge presents its own status +// fields. Traits are stateless, so code can use them with only a host, a URL, +// or a request. `open` creates the adapter that talks to the forge. +export interface ForgeTraits< + Request extends ReviewRequest = ReviewRequest, + Adapter extends Forge = Forge, +> { + readonly kind: Request['forge']; + // "MR" or "PR". + readonly noun: string; + // What the forge calls CI results, such as "pipeline". + readonly ci: string; + // Whether the forge serves a host that isn't configured, judging by its name. + recognizes(host: string): boolean; + // An adapter that runs the forge's CLI in `directory`. + open(run: Exec, directory: string): Adapter; + // Whether a request came from this forge. + owns(request: ReviewRequest): request is Request; + // The PR/MR a web URL names. Undefined for other URLs, such as issues. + parseUrl(url: string): ReviewRef | undefined; + // The issue a web URL names. Undefined for other URLs, such as PRs/MRs. + parseIssueUrl(url: string): ReviewRef | undefined; + // Writes a request number the way the forge does, such as `!45` or `#7`. + reference(iid: string): string; + // Writes an issue number the way the forge does, such as `#12`. + issueReference(iid: string): string; + // The request numbers in a session title's managed prefix, such as + // `[#12, !45]`, in order. Some may name issues rather than PRs/MRs. + titleReferences(title: string | undefined): string[]; + // Footer segments for forge-specific status, such as a requested change. + indicators(request: Request): Indicator[]; + // Status dialog lines for forge-specific fields, such as approval rules. + details(request: Request): string[]; + // Optional capabilities. Shared code checks for these, never for a forge. + readonly automatedReview?: AutomatedReview; + readonly feedback?: ReviewFeedback; +} + +// `running` reviews poll faster, and a review that goes from `running` to +// `feedback` left something for the agent to act on. Other final states, such +// as an approval, are `settled`. +export type AutomatedReviewState = 'running' | 'feedback' | 'settled'; + +// A forge's automated code reviewer, such as GitLab Duo. +export interface AutomatedReview { + readonly name: string; + // The reviewer's review of a request, or undefined without one. + status(request: Request): { state: AutomatedReviewState; label: string } | undefined; +} + +// A comment on a PR/MR, as read by a forge's `feedback` capability. The forge +// leaves out entries that aren't comments, such as GitLab's system notes. +export interface ReviewComment { + // Identifies the comment, for remembering which comments were announced. + id: string; + // Orders comments by when they were posted. The forge guarantees that a + // comment posted later has a higher order than every earlier comment on the + // same request, across all of the forge's comment types. + order: number; + url: string; + // When the comment was last written, in milliseconds. + editedAt: number; + // Whether its thread is resolved. Comments outside threads are unresolved. + resolved: boolean; + bot: boolean; + authorID: string; + username: string; +} + +export interface FeedbackSnapshot { + // False when a page is missing or the viewer is unknown. A complete snapshot + // covers every comment, or the newest comments the forge reads. + complete: boolean; + // The ID of the user that the forge's CLI is logged in as. + viewerID?: string; + comments: ReviewComment[]; +} + +// Reads a request's review comments, for announcing new human feedback. +export interface ReviewFeedback { + // Undefined when the request doesn't exist. + fetch(run: Exec, directory: string, request: Request): Promise; +} + +// A type guard for one forge's requests, for its traits' `owns`. +export const ownedBy = + (kind: Kind) => + (request: ReviewRequest): request is Extract => + request.forge === kind; + +// The server prefixes titles with the session's references, such as +// `[#123, !456] Title`. Returns the prefix, or "" without one. +export const titlePrefix = (title: string | undefined) => title?.match(/^\[[^\]]*\]/)?.[0] ?? ''; + +// The numbers that follow `sigil` in a title's managed prefix, in order. +export function prefixReferences(title: string | undefined, sigil: string): string[] { + return titlePrefix(title) + .slice(1, -1) + .split(/[\s,]+/) + .flatMap((token) => + token.startsWith(sigil) && /^[1-9]\d*$/.test(token.slice(sigil.length)) + ? [token.slice(sigil.length)] + : [], + ); +} + +// Parses a forge's web URL whose path matches `pattern`, capturing the project +// path and the request number. +export function parseWebUrl( + kind: ForgeKind, + value: string, + pattern: RegExp, +): ReviewRef | undefined { + let url: URL; + try { + url = new URL(value); + } catch { + return undefined; + } + if (!['https:', 'http:'].includes(url.protocol)) return undefined; + const match = url.pathname.match(pattern); + if (!match) return undefined; + return { + forge: kind, + host: url.hostname.toLowerCase(), + project: decodeURIComponent(match[1]), + iid: match[2], + }; +} + +export const yesNo = (value: boolean) => (value ? 'yes' : 'no'); +export const humanize = (value: string) => value.toLowerCase().replace(/_/g, ' '); + +export type ErrorKind = 'auth' | 'rate-limit' | 'missing-glab' | 'missing-gh' | 'request'; + +export class ForgeError extends Error { + constructor( + readonly kind: ErrorKind, + message: string, + ) { + super(message); + } +} + +export interface Forge { + readonly traits: ForgeTraits; + // Looks up one PR/MR by number and returns the match from the first project + // that has it, so `origin` wins over other remotes. + findByNumber( + projects: readonly RemoteProject[], + number: string, + ): Promise; + // Open PRs/MRs from the repository's current branch, newest first. + findByBranch(repository: Repository): Promise; + // The number of the newest open PR/MR from the repository's current branch. + // Unlike `findByBranch`, it reads no status, so session titles stay cheap. + findNumberByBranch(repository: Repository): Promise; + // The branch a PR/MR was opened from. Undefined when the PR/MR doesn't exist. + sourceBranch(ref: ReviewRef): Promise; +} diff --git a/packages/forge-session/src/forges.test.ts b/packages/forge-session/src/forges.test.ts new file mode 100644 index 0000000..477f4f4 --- /dev/null +++ b/packages/forge-session/src/forges.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test'; +import type { Exec } from './exec'; +import { Forges, hostsFrom, parseReference } from './forges'; +import { GitHubForge } from './github'; +import { GitLabForge } from './gitlab'; + +const remotes: Exec = async (_file, args) => ({ + code: 0, + stderr: '', + stdout: + args[0] === 'config' + ? 'remote.origin.url git@github.com:o/r.git\nremote.lab.url git@gitlab.example.com:g/p.git' + : '', +}); + +const forges = new Forges(remotes, { + gitlab: ['gitlab.example.com'], + github: ['github.com', 'ghe.example.com'], +}); + +describe('Forges', () => { + test('classifies configured and recognizable hosts', () => { + expect(forges.kind('github.com')).toBe('github'); + expect(forges.kind('ghe.example.com')).toBe('github'); + expect(forges.kind('gitlab.example.com')).toBe('gitlab'); + expect(forges.kind('gitlab.com')).toBe('gitlab'); + expect(forges.kind('bitbucket.org')).toBeUndefined(); + }); + + test('creates the adapter for a host', () => { + expect(forges.forHost('ghe.example.com', '/repo')).toBeInstanceOf(GitHubForge); + expect(forges.forHost('gitlab.example.com', '/repo')).toBeInstanceOf(GitLabForge); + expect(forges.forHost('bitbucket.org', '/repo')).toBeUndefined(); + }); + + test('prefers configured hosts over recognized ones', () => { + expect( + new Forges(remotes, { gitlab: [], github: ['gitlab.mirror.example.com'] }).kind( + 'gitlab.mirror.example.com', + ), + ).toBe('github'); + }); + + test("lists only the requested forge's projects, even when origin is on another forge", async () => { + expect(await forges.projects('/repo', 'github')).toEqual([{ host: 'github.com', path: 'o/r' }]); + expect(await forges.projects('/repo', 'gitlab')).toEqual([ + { host: 'gitlab.example.com', path: 'g/p' }, + ]); + const mirrored: Exec = async () => ({ + code: 0, + stderr: '', + stdout: + 'remote.origin.url git@gitlab.example.com:g/p.git\nremote.mirror.url git@github.com:o/r.git', + }); + expect(await new Forges(mirrored, forges.hosts).projects('/repo', 'github')).toEqual([ + { host: 'github.com', path: 'o/r' }, + ]); + }); + + test('reads host options with defaults', () => { + expect(hostsFrom({})).toEqual({ gitlab: ['gitlab.com'], github: ['github.com'] }); + expect(hostsFrom({ hosts: ['gitlab.example.com', 3], githubHosts: [] })).toEqual({ + gitlab: ['gitlab.example.com'], + github: [], + }); + }); + + test('parses PR/MR and issue URLs on either forge', () => { + expect(parseReference('https://gitlab.com/g/p/-/merge_requests/4')).toEqual({ + ref: { forge: 'gitlab', host: 'gitlab.com', project: 'g/p', iid: '4' }, + issue: false, + }); + expect(parseReference('https://gitlab.com/g/p/-/issues/4')).toMatchObject({ + ref: { forge: 'gitlab' }, + issue: true, + }); + expect(parseReference('https://github.com/o/r/pull/4')).toMatchObject({ + ref: { forge: 'github' }, + issue: false, + }); + expect(parseReference('https://github.com/o/r/issues/4')).toMatchObject({ + ref: { forge: 'github' }, + issue: true, + }); + expect(parseReference('https://github.com/o/r')).toBeUndefined(); + }); + + test('resolves the branch only on supported forges', async () => { + const unsupported: Exec = async (_file, args) => ({ + code: 0, + stderr: '', + stdout: + args[0] === 'config' + ? 'remote.origin.url git@bitbucket.org:o/r.git' + : args[0] === 'symbolic-ref' && args.length === 4 + ? 'feature' + : '/repo', + }); + expect(await new Forges(unsupported, forges.hosts).repository('/repo')).toEqual({ + kind: 'none', + reason: 'Not a supported forge remote', + }); + }); +}); diff --git a/packages/forge-session/src/forges.ts b/packages/forge-session/src/forges.ts new file mode 100644 index 0000000..ac1ddcd --- /dev/null +++ b/packages/forge-session/src/forges.ts @@ -0,0 +1,102 @@ +import type { Exec } from './exec'; +import type { Forge, ForgeKind, ForgeTraits, ReviewRef, ReviewRequest } from './forge'; +import { + resolveProjects, + resolveRepository, + type RemoteProject, + type RepositoryLookup, +} from './git'; +import { githubTraits } from './github'; +import { gitlabTraits } from './gitlab'; + +const registry = { gitlab: gitlabTraits, github: githubTraits }; + +export const kinds = Object.keys(registry) as ForgeKind[]; + +// The traits of one forge. +export const traits = (kind: Kind) => registry[kind]; + +// The traits of a request's forge. The lookup keys on the request's own +// forge, so the traits' methods always receive a request of their type. +export const traitsOf = (request: ReviewRequest): ForgeTraits => registry[request.forge]; + +// The request's number as its forge writes it, such as `!45` or `#7`. +export const reference = (request: ReviewRequest) => traitsOf(request).reference(request.iid); + +// The PR/MR a web URL names on any forge. +export function parseUrl(url: string): ReviewRef | undefined { + for (const kind of kinds) { + const ref = registry[kind].parseUrl(url); + if (ref) return ref; + } + return undefined; +} + +// The PR/MR or issue a web URL names on any forge. +export function parseReference(url: string): { ref: ReviewRef; issue: boolean } | undefined { + for (const kind of kinds) { + const request = registry[kind].parseUrl(url); + if (request) return { ref: request, issue: false }; + const issue = registry[kind].parseIssueUrl(url); + if (issue) return { ref: issue, issue: true }; + } + return undefined; +} + +// Hosts each forge serves, besides the hosts it recognizes by name. +export type ForgeHosts = Record; + +// The `hosts` and `githubHosts` plugin options, which both entry points read. +export function hostsFrom(options: Record): ForgeHosts { + const list = (value: unknown, fallback: string) => + Array.isArray(value) + ? value.filter((host): host is string => typeof host === 'string') + : [fallback]; + return { + gitlab: list(options.hosts, 'gitlab.com'), + github: list(options.githubHosts, 'github.com'), + }; +} + +// What PR/MR lookup needs from the registry, so tests can supply fakes. +export interface ForgeCatalog { + readonly kinds: readonly ForgeKind[]; + kind(host: string): ForgeKind | undefined; + forHost(host: string, directory: string): Forge | undefined; + projects(directory: string, kind: ForgeKind): Promise; + repository(directory: string): Promise; +} + +// Selects and creates forge adapters for a checkout. Adapters are created per +// directory, because `gh` and `glab` read repository context from their cwd. +export class Forges implements ForgeCatalog { + readonly kinds = kinds; + + constructor( + private readonly run: Exec, + readonly hosts: ForgeHosts, + ) {} + + // Configured hosts win over hosts that a forge recognizes by name. + kind(host: string): ForgeKind | undefined { + return ( + kinds.find((kind) => this.hosts[kind].includes(host)) ?? + kinds.find((kind) => registry[kind].recognizes(host)) + ); + } + + forHost(host: string, directory: string): Forge | undefined { + const kind = this.kind(host); + return kind ? registry[kind].open(this.run, directory) : undefined; + } + + // The checkout's projects on one forge, `origin` first. + async projects(directory: string, kind: ForgeKind): Promise { + const lookup = await resolveProjects(this.run, directory, (host) => this.kind(host) === kind); + return lookup.kind === 'projects' ? lookup.projects : []; + } + + repository(directory: string): Promise { + return resolveRepository(this.run, directory, (host) => this.kind(host) !== undefined); + } +} diff --git a/packages/forge-session/src/format.test.ts b/packages/forge-session/src/format.test.ts new file mode 100644 index 0000000..4ee2f19 --- /dev/null +++ b/packages/forge-session/src/format.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, test } from 'bun:test'; +import { detailsMessage, footerSegments, responsiveFooterSegments, segmentsWidth } from './format'; +import type { Repository } from './git'; +import type { GitLabMergeRequest } from './forge'; +import type { Snapshot } from './store'; + +const repository: Repository = { + head: 'local', + branch: 'feature', + sourceBranch: 'feature', + source: { host: 'gitlab.com', path: 'group/project' }, + targets: [], +}; + +const mr = (overrides: Partial = {}): GitLabMergeRequest => ({ + forge: 'gitlab', + conflictsKnown: true, + awaitingReviewers: [], + reviewersComplete: true, + iid: '4281', + title: 'Add things', + url: 'https://gitlab.com/group/project/-/merge_requests/4281', + state: 'opened', + draft: false, + conflicts: false, + approved: false, + hasApprovals: false, + approvalRequirementsSatisfied: false, + targetProject: 'group/project', + targetBranch: 'main', + headSha: 'local', + pipeline: { status: 'FAILED', label: 'failed' }, + unresolvedThreads: 2, + threadsComplete: true, + ...overrides, +}); + +const snapshot = (requests: GitLabMergeRequest[], extra: Partial = {}): Snapshot => ({ + lookup: { kind: 'found', repository, requests }, + fetchedAt: 0, + loading: false, + ...extra, +}); + +const text = (value: Snapshot) => + footerSegments(value) + .map((segment) => segment.text) + .join(' · '); + +describe('footerSegments', () => { + test('shows an active Duo review', () => { + expect(text(snapshot([mr({ duoReviewState: 'REVIEW_STARTED' })]))).toBe( + '!4281 · CI failed · 2 unresolved threads · 🤖 reviewing', + ); + }); + + test.each(['REVIEWED', 'UNREVIEWED', 'APPROVED', undefined])( + 'omits inactive Duo reviews: %s', + (duoReviewState) => { + expect(text(snapshot([mr({ duoReviewState })]))).not.toContain('🤖'); + }, + ); + + test('omits the Duo indicator on merged MRs', () => { + expect(text(snapshot([mr({ state: 'merged', duoReviewState: 'REVIEW_STARTED' })]))).toBe( + '!4281 · merged', + ); + }); + test('shows the MR, pipeline, and threads', () => { + expect(text(snapshot([mr()]))).toBe('!4281 · CI failed · 2 unresolved threads'); + }); + + test('adds conflicts and approval, and omits resolved threads', () => { + const value = snapshot([ + mr({ unresolvedThreads: 0, conflicts: true, approved: true, pipeline: undefined }), + ]); + expect(text(value)).toBe('!4281 · no pipeline · conflicts · approved'); + }); + + test('shows pending reviewers instead of approval', () => { + const base = { unresolvedThreads: 0, pipeline: undefined, approved: true }; + expect(text(snapshot([mr({ ...base, awaitingReviewers: ['david'] })]))).toBe( + '!4281 · no pipeline · awaiting @david', + ); + expect(text(snapshot([mr({ ...base, awaitingReviewers: ['a', 'b'] })]))).toBe( + '!4281 · no pipeline · awaiting @a, @b', + ); + expect(text(snapshot([mr({ ...base, awaitingReviewers: ['a', 'b', 'c'] })]))).toBe( + '!4281 · no pipeline · awaiting 3 reviewers', + ); + expect(text(snapshot([mr({ ...base, awaitingReviewers: [] })]))).toBe( + '!4281 · no pipeline · approved', + ); + }); + + test('marks incomplete thread counts', () => { + expect(text(snapshot([mr({ unresolvedThreads: 1, threadsComplete: false })]))).toContain( + '1+ unresolved threads', + ); + }); + + test('is empty without an MR or repository', () => { + expect(footerSegments(snapshot([]))).toEqual([]); + expect( + footerSegments({ lookup: { kind: 'none', reason: 'Detached HEAD' }, loading: false }), + ).toEqual([]); + expect(footerSegments({ loading: true })).toEqual([]); + }); + + test('distinguishes a failed lookup from no MR', () => { + const error = { kind: 'auth' as const, message: 'GitLab authentication failed', at: 0 }; + expect(text({ loading: false, error })).toBe('PR/MR status unavailable'); + expect(text(snapshot([mr()], { error }))).toBe( + '!4281 · CI failed · 2 unresolved threads · stale', + ); + }); + + test('does not pick one of several MRs', () => { + expect(text(snapshot([mr(), mr({ iid: '9' })]))).toBe( + '!4281 · !9 · 2 open MRs, run /mr-status', + ); + }); + + test('summarizes several session targets', () => { + const targets = ( + requests: GitLabMergeRequest[], + failed?: { url: string; reason: string }[], + ): Snapshot => ({ + lookup: { + kind: 'found', + explicitTarget: true, + sessionTarget: '4 PRs/MRs set with set_session_target', + requests, + ...(failed ? { failed } : {}), + }, + loading: false, + }); + const requests = [ + mr({ iid: '1', duoReviewState: 'REVIEW_STARTED' }), + mr({ + iid: '2', + duoReviewState: 'REVIEW_STARTED', + conflicts: true, + pipeline: { status: 'SUCCESS', label: 'passed' }, + }), + mr({ iid: '3', approved: true, pipeline: undefined }), + mr({ iid: '4', state: 'merged' }), + mr({ iid: '5', state: 'closed' }), + ]; + expect(text(targets(requests))).toBe( + '5 MRs · 🤖 2 reviewing · 1 CI failed · 1 conflict · 1 approved · 1 merged · 1 closed', + ); + expect(footerSegments(targets(requests)).every((segment) => !segment.url)).toBe(true); + + const failed = [ + { + url: 'https://gitlab.com/group/project/-/merge_requests/6', + reason: 'GitLab request failed', + }, + ]; + const partial = targets(requests.slice(2, 4), failed); + expect(text(partial)).toBe('2 MRs · 1 approved · 1 merged · 1 unavailable'); + expect(detailsMessage(partial)).toContain( + 'Unavailable: https://gitlab.com/group/project/-/merge_requests/6 (GitLab request failed)', + ); + // One remaining target shows its full status, and still reports the others. + expect(text(targets([mr()], failed))).toBe( + '!4281 · CI failed · 2 unresolved threads · 1 unavailable', + ); + }); + + test('shows only the state of a merged or closed session target', () => { + const merged = { + lookup: { + kind: 'found' as const, + sessionTarget: '!4281 (from the session title)', + requests: [mr({ state: 'merged' })], + }, + loading: false, + }; + expect(text(merged)).toBe('!4281 · merged'); + expect(detailsMessage(merged)).toContain('Session target: !4281'); + }); + + test('links the MR and pipeline', () => { + const [iid, pipeline, threads] = footerSegments( + snapshot([ + mr({ + pipeline: { + status: 'SUCCESS', + label: 'passed', + url: 'https://gitlab.com/p/-/pipelines/1', + }, + }), + ]), + ); + expect(iid.url).toBe('https://gitlab.com/group/project/-/merge_requests/4281'); + expect(pipeline.url).toBe('https://gitlab.com/p/-/pipelines/1'); + expect(threads.url).toBeUndefined(); + }); +}); + +describe('detailsMessage', () => { + test("includes Duo's review state when available", () => { + expect(detailsMessage(snapshot([mr({ duoReviewState: 'REVIEW_STARTED' })]))).toContain( + 'Duo review: review started', + ); + expect(detailsMessage(snapshot([mr({ duoReviewState: 'REVIEWED' })]))).toContain( + 'Duo review: reviewed', + ); + expect(detailsMessage(snapshot([mr()]))).not.toContain('Duo review:'); + }); + test('distinguishes actual approvals from satisfied requirements', () => { + const message = detailsMessage(snapshot([mr({ approvalRequirementsSatisfied: true })])); + expect(message).toContain('Human approvals: no · Approval requirements satisfied: yes'); + }); + + test('lists pending reviewers', () => { + expect(detailsMessage(snapshot([mr({ awaitingReviewers: ['david', 'erin'] })]))).toContain( + 'Awaiting review: @david, @erin', + ); + expect(detailsMessage(snapshot([mr()]))).not.toContain('Awaiting review:'); + }); + + test('reports unavailable approval requirements as unknown', () => { + const message = detailsMessage(snapshot([mr({ approvalRequirementsSatisfied: null })])); + expect(message).toContain('Approval requirements satisfied: unknown'); + }); + + test('includes the URL and head mismatch', () => { + const message = detailsMessage(snapshot([mr({ headSha: 'remote' })])); + expect(message).toContain('https://gitlab.com/group/project/-/merge_requests/4281'); + expect(message).toContain('Local HEAD differs'); + }); + + test('explains why no lookup happened', () => { + expect( + detailsMessage({ lookup: { kind: 'none', reason: 'Detached HEAD' }, loading: false }), + ).toContain('Detached HEAD'); + }); +}); + +describe('responsiveFooterSegments', () => { + const value = snapshot([ + mr({ + iid: '259389', + duoReviewState: 'REVIEW_STARTED', + pipeline: { status: 'SUCCESS', label: 'passed', url: 'https://gitlab.com/p/-/pipelines/1' }, + }), + ]); + const responsiveText = (width: number) => + responsiveFooterSegments(value, width) + .map((segment) => segment.text) + .join(' · '); + + test('selects full, compact, and minimal formats', () => { + expect(responsiveText(100)).toBe('!259389 · CI passed · 2 unresolved threads · 🤖 reviewing'); + expect(responsiveText(35)).toBe('!259389 · CI ✓ · 2 threads · 🤖'); + expect(responsiveText(20)).toBe('!259389 · 🤖'); + expect(responsiveText(7)).toBe('!259389'); + expect(responsiveText(2)).toBe(''); + expect(responsiveText(0)).toBe(''); + }); + + test('measures emoji in terminal cells', () => { + expect(segmentsWidth([{ text: '🤖', tone: 'warning' }])).toBe(2); + expect(responsiveText(12)).toBe('!259389 · 🤖'); + expect(responsiveText(11)).toBe('!259389'); + }); + + test('preserves links and tones in compact mode', () => { + const segments = responsiveFooterSegments(value, 35); + expect(segments[0].url).toBe(mr().url); + expect(segments[1]).toMatchObject({ + text: 'CI ✓', + tone: 'success', + url: 'https://gitlab.com/p/-/pipelines/1', + }); + expect(segments[3]).toMatchObject({ text: '🤖', tone: 'warning' }); + }); + + test('keeps conflict and stale warnings in minimal mode', () => { + const sample = snapshot([mr({ conflicts: true })], { + error: { kind: 'request', message: 'timeout', at: 0 }, + }); + expect( + responsiveFooterSegments(sample, 28) + .map((segment) => segment.text) + .join(' · '), + ).toBe('!4281 · conflicts · stale'); + }); + + test('never exceeds the allocated width', () => { + const samples = [ + value, + snapshot([mr({ conflicts: true, approved: true })]), + snapshot([mr({ awaitingReviewers: ['david', 'erin'] })]), + snapshot([mr({ threadsComplete: false })]), + snapshot([mr(), mr({ iid: '9' })]), + ]; + for (const sample of samples) { + for (let width = 0; width < 100; width++) { + expect(segmentsWidth(responsiveFooterSegments(sample, width))).toBeLessThanOrEqual(width); + } + } + }); + + test('restores the full format when space increases', () => { + responsiveFooterSegments(value, 12); + expect(responsiveFooterSegments(value, 100)).toEqual(footerSegments(value)); + }); +}); diff --git a/packages/forge-session/src/format.ts b/packages/forge-session/src/format.ts new file mode 100644 index 0000000..bb701e6 --- /dev/null +++ b/packages/forge-session/src/format.ts @@ -0,0 +1,240 @@ +import { automatedReview } from './automated-review-watch'; +import { yesNo, type Indicator, type Pipeline, type ReviewRequest, type Tone } from './forge'; +import { reference, traitsOf } from './forges'; +import type { Snapshot } from './store'; + +export type { Tone }; +export type Segment = Indicator; + +const RUNNING = new Set([ + 'CREATED', + 'WAITING_FOR_RESOURCE', + 'PREPARING', + 'PENDING', + 'RUNNING', + 'SCHEDULED', + 'WAITING_FOR_CALLBACK', + 'CANCELING', +]); + +export function pipelineTone(pipeline: Pipeline | undefined): Tone { + if (!pipeline) return 'muted'; + if (pipeline.status === 'FAILED') return 'error'; + if (pipeline.status === 'SUCCESS') + return pipeline.label.includes('warning') ? 'warning' : 'success'; + if (RUNNING.has(pipeline.status)) return 'warning'; + return 'muted'; +} + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`; +const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1); +const COMPACT_CI: Partial> = { + success: 'CI ✓', + error: 'CI ✗', + warning: 'CI …', +}; + +// Names one or two reviewers and counts larger groups to keep the footer short. +function awaitingText(reviewers: string[]) { + if (reviewers.length > 2) return `awaiting ${reviewers.length} reviewers`; + return `awaiting ${reviewers.map((username) => `@${username}`).join(', ')}`; +} + +function threadsText(request: ReviewRequest) { + return `${request.unresolvedThreads}${request.threadsComplete ? '' : '+'} unresolved thread${request.unresolvedThreads === 1 && request.threadsComplete ? '' : 's'}`; +} + +const link = (request: ReviewRequest): Segment => ({ + text: reference(request), + tone: 'muted', + url: request.url, + essential: true, +}); + +function ci(request: ReviewRequest): Segment { + const tone = pipelineTone(request.pipeline); + if (!request.pipeline) return { text: `no ${traitsOf(request).ci}`, compact: 'no CI', tone }; + return { + text: `CI ${request.pipeline.label}`, + compact: COMPACT_CI[tone], + tone, + url: request.pipeline.url, + }; +} + +// Counts the PRs/MRs in each state, for a session with several targets. +// Clicking a segment without a link opens the status dialog with the details. +function targetsSummary(requests: ReviewRequest[]): Segment[] { + const nouns = new Set(requests.map((request) => traitsOf(request).noun)); + const noun = nouns.size === 1 ? [...nouns][0] : 'PR/MR'; + const count = + requests.length === 1 + ? `1 ${noun}` + : `${requests.length} ${noun === 'PR/MR' ? 'PRs/MRs' : `${noun}s`}`; + const open = requests.filter((request) => request.state === 'opened'); + const segments: Segment[] = [{ text: count, tone: 'muted', essential: true }]; + const reviewing = open.filter((request) => automatedReview(request)?.state === 'running').length; + if (reviewing) + segments.push({ + text: `🤖 ${reviewing} reviewing`, + compact: `🤖 ${reviewing}`, + tone: 'warning', + essential: true, + }); + const failing = open.filter((request) => pipelineTone(request.pipeline) === 'error').length; + if (failing) + segments.push({ text: `${failing} CI failed`, compact: `CI ✗ ${failing}`, tone: 'error' }); + const conflicts = open.filter((request) => request.conflicts).length; + if (conflicts) + segments.push({ text: plural(conflicts, 'conflict'), tone: 'error', essential: true }); + const approved = open.filter( + (request) => request.approved && request.awaitingReviewers.length === 0, + ).length; + if (approved) segments.push({ text: `${approved} approved`, tone: 'success' }); + const merged = requests.filter((request) => request.state === 'merged').length; + if (merged) segments.push({ text: `${merged} merged`, tone: 'success' }); + const closed = requests.length - open.length - merged; + if (closed) segments.push({ text: `${closed} closed`, tone: 'muted' }); + return segments; +} + +export function footerSegments(snapshot: Snapshot): Segment[] { + const { lookup, error } = snapshot; + const stale: Segment[] = error ? [{ text: 'stale', tone: 'warning', essential: true }] : []; + if (!lookup || lookup.kind === 'none' || lookup.requests.length === 0) { + return error ? [{ text: 'PR/MR status unavailable', tone: 'warning' }] : []; + } + if (lookup.failed?.length) { + stale.unshift({ + text: `${lookup.failed.length} unavailable`, + tone: 'warning', + essential: true, + }); + } + + const requests = lookup.requests; + if (lookup.explicitTarget && requests.length > 1) return [...targetsSummary(requests), ...stale]; + if (requests.length > 1) { + const noun = traitsOf(requests[0]).noun; + return [ + ...requests.map(link), + { + text: `${plural(requests.length, `open ${noun}`)}, run /${noun.toLowerCase()}-status`, + tone: 'warning', + }, + ...stale, + ]; + } + + const [request] = requests; + if (request.state !== 'opened') { + return [ + link(request), + { + text: request.state, + tone: request.state === 'merged' ? 'success' : 'muted', + essential: true, + }, + ...stale, + ]; + } + const segments = [link(request), ci(request)]; + if (request.unresolvedThreads > 0 || !request.threadsComplete) { + const text = threadsText(request); + segments.push({ text, compact: text.replace('unresolved ', ''), tone: 'warning' }); + } + if (automatedReview(request)?.state === 'running') { + segments.push({ text: '🤖 reviewing', compact: '🤖', tone: 'warning', essential: true }); + } + segments.push(...traitsOf(request).indicators(request)); + if (request.conflicts) segments.push({ text: 'conflicts', tone: 'error', essential: true }); + if (!request.conflictsKnown) segments.push({ text: 'mergeability unknown', tone: 'muted' }); + if (request.awaitingReviewers.length) + segments.push({ text: awaitingText(request.awaitingReviewers), tone: 'muted' }); + else if (request.approved) segments.push({ text: 'approved', tone: 'success' }); + return [...segments, ...stale]; +} + +export const segmentsWidth = (segments: Segment[]) => + Bun.stringWidth(segments.map((segment) => segment.text).join(' · ')); + +export function responsiveFooterSegments(snapshot: Snapshot, width: number): Segment[] { + const full = footerSegments(snapshot); + if (segmentsWidth(full) <= width) return full; + + const compact = full.map( + (segment): Segment => ({ + ...segment, + text: segment.compact ?? segment.text, + }), + ); + if (segmentsWidth(compact) <= width) return compact; + + const minimal = compact.filter((segment) => segment.essential); + if (segmentsWidth(minimal) <= width) return minimal; + + // Keep the PR/MR link first, then add only complete indicators that fit. + if (minimal[0] && segmentsWidth([minimal[0]]) > width) return []; + const fitted: Segment[] = []; + for (const segment of minimal) { + if (segmentsWidth([...fitted, segment]) <= width) fitted.push(segment); + } + return fitted; +} + +const time = (value: number) => new Date(value).toLocaleTimeString(); + +function describe(request: ReviewRequest, head: string): string[] { + const traits = traitsOf(request); + const lines = [ + `${reference(request)} ${request.title}${request.state === 'opened' ? '' : ` (${request.state})`}`, + `Target: ${request.targetProject} → ${request.targetBranch}`, + `${capitalize(traits.ci)}: ${request.pipeline ? request.pipeline.label : 'none'}${request.pipeline?.url ? ` (${request.pipeline.url})` : ''}`, + `Unresolved threads: ${request.unresolvedThreads}${request.threadsComplete ? '' : '+'}`, + `Draft: ${yesNo(request.draft)} · Conflicts: ${request.conflictsKnown ? yesNo(request.conflicts) : 'unknown'}`, + ...traits.details(request), + ]; + const review = automatedReview(request); + if (review) lines.push(`${review.name} review: ${review.label}`); + if (request.awaitingReviewers.length) { + lines.push( + `Awaiting review: ${request.awaitingReviewers.map((username) => `@${username}`).join(', ')}`, + ); + } + if (!request.reviewersComplete) lines.push('Reviewer list is partial'); + if (head && request.headSha && head !== request.headSha) { + lines.push(`Local HEAD differs from the ${traits.noun} head commit (unpushed or not fetched)`); + } + lines.push(request.url); + return lines; +} + +export function detailsMessage(snapshot: Snapshot): string { + const { lookup, error, fetchedAt } = snapshot; + const lines: string[] = []; + + if (!lookup) { + lines.push(error ? 'Could not look up the PR/MR.' : 'No data yet.'); + } else if (lookup.kind === 'none') { + lines.push(`No PR/MR lookup: ${lookup.reason}.`); + } else { + const { repository, sessionTarget, requests } = lookup; + if (repository) { + const branch = + repository.branch === repository.sourceBranch + ? repository.branch + : `${repository.branch} (pushes to ${repository.sourceBranch})`; + lines.push(`Branch: ${branch} in ${repository.source.path}`); + if (requests.length === 0) lines.push('', 'No open PR/MR for this branch.'); + } else if (sessionTarget) { + lines.push(`Session target: ${sessionTarget}`); + } + for (const { url, reason } of lookup.failed ?? []) + lines.push(`Unavailable: ${url} (${reason})`); + for (const request of requests) lines.push('', ...describe(request, repository?.head ?? '')); + } + + if (error) lines.push('', `Last refresh failed at ${time(error.at)}: ${error.message}`); + if (fetchedAt !== undefined) lines.push('', `Last fetched: ${time(fetchedAt)}`); + return lines.join('\n'); +} diff --git a/packages/forge-session/src/git.test.ts b/packages/forge-session/src/git.test.ts new file mode 100644 index 0000000..37eafb3 --- /dev/null +++ b/packages/forge-session/src/git.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'bun:test'; +import type { Exec } from './exec'; +import { parseRemote, resolveProjects, resolveRepository } from './git'; + +const onGitLab = (host: string) => host === 'gitlab.com'; + +describe('resolveProjects', () => { + test('lists GitLab remotes with origin first', async () => { + const run = fakeGit({ + 'config --get-regexp ^remote\\..*\\.url$': [ + 'remote.security.url git@gitlab.com:gitlab-org/security/gitlab.git', + 'remote.github.url git@github.com:me/gitlab.git', + 'remote.origin.url git@gitlab.com:gitlab-org/gitlab.git', + ].join('\n'), + }); + expect(await resolveProjects(run, '/repo', onGitLab)).toEqual({ + kind: 'projects', + projects: [ + { host: 'gitlab.com', path: 'gitlab-org/gitlab' }, + { host: 'gitlab.com', path: 'gitlab-org/security/gitlab' }, + ], + }); + }); + + test('reports a checkout without GitLab remotes', async () => { + const run = fakeGit({ + 'config --get-regexp ^remote\\..*\\.url$': 'remote.origin.url git@github.com:me/x.git', + }); + expect(await resolveProjects(run, '/repo', onGitLab)).toEqual({ + kind: 'none', + reason: 'No matching forge remote', + }); + }); +}); + +describe('parseRemote', () => { + test.each([ + ['git@gitlab.com:gitlab-org/gitlab.git', { host: 'gitlab.com', path: 'gitlab-org/gitlab' }], + [ + 'ssh://git@gitlab.com:2222/group/sub/project.git', + { host: 'gitlab.com', path: 'group/sub/project' }, + ], + ['https://oauth2@GitLab.com/group/project/', { host: 'gitlab.com', path: 'group/project' }], + ['gitlab.com:group/project', { host: 'gitlab.com', path: 'group/project' }], + ])('parses %s', (url, expected) => { + expect(parseRemote(url)).toEqual(expected); + }); + + test.each(['/srv/repo.git', 'file:///srv/repo.git', 'https://gitlab.com/project', 'not a url'])( + 'rejects %s', + (url) => { + expect(parseRemote(url)).toBeUndefined(); + }, + ); +}); + +type Responses = Record; + +function fakeGit(responses: Responses): Exec { + return async (_file, args) => { + const output = responses[args.join(' ')]; + return output === undefined + ? { stdout: '', stderr: 'fatal', code: 128 } + : { stdout: `${output}\n`, stderr: '', code: 0 }; + }; +} + +const REFS = + 'for-each-ref --format=%(push:remotename)%00%(push:remoteref)%00%(upstream:remotename)%00%(upstream:remoteref) refs/heads/feature'; + +const base: Responses = { + 'rev-parse --show-toplevel': '/repo', + 'symbolic-ref --quiet --short HEAD': 'feature', + 'rev-parse HEAD': 'abc123', + 'config --get-regexp ^remote\\..*\\.url$': [ + 'remote.origin.url git@gitlab.com:me/project.git', + 'remote.upstream.url https://gitlab.com/group/project.git', + 'remote.github.url git@github.com:me/project.git', + ].join('\n'), + [REFS]: 'origin\0refs/heads/remote-feature\0origin\0refs/heads/remote-feature', + 'symbolic-ref --quiet --short refs/remotes/origin/HEAD': 'origin/main', +}; + +describe('resolveRepository', () => { + test('uses the push branch and includes same-host remotes as targets', async () => { + const lookup = await resolveRepository(fakeGit(base), '/repo', onGitLab); + expect(lookup).toEqual({ + kind: 'repository', + repository: { + head: 'abc123', + branch: 'feature', + sourceBranch: 'remote-feature', + source: { host: 'gitlab.com', path: 'me/project' }, + targets: [ + { host: 'gitlab.com', path: 'me/project' }, + { host: 'gitlab.com', path: 'group/project' }, + ], + }, + }); + }); + + test('falls back to origin and the local branch name without tracking', async () => { + const lookup = await resolveRepository( + fakeGit({ ...base, [REFS]: '\0\0\0' }), + '/repo', + onGitLab, + ); + expect(lookup.kind === 'repository' && lookup.repository.sourceBranch).toBe('feature'); + }); + + test('reports detached HEAD', async () => { + const responses = { ...base, 'symbolic-ref --quiet --short HEAD': undefined }; + expect(await resolveRepository(fakeGit(responses), '/repo', onGitLab)).toEqual({ + kind: 'none', + reason: 'Detached HEAD', + }); + }); + + test('reports a non-repository', async () => { + const lookup = await resolveRepository(fakeGit({}), '/tmp', onGitLab); + expect(lookup).toEqual({ kind: 'none', reason: 'Not a Git repository' }); + }); + + test('ignores branches whose source remote is not GitLab', async () => { + const responses = { ...base, [REFS]: 'github\0refs/heads/feature\0\0' }; + expect(await resolveRepository(fakeGit(responses), '/repo', onGitLab)).toEqual({ + kind: 'none', + reason: 'Not a supported forge remote', + }); + }); + + test("skips the remote's default branch", async () => { + const responses = { ...base, [REFS]: 'origin\0refs/heads/main\0\0' }; + expect(await resolveRepository(fakeGit(responses), '/repo', onGitLab)).toEqual({ + kind: 'none', + reason: 'Default branch', + }); + }); +}); diff --git a/packages/forge-session/src/git.ts b/packages/forge-session/src/git.ts new file mode 100644 index 0000000..a4d3d71 --- /dev/null +++ b/packages/forge-session/src/git.ts @@ -0,0 +1,170 @@ +import type { Exec } from './exec'; + +export interface RemoteProject { + host: string; + path: string; +} + +export interface Repository { + head: string; + branch: string; + // The branch name on the remote the MR's source project uses. It differs from + // `branch` when the local branch tracks a differently named remote branch. + sourceBranch: string; + source: RemoteProject; + // Projects that could own the MR: the source project and every other remote + // on the same host, so MRs from a fork into its upstream are found. + targets: RemoteProject[]; +} + +export type RepositoryLookup = + | { kind: 'repository'; repository: Repository } + | { kind: 'none'; reason: string }; + +export function parseRemote(url: string): RemoteProject | undefined { + const trimmed = url.trim(); + let host: string; + let path: string; + + const scp = trimmed.match(/^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/); + if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + host = scp[1]; + path = scp[2]; + } else { + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return undefined; + } + if (!['ssh:', 'https:', 'http:', 'git+ssh:', 'ssh+git:'].includes(parsed.protocol)) + return undefined; + host = parsed.hostname; + path = decodeURIComponent(parsed.pathname); + } + + path = path + .replace(/^\/+/, '') + .replace(/\/+$/, '') + .replace(/\.git$/, ''); + if (!host || !path.includes('/')) return undefined; + return { host: host.toLowerCase(), path }; +} + +// Whether a remote host belongs to a forge the caller can look up. +export type HostFilter = (host: string) => boolean; + +const stripHeads = (ref: string) => ref.replace(/^refs\/heads\//, ''); + +async function git(run: Exec, directory: string, args: string[]) { + const result = await run('git', args, directory); + return result.code === 0 ? result.stdout.trim() : undefined; +} + +export async function resolveRepository( + run: Exec, + directory: string, + supported: HostFilter, +): Promise { + const top = await git(run, directory, ['rev-parse', '--show-toplevel']); + if (top === undefined) return { kind: 'none', reason: 'Not a Git repository' }; + + const branch = await git(run, directory, ['symbolic-ref', '--quiet', '--short', 'HEAD']); + if (!branch) return { kind: 'none', reason: 'Detached HEAD' }; + + const [head, refs, remoteConfig] = await Promise.all([ + git(run, directory, ['rev-parse', 'HEAD']), + git(run, directory, [ + 'for-each-ref', + '--format=%(push:remotename)%00%(push:remoteref)%00%(upstream:remotename)%00%(upstream:remoteref)', + `refs/heads/${branch}`, + ]), + git(run, directory, ['config', '--get-regexp', '^remote\\..*\\.url$']), + ]); + + const remotes = new Map(); + for (const line of (remoteConfig ?? '').split('\n')) { + const match = line.match(/^remote\.(.+)\.url\s+(.+)$/); + if (match) remotes.set(match[1], match[2]); + } + if (remotes.size === 0) return { kind: 'none', reason: 'No Git remotes' }; + + const [pushRemote, pushRef, upstreamRemote, upstreamRef] = (refs ?? '').split('\0'); + let sourceRemote: string | undefined; + let sourceBranch = branch; + if (pushRemote && remotes.has(pushRemote)) { + sourceRemote = pushRemote; + if (pushRef) sourceBranch = stripHeads(pushRef); + } else if (upstreamRemote && remotes.has(upstreamRemote)) { + sourceRemote = upstreamRemote; + if (upstreamRef) sourceBranch = stripHeads(upstreamRef); + } else if (remotes.has('origin')) { + sourceRemote = 'origin'; + } else if (remotes.size === 1) { + sourceRemote = [...remotes.keys()][0]; + } + if (!sourceRemote) return { kind: 'none', reason: 'No remote for the current branch' }; + + const source = parseRemote(remotes.get(sourceRemote)!); + if (!source || !supported(source.host)) + return { kind: 'none', reason: 'Not a supported forge remote' }; + + const remoteHead = await git(run, directory, [ + 'symbolic-ref', + '--quiet', + '--short', + `refs/remotes/${sourceRemote}/HEAD`, + ]); + if (remoteHead && remoteHead === `${sourceRemote}/${sourceBranch}`) { + return { kind: 'none', reason: 'Default branch' }; + } + + const targets: RemoteProject[] = [source]; + for (const url of remotes.values()) { + const project = parseRemote(url); + if (!project || project.host !== source.host) continue; + if (targets.some((target) => target.path.toLowerCase() === project.path.toLowerCase())) + continue; + targets.push(project); + } + + return { + kind: 'repository', + repository: { head: head ?? '', branch, sourceBranch, source, targets }, + }; +} + +export type ProjectsLookup = + | { kind: 'projects'; projects: RemoteProject[] } + | { kind: 'none'; reason: string }; + +// Lists the projects on accepted hosts behind a checkout's remotes, `origin` +// first and all on one host, for looking up a PR/MR by number regardless of the +// checked-out branch. +export async function resolveProjects( + run: Exec, + directory: string, + accepted: HostFilter, +): Promise { + const remoteConfig = await git(run, directory, ['config', '--get-regexp', '^remote\\..*\\.url$']); + if (remoteConfig === undefined) return { kind: 'none', reason: 'No Git remotes' }; + + const remotes: Array<[string, string]> = []; + for (const line of remoteConfig.split('\n')) { + const match = line.match(/^remote\.(.+)\.url\s+(.+)$/); + if (match) remotes.push([match[1], match[2]]); + } + remotes.sort(([a], [b]) => Number(b === 'origin') - Number(a === 'origin')); + + const projects: RemoteProject[] = []; + for (const [, url] of remotes) { + const project = parseRemote(url); + if (!project || !accepted(project.host)) continue; + if (projects.length > 0 && project.host !== projects[0].host) continue; + if (projects.some((known) => known.path.toLowerCase() === project.path.toLowerCase())) continue; + projects.push(project); + } + return projects.length > 0 + ? { kind: 'projects', projects } + : { kind: 'none', reason: 'No matching forge remote' }; +} diff --git a/packages/forge-session/src/github.test.ts b/packages/forge-session/src/github.test.ts new file mode 100644 index 0000000..cbed2f0 --- /dev/null +++ b/packages/forge-session/src/github.test.ts @@ -0,0 +1,309 @@ +import { expect, test } from 'bun:test'; +import { checks, ghGraphQL, GitHubForge, type GraphQL } from './github'; +import { classifyTargets } from './target'; +import { detailsMessage, responsiveFooterSegments } from './format'; + +const pull = (number = 1) => ({ + number, + title: 'A PR', + url: `https://github.com/owner/repo/pull/${number}`, + state: 'OPEN', + isDraft: false, + mergeable: 'UNKNOWN', + reviewDecision: 'CHANGES_REQUESTED', + headRefOid: 'abc', + headRefName: 'feature', + headRepository: { nameWithOwner: 'fork/repo' }, + baseRepository: { nameWithOwner: 'owner/repo' }, + baseRefName: 'main', + updatedAt: '2026-10-09', + reviewThreads: { nodes: [{ isResolved: false }], pageInfo: { hasNextPage: false } }, + reviewRequests: { + nodes: [{ requestedReviewer: { login: 'human' } }], + pageInfo: { hasNextPage: false }, + }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'FAILURE' }], + pageInfo: { hasNextPage: false }, + }, + }, + }, + }, + ], + }, +}); + +const projects = [{ host: 'github.com', path: 'owner/repo' }]; +const byNumber = (graphql: GraphQL) => new GitHubForge(graphql).findByNumber(projects, '1'); + +test('normalizes GitHub status without inventing approval requirements', async () => { + const pr = await byNumber(async () => ({ repository: { pullRequest: pull() } })); + expect(pr).toMatchObject({ + forge: 'github', + state: 'opened', + conflicts: false, + conflictsKnown: false, + reviewDecision: 'CHANGES_REQUESTED', + awaitingReviewers: ['human'], + unresolvedThreads: 1, + }); + expect(pr).not.toHaveProperty('approvalRequirementsSatisfied'); + const snapshot = { loading: false, lookup: { kind: 'found' as const, requests: [pr!] } }; + expect(detailsMessage(snapshot)).toContain('Conflicts: unknown'); + expect(detailsMessage(snapshot)).toContain('Review decision: changes requested'); + expect(responsiveFooterSegments(snapshot, 100).map((segment) => segment.text)).toContain('#1'); + expect(responsiveFooterSegments(snapshot, 100).map((segment) => segment.text)).toContain( + 'changes requested', + ); + for (let width = 0; width < 100; width++) + expect( + Bun.stringWidth( + responsiveFooterSegments(snapshot, width) + .map((segment) => segment.text) + .join(' · '), + ), + ).toBeLessThanOrEqual(width); +}); + +test('returns undefined when no project has the PR', async () => { + expect(await byNumber(async () => ({ repository: { pullRequest: null } }))).toBeUndefined(); + expect(await new GitHubForge(async () => ({})).findByNumber([], '1')).toBeUndefined(); +}); + +test("treats a number that isn't a PR as missing, as gh reports it", async () => { + // Captured from `gh api graphql` for a number without a PR; gh exits 1. + const stdout = JSON.stringify({ + data: { repository: { pullRequest: null } }, + errors: [ + { + type: 'NOT_FOUND', + path: ['repository', 'pullRequest'], + message: 'Could not resolve to a PullRequest with the number of 42.', + }, + ], + }); + const gh = ghGraphQL( + async () => ({ + code: 1, + stdout, + stderr: 'gh: Could not resolve to a PullRequest with the number of 42.', + }), + '/repo', + ); + expect(await byNumber(gh)).toBeUndefined(); + + const mixed = JSON.stringify({ + data: { repository: null }, + errors: [ + { type: 'NOT_FOUND', message: 'missing' }, + { type: 'FORBIDDEN', message: 'Resource not accessible' }, + ], + }); + await expect( + ghGraphQL(async () => ({ code: 1, stdout: mixed, stderr: '' }), '/repo')( + 'github.com', + 'query', + {}, + ), + ).rejects.toMatchObject({ message: 'Resource not accessible' }); + await expect( + ghGraphQL(async () => ({ code: 1, stdout: '{}', stderr: 'gh: HTTP 502' }), '/repo')( + 'github.com', + 'query', + {}, + ), + ).rejects.toMatchObject({ kind: 'request' }); +}); + +test('check rollup prioritizes failures and never marks partial checks green', () => { + expect( + checks( + [ + { __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'FAILURE' }, + { __typename: 'StatusContext', state: 'PENDING' }, + ], + true, + 'url', + ), + ).toMatchObject({ status: 'FAILED', label: '1 failed, 1 pending' }); + expect( + checks([{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SKIPPED' }], true, 'url') + .status, + ).toBe('SUCCESS'); + expect(checks([], false, 'url').status).toBe('PENDING'); + expect( + checks( + [{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'NEW_UNKNOWN_VALUE' }], + true, + 'url', + ).status, + ).toBe('PENDING'); +}); + +test('filters same-named branches from other forks and deduplicates remotes', async () => { + const forge = new GitHubForge(async () => ({ + repository: { + pullRequests: { + nodes: [pull(), { ...pull(2), headRepository: { nameWithOwner: 'other/repo' } }], + pageInfo: { hasNextPage: false }, + }, + }, + })); + const pr = await forge.findByBranch({ + head: 'abc', + branch: 'feature', + sourceBranch: 'feature', + source: { host: 'github.com', path: 'fork/repo' }, + targets: [ + { host: 'github.com', path: 'owner/repo' }, + { host: 'github.com', path: 'fork/repo' }, + ], + }); + expect(pr.map((item) => item.iid)).toEqual(['1']); +}); + +test('finds the newest PR number from the source fork without reading status', async () => { + const queries: string[] = []; + const forge = new GitHubForge(async (_host, query, variables) => { + queries.push(query); + const nodes = + variables.repo === 'repo' && variables.owner === 'owner' + ? [ + { + number: 3, + updatedAt: '2026-10-09T00:00:00Z', + headRepository: { nameWithOwner: 'other/repo' }, + }, + { + number: 1, + updatedAt: '2026-10-01T00:00:00Z', + headRepository: { nameWithOwner: 'Fork/Repo' }, + }, + ] + : [ + { + number: 2, + updatedAt: '2026-10-05T00:00:00Z', + headRepository: { nameWithOwner: 'fork/repo' }, + }, + ]; + return { repository: { pullRequests: { nodes } } }; + }); + const number = await forge.findNumberByBranch({ + head: 'abc', + branch: 'feature', + sourceBranch: 'feature', + source: { host: 'github.com', path: 'fork/repo' }, + targets: [ + { host: 'github.com', path: 'owner/repo' }, + { host: 'github.com', path: 'fork/repo' }, + ], + }); + expect(number).toBe('2'); + expect(queries.every((query) => !query.includes('reviewThreads'))).toBe(true); +}); + +test("reads a PR's source branch and treats a missing PR as unknown", async () => { + const ref = { forge: 'github' as const, host: 'github.com', project: 'owner/repo', iid: '7' }; + let seen: Record = {}; + const forge = new GitHubForge(async (_host, _query, variables) => { + seen = variables; + return { repository: { pullRequest: { headRefName: '34-fix' } } }; + }); + expect(await forge.sourceBranch(ref)).toBe('34-fix'); + expect(seen).toEqual({ owner: 'owner', repo: 'repo', number: 7 }); + expect( + await new GitHubForge(async () => ({ repository: { pullRequest: null } })).sourceBranch(ref), + ).toBeUndefined(); +}); + +test('uses typed integer GraphQL variables and classifies missing gh', async () => { + const args: string[][] = []; + const gh = ghGraphQL(async (_file, input) => { + args.push(input); + return { + code: 0, + stdout: JSON.stringify({ data: { repository: { pullRequest: pull() } } }), + stderr: '', + }; + }, '/repo'); + await byNumber(gh); + expect(args[0]).toContain('number=1'); + expect(args[0][args[0].indexOf('number=1') - 1]).toBe('-F'); + await expect( + ghGraphQL(async () => ({ code: -1, stdout: '', stderr: 'spawn gh ENOENT' }), '/repo')( + 'github.com', + 'query', + {}, + ), + ).rejects.toMatchObject({ kind: 'missing-gh' }); +}); + +test('classifies PR targets separately from issues', () => { + expect(classifyTargets({ url: 'https://github.com/owner/repo/pull/42' })).toMatchObject([ + { kind: 'merge-request', ref: { forge: 'github', iid: '42' } }, + ]); + expect(classifyTargets({ url: 'https://github.com/owner/repo/issues/42' })).toMatchObject([ + { kind: 'other' }, + ]); +}); + +test('loads subsequent unresolved-thread pages', async () => { + const first = pull(); + first.reviewThreads.pageInfo = { + hasNextPage: true, + endCursor: 'next', + } as typeof first.reviewThreads.pageInfo; + const pr = await byNumber(async (_host, query) => + query.includes('$after') + ? { + repository: { + pullRequest: { + reviewThreads: { nodes: [{ isResolved: false }], pageInfo: { hasNextPage: false } }, + }, + }, + } + : { repository: { pullRequest: first } }, + ); + expect(pr?.unresolvedThreads).toBe(2); + expect(pr?.threadsComplete).toBe(true); +}); + +test('loads check pages and distinguishes authentication from rate limits', async () => { + const first = pull(); + const contexts = first.commits.nodes[0].commit.statusCheckRollup.contexts; + contexts.nodes = [{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' }]; + contexts.pageInfo = { hasNextPage: true, endCursor: 'checks' } as typeof contexts.pageInfo; + const pr = await byNumber(async (_host, query) => + query.includes('$oid') + ? { + repository: { + object: { + statusCheckRollup: { + contexts: { + nodes: [{ __typename: 'StatusContext', state: 'PENDING' }], + pageInfo: { hasNextPage: false }, + }, + }, + }, + }, + } + : { repository: { pullRequest: first } }, + ); + expect(pr?.pipeline).toMatchObject({ status: 'PENDING', label: '1 pending' }); + for (const [message, kind] of [ + ['API rate limit exceeded', 'rate-limit'], + ['401 authentication failed', 'auth'], + ]) { + const gh = ghGraphQL( + async () => ({ code: 1, stdout: JSON.stringify({ errors: [{ message }] }), stderr: '' }), + '/repo', + ); + await expect(gh('github.com', 'query', {})).rejects.toMatchObject({ kind }); + } +}); diff --git a/packages/forge-session/src/github.ts b/packages/forge-session/src/github.ts new file mode 100644 index 0000000..1813443 --- /dev/null +++ b/packages/forge-session/src/github.ts @@ -0,0 +1,379 @@ +import type { Exec } from './exec'; +import { + ForgeError, + humanize, + ownedBy, + parseWebUrl, + prefixReferences, + type Forge, + type ForgeTraits, + type GitHubPullRequest, + type Pipeline, + type ReviewRef, +} from './forge'; +import type { RemoteProject, Repository } from './git'; +import { gitlabTraits } from './gitlab'; + +interface Connection { + nodes: T[]; + pageInfo?: { hasNextPage: boolean; endCursor?: string }; +} + +interface Check { + __typename: string; + status?: string; + conclusion?: string; + state?: string; + detailsUrl?: string; + targetUrl?: string; +} + +interface Pull { + number: number; + title: string; + url: string; + state: string; + isDraft: boolean; + mergeable: string; + reviewDecision?: string | null; + headRefOid: string; + headRefName: string; + headRepository?: { nameWithOwner: string } | null; + baseRepository: { nameWithOwner: string }; + baseRefName: string; + updatedAt: string; + reviewThreads: Connection<{ isResolved: boolean }>; + reviewRequests: Connection<{ requestedReviewer?: { login?: string; name?: string } }>; + commits: { + nodes: Array<{ commit: { statusCheckRollup?: { contexts: Connection } | null } }>; + }; +} + +const fields = `number title url state isDraft mergeable reviewDecision headRefOid headRefName + headRepository { nameWithOwner } baseRepository { nameWithOwner } baseRefName updatedAt + reviewThreads(first:100) { nodes { isResolved } pageInfo { hasNextPage endCursor } } + reviewRequests(first:100) { nodes { requestedReviewer { ... on User { login } ... on Team { name } } } pageInfo { hasNextPage } } + commits(last:1) { nodes { commit { statusCheckRollup { contexts(first:100) { + nodes { __typename ... on CheckRun { status conclusion detailsUrl } ... on StatusContext { state targetUrl } } + pageInfo { hasNextPage endCursor } + } } } } }`; + +// Pagination stops after this many pages; results are then marked partial. +const MAX_PAGES = 50; + +export type GraphQL = ( + host: string, + query: string, + variables: Record, +) => Promise>; + +export function ghGraphQL(run: Exec, cwd: string): GraphQL { + return async (host, query, variables) => { + const args = ['api', 'graphql', '--hostname', host, '-f', `query=${query}`]; + for (const [name, value] of Object.entries(variables)) + args.push(typeof value === 'number' ? '-F' : '-f', `${name}=${value}`); + const result = await run('gh', args, cwd); + let body: { + data?: Record; + errors?: Array<{ type?: string; message: string }>; + }; + try { + body = JSON.parse(result.stdout); + } catch { + throw classifyError(result.stderr, result.code); + } + // A missing object, such as a number that names an issue rather than a PR, + // is a NOT_FOUND error next to a null field. gh exits 1 for it. + const errors = (body.errors ?? []).filter((error) => error.type !== 'NOT_FOUND'); + if (!body.data || errors.length || (result.code !== 0 && !body.errors?.length)) { + throw classifyError(errors[0]?.message ?? result.stderr, result.code); + } + return body.data; + }; +} + +export function classifyError(message: string, code: number): ForgeError { + const text = message.trim() || `gh exited with code ${code}`; + if (/ENOENT|command not found/i.test(text)) + return new ForgeError('missing-gh', 'gh is not installed'); + if (/401|unauthori[sz]ed|authenticate|not logged|token.*expired/i.test(text)) + return new ForgeError('auth', 'GitHub authentication failed. Run `gh auth status`.'); + if (/429|rate limit|too many requests/i.test(text)) + return new ForgeError('rate-limit', 'GitHub rate limit reached'); + return new ForgeError('request', text.split('\n')[0]); +} + +const FAILED = [ + 'FAILURE', + 'ERROR', + 'TIMED_OUT', + 'CANCELLED', + 'ACTION_REQUIRED', + 'STARTUP_FAILURE', + 'STALE', +]; +const CONCLUSIONS = [ + 'SUCCESS', + 'NEUTRAL', + 'SKIPPED', + 'FAILURE', + 'TIMED_OUT', + 'CANCELLED', + 'ACTION_REQUIRED', + 'STARTUP_FAILURE', + 'STALE', +]; +const STATES = ['SUCCESS', 'FAILURE', 'ERROR', 'PENDING', 'EXPECTED']; + +// Summarizes the head commit's checks. Failures win over pending checks, and +// partial or unrecognized results never report success. +export function checks(nodes: Check[], complete: boolean, url: string): Pipeline { + const failed = nodes.filter((node) => + FAILED.includes(node.conclusion ?? node.state ?? ''), + ).length; + const pending = nodes.filter((node) => + node.status ? node.status !== 'COMPLETED' : ['PENDING', 'EXPECTED'].includes(node.state ?? ''), + ).length; + const unknown = nodes.some((node) => + node.status === 'COMPLETED' + ? !CONCLUSIONS.includes(node.conclusion ?? '') + : !node.status && !STATES.includes(node.state ?? ''), + ); + if (failed) + return { + status: 'FAILED', + label: `${failed} failed${pending ? `, ${pending} pending` : ''}${complete ? '' : ' (partial)'}`, + url, + }; + if (pending || !complete || unknown) + return { + status: 'PENDING', + label: `${pending ? `${pending} pending` : 'unknown'}${complete ? '' : ' (partial)'}`, + url, + }; + return { status: 'SUCCESS', label: `${nodes.length} passed or skipped`, url }; +} + +// GitHub URLs are accepted on any host, for GitHub Enterprise, except GitLab +// hosts, which also answer legacy paths such as `/group/project/issues/1`. +function parseGitHubUrl(url: string, pattern: RegExp) { + const ref = parseWebUrl('github', url, pattern); + return ref && !gitlabTraits.recognizes(ref.host) ? ref : undefined; +} + +export const githubTraits: ForgeTraits = { + kind: 'github', + noun: 'PR', + ci: 'checks', + // GitHub hosts come only from the `githubHosts` option. + recognizes: () => false, + open: (run, directory) => new GitHubForge(ghGraphQL(run, directory)), + owns: ownedBy('github'), + parseUrl: (url) => parseGitHubUrl(url, /^\/([^/]+\/[^/]+)\/pull\/([1-9]\d*)\/?$/), + parseIssueUrl: (url) => parseGitHubUrl(url, /^\/([^/]+\/[^/]+)\/issues\/([1-9]\d*)\/?$/), + reference: (iid) => `#${iid}`, + issueReference: (iid) => `#${iid}`, + // `#N` also names issues, such as in `[#42, #108]`. + titleReferences: (title) => prefixReferences(title, '#'), + indicators: (request) => + request.reviewDecision === 'CHANGES_REQUESTED' + ? [{ text: 'changes requested', tone: 'warning', essential: true }] + : [], + details: (request) => [ + `Review decision: ${request.reviewDecision ? humanize(request.reviewDecision) : 'not reported'}`, + ], +}; + +// Reads GitHub pull requests through `gh api graphql`, using gh's login. +export class GitHubForge implements Forge { + readonly traits = githubTraits; + + constructor(readonly graphql: GraphQL) {} + + async findByNumber( + projects: readonly RemoteProject[], + number: string, + ): Promise { + for (const project of projects) { + const [owner, repo] = project.path.split('/'); + const data = await this.graphql( + project.host, + `query($owner:String!,$repo:String!,$number:Int!) { + repository(owner:$owner,name:$repo) { pullRequest(number:$number) { ${fields} } } + }`, + { owner, repo, number: Number(number) }, + ); + const node = (data.repository as { pullRequest?: Pull } | undefined)?.pullRequest; + if (node) return this.normalize(project.host, node); + } + return undefined; + } + + async findNumberByBranch(repository: Repository): Promise { + let newest: { number: number; updatedAt: string } | undefined; + for (const target of repository.targets) { + const [owner, repo] = target.path.split('/'); + const data = await this.graphql( + target.host, + `query($owner:String!,$repo:String!,$branch:String!) { + repository(owner:$owner,name:$repo) { pullRequests(first:100,states:OPEN,headRefName:$branch,orderBy:{field:UPDATED_AT,direction:DESC}) { + nodes { number updatedAt headRepository { nameWithOwner } } + } } }`, + { owner, repo, branch: repository.sourceBranch }, + ); + const nodes = + ( + data.repository as + | { pullRequests?: Connection> } + | undefined + )?.pullRequests?.nodes ?? []; + for (const node of nodes) { + // A fork's branch with the same name must not name this branch's PR. + if ( + node.headRepository?.nameWithOwner.toLowerCase() !== repository.source.path.toLowerCase() + ) + continue; + if (!newest || node.updatedAt > newest.updatedAt) newest = node; + } + } + return newest ? String(newest.number) : undefined; + } + + async sourceBranch(ref: ReviewRef): Promise { + const [owner, repo] = ref.project.split('/'); + const data = await this.graphql( + ref.host, + `query($owner:String!,$repo:String!,$number:Int!) { + repository(owner:$owner,name:$repo) { pullRequest(number:$number) { headRefName } } + }`, + { owner, repo, number: Number(ref.iid) }, + ); + const branch = ( + data.repository as { pullRequest?: { headRefName?: unknown } | null } | undefined + )?.pullRequest?.headRefName; + return typeof branch === 'string' && branch ? branch : undefined; + } + + async findByBranch(repository: Repository): Promise { + const found = new Map(); + for (const target of repository.targets) { + const [owner, repo] = target.path.split('/'); + let after: string | undefined; + let complete = false; + for (let page = 0; page < MAX_PAGES; page++) { + const data = await this.graphql( + target.host, + `query($owner:String!,$repo:String!,$branch:String!${after ? ',$after:String!' : ''}) { + repository(owner:$owner,name:$repo) { pullRequests(first:100,states:OPEN,headRefName:$branch${after ? ',after:$after' : ''}) { + nodes { ${fields} } pageInfo { hasNextPage endCursor } + } } }`, + { owner, repo, branch: repository.sourceBranch, ...(after ? { after } : {}) }, + ); + const pulls = (data.repository as { pullRequests?: Connection } | undefined) + ?.pullRequests; + if (!pulls) throw new ForgeError('request', 'GitHub repository unavailable'); + for (const node of pulls.nodes) { + // A fork's branch with the same name must not be reported as this branch's PR. + if ( + node.headRepository?.nameWithOwner.toLowerCase() !== + repository.source.path.toLowerCase() + ) + continue; + if (!found.has(node.url)) found.set(node.url, await this.normalize(target.host, node)); + } + if (!pulls.pageInfo?.hasNextPage) { + complete = true; + break; + } + after = pulls.pageInfo.endCursor; + if (!after) break; + } + if (!complete) + throw new ForgeError('request', 'GitHub PR discovery exceeded its pagination limit'); + } + return [...found.values()].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')); + } + + private async threads(host: string, node: Pull) { + const [owner, repo] = node.baseRepository.nameWithOwner.split('/'); + const threads = [...node.reviewThreads.nodes]; + let page = node.reviewThreads.pageInfo; + for (let count = 1; page?.hasNextPage && page.endCursor && count < MAX_PAGES; count++) { + const data = await this.graphql( + host, + `query($owner:String!,$repo:String!,$number:Int!,$after:String!) { + repository(owner:$owner,name:$repo) { pullRequest(number:$number) { + reviewThreads(first:100,after:$after) { nodes { isResolved } pageInfo { hasNextPage endCursor } } + } } }`, + { owner, repo, number: node.number, after: page.endCursor }, + ); + const next = ( + data.repository as { pullRequest?: { reviewThreads?: Pull['reviewThreads'] } } | undefined + )?.pullRequest?.reviewThreads; + if (!next) break; + threads.push(...next.nodes); + page = next.pageInfo; + } + return { + unresolved: threads.filter((thread) => !thread.isResolved).length, + complete: !page?.hasNextPage, + }; + } + + private async contexts(host: string, node: Pull) { + const [owner, repo] = node.baseRepository.nameWithOwner.split('/'); + const rollup = node.commits.nodes[0]?.commit.statusCheckRollup?.contexts; + const contexts = [...(rollup?.nodes ?? [])]; + let cursor = rollup?.pageInfo; + for (let count = 1; cursor?.hasNextPage && cursor.endCursor && count < MAX_PAGES; count++) { + const data = await this.graphql( + host, + `query($owner:String!,$repo:String!,$oid:GitObjectID!,$after:String!) { + repository(owner:$owner,name:$repo) { object(oid:$oid) { ... on Commit { statusCheckRollup { + contexts(first:100,after:$after) { nodes { __typename ... on CheckRun { status conclusion detailsUrl } ... on StatusContext { state targetUrl } } pageInfo { hasNextPage endCursor } } + } } } } }`, + { owner, repo, oid: node.headRefOid, after: cursor.endCursor }, + ); + const next = ( + data.repository as + | { object?: { statusCheckRollup?: { contexts?: Connection } } } + | undefined + )?.object?.statusCheckRollup?.contexts; + if (!next) break; + contexts.push(...next.nodes); + cursor = next.pageInfo; + } + return { nodes: contexts, complete: !cursor?.hasNextPage }; + } + + private async normalize(host: string, node: Pull): Promise { + const [threads, contexts] = [await this.threads(host, node), await this.contexts(host, node)]; + return { + forge: 'github', + iid: String(node.number), + title: node.title, + url: node.url, + state: node.state === 'OPEN' ? 'opened' : node.state.toLowerCase(), + draft: node.isDraft, + conflicts: node.mergeable === 'CONFLICTING', + conflictsKnown: node.mergeable !== 'UNKNOWN', + approved: node.reviewDecision === 'APPROVED', + reviewDecision: node.reviewDecision ?? undefined, + awaitingReviewers: node.reviewRequests.nodes.flatMap((request) => { + const reviewer = request.requestedReviewer; + return reviewer?.login ? [reviewer.login] : reviewer?.name ? [reviewer.name] : []; + }), + reviewersComplete: !node.reviewRequests.pageInfo?.hasNextPage, + targetProject: node.baseRepository.nameWithOwner, + targetBranch: node.baseRefName, + headSha: node.headRefOid, + updatedAt: node.updatedAt, + pipeline: + contexts.nodes.length || !contexts.complete + ? checks(contexts.nodes, contexts.complete, `${node.url}/checks`) + : undefined, + unresolvedThreads: threads.unresolved, + threadsComplete: threads.complete, + }; + } +} diff --git a/packages/forge-session/src/gitlab-feedback.test.ts b/packages/forge-session/src/gitlab-feedback.test.ts new file mode 100644 index 0000000..7db2816 --- /dev/null +++ b/packages/forge-session/src/gitlab-feedback.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test'; +import type { GraphQL } from './gitlab'; +import { fetchComments, MAX_COMMENT_PAGES, toComment } from './gitlab-feedback'; + +const node = (id: string, overrides: Record = {}) => ({ + id: `gid://gitlab/DiscussionNote/${id}`, + url: `https://gitlab.com/g/p/-/merge_requests/1#note_${id}`, + createdAt: '2026-10-09T10:00:00Z', + updatedAt: '2026-10-09T10:05:00Z', + lastEditedAt: '2026-10-09T10:03:00Z', + system: false, + discussion: { resolved: false }, + author: { id: 'gid://gitlab/User/2', username: 'alice', bot: false }, + ...overrides, +}); + +// `previous` is the cursor for the page of older comments, if there is one. +const page = ( + nodes: unknown[], + previous?: string, + viewer: string | null = 'gid://gitlab/User/1', +) => ({ + currentUser: viewer ? { id: viewer } : null, + project: { + mergeRequest: { + notes: { pageInfo: { hasPreviousPage: !!previous, startCursor: previous ?? null }, nodes }, + }, + }, +}); + +describe('toComment', () => { + test('uses the numeric ID as the order and the last edit time', () => { + expect(toComment(node('5'))).toEqual({ + id: '5', + order: 5, + url: 'https://gitlab.com/g/p/-/merge_requests/1#note_5', + editedAt: Date.parse('2026-10-09T10:03:00Z'), + resolved: false, + bot: false, + authorID: 'gid://gitlab/User/2', + username: 'alice', + }); + }); + + test('falls back to the update and creation times', () => { + expect(toComment(node('5', { lastEditedAt: null }))?.editedAt).toBe( + Date.parse('2026-10-09T10:05:00Z'), + ); + expect(toComment(node('5', { lastEditedAt: null, updatedAt: null }))?.editedAt).toBe( + Date.parse('2026-10-09T10:00:00Z'), + ); + }); + + test('skips system notes and notes without an author or a valid time', () => { + expect(toComment(node('5', { system: true }))).toBeUndefined(); + expect(toComment(node('5', { author: null }))).toBeUndefined(); + expect(toComment(node('5', { lastEditedAt: 'nope' }))).toBeUndefined(); + expect(toComment(node('5', { id: 'gid://gitlab/Note/abc' }))).toBeUndefined(); + }); + + test("reads the thread's resolution", () => { + expect(toComment(node('5', { discussion: { resolved: true } }))?.resolved).toBe(true); + }); +}); + +describe('fetchComments', () => { + test('pages backwards from the newest comment and returns them oldest first', async () => { + const calls: Record[] = []; + const graphql: GraphQL = async (_host, _query, variables) => { + calls.push(variables); + return variables.before ? page([node('1'), node('2')]) : page([node('3'), node('4')], 'c3'); + }; + const snapshot = await fetchComments(graphql, 'gitlab.com', 'g/p', '1'); + expect(snapshot?.complete).toBe(true); + expect(snapshot?.viewerID).toBe('gid://gitlab/User/1'); + expect(snapshot?.comments.map((n) => n.id)).toEqual(['1', '2', '3', '4']); + expect(calls).toEqual([ + { project: 'g/p', iid: '1' }, + { project: 'g/p', iid: '1', before: 'c3' }, + ]); + }); + + test('returns undefined for a missing MR', async () => { + const graphql: GraphQL = async () => ({ + currentUser: { id: 'u' }, + project: { mergeRequest: null }, + }); + expect(await fetchComments(graphql, 'gitlab.com', 'g/p', '1')).toBeUndefined(); + }); + + test('stops at the newest comments and treats the full window as complete', async () => { + let calls = 0; + // Each call returns an older page, with lower IDs. + const graphql: GraphQL = async () => { + calls++; + return page([node(String(1000 - calls))], `c${calls}`); + }; + const snapshot = await fetchComments(graphql, 'gitlab.com', 'g/p', '1'); + expect(calls).toBe(MAX_COMMENT_PAGES); + expect(snapshot?.complete).toBe(true); + expect(snapshot?.comments.at(0)?.id).toBe(String(1000 - MAX_COMMENT_PAGES)); + expect(snapshot?.comments.at(-1)?.id).toBe('999'); + }); + + test('marks a snapshot incomplete when an older page disappears', async () => { + const graphql: GraphQL = async (_host, _query, variables) => + variables.before ? { project: null } : page([node('2')], 'c2'); + expect((await fetchComments(graphql, 'gitlab.com', 'g/p', '1'))?.complete).toBe(false); + }); + + test('marks a snapshot incomplete when the older page has no cursor', async () => { + const graphql: GraphQL = async () => ({ + currentUser: { id: 'u' }, + project: { + mergeRequest: { + notes: { pageInfo: { hasPreviousPage: true, startCursor: null }, nodes: [] }, + }, + }, + }); + expect((await fetchComments(graphql, 'gitlab.com', 'g/p', '1'))?.complete).toBe(false); + }); + + test("marks a snapshot incomplete without the viewer's ID", async () => { + const graphql: GraphQL = async () => page([node('1')], undefined, null); + expect((await fetchComments(graphql, 'gitlab.com', 'g/p', '1'))?.complete).toBe(false); + }); +}); diff --git a/packages/forge-session/src/gitlab-feedback.ts b/packages/forge-session/src/gitlab-feedback.ts new file mode 100644 index 0000000..ae5e4ac --- /dev/null +++ b/packages/forge-session/src/gitlab-feedback.ts @@ -0,0 +1,97 @@ +// Reads a GitLab MR's comments for GitLab's `feedback` capability. +import type { FeedbackSnapshot, ReviewComment } from './forge'; +import type { GraphQL } from './gitlab'; + +interface NoteNode { + id: string; + url: string | null; + createdAt: string; + updatedAt: string | null; + lastEditedAt: string | null; + system: boolean; + discussion: { resolved: boolean } | null; + author: { id: string; username: string; bot: boolean } | null; +} + +const PAGE_SIZE = 100; +// The newest comments the plugin reads. New comments and replies are always +// the newest, so older ones only matter as activity. +export const MAX_COMMENT_PAGES = 20; + +// ONLY_COMMENTS leaves out system notes such as "added 1 commit". Pages run +// backwards from the newest comment, in creation order within each page. +export const COMMENTS_QUERY = `query($project: ID!, $iid: String!, $before: String) { + currentUser { id } + project(fullPath: $project) { + mergeRequest(iid: $iid) { + notes(last: ${PAGE_SIZE}, before: $before, filter: ONLY_COMMENTS) { + pageInfo { hasPreviousPage startCursor } + nodes { + id url createdAt updatedAt lastEditedAt system + discussion { resolved } + author { id username bot } + } + } + } + } +}`; + +// GitLab's global ID prefix changes with the note's type, for example when a +// reply turns a comment into a discussion, so only the number identifies it. +const noteNumber = (id: string) => id.slice(id.lastIndexOf('/') + 1); + +const timestamp = (node: NoteNode) => + Date.parse(node.lastEditedAt ?? node.updatedAt ?? node.createdAt); + +// Converts a note to a comment. System notes aren't comments, and the query +// already excludes them, so any that slip through are skipped too. +export function toComment(node: NoteNode): ReviewComment | undefined { + const editedAt = timestamp(node); + const id = noteNumber(node.id); + if (node.system || !node.author || !Number.isFinite(editedAt) || !/^\d+$/.test(id)) + return undefined; + return { + id, + // All notes on an MR share one ID sequence, so a later note has a higher ID. + order: Number(id), + url: node.url ?? '', + editedAt, + resolved: node.discussion?.resolved === true, + bot: node.author.bot, + authorID: node.author.id, + username: node.author.username, + }; +} + +// Reads the MR's newest comments, oldest first, or undefined when the MR +// doesn't exist. +export async function fetchComments( + graphql: GraphQL, + host: string, + project: string, + iid: string, +): Promise { + const pages: ReviewComment[][] = []; + let viewerID: string | undefined; + let before: string | undefined; + const snapshot = (complete: boolean): FeedbackSnapshot => ({ + // Without the viewer's ID, their own comments can't be told apart. + complete: complete && viewerID !== undefined, + viewerID, + comments: pages.toReversed().flat(), + }); + for (let page = 0; page < MAX_COMMENT_PAGES; page++) { + const variables: Record = { project, iid }; + if (before) variables.before = before; + const data = await graphql(host, COMMENTS_QUERY, variables); + viewerID ??= data?.currentUser?.id ?? undefined; + const connection = data?.project?.mergeRequest?.notes; + if (!connection) return page === 0 ? undefined : snapshot(false); + pages.push((connection.nodes as NoteNode[]).flatMap((node) => toComment(node) ?? [])); + if (!connection.pageInfo.hasPreviousPage) return snapshot(true); + before = connection.pageInfo.startCursor ?? undefined; + if (!before) return snapshot(false); + } + // A full window is complete: anything older is outside what the plugin watches. + return snapshot(true); +} diff --git a/packages/forge-session/src/gitlab.test.ts b/packages/forge-session/src/gitlab.test.ts new file mode 100644 index 0000000..f26027d --- /dev/null +++ b/packages/forge-session/src/gitlab.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, test } from 'bun:test'; +import type { Exec } from './exec'; +import type { Repository } from './git'; +import { footerSegments } from './format'; +import { ForgeError } from './forge'; +import { + classifyError, + DISCUSSIONS_QUERY, + GitLabForge, + glabGraphQL, + SOURCE_BRANCH_QUERY, + type GraphQL, +} from './gitlab'; + +const repository: Repository = { + head: 'abc', + branch: 'feature', + sourceBranch: 'feature', + source: { host: 'gitlab.com', path: 'me/project' }, + targets: [ + { host: 'gitlab.com', path: 'me/project' }, + { host: 'gitlab.com', path: 'group/project' }, + ], +}; + +const discussions = (nodes: Array<[boolean, boolean]>, endCursor: string | null = null) => ({ + pageInfo: { hasNextPage: endCursor !== null, endCursor }, + nodes: nodes.map(([resolvable, resolved]) => ({ resolvable, resolved })), +}); + +const node = (overrides: Record) => ({ + iid: '1', + title: 'Title', + webUrl: 'https://gitlab.com/group/project/-/merge_requests/1', + state: 'OPENED', + draft: false, + conflicts: false, + approved: false, + approvedBy: { nodes: [] }, + detailedMergeStatus: 'MERGEABLE', + diffHeadSha: 'abc', + updatedAt: '2026-10-01T00:00:00Z', + targetBranch: 'main', + sourceProject: { fullPath: 'me/project' }, + targetProject: { fullPath: 'group/project' }, + headPipeline: { + status: 'FAILED', + path: '/group/project/-/pipelines/9', + detailedStatus: { label: 'failed' }, + }, + discussions: discussions([]), + ...overrides, +}); + +describe('GitLabForge.findByBranch', () => { + test('counts unresolved threads across pages and filters other source projects', async () => { + const calls: Array> = []; + const graphql: GraphQL = async (_host, query, variables) => { + calls.push(variables); + if (query === DISCUSSIONS_QUERY) { + if (variables.after === 'c1') + return { project: { mergeRequest: { discussions: discussions([[true, false]], 'c2') } } }; + return { + project: { + mergeRequest: { + discussions: discussions([ + [true, false], + [true, true], + ]), + }, + }, + }; + } + return { + p0: { mergeRequests: { nodes: [] } }, + p1: { + mergeRequests: { + nodes: [ + node({ + discussions: discussions( + [ + [true, false], + [false, false], + [true, true], + ], + 'c1', + ), + }), + node({ iid: '2', sourceProject: { fullPath: 'someone-else/project' } }), + ], + }, + }, + }; + }; + + const result = await new GitLabForge(graphql).findByBranch(repository); + expect(calls[0]).toEqual({ branch: 'feature', p0: 'me/project', p1: 'group/project' }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + iid: '1', + unresolvedThreads: 3, + threadsComplete: true, + pipeline: { + status: 'FAILED', + label: 'failed', + url: 'https://gitlab.com/group/project/-/pipelines/9', + }, + }); + }); + + test('returns an empty list without an MR and handles a missing pipeline', async () => { + const empty: GraphQL = async () => ({ p0: { mergeRequests: { nodes: [] } }, p1: null }); + expect(await new GitLabForge(empty).findByBranch(repository)).toEqual([]); + + const noPipeline: GraphQL = async () => ({ + p0: { + mergeRequests: { + nodes: [node({ headPipeline: null, sourceProject: { fullPath: 'Me/Project' } })], + }, + }, + }); + const [mr] = await new GitLabForge(noPipeline).findByBranch(repository); + expect(mr.pipeline).toBeUndefined(); + }); + + test('lists several MRs from the same branch newest first', async () => { + const graphql: GraphQL = async () => ({ + p0: { mergeRequests: { nodes: [node({ iid: '5', updatedAt: '2026-09-01T00:00:00Z' })] } }, + p1: { mergeRequests: { nodes: [node({ iid: '7', updatedAt: '2026-10-02T00:00:00Z' })] } }, + }); + expect((await new GitLabForge(graphql).findByBranch(repository)).map((mr) => mr.iid)).toEqual([ + '7', + '5', + ]); + }); +}); + +describe('GitLabForge title lookups', () => { + test('finds the newest MR number from the source project without reading status', async () => { + let seen: { query: string; variables: Record } | undefined; + const graphql: GraphQL = async (_host, query, variables) => { + seen = { query, variables }; + return { + p0: { + mergeRequests: { + nodes: [ + { + iid: '5', + updatedAt: '2026-09-01T00:00:00Z', + sourceProject: { fullPath: 'me/project' }, + }, + ], + }, + }, + p1: { + mergeRequests: { + nodes: [ + { + iid: '9', + updatedAt: '2026-10-09T00:00:00Z', + sourceProject: { fullPath: 'someone-else/project' }, + }, + { + iid: '7', + updatedAt: '2026-10-02T00:00:00Z', + sourceProject: { fullPath: 'Me/Project' }, + }, + ], + }, + }, + }; + }; + expect(await new GitLabForge(graphql).findNumberByBranch(repository)).toBe('7'); + expect(seen?.variables).toEqual({ branch: 'feature', p0: 'me/project', p1: 'group/project' }); + expect(seen?.query).not.toContain('discussions'); + expect( + await new GitLabForge(async () => ({ p0: null, p1: null })).findNumberByBranch(repository), + ).toBeUndefined(); + }); + + test("reads an MR's source branch", async () => { + let seen: Record = {}; + const graphql: GraphQL = async (_host, query, variables) => { + seen = variables; + expect(query).toBe(SOURCE_BRANCH_QUERY); + return { project: { mergeRequest: { sourceBranch: '12-fix' } } }; + }; + const ref = { forge: 'gitlab' as const, host: 'gitlab.com', project: 'g/p', iid: '4' }; + expect(await new GitLabForge(graphql).sourceBranch(ref)).toBe('12-fix'); + expect(seen).toEqual({ project: 'g/p', iid: '4' }); + expect( + await new GitLabForge(async () => ({ project: { mergeRequest: null } })).sourceBranch(ref), + ).toBeUndefined(); + expect( + await new GitLabForge(async () => ({ + project: { mergeRequest: { sourceBranch: '' } }, + })).sourceBranch(ref), + ).toBeUndefined(); + }); +}); + +describe('GitLabForge.findByNumber', () => { + const projects = [ + { host: 'gitlab.com', path: 'group/project' }, + { host: 'gitlab.com', path: 'security/project' }, + ]; + + test.each(['REVIEW_STARTED', 'REVIEWED', 'UNREVIEWED', 'APPROVED', null])( + "reads Duo's review state: %s", + async (reviewState) => { + const graphql: GraphQL = async (_host, query) => { + expect(query).toContain( + 'type username bot mergeRequestInteraction { reviewState approved }', + ); + return { + p0: { + mergeRequest: node({ + reviewers: { + nodes: [ + { type: 'HUMAN', mergeRequestInteraction: { reviewState: 'REVIEW_STARTED' } }, + { type: 'DUO_CODE_REVIEW_BOT', mergeRequestInteraction: { reviewState } }, + ], + }, + }), + }, + }; + }; + const mr = await new GitLabForge(graphql).findByNumber(projects, '1'); + expect(mr?.duoReviewState).toBe(reviewState ?? undefined); + }, + ); + + test('does not report human or unrelated bot reviews as Duo', async () => { + const graphql: GraphQL = async () => ({ + p0: { + mergeRequest: node({ + reviewers: { + nodes: [ + { type: 'HUMAN', mergeRequestInteraction: { reviewState: 'REVIEW_STARTED' } }, + { type: 'PROJECT_BOT', mergeRequestInteraction: { reviewState: 'REVIEW_STARTED' } }, + ], + }, + }), + }, + }); + expect( + (await new GitLabForge(graphql).findByNumber(projects, '1'))?.duoReviewState, + ).toBeUndefined(); + }); + + test('handles a Duo reviewer with no interaction', async () => { + const graphql: GraphQL = async () => ({ + p0: { + mergeRequest: node({ + reviewers: { nodes: [{ type: 'DUO_CODE_REVIEW_BOT', mergeRequestInteraction: null }] }, + }), + }, + }); + expect( + (await new GitLabForge(graphql).findByNumber(projects, '1'))?.duoReviewState, + ).toBeUndefined(); + }); + + test.each([ + { requirements: true, approvers: [], approved: false }, + { requirements: true, approvers: [{ id: 'gid://gitlab/User/1' }], approved: true }, + { requirements: false, approvers: [{ id: 'gid://gitlab/User/1' }], approved: false }, + { requirements: null, approvers: [{ id: 'gid://gitlab/User/1' }], approved: false }, + ])( + 'requires actual approvals and satisfied requirements: %j', + async ({ requirements, approvers, approved }) => { + const graphql: GraphQL = async () => ({ + p0: { mergeRequest: node({ approved: requirements, approvedBy: { nodes: approvers } }) }, + }); + const mr = await new GitLabForge(graphql).findByNumber(projects, '1'); + expect(mr).toMatchObject({ + approved, + hasApprovals: approvers.length > 0, + approvalRequirementsSatisfied: requirements, + }); + const segments = footerSegments({ + loading: false, + lookup: { kind: 'found', requests: [mr!] }, + }); + expect(segments.some((segment) => segment.text === 'approved')).toBe(approved); + }, + ); + + // Mirrors gitlab-org/gitlab!260438: rules need no approvals, Duo approved, + // and the human reviewer hasn't reviewed yet. + const duo = { + type: 'DUO_CODE_REVIEW_BOT', + username: 'GitLabDuo', + bot: true, + mergeRequestInteraction: { reviewState: 'APPROVED', approved: true }, + }; + const human = ( + username: string, + approved: boolean, + reviewState = approved ? 'APPROVED' : 'UNREVIEWED', + ) => ({ + type: 'HUMAN', + username, + bot: false, + mergeRequestInteraction: { reviewState, approved }, + }); + + test("ignores a bot's approval", async () => { + const graphql: GraphQL = async () => ({ + p0: { + mergeRequest: node({ + approved: true, + approvedBy: { nodes: [{ id: 'gid://gitlab/User/9', bot: true }] }, + reviewers: { nodes: [duo] }, + }), + }, + }); + expect(await new GitLabForge(graphql).findByNumber(projects, '1')).toMatchObject({ + approved: false, + hasApprovals: false, + awaitingReviewers: [], + }); + }); + + test("waits for human reviewers who haven't approved", async () => { + const graphql: GraphQL = async () => ({ + p0: { + mergeRequest: node({ + approved: true, + approvedBy: { + nodes: [ + { id: 'gid://gitlab/User/9', bot: true }, + { id: 'gid://gitlab/User/1', bot: false }, + ], + }, + reviewers: { + nodes: [ + duo, + human('alice', true), + human('david', false), + human('erin', false, 'REVIEWED'), + ], + }, + }), + }, + }); + const mr = await new GitLabForge(graphql).findByNumber(projects, '1'); + expect(mr).toMatchObject({ + approved: false, + hasApprovals: true, + awaitingReviewers: ['david', 'erin'], + }); + const texts = footerSegments({ + loading: false, + lookup: { kind: 'found', requests: [mr!] }, + }).map((segment) => segment.text); + expect(texts).toContain('awaiting @david, @erin'); + expect(texts).not.toContain('approved'); + }); + + test('shows approved once every human reviewer approved', async () => { + const graphql: GraphQL = async () => ({ + p0: { + mergeRequest: node({ + approved: true, + approvedBy: { nodes: [{ id: 'gid://gitlab/User/1', bot: false }] }, + reviewers: { nodes: [duo, human('alice', true)] }, + }), + }, + }); + expect(await new GitLabForge(graphql).findByNumber(projects, '1')).toMatchObject({ + approved: true, + awaitingReviewers: [], + }); + }); + + test('prefers the first project that has the MR', async () => { + let seen: Record = {}; + const graphql: GraphQL = async (_host, _query, variables) => { + seen = variables; + return { + p0: { mergeRequest: node({ iid: '42', state: 'MERGED' }) }, + p1: { mergeRequest: node({ iid: '42', title: 'Security fix' }) }, + }; + }; + const mr = await new GitLabForge(graphql).findByNumber(projects, '42'); + expect(seen).toEqual({ iid: '42', p0: 'group/project', p1: 'security/project' }); + expect(mr).toMatchObject({ iid: '42', title: 'Title', state: 'merged' }); + }); + + test('returns undefined when no project has the MR', async () => { + const graphql: GraphQL = async () => ({ p0: { mergeRequest: null }, p1: null }); + expect(await new GitLabForge(graphql).findByNumber(projects, '42')).toBeUndefined(); + }); +}); + +describe('glabGraphQL', () => { + const run = + (stdout: string, stderr = '', code = 0): Exec => + async () => ({ stdout, stderr, code }); + + test('passes variables as raw strings', async () => { + let seen: string[] = []; + const exec: Exec = async (_file, args) => { + seen = args; + return { stdout: '{"data":{"ok":true}}', stderr: '', code: 0 }; + }; + expect(await glabGraphQL(exec, '/repo')('gitlab.com', 'query', { branch: '123' })).toEqual({ + ok: true, + }); + expect(seen).toEqual([ + 'api', + 'graphql', + '--hostname', + 'gitlab.com', + '-f', + 'query=query', + '-f', + 'branch=123', + ]); + }); + + test('classifies failures', async () => { + const fail = (exec: Exec) => + glabGraphQL(exec, '/repo')('gitlab.com', 'q', {}).catch((error) => error); + expect((await fail(run('', 'glab: 401 Unauthorized', 1))).kind).toBe('auth'); + expect((await fail(run('', 'spawn glab ENOENT', -1))).kind).toBe('missing-glab'); + expect((await fail(run('', 'glab: 429 Too Many Requests', 1))).kind).toBe('rate-limit'); + const graphqlError = await fail(run('{"errors":[{"message":"Field missing"}]}', '', 1)); + expect(graphqlError).toBeInstanceOf(ForgeError); + expect(graphqlError.message).toBe('Field missing'); + }); + + test('fails the lookup when a field errored, even next to data', async () => { + const fail = (exec: Exec) => + glabGraphQL(exec, '/repo')('gitlab.com', 'q', {}).catch((error) => error); + // A timed-out field is `null` next to its error, which isn't a missing MR. + const partial = '{"data":{"p0":null},"errors":[{"message":"request timed out","path":["p0"]}]}'; + expect(await fail(run(partial))).toMatchObject({ + kind: 'request', + message: 'request timed out', + }); + expect(await fail(run('{"data":{"p0":null}}', 'glab: connection reset', 1))).toMatchObject({ + message: 'connection reset', + }); + expect(await fail(run('{"data":null}'))).toMatchObject({ message: 'GitLab returned no data' }); + expect(await fail(run('[1, 2]'))).toBeInstanceOf(ForgeError); + // A project or MR that doesn't exist is `null` without an error. + expect(await glabGraphQL(run('{"data":{"p0":null}}'), '/repo')('gitlab.com', 'q', {})).toEqual({ + p0: null, + }); + }); + + test('classifyError keeps the first line of unknown errors', () => { + expect(classifyError('glab: dial tcp: timeout\nmore', 1)).toMatchObject({ + kind: 'request', + message: 'dial tcp: timeout', + }); + }); +}); diff --git a/packages/forge-session/src/gitlab.ts b/packages/forge-session/src/gitlab.ts new file mode 100644 index 0000000..2ad73d2 --- /dev/null +++ b/packages/forge-session/src/gitlab.ts @@ -0,0 +1,383 @@ +import type { Exec } from './exec'; +import { + ForgeError, + humanize, + ownedBy, + parseWebUrl, + prefixReferences, + yesNo, + type AutomatedReviewState, + type Forge, + type ForgeTraits, + type GitLabMergeRequest, + type ReviewRef, +} from './forge'; +import type { RemoteProject, Repository } from './git'; +import { fetchComments } from './gitlab-feedback'; + +interface DiscussionPage { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: Array<{ resolvable: boolean; resolved: boolean }>; +} + +interface MergeRequestNode { + iid: string; + title: string; + webUrl: string; + state: string; + draft: boolean; + conflicts: boolean; + approved: boolean | null; + approvedBy: { nodes: Array<{ id: string; bot?: boolean }> } | null; + detailedMergeStatus: string | null; + diffHeadSha: string | null; + updatedAt: string | null; + targetBranch: string; + sourceProject: { fullPath: string } | null; + targetProject: { fullPath: string }; + headPipeline: { + status: string; + path: string | null; + detailedStatus: { label: string | null } | null; + } | null; + discussions: DiscussionPage; + reviewers?: { nodes: ReviewerNode[] } | null; +} + +interface ReviewerNode { + type: string; + username?: string; + bot?: boolean; + mergeRequestInteraction: { reviewState: string | null; approved?: boolean | null } | null; +} + +const DISCUSSIONS = 'pageInfo { hasNextPage endCursor } nodes { resolvable resolved }'; +const PAGE_SIZE = 100; +export const MAX_DISCUSSION_PAGES = 50; + +const MERGE_REQUEST_FIELDS = ` + iid title webUrl state draft conflicts approved detailedMergeStatus diffHeadSha updatedAt targetBranch + approvedBy(first: 100) { nodes { id bot } } + sourceProject { fullPath } + targetProject { fullPath } + headPipeline { status path detailedStatus { label } } + reviewers(first: 100) { nodes { type username bot mergeRequestInteraction { reviewState approved } } } + discussions(first: ${PAGE_SIZE}) { ${DISCUSSIONS} } +`; + +export function mergeRequestsQuery(projectCount: number): string { + const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join( + ', ', + ); + const projects = Array.from( + { length: projectCount }, + (_, index) => + `p${index}: project(fullPath: $p${index}) { + mergeRequests(sourceBranches: [$branch], state: opened, first: 20) { nodes { ${MERGE_REQUEST_FIELDS} } } + }`, + ).join('\n'); + return `query($branch: String!, ${variables}) {\n${projects}\n}`; +} + +export function mergeRequestQuery(projectCount: number): string { + const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join( + ', ', + ); + const projects = Array.from( + { length: projectCount }, + (_, index) => + `p${index}: project(fullPath: $p${index}) { mergeRequest(iid: $iid) { ${MERGE_REQUEST_FIELDS} } }`, + ).join('\n'); + return `query($iid: String!, ${variables}) {\n${projects}\n}`; +} + +// Only the fields that pick the newest MR from the source project. +export function mergeRequestNumbersQuery(projectCount: number): string { + const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join( + ', ', + ); + const projects = Array.from( + { length: projectCount }, + (_, index) => + `p${index}: project(fullPath: $p${index}) { + mergeRequests(sourceBranches: [$branch], state: opened, first: 20) { nodes { iid updatedAt sourceProject { fullPath } } } + }`, + ).join('\n'); + return `query($branch: String!, ${variables}) {\n${projects}\n}`; +} + +export const SOURCE_BRANCH_QUERY = `query($project: ID!, $iid: String!) { + project(fullPath: $project) { mergeRequest(iid: $iid) { sourceBranch } } +}`; + +export const DISCUSSIONS_QUERY = `query($project: ID!, $iid: String!, $after: String) { + project(fullPath: $project) { + mergeRequest(iid: $iid) { discussions(first: ${PAGE_SIZE}, after: $after) { ${DISCUSSIONS} } } + } +}`; + +export function classifyError(message: string, code: number): ForgeError { + const text = message.trim().replace(/^glab:\s*/, '') || `glab exited with code ${code}`; + if (/ENOENT|command not found/i.test(text)) + return new ForgeError('missing-glab', 'glab is not installed'); + if ( + /\b401\b|unauthori[sz]ed|not logged in|authenticate|invalid token|token.*expired/i.test(text) + ) { + return new ForgeError('auth', 'GitLab authentication failed. Run `glab auth status`.'); + } + if (/\b429\b|rate limit|too many requests/i.test(text)) + return new ForgeError('rate-limit', 'GitLab rate limit reached'); + return new ForgeError('request', text.split('\n')[0]); +} + +// The `data` of a GraphQL response. Each query casts its fields to the node +// types it requested. +export type GraphQLData = Record; + +export type GraphQL = ( + host: string, + query: string, + variables: Record, +) => Promise; + +interface GraphQLResponse { + data?: GraphQLData | null; + errors?: { message?: unknown }[]; +} + +const isResponse = (body: unknown): body is GraphQLResponse => + typeof body === 'object' && + body !== null && + (!('errors' in body) || Array.isArray((body as GraphQLResponse).errors)) && + (!('data' in body) || typeof (body as GraphQLResponse).data === 'object'); + +export function glabGraphQL(run: Exec, cwd: string): GraphQL { + return async (host, query, variables) => { + const args = ['api', 'graphql', '--hostname', host, '-f', `query=${query}`]; + for (const [name, value] of Object.entries(variables)) args.push('-f', `${name}=${value}`); + const result = await run('glab', args, cwd); + let body: unknown; + try { + body = JSON.parse(result.stdout); + } catch { + throw classifyError(result.stderr, result.code); + } + if (!isResponse(body)) + throw classifyError(result.stderr || 'Unexpected GitLab response', result.code); + // GitLab returns `null` for a field that failed, next to the error, and + // for a project or MR that doesn't exist, without one. Only the second is + // a missing PR/MR. Any error, or a failed command, fails the whole lookup, + // so a timeout is retried instead of reported as no MR. + if (body.errors?.length) + throw classifyError(String(body.errors[0]?.message ?? ''), result.code); + if (result.code !== 0) throw classifyError(result.stderr, result.code); + if (!body.data) throw classifyError('GitLab returned no data', result.code); + return body.data; + }; +} + +const countUnresolved = (page: DiscussionPage) => + page.nodes.filter((discussion) => discussion.resolvable && !discussion.resolved).length; + +export const gitlabTraits: ForgeTraits = { + kind: 'gitlab', + noun: 'MR', + ci: 'pipeline', + // Self-managed instances are usually named gitlab.. + recognizes: (host) => /(^|\.)gitlab\./.test(host), + open: (run, directory) => new GitLabForge(glabGraphQL(run, directory)), + owns: ownedBy('gitlab'), + parseUrl: (url) => parseWebUrl('gitlab', url, /^\/(.+)\/-\/merge_requests\/([1-9]\d*)\/?$/), + // Issues can also be opened as work items. + parseIssueUrl: (url) => + parseWebUrl('gitlab', url, /^\/(.+)\/-\/(?:issues|work_items)\/([1-9]\d*)\/?$/), + reference: (iid) => `!${iid}`, + issueReference: (iid) => `#${iid}`, + titleReferences: (title) => prefixReferences(title, '!'), + indicators: () => [], + details(request) { + const requirements = + request.approvalRequirementsSatisfied === null + ? 'unknown' + : yesNo(request.approvalRequirementsSatisfied); + const lines = [ + `Human approvals: ${yesNo(request.hasApprovals)} · Approval requirements satisfied: ${requirements}`, + ]; + if (request.mergeStatus) lines.push(`Merge status: ${humanize(request.mergeStatus)}`); + return lines; + }, + automatedReview: { + name: 'GitLab Duo', + status(request) { + const state = request.duoReviewState; + if (!state) return undefined; + return { state: duoState(state), label: humanize(state) }; + }, + }, + feedback: { + async fetch(run, directory, request) { + const ref = gitlabTraits.parseUrl(request.url); + return ref + ? fetchComments(glabGraphQL(run, directory), ref.host, ref.project, ref.iid) + : undefined; + }, + }, +}; + +// Final Duo states that can leave feedback to act on. An approval, or a review +// that was reset to unreviewed, needs nothing from the agent. New GitLab +// states stay silent until they are added here. +const DUO_FEEDBACK = new Set(['REVIEWED', 'REQUESTED_CHANGES']); + +function duoState(state: string): AutomatedReviewState { + if (state === 'REVIEW_STARTED') return 'running'; + return DUO_FEEDBACK.has(state) ? 'feedback' : 'settled'; +} + +// Reads GitLab merge requests through `glab api graphql`, using glab's login. +export class GitLabForge implements Forge { + readonly traits = gitlabTraits; + + constructor(readonly graphql: GraphQL) {} + + async findByBranch(repository: Repository): Promise { + const { source, targets, sourceBranch } = repository; + const variables: Record = { branch: sourceBranch }; + targets.forEach((target, index) => (variables[`p${index}`] = target.path)); + const data = await this.graphql(source.host, mergeRequestsQuery(targets.length), variables); + + const sourcePath = source.path.toLowerCase(); + const seen = new Set(); + const nodes: MergeRequestNode[] = []; + targets.forEach((_, index) => { + for (const node of (data?.[`p${index}`]?.mergeRequests?.nodes ?? []) as MergeRequestNode[]) { + // Another project's branch with the same name must not be reported as this branch's MR. + if (node.sourceProject?.fullPath.toLowerCase() !== sourcePath) continue; + const key = `${node.targetProject.fullPath}!${node.iid}`; + if (seen.has(key)) continue; + seen.add(key); + nodes.push(node); + } + }); + + const requests = await Promise.all(nodes.map((node) => this.normalize(source.host, node))); + return requests.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')); + } + + async findNumberByBranch(repository: Repository): Promise { + const { source, targets, sourceBranch } = repository; + const variables: Record = { branch: sourceBranch }; + targets.forEach((target, index) => (variables[`p${index}`] = target.path)); + const data = await this.graphql( + source.host, + mergeRequestNumbersQuery(targets.length), + variables, + ); + + const sourcePath = source.path.toLowerCase(); + const nodes: Array<{ + iid: string; + updatedAt: string | null; + sourceProject: { fullPath: string } | null; + }> = targets.flatMap((_, index) => data?.[`p${index}`]?.mergeRequests?.nodes ?? []); + return ( + nodes + // Another project's branch with the same name must not name this branch's MR. + .filter((node) => node.sourceProject?.fullPath.toLowerCase() === sourcePath) + .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))[0]?.iid + ); + } + + async sourceBranch(ref: ReviewRef): Promise { + const data = await this.graphql(ref.host, SOURCE_BRANCH_QUERY, { + project: ref.project, + iid: ref.iid, + }); + const branch = data?.project?.mergeRequest?.sourceBranch; + return typeof branch === 'string' && branch ? branch : undefined; + } + + async findByNumber( + projects: readonly RemoteProject[], + iid: string, + ): Promise { + if (projects.length === 0) return undefined; + const host = projects[0].host; + const variables: Record = { iid }; + projects.forEach((project, index) => (variables[`p${index}`] = project.path)); + const data = await this.graphql(host, mergeRequestQuery(projects.length), variables); + for (let index = 0; index < projects.length; index++) { + const node: MergeRequestNode | undefined = data?.[`p${index}`]?.mergeRequest ?? undefined; + if (node) return this.normalize(host, node); + } + return undefined; + } + + private async unresolvedThreads(host: string, node: MergeRequestNode) { + let count = countUnresolved(node.discussions); + let page = node.discussions.pageInfo; + let pages = 1; + while (page.hasNextPage && page.endCursor && pages < MAX_DISCUSSION_PAGES) { + const data = await this.graphql(host, DISCUSSIONS_QUERY, { + project: node.targetProject.fullPath, + iid: node.iid, + after: page.endCursor, + }); + const next: DiscussionPage | undefined = data?.project?.mergeRequest?.discussions; + if (!next) break; + count += countUnresolved(next); + page = next.pageInfo; + pages++; + } + return { count, complete: !page.hasNextPage }; + } + + private async normalize(host: string, node: MergeRequestNode): Promise { + const threads = await this.unresolvedThreads(host, node); + const pipeline = node.headPipeline; + // A bot approval, such as Duo's when its review finds nothing, can satisfy + // rules that require no approvals, so only people's approvals count. + const hasApprovals = (node.approvedBy?.nodes ?? []).some((approver) => approver.bot !== true); + const awaitingReviewers = (node.reviewers?.nodes ?? []) + .filter( + (reviewer) => + reviewer.bot !== true && + reviewer.username && + reviewer.mergeRequestInteraction?.approved !== true, + ) + .map((reviewer) => reviewer.username!); + return { + forge: 'gitlab', + iid: node.iid, + title: node.title, + url: node.webUrl, + state: node.state.toLowerCase(), + draft: node.draft, + conflicts: node.conflicts, + conflictsKnown: true, + approved: node.approved === true && hasApprovals && awaitingReviewers.length === 0, + hasApprovals, + approvalRequirementsSatisfied: node.approved, + awaitingReviewers, + reviewersComplete: true, + mergeStatus: node.detailedMergeStatus ?? undefined, + targetProject: node.targetProject.fullPath, + targetBranch: node.targetBranch, + headSha: node.diffHeadSha ?? undefined, + updatedAt: node.updatedAt ?? undefined, + pipeline: pipeline + ? { + status: pipeline.status, + label: + pipeline.detailedStatus?.label ?? pipeline.status.toLowerCase().replace(/_/g, ' '), + url: pipeline.path ? `https://${host}${pipeline.path}` : undefined, + } + : undefined, + unresolvedThreads: threads.count, + threadsComplete: threads.complete, + duoReviewState: + node.reviewers?.nodes.find((reviewer) => reviewer.type === 'DUO_CODE_REVIEW_BOT') + ?.mergeRequestInteraction?.reviewState ?? undefined, + }; + } +} diff --git a/packages/forge-session/src/human-review-watch.test.ts b/packages/forge-session/src/human-review-watch.test.ts new file mode 100644 index 0000000..407dbab --- /dev/null +++ b/packages/forge-session/src/human-review-watch.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, test } from 'bun:test'; +import type { FeedbackSnapshot, ReviewComment, ReviewRequest } from './forge'; +import { + baselineOf, + createFeedbackWatcher, + eligibleFeedback, + FETCH_INTERVAL, + feedbackKey, + feedbackMessage, + feedbackToast, + recordFeedback, + RETRY_DELAY, + SETTLE_TIME, + type FeedbackLog, + type SeenComments, +} from './human-review-watch'; + +const MINUTE = 60_000; +const mr = { + forge: 'gitlab', + iid: '7', + url: 'https://gitlab.com/g/p/-/merge_requests/7', +} as ReviewRequest; + +const comment = (id: string, overrides: Partial = {}): ReviewComment => ({ + id, + order: Number(id), + url: `${mr.url}#note_${id}`, + editedAt: 0, + resolved: false, + bot: false, + authorID: 'gid://gitlab/User/2', + username: 'alice', + ...overrides, +}); + +const snapshot = (...comments: ReviewComment[]): FeedbackSnapshot => ({ + complete: true, + viewerID: 'gid://gitlab/User/1', + comments, +}); + +const seen = (baseline = 0, ...announced: string[]): SeenComments => ({ + baseline, + announced: new Set(announced), +}); + +describe('eligibleFeedback', () => { + test('requires a comment to stay unchanged for the settle time', () => { + const comments = snapshot(comment('1', { editedAt: 0 })); + expect(eligibleFeedback(comments, seen(), SETTLE_TIME - 1)).toEqual([]); + expect(eligibleFeedback(comments, seen(), SETTLE_TIME)).toEqual([comment('1')]); + }); + + test('skips baseline, announced, bot, own, and resolved comments', () => { + const comments = snapshot( + comment('10'), + comment('11'), + comment('13', { bot: true }), + comment('14', { authorID: 'gid://gitlab/User/1' }), + comment('15', { resolved: true }), + comment('16'), + ); + expect(eligibleFeedback(comments, seen(10, '11'), SETTLE_TIME).map((n) => n.id)).toEqual([ + '16', + ]); + }); + + test('treats comments at or below the baseline as old after deletions shift the window', () => { + // Comments 3 and 4 predate the baseline of 9, but only now appear in the window. + const comments = snapshot(comment('3'), comment('4'), comment('12')); + expect(eligibleFeedback(comments, seen(9), SETTLE_TIME).map((n) => n.id)).toEqual(['12']); + }); + + test("orders comments by the forge's order, not by ID", () => { + // Opaque IDs from separate comment types, as on GitHub. + const comments = snapshot( + comment('1', { id: 'review-9', order: 20 }), + comment('1', { id: 'issue-500', order: 5 }), + ); + expect(eligibleFeedback(comments, seen(10), SETTLE_TIME).map((c) => c.id)).toEqual([ + 'review-9', + ]); + expect( + eligibleFeedback(snapshot(comment('100')), seen(99), SETTLE_TIME).map((c) => c.id), + ).toEqual(['100']); + }); + + test('waits until no human comment has changed for the settle time', () => { + const comments = snapshot( + comment('1', { editedAt: 0 }), + comment('2', { editedAt: 2 * MINUTE }), + ); + expect(eligibleFeedback(comments, seen(), SETTLE_TIME)).toEqual([]); + expect(eligibleFeedback(comments, seen(), 2 * MINUTE + SETTLE_TIME).map((n) => n.id)).toEqual([ + '1', + '2', + ]); + }); + + test('counts edits to old comments as activity', () => { + const comments = snapshot( + comment('1', { editedAt: 4 * MINUTE }), + comment('2', { editedAt: 0 }), + ); + expect(eligibleFeedback(comments, seen(1), SETTLE_TIME)).toEqual([]); + }); + + test('ignores activity from bots, the viewer, and resolved threads', () => { + const recent = 4 * MINUTE; + const comments = snapshot( + comment('1', { editedAt: 0 }), + comment('2', { bot: true, editedAt: recent }), + comment('3', { authorID: 'gid://gitlab/User/1', editedAt: recent }), + comment('5', { resolved: true, editedAt: recent }), + ); + expect(eligibleFeedback(comments, seen(), SETTLE_TIME).map((n) => n.id)).toEqual(['1']); + }); +}); + +describe('baselineOf', () => { + test('returns the highest comment order, or 0 without comments', () => { + expect(baselineOf(snapshot(comment('9'), comment('100'), comment('42')))).toBe(100); + expect(baselineOf(snapshot())).toBe(0); + }); +}); + +describe('recordFeedback', () => { + test('keeps the first baseline, adds announced IDs once, and prunes old records', () => { + const log: FeedbackLog = { records: { old: { baseline: 1, announced: [], seenAt: 0 } } }; + const now = 31 * 24 * 60 * MINUTE; + recordFeedback(log, 'k', { baseline: 5 }, now); + recordFeedback(log, 'k', { baseline: 8, announced: ['6', '7'] }, now); + recordFeedback(log, 'k', { announced: ['7', '9'] }, now); + expect(log.records).toEqual({ k: { baseline: 5, announced: ['6', '7', '9'], seenAt: now } }); + }); +}); + +describe('feedback text', () => { + test('names authors and links each comment', () => { + const comments = [comment('1'), comment('2', { username: 'bob' }), comment('3')]; + const message = feedbackMessage(mr, comments); + expect(message).toContain('New review feedback from @alice and @bob on !7'); + expect(message).toContain(`- @bob: ${mr.url}#note_2`); + expect(feedbackToast(mr, comments)).toEqual({ + title: 'New review feedback on !7', + message: '3 comments from @alice and @bob. Sent to the agent.', + }); + expect(feedbackToast(mr, [comment('1')]).message).toBe( + '1 comment from @alice. Sent to the agent.', + ); + }); + + test('lists three or more authors with commas and caps the links', () => { + const comments = Array.from({ length: 22 }, (_, index) => + comment(String(index), { username: ['a', 'b', 'c'][index % 3] }), + ); + const message = feedbackMessage(mr, comments); + expect(message).toContain('from @a, @b, and @c on'); + expect(message).toContain('- and 2 more'); + }); +}); + +function harness(initial: FeedbackSnapshot | null = snapshot()) { + let clock = 0; + let current = initial; + let isCurrent = true; + let fail: Error | undefined; + const log: FeedbackLog = { records: {} }; + const sent: string[][] = []; + const sendErrors: unknown[] = []; + const fetchErrors: unknown[] = []; + let fetches = 0; + const watcher = createFeedbackWatcher({ + record: async (key) => log.records[key], + persist: (key, change, at) => recordFeedback(log, key, change, at), + fetch: async () => { + fetches++; + if (current === null) throw new Error('fetch failed'); + return current; + }, + current: () => isCurrent, + send: async (_sessionID, _mr, comments) => { + if (fail) throw fail; + sent.push(comments.map((n) => n.id)); + }, + onSendError: (error) => sendErrors.push(error), + onFetchError: (error) => fetchErrors.push(error), + now: () => clock, + }); + return { + watcher, + log, + sent, + sendErrors, + fetchErrors, + fetches: () => fetches, + set: (next: FeedbackSnapshot | null) => (current = next), + setCurrent: (value: boolean) => (isCurrent = value), + setFail: (error: Error | undefined) => (fail = error), + // Advances past the fetch throttle by default. + tick: (ms = FETCH_INTERVAL) => (clock += ms), + check: () => watcher.check('ses_1', mr), + now: () => clock, + }; +} + +describe('createFeedbackWatcher', () => { + test("doesn't fetch feedback for a PR/MR that is no longer a target", async () => { + const h = harness(); + h.setCurrent(false); + await h.check(); + expect(h.fetches()).toBe(0); + // The skipped check doesn't count against the fetch throttle. + h.setCurrent(true); + await h.check(); + expect(h.fetches()).toBe(1); + }); + + test('establishes a baseline on the first complete snapshot', async () => { + const h = harness(snapshot(comment('1'))); + await h.check(); + h.tick(SETTLE_TIME); + await h.check(); + expect(h.sent).toEqual([]); + expect(h.log.records[feedbackKey('ses_1', mr)]).toMatchObject({ baseline: 1, announced: [] }); + }); + + test("doesn't announce older comments that deletions shift into the window", async () => { + const h = harness(snapshot(comment('50'))); + await h.check(); + h.set(snapshot(comment('3'), comment('50'), comment('60'))); + h.tick(SETTLE_TIME); + await h.check(); + expect(h.sent).toEqual([['60']]); + }); + + test("doesn't baseline from an incomplete snapshot", async () => { + const h = harness({ ...snapshot(comment('1')), complete: false }); + await h.check(); + expect(h.log.records).toEqual({}); + }); + + test('announces a new comment once it has settled', async () => { + const h = harness(); + await h.check(); + h.set(snapshot(comment('1', { editedAt: h.now() }))); + h.tick(SETTLE_TIME - MINUTE); + await h.check(); + expect(h.sent).toEqual([]); + h.tick(MINUTE); + await h.check(); + expect(h.sent).toEqual([['1']]); + h.tick(); + await h.check(); + expect(h.sent).toEqual([['1']]); + }); + + test('restarts the settle time after an edit', async () => { + const h = harness(); + await h.check(); + h.set(snapshot(comment('1', { editedAt: h.now() }))); + h.tick(4 * MINUTE); + h.set(snapshot(comment('1', { editedAt: h.now() }))); + h.tick(2 * MINUTE); + await h.check(); + expect(h.sent).toEqual([]); + h.tick(3 * MINUTE); + await h.check(); + expect(h.sent).toEqual([['1']]); + }); + + test('a newer comment delays the whole batch', async () => { + const h = harness(); + await h.check(); + const start = h.now(); + h.tick(2 * MINUTE); + h.set( + snapshot( + comment('1', { editedAt: start }), + comment('2', { editedAt: start }), + comment('3', { editedAt: h.now() }), + ), + ); + h.tick(3 * MINUTE); + await h.check(); + expect(h.sent).toEqual([]); + h.tick(2 * MINUTE); + await h.check(); + expect(h.sent).toEqual([['1', '2', '3']]); + }); + + test('an edit to an announced comment delays new ones', async () => { + const h = harness(); + await h.check(); + h.set(snapshot(comment('1'))); + h.tick(SETTLE_TIME); + await h.check(); + expect(h.sent).toEqual([['1']]); + h.set( + snapshot( + comment('1', { editedAt: h.now() }), + comment('2', { editedAt: h.now() - SETTLE_TIME }), + ), + ); + h.tick(); + await h.check(); + expect(h.sent).toEqual([['1']]); + h.tick(SETTLE_TIME); + await h.check(); + expect(h.sent).toEqual([['1'], ['2']]); + }); + + test("doesn't repeat announced comments after a restart", async () => { + const h = harness(); + await h.check(); + h.set(snapshot(comment('1'))); + h.tick(SETTLE_TIME); + await h.check(); + const restarted = createFeedbackWatcher({ + record: async (key) => h.log.records[key], + persist: (key, change, at) => recordFeedback(h.log, key, change, at), + fetch: async () => snapshot(comment('1')), + current: () => true, + send: async (_sessionID, _mr, comments) => void h.sent.push(comments.map((n) => n.id)), + now: () => h.now() + SETTLE_TIME, + }); + await restarted.check('ses_1', mr); + expect(h.sent).toEqual([['1']]); + }); + + test('announces comments posted while no TUI was watching', async () => { + const h = harness(); + await h.check(); + const restarted = createFeedbackWatcher({ + record: async (key) => h.log.records[key], + persist: (key, change, at) => recordFeedback(h.log, key, change, at), + fetch: async () => snapshot(comment('1')), + current: () => true, + send: async (_sessionID, _mr, comments) => void h.sent.push(comments.map((n) => n.id)), + now: () => SETTLE_TIME, + }); + await restarted.check('ses_1', mr); + expect(h.sent).toEqual([['1']]); + }); + + test('retries a failed send after a delay', async () => { + const h = harness(); + await h.check(); + h.set(snapshot(comment('1'))); + h.setFail(new Error('offline')); + h.tick(SETTLE_TIME); + await h.check(); + expect(h.sendErrors).toEqual([new Error('offline')]); + h.setFail(undefined); + h.tick(); + await h.check(); + expect(h.sent).toEqual([]); + h.tick(RETRY_DELAY); + await h.check(); + expect(h.sent).toEqual([['1']]); + }); + + test('sends a batch once when checks overlap', async () => { + let release: () => void = () => {}; + const log: FeedbackLog = { + records: { [feedbackKey('ses_1', mr)]: { baseline: 0, announced: [], seenAt: 0 } }, + }; + const sent: string[][] = []; + let clock = SETTLE_TIME; + const watcher = createFeedbackWatcher({ + record: async (key) => log.records[key], + persist: (key, change, at) => recordFeedback(log, key, change, at), + fetch: async () => snapshot(comment('1')), + current: () => true, + send: (_sessionID, _mr, comments) => + new Promise((resolve) => { + sent.push(comments.map((n) => n.id)); + release = resolve; + }), + now: () => clock, + }); + const first = watcher.check('ses_1', mr); + await Bun.sleep(0); + clock += FETCH_INTERVAL; + await watcher.check('ses_1', mr); + release(); + await first; + clock += FETCH_INTERVAL; + await watcher.check('ses_1', mr); + expect(sent).toEqual([['1']]); + }); + + test('throttles fetches', async () => { + const h = harness(); + await h.check(); + h.tick(FETCH_INTERVAL - 1); + await h.check(); + expect(h.fetches()).toBe(1); + h.tick(1); + await h.check(); + expect(h.fetches()).toBe(2); + }); + + test('drops results for a request that is no longer the target', async () => { + const h = harness(); + h.setCurrent(false); + await h.check(); + expect(h.log.records).toEqual({}); + }); + + test('reports fetch failures without recording anything', async () => { + const h = harness(null); + await h.check(); + expect(h.fetchErrors).toEqual([new Error('fetch failed')]); + expect(h.log.records).toEqual({}); + }); +}); diff --git a/packages/forge-session/src/human-review-watch.ts b/packages/forge-session/src/human-review-watch.ts new file mode 100644 index 0000000..29eb695 --- /dev/null +++ b/packages/forge-session/src/human-review-watch.ts @@ -0,0 +1,220 @@ +import type { FeedbackSnapshot, ReviewComment, ReviewRequest } from './forge'; +import { reference } from './forges'; + +// Humans add and edit comments over a review, so new comments are announced +// only after no human comment has changed for this long. +export const SETTLE_TIME = 5 * 60_000; +// Feedback is fetched at most this often per session and request, even when +// the status polls faster, such as during an automated review. +export const FETCH_INTERVAL = 60_000; +// A failed send is retried after this long instead of on every poll. +export const RETRY_DELAY = 10 * 60_000; +// Records of requests not seen for this long are dropped. The next observation +// starts a fresh baseline, so old comments are never replayed. +const RECORD_RETENTION = 30 * 24 * 60 * 60_000; +// How stale a record's `seenAt` can get before an observation refreshes it. +const TOUCH_INTERVAL = 24 * 60 * 60_000; +const MAX_LINKS = 20; + +export interface FeedbackRecord { + // The highest comment order in the first complete snapshot. Comments at or + // below it predate watching, even if deleted comments later shift them into + // the window of newest comments that the forge reads. + baseline: number; + // IDs of comments already announced to the session. + announced: string[]; + seenAt: number; +} + +export interface FeedbackChange { + // Only used when the record is created. + baseline?: number; + announced?: string[]; +} + +export interface FeedbackLog { + // Keyed by `feedbackKey`. + records: Record; +} + +export const feedbackKey = (sessionID: string, request: ReviewRequest) => + `${sessionID} ${request.url}`; + +// The record after a change, creating it if needed. +export function applyFeedback( + record: FeedbackRecord | undefined, + change: FeedbackChange, + now: number, +): FeedbackRecord { + const announced = [...(record?.announced ?? [])]; + for (const id of change.announced ?? []) if (!announced.includes(id)) announced.push(id); + return { baseline: record?.baseline ?? change.baseline ?? 0, announced, seenAt: now }; +} + +// Whether a record is old enough to drop. The next observation starts a fresh +// baseline, so old comments are never replayed. +export const isExpired = (record: FeedbackRecord, now: number) => + now - record.seenAt > RECORD_RETENTION; + +// Applies a change to a record in a log, and prunes stale records. +export function recordFeedback(log: FeedbackLog, key: string, change: FeedbackChange, now: number) { + log.records[key] = applyFeedback(log.records[key], change, now); + for (const [entry, value] of Object.entries(log.records)) { + if (isExpired(value, now)) delete log.records[entry]; + } +} + +export interface SeenComments { + baseline: number; + announced: Set; +} + +// The highest comment order in a snapshot, or 0 for a request without comments. +export const baselineOf = (snapshot: FeedbackSnapshot) => + snapshot.comments.reduce((highest, comment) => Math.max(highest, comment.order), 0); + +const isNew = (comment: ReviewComment, seen: SeenComments) => + comment.order > seen.baseline && !seen.announced.has(comment.id); + +// Unresolved comments from people other than the viewer. +const humanFeedback = (snapshot: FeedbackSnapshot) => + snapshot.comments.filter( + (comment) => !comment.bot && comment.authorID !== snapshot.viewerID && !comment.resolved, + ); + +// New human comments, but only once no human comment, announced or not, has +// been added or edited for SETTLE_TIME. Any activity restarts the wait for the +// whole batch, so a review in progress is announced once it goes quiet. +export function eligibleFeedback( + snapshot: FeedbackSnapshot, + seen: SeenComments, + now: number, +): ReviewComment[] { + const human = humanFeedback(snapshot); + const pending = human.filter((comment) => isNew(comment, seen)); + if (pending.length === 0) return []; + const lastActivity = Math.max(...human.map((comment) => comment.editedAt)); + return now - lastActivity >= SETTLE_TIME ? pending : []; +} + +const authors = (comments: ReviewComment[]) => [ + ...new Set(comments.map((comment) => `@${comment.username}`)), +]; + +function list(items: string[]): string { + if (items.length <= 2) return items.join(' and '); + return `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}`; +} + +export function feedbackMessage(request: ReviewRequest, comments: ReviewComment[]): string { + const links = comments + .slice(0, MAX_LINKS) + .map((comment) => `- @${comment.username}: ${comment.url}`); + if (comments.length > MAX_LINKS) links.push(`- and ${comments.length - MAX_LINKS} more`); + return [ + `New review feedback from ${list(authors(comments))} on ${reference(request)} (${request.url}).`, + 'No review comment has changed for at least 5 minutes. New comments:', + ...links, + 'Fetch these comments and their discussion threads, then assess and address the feedback.', + ].join('\n'); +} + +export function feedbackToast(request: ReviewRequest, comments: ReviewComment[]) { + const count = comments.length === 1 ? '1 comment' : `${comments.length} comments`; + return { + title: `New review feedback on ${reference(request)}`, + message: `${count} from ${list(authors(comments))}. Sent to the agent.`, + }; +} + +export interface FeedbackWatcherOptions { + // The stored record of a `feedbackKey`, which may lag behind local writes. + record: (key: string) => Promise; + persist: (key: string, change: FeedbackChange, now: number) => void; + fetch: (sessionID: string, request: ReviewRequest) => Promise; + // Whether the request is still the session's target, checked before and + // after each fetch. + current: (sessionID: string, request: ReviewRequest) => boolean; + // Rejects when the session couldn't be told. + send: (sessionID: string, request: ReviewRequest, comments: ReviewComment[]) => Promise; + onFetchError?: (error: unknown) => void; + onSendError?: (error: unknown) => void; + now?: () => number; +} + +export function createFeedbackWatcher(options: FeedbackWatcherOptions) { + const now = options.now ?? Date.now; + // Local copies of records, which storage may not reflect yet. + const local = new Map(); + const reserved = new Set(); + const inflight = new Set(); + const fetchedAt = new Map(); + const retryAt = new Map(); + + // Merges the stored and local records. The higher baseline is the safer one. + const seen = (key: string, shared: FeedbackRecord | undefined): SeenComments | undefined => { + const mine = local.get(key); + if (!shared && !mine) return undefined; + return { + baseline: Math.max(shared?.baseline ?? 0, mine?.baseline ?? 0), + announced: new Set([...(shared?.announced ?? []), ...(mine?.announced ?? [])]), + }; + }; + + const remember = (key: string, change: FeedbackChange, at: number) => { + const mine = local.get(key) ?? { baseline: change.baseline ?? 0, announced: new Set() }; + for (const id of change.announced ?? []) mine.announced.add(id); + local.set(key, mine); + options.persist(key, change, at); + }; + + async function observe(sessionID: string, request: ReviewRequest, snapshot: FeedbackSnapshot) { + if (!snapshot.complete) return; + const key = feedbackKey(sessionID, request); + const stored = await options.record(key); + const at = now(); + const previous = seen(key, stored); + if (!previous) return remember(key, { baseline: baselineOf(snapshot) }, at); + if (at < (retryAt.get(key) ?? 0)) return; + const comments = eligibleFeedback(snapshot, previous, at).filter( + (comment) => !reserved.has(`${key} ${comment.id}`), + ); + if (comments.length === 0) { + if (stored && at - stored.seenAt > TOUCH_INTERVAL) options.persist(key, {}, at); + return; + } + const ids = comments.map((comment) => comment.id); + for (const id of ids) reserved.add(`${key} ${id}`); + try { + await options.send(sessionID, request, comments); + retryAt.delete(key); + remember(key, { announced: ids }, now()); + } catch (error) { + retryAt.set(key, now() + RETRY_DELAY); + options.onSendError?.(error); + } finally { + for (const id of ids) reserved.delete(`${key} ${id}`); + } + } + + return { + // Fetches the request's comments, unless a fetch is running or ran + // recently, and announces settled feedback. Resolves once that work is done. + async check(sessionID: string, request: ReviewRequest): Promise { + const key = feedbackKey(sessionID, request); + if (inflight.has(key) || now() - (fetchedAt.get(key) ?? -Infinity) < FETCH_INTERVAL) return; + if (!options.current(sessionID, request)) return; + inflight.add(key); + fetchedAt.set(key, now()); + try { + const snapshot = await options.fetch(sessionID, request); + if (!snapshot || !options.current(sessionID, request)) return; + await observe(sessionID, request, snapshot); + } catch (error) { + options.onFetchError?.(error); + } finally { + inflight.delete(key); + } + }, + }; +} diff --git a/packages/forge-session/src/index.ts b/packages/forge-session/src/index.ts new file mode 100644 index 0000000..06fa602 --- /dev/null +++ b/packages/forge-session/src/index.ts @@ -0,0 +1,39 @@ +// Merges opencode-forge-session-title and forge-review-status. This server +// entry point owns each session's targets: the `set_session_target` tool, the +// agent guidance, and the title prefix. It also watches the PRs/MRs of the +// sessions that CLIs show and tells sessions about their reviews. +// opencode.json loads this directory through a path entry; OpenCode then loads +// tui.tsx, which renders the status that this entry point serves, in the CLI. +// +// The ID is opencode-forge-session-title's, so stored targets carry over. +import type { Plugin } from '@opencode/plugin'; +import { exec, execWithTimeout } from './exec'; +import { Forges, hostsFrom } from './forges'; +import { CONCURRENCY, limitExec } from './limit'; +import { createTitleLookups } from './lookups'; +import { setupServer } from './server'; + +export default { + id: 'opencode-forge-session-title', + async setup(ctx) { + const hosts = hostsFrom((ctx.options ?? {}) as Record); + // Status polling and feedback fetches share one limit across sessions, + // and stop running commands once the plugin stops. Title lookups run on + // their own, so a polling backlog can't delay set_session_target, which + // waits for the source branch lookup and so also gives up sooner. + const stopped = new AbortController(); + const statusExec = limitExec(exec, CONCURRENCY, stopped.signal); + const cleanup = await setupServer( + ctx, + createTitleLookups(new Forges(exec, hosts), execWithTimeout(15_000)), + { + forges: new Forges(statusExec, hosts), + exec: statusExec, + }, + ); + return async () => { + stopped.abort(); + await cleanup(); + }; + }, +} satisfies Plugin.Plugin; diff --git a/packages/forge-session/src/limit.test.ts b/packages/forge-session/src/limit.test.ts new file mode 100644 index 0000000..c775424 --- /dev/null +++ b/packages/forge-session/src/limit.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import type { Exec } from './exec'; +import { createLimiter, limitExec, mapLimit } from './limit'; + +describe('mapLimit', () => { + test('keeps the order of the items', async () => { + expect( + await mapLimit([3, 1, 2], 2, async (value) => (await Bun.sleep(value), value * 10)), + ).toEqual([30, 10, 20]); + expect(await mapLimit([], 4, async (value) => value)).toEqual([]); + }); +}); + +describe('createLimiter', () => { + test('runs at most the limit at once, in order, even when tasks arrive during a handoff', async () => { + const limited = createLimiter(2); + let running = 0; + let peak = 0; + const order: number[] = []; + const task = (id: number) => + limited(async () => { + peak = Math.max(peak, ++running); + order.push(id); + await Bun.sleep(1); + running--; + // A task queued as a slot frees up must still wait its turn. + if (id === 1) void task(99); + }); + await Promise.all([1, 2, 3, 4, 5].map(task)); + await Bun.sleep(5); + expect(peak).toBe(2); + expect(order.slice(0, 5)).toEqual([1, 2, 3, 4, 5]); + }); + + test('frees the slot of a failed task', async () => { + const limited = createLimiter(1); + await expect(limited(async () => Promise.reject(new Error('nope')))).rejects.toThrow('nope'); + expect(await limited(async () => 'ok')).toBe('ok'); + }); +}); + +describe('limitExec', () => { + test('shares one limit across every command', async () => { + let running = 0; + let peak = 0; + const run: Exec = async () => { + peak = Math.max(peak, ++running); + await Bun.sleep(1); + running--; + return { stdout: '', stderr: '', code: 0 }; + }; + const limited = limitExec(run); + await Promise.all(Array.from({ length: 50 }, () => limited('glab', [], '/repo'))); + expect(peak).toBe(4); + }); +}); + +describe('limitExec cancellation', () => { + test('stops running queued and new commands once the signal aborts', async () => { + const ran: string[] = []; + let finish = () => {}; + const run: Exec = async (file) => { + ran.push(file); + if (file === 'first') await new Promise((resolve) => (finish = resolve)); + return { stdout: '', stderr: '', code: 0 }; + }; + const stopped = new AbortController(); + const limited = limitExec(run, 1, stopped.signal); + const first = limited('first', [], '/repo'); + const queued = limited('queued', [], '/repo'); + await Bun.sleep(0); + stopped.abort(); + finish(); + expect((await first).code).toBe(0); + expect(await queued).toEqual({ stdout: '', stderr: 'The plugin stopped', code: -1 }); + expect((await limited('later', [], '/repo')).code).toBe(-1); + expect(ran).toEqual(['first']); + }); +}); diff --git a/packages/forge-session/src/limit.ts b/packages/forge-session/src/limit.ts new file mode 100644 index 0000000..81ccf12 --- /dev/null +++ b/packages/forge-session/src/limit.ts @@ -0,0 +1,56 @@ +import type { Exec } from './exec'; + +// How many forge requests run at once. +export const CONCURRENCY = 4; + +// Maps `items` with at most `limit` calls of `fn` running at once, keeping +// the order of `items` in the results. +export async function mapLimit( + items: readonly T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const index = next++; + results[index] = await fn(items[index]); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} + +// Runs at most `limit` tasks at once, in the order they were queued. +export function createLimiter(limit: number) { + let running = 0; + const queue: Array<() => void> = []; + // A finished task hands its slot to the next queued one, so a task that + // arrives during the handoff can't take the slot too. + const release = () => { + const next = queue.shift(); + if (next) next(); + else running--; + }; + return async (task: () => Promise): Promise => { + if (running >= limit) await new Promise((resolve) => queue.push(resolve)); + else running++; + try { + return await task(); + } finally { + release(); + } + }; +} + +// An `exec` that shares the limiter, so every status lookup and feedback +// fetch, across all sessions, runs at most `limit` forge commands at once. +// Once `signal` aborts, such as when the plugin stops, queued and new +// commands fail without running. +export function limitExec(run: Exec, limit = CONCURRENCY, signal?: AbortSignal): Exec { + const limited = createLimiter(limit); + const cancelled = { stdout: '', stderr: 'The plugin stopped', code: -1 }; + return (file, args, cwd) => + limited(async () => (signal?.aborted ? cancelled : run(file, args, cwd))); +} diff --git a/packages/forge-session/src/locate.test.ts b/packages/forge-session/src/locate.test.ts new file mode 100644 index 0000000..61901dd --- /dev/null +++ b/packages/forge-session/src/locate.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, test } from 'bun:test'; +import type { + Forge, + ForgeKind, + GitHubPullRequest, + GitLabMergeRequest, + ReviewRequest, +} from './forge'; +import { kinds, traits, type ForgeCatalog } from './forges'; +import type { RemoteProject, Repository, RepositoryLookup } from './git'; +import { locate } from './locate'; +import { classifyTargets } from './target'; + +// Finds `found` by its own number only, like a forge that has just that PR/MR. +class FakeForge implements Forge { + readonly calls: string[] = []; + readonly traits; + + constructor( + kind: ForgeKind, + private readonly found?: ReviewRequest, + private readonly branch: ReviewRequest[] = [], + ) { + this.traits = traits(kind); + } + + async findByNumber(projects: readonly RemoteProject[], number: string) { + this.calls.push(`number ${projects.map((project) => project.path).join(',')} ${number}`); + return this.found?.iid === number ? this.found : undefined; + } + + async findByBranch(repository: Repository) { + this.calls.push(`branch ${repository.sourceBranch}`); + return this.branch; + } + + async findNumberByBranch() { + return this.branch[0]?.iid; + } + + async sourceBranch() { + return undefined; + } +} + +const merge = { + forge: 'gitlab', + iid: '45', + url: 'https://gitlab.com/g/p/-/merge_requests/45', +} as GitLabMergeRequest; +const pull = { + forge: 'github', + iid: '7', + url: 'https://github.com/o/r/pull/7', +} as GitHubPullRequest; +const repository = (host: string): RepositoryLookup => ({ + kind: 'repository', + repository: { + head: 'abc', + branch: 'feature', + sourceBranch: 'feature', + source: { host, path: 'o/r' }, + targets: [], + }, +}); +const onGitLab = { gitlab: [{ host: 'gitlab.com', path: 'g/p' }] }; +const onGitHub = { github: [{ host: 'github.com', path: 'o/r' }] }; + +function catalog( + forges: Partial>, + extra: { + projects?: Partial>; + repository?: RepositoryLookup; + } = {}, +): ForgeCatalog { + const kind = (host: string): ForgeKind | undefined => + host === 'github.com' ? 'github' : host.includes('gitlab') ? 'gitlab' : undefined; + return { + kinds, + kind, + forHost: (host) => { + const match = kind(host); + return match ? forges[match] : undefined; + }, + projects: async (_directory, match) => extra.projects?.[match] ?? [], + repository: async () => extra.repository ?? { kind: 'none', reason: 'Not a Git repository' }, + }; +} + +const target = (url: string) => classifyTargets({ url }); +const targets = (...urls: string[]) => classifyTargets({ targets: urls.map((url) => ({ url })) }); + +const mergeOf = (iid: string, overrides: Partial = {}) => + ({ + forge: 'gitlab', + iid, + url: `https://gitlab.com/g/p/-/merge_requests/${iid}`, + ...overrides, + }) as GitLabMergeRequest; + +// Finds requests by number, fails for the numbers in `failing`, and records +// how many lookups ran at once. +class FailingForge extends FakeForge { + peak = 0; + private running = 0; + + constructor( + private readonly requests: Map, + private readonly failing = new Set(), + ) { + super('gitlab'); + } + + override async findByNumber(_projects: readonly RemoteProject[], number: string) { + this.peak = Math.max(this.peak, ++this.running); + await new Promise((resolve) => setTimeout(resolve, 1)); + this.running--; + if (this.failing.has(number)) throw new Error('glab failed'); + return this.requests.get(number); + } +} + +describe('locate', () => { + test('resolves explicit targets with the matching forge and skips titles and the branch', async () => { + const gitlab = new FakeForge('gitlab', merge); + const github = new FakeForge('github', pull); + const forges = catalog( + { gitlab, github }, + { projects: onGitLab, repository: repository('gitlab.com') }, + ); + expect(await locate(forges, { directory: '/repo', targets: target(merge.url) })).toEqual({ + kind: 'found', + sessionTarget: merge.url, + explicitTarget: true, + requests: [merge], + }); + expect( + await locate(forges, { directory: '/repo', targets: target(pull.url), title: '[!45]' }), + ).toMatchObject({ + explicitTarget: true, + requests: [pull], + }); + expect(gitlab.calls).toEqual(['number g/p 45']); + expect(github.calls).toEqual(['number o/r 7']); + }); + + test('rejects explicit targets on unconfigured hosts', async () => { + expect( + await locate(catalog({}), { + directory: '/repo', + targets: target('https://github.example.com/o/r/pull/7'), + }), + ).toEqual({ + kind: 'none', + reason: 'Unsupported session target host', + }); + }); + + test('never falls back to the branch for an unresolved explicit target or an issue', async () => { + const gitlab = new FakeForge('gitlab', undefined, [merge]); + const forges = catalog({ gitlab }, { repository: repository('gitlab.com') }); + expect(await locate(forges, { directory: '/repo', targets: target(merge.url) })).toEqual({ + kind: 'none', + reason: 'Explicit PR/MR target could not be resolved', + }); + expect( + await locate(forges, { + directory: '/repo', + targets: target('https://gitlab.com/g/p/-/issues/3'), + }), + ).toMatchObject({ kind: 'none' }); + expect(gitlab.calls).toEqual(['number g/p 45']); + }); + + test('rethrows the lookup error of a single target', async () => { + const gitlab = new FailingForge(new Map(), new Set(['45'])); + await expect( + locate(catalog({ gitlab }), { directory: '/repo', targets: target(merge.url) }), + ).rejects.toThrow('glab failed'); + }); + + test('looks up every target in order, skipping issues', async () => { + const gitlab = new FailingForge( + new Map([ + ['1', mergeOf('1')], + ['2', mergeOf('2')], + ]), + ); + const lookup = await locate(catalog({ gitlab }), { + directory: '/repo', + targets: targets(mergeOf('2').url, 'https://gitlab.com/g/p/-/issues/9', mergeOf('1').url), + }); + expect(lookup).toEqual({ + kind: 'found', + sessionTarget: '2 PRs/MRs set with set_session_target', + explicitTarget: true, + requests: [mergeOf('2'), mergeOf('1')], + }); + }); + + test("keeps the other targets when one can't be found or fails, reusing its previous result", async () => { + const gitlab = new FailingForge(new Map([['1', mergeOf('1')]]), new Set(['2'])); + const urls = targets(mergeOf('1').url, mergeOf('2').url, mergeOf('3').url); + const previous = { + kind: 'found' as const, + explicitTarget: true, + requests: [mergeOf('2', { duoReviewState: 'REVIEW_STARTED' })], + }; + expect( + await locate(catalog({ gitlab }), { directory: '/repo', targets: urls, previous }), + ).toMatchObject({ + requests: [mergeOf('1'), mergeOf('2', { duoReviewState: 'REVIEW_STARTED' })], + failed: [ + { url: mergeOf('2').url, reason: 'glab failed' }, + { url: mergeOf('3').url, reason: 'Explicit PR/MR target could not be resolved' }, + ], + }); + // Without a previous result, the failed target is only listed. + expect(await locate(catalog({ gitlab }), { directory: '/repo', targets: urls })).toMatchObject({ + requests: [mergeOf('1')], + failed: [{ url: mergeOf('2').url }, { url: mergeOf('3').url }], + }); + }); + + test('throws when every lookup of several targets fails, and explains when none resolves', async () => { + const failing = new FailingForge(new Map(), new Set(['1', '2'])); + const urls = targets(mergeOf('1').url, mergeOf('2').url); + await expect( + locate(catalog({ gitlab: failing }), { directory: '/repo', targets: urls }), + ).rejects.toThrow('glab failed'); + expect( + await locate(catalog({ gitlab: new FailingForge(new Map()) }), { + directory: '/repo', + targets: urls, + }), + ).toEqual({ + kind: 'none', + reason: 'No session target PR/MR could be resolved', + }); + expect( + await locate(catalog({}), { + directory: '/repo', + targets: targets('https://gitlab.com/g/p/-/issues/1', 'https://gitlab.com/g/p/-/issues/2'), + }), + ).toEqual({ kind: 'none', reason: 'No session target is a PR/MR' }); + }); + + test('limits concurrent lookups', async () => { + const gitlab = new FailingForge( + new Map(Array.from({ length: 10 }, (_, index) => [`${index + 1}`, mergeOf(`${index + 1}`)])), + ); + const urls = targets(...Array.from({ length: 10 }, (_, index) => mergeOf(`${index + 1}`).url)); + const lookup = await locate(catalog({ gitlab }), { directory: '/repo', targets: urls }); + expect(lookup).toMatchObject({ kind: 'found' }); + expect(lookup.kind === 'found' && lookup.requests.map((request) => request.iid)).toEqual([ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + ]); + expect(gitlab.peak).toBe(4); + }); + + test("uses a title reference on the checkout's forge", async () => { + expect( + await locate(catalog({ gitlab: new FakeForge('gitlab', merge) }, { projects: onGitLab }), { + directory: '/repo', + title: '[#12, !45]', + }), + ).toMatchObject({ + sessionTarget: '!45 (from the session title)', + requests: [merge], + }); + expect( + await locate(catalog({ github: new FakeForge('github', pull) }, { projects: onGitHub }), { + directory: '/repo', + title: '[#7]', + }), + ).toMatchObject({ + sessionTarget: '#7 (from the session title)', + requests: [pull], + }); + }); + + test('skips title references that name issues, such as `[#42, #7]`', async () => { + const github = new FakeForge('github', pull); + const lookup = await locate(catalog({ github }, { projects: onGitHub }), { + directory: '/repo', + title: '[#42, #7]', + }); + expect(lookup).toMatchObject({ requests: [pull] }); + expect(github.calls).toEqual(['number o/r 42', 'number o/r 7']); + }); + + test('falls back to the branch when no title reference is a PR/MR', async () => { + const gitlab = new FakeForge('gitlab', undefined, [merge]); + const forges = catalog( + { gitlab }, + { projects: onGitLab, repository: repository('gitlab.com') }, + ); + // `#12` is a GitLab issue, and the checkout has no GitHub remote to check it on. + expect(await locate(forges, { directory: '/repo', title: '[#12, !45]' })).toMatchObject({ + requests: [merge], + }); + expect(gitlab.calls).toEqual(['number g/p 45', 'branch feature']); + + const github = new FakeForge('github', undefined, [pull]); + const issue = catalog({ github }, { projects: onGitHub, repository: repository('github.com') }); + expect(await locate(issue, { directory: '/repo', title: '[#42]' })).toMatchObject({ + requests: [pull], + }); + expect(github.calls).toEqual(['number o/r 42', 'branch feature']); + }); + + test("dispatches branch lookups by the source remote's forge", async () => { + const github = new FakeForge('github', undefined, [pull]); + const lookup = await locate(catalog({ github }, { repository: repository('github.com') }), { + directory: '/repo', + }); + expect(lookup).toMatchObject({ kind: 'found', requests: [pull] }); + expect(github.calls).toEqual(['branch feature']); + expect(await locate(catalog({ github }), { directory: '/repo' })).toEqual({ + kind: 'none', + reason: 'Not a Git repository', + }); + }); +}); diff --git a/packages/forge-session/src/locate.ts b/packages/forge-session/src/locate.ts new file mode 100644 index 0000000..87e6492 --- /dev/null +++ b/packages/forge-session/src/locate.ts @@ -0,0 +1,137 @@ +import type { ReviewRequest } from './forge'; +import { parseUrl, traits, type ForgeCatalog } from './forges'; +import { CONCURRENCY, mapLimit } from './limit'; +import type { Lookup } from './store'; +import { sameRef, type SessionTarget } from './target'; + +export interface Hints { + directory: string; + // The session's explicit targets, stored by set_session_target. + targets?: SessionTarget[]; + // The session title without the prefix this plugin wrote, so only a + // reference that the user wrote, such as `[!45]`, counts. A branch prefix + // the plugin wrote would otherwise outlive the branch's PR/MR. + title?: string; + // The session's previous lookup, which stands in for a target whose lookup + // fails, so a passing error doesn't look like a change of review state. + previous?: Lookup; +} + +const UNRESOLVED = 'Explicit PR/MR target could not be resolved'; + +type Outcome = { request: ReviewRequest } | { missing: string } | { error: unknown }; + +// Looks up each PR/MR target. A failed lookup reuses the target's previous +// result and is listed in `failed`; it throws only when no lookup succeeded. +async function locateTargets( + forges: ForgeCatalog, + directory: string, + targets: SessionTarget[], + previous?: Lookup, +): Promise { + const requests = targets.flatMap((target) => (target.kind === 'merge-request' ? [target] : [])); + // An explicit target that isn't a PR/MR, such as an issue, means the session + // isn't about one, so the branch's PR/MR would be misleading. + if (requests.length === 0) { + const reason = + targets.length === 1 + ? `The session target is not a PR/MR (${targets[0].url})` + : 'No session target is a PR/MR'; + return { kind: 'none', reason }; + } + + const outcomes = await mapLimit(requests, CONCURRENCY, async ({ ref }): Promise => { + const forge = + forges.kind(ref.host) === ref.forge ? forges.forHost(ref.host, directory) : undefined; + if (!forge) return { missing: 'Unsupported session target host' }; + try { + const request = await forge.findByNumber([{ host: ref.host, path: ref.project }], ref.iid); + return request ? { request } : { missing: UNRESOLVED }; + } catch (error) { + return { error }; + } + }); + + const earlier = previous?.kind === 'found' ? previous.requests : []; + const found: ReviewRequest[] = []; + const failed: { url: string; reason: string }[] = []; + let error: unknown; + outcomes.forEach((outcome, index) => { + const { ref, url } = requests[index]; + if ('request' in outcome) { + found.push(outcome.request); + return; + } + if ('missing' in outcome) { + failed.push({ url, reason: outcome.missing }); + return; + } + error ??= outcome.error; + const last = earlier.find((request) => { + const parsed = parseUrl(request.url); + return parsed && sameRef(parsed, ref); + }); + if (last) found.push(last); + failed.push({ + url, + reason: outcome.error instanceof Error ? outcome.error.message : String(outcome.error), + }); + }); + + if (error !== undefined && !outcomes.some((outcome) => 'request' in outcome)) throw error; + // Never fall back to the branch, which may have a different PR/MR. + if (found.length === 0) + return { + kind: 'none', + reason: + requests.length === 1 ? failed[0].reason : 'No session target PR/MR could be resolved', + }; + return { + kind: 'found', + sessionTarget: + requests.length === 1 + ? found[0].url + : `${requests.length} PRs/MRs set with set_session_target`, + explicitTarget: true, + requests: found, + ...(failed.length ? { failed } : {}), + }; +} + +// Picks the session's PRs/MRs: the explicit targets first, then a title +// reference, and finally the checked-out branch's open PRs/MRs. +export async function locate(forges: ForgeCatalog, hints: Hints): Promise { + const { directory, targets, title } = hints; + if (targets?.length) return locateTargets(forges, directory, targets, hints.previous); + + // Each forge reads its own references from the title, on the forges the + // checkout has remotes for. A reference can also name an issue, so it only + // counts once the forge finds a PR/MR with that number. + for (const kind of forges.kinds) { + const numbers = traits(kind).titleReferences(title); + if (numbers.length === 0) continue; + const projects = await forges.projects(directory, kind); + const forge = projects.length ? forges.forHost(projects[0].host, directory) : undefined; + if (!forge) continue; + for (const number of numbers) { + const request = await forge.findByNumber(projects, number); + if (request) { + return { + kind: 'found', + sessionTarget: `${forge.traits.reference(number)} (from the session title)`, + requests: [request], + }; + } + } + } + + const lookup = await forges.repository(directory); + if (lookup.kind === 'none') return lookup; + const forge = forges.forHost(lookup.repository.source.host, directory); + if (!forge) return { kind: 'none', reason: 'Not a supported forge remote' }; + return { + kind: 'found', + repository: lookup.repository, + requests: await forge.findByBranch(lookup.repository), + }; +} diff --git a/packages/forge-session/src/lookups.test.ts b/packages/forge-session/src/lookups.test.ts new file mode 100644 index 0000000..c13062a --- /dev/null +++ b/packages/forge-session/src/lookups.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test'; +import type { Exec } from './exec'; +import type { Forge, ReviewRef } from './forge'; +import { traits, type ForgeCatalog } from './forges'; +import type { Repository } from './git'; +import { createTitleLookups, NUMBER_TTL } from './lookups'; + +const repository: Repository = { + head: 'abc', + branch: 'local-12-fix', + sourceBranch: '12-fix', + source: { host: 'gitlab.com', path: 'me/project' }, + targets: [{ host: 'gitlab.com', path: 'me/project' }], +}; + +function catalog(numbers: Array): { + forges: ForgeCatalog; + calls: string[]; +} { + const calls: string[] = []; + const forge = { + traits: traits('gitlab'), + async findNumberByBranch(input: Repository) { + calls.push(input.sourceBranch); + const next = numbers.shift(); + if (next instanceof Error) throw next; + return next; + }, + } as unknown as Forge; + return { + calls, + forges: { + kinds: ['gitlab'], + kind: () => 'gitlab', + forHost: (host) => (host === 'gitlab.com' ? forge : undefined), + projects: async () => [], + repository: async () => ({ kind: 'repository', repository }), + }, + }; +} + +const unused: Exec = async () => ({ code: 1, stdout: '', stderr: 'unused' }); + +describe('createTitleLookups', () => { + test('reports the local branch with the number found for its pushed branch', async () => { + const { forges, calls } = catalog(['45']); + const found = await createTitleLookups(forges, unused).branch('/repo'); + expect(found).toMatchObject({ branch: 'local-12-fix', number: '45' }); + expect(found?.forge.kind).toBe('gitlab'); + expect(calls).toEqual(['12-fix']); + }); + + test('keeps found numbers and retries missing or failed lookups', async () => { + const { forges, calls } = catalog([undefined, new Error('offline'), '45']); + const lookups = createTitleLookups(forges, unused); + expect((await lookups.branch('/repo'))?.number).toBeUndefined(); + expect((await lookups.branch('/repo'))?.number).toBeUndefined(); + expect((await lookups.branch('/repo'))?.number).toBe('45'); + expect((await lookups.branch('/repo'))?.number).toBe('45'); + expect(calls).toHaveLength(3); + }); + + test('looks a found number up again once it expires, keeping it if that lookup fails', async () => { + let clock = 0; + const { forges, calls } = catalog(['1', new Error('offline'), '2', undefined]); + const lookups = createTitleLookups(forges, unused, () => clock); + expect((await lookups.branch('/repo'))?.number).toBe('1'); + clock += NUMBER_TTL - 1; + expect((await lookups.branch('/repo'))?.number).toBe('1'); + expect(calls).toHaveLength(1); + clock += 1; + expect((await lookups.branch('/repo'))?.number).toBe('1'); + // A new PR/MR from the same branch replaces the old one. + expect((await lookups.branch('/repo'))?.number).toBe('2'); + clock += NUMBER_TTL; + // The forge reports no open PR/MR anymore. + expect((await lookups.branch('/repo'))?.number).toBeUndefined(); + expect(calls).toHaveLength(4); + }); + + test('skips checkouts without a supported repository', async () => { + const { forges } = catalog([]); + const none: ForgeCatalog = { + ...forges, + repository: async () => ({ kind: 'none', reason: 'Default branch' }), + }; + expect(await createTitleLookups(none, unused).branch('/repo')).toBeUndefined(); + }); + + test('reads the source branch with the forge that the URL names', async () => { + const runs: string[][] = []; + const run: Exec = async (file, args) => { + runs.push([file, ...args]); + return { + code: 0, + stderr: '', + stdout: JSON.stringify({ + data: { repository: { pullRequest: { headRefName: '34-fix' } } }, + }), + }; + }; + const ref: ReviewRef = { + forge: 'github', + host: 'github.example.com', + project: 'o/r', + iid: '7', + }; + expect(await createTitleLookups(catalog([]).forges, run).sourceBranch(ref, '/repo')).toBe( + '34-fix', + ); + expect(runs[0].slice(0, 5)).toEqual([ + 'gh', + 'api', + 'graphql', + '--hostname', + 'github.example.com', + ]); + }); + + test('treats a failed source branch lookup as unknown', async () => { + const run: Exec = async () => ({ code: 1, stdout: '', stderr: 'gh: HTTP 502' }); + const ref: ReviewRef = { forge: 'gitlab', host: 'gitlab.com', project: 'g/p', iid: '7' }; + expect( + await createTitleLookups(catalog([]).forges, run).sourceBranch(ref, '/repo'), + ).toBeUndefined(); + }); +}); diff --git a/packages/forge-session/src/lookups.ts b/packages/forge-session/src/lookups.ts new file mode 100644 index 0000000..2b5e323 --- /dev/null +++ b/packages/forge-session/src/lookups.ts @@ -0,0 +1,67 @@ +import type { Exec } from './exec'; +import type { ForgeTraits, ReviewRef } from './forge'; +import { traits, type ForgeCatalog } from './forges'; + +// What session titles need from the forges. Status lookups use the same forge +// catalog, so both agree on which remote, branch, and PR/MR a checkout has. +export interface TitleLookups { + // The checked-out branch and the number of its newest open PR/MR. Undefined + // when the checkout has no supported forge remote or is on its default branch. + branch( + directory: string, + ): Promise<{ forge: ForgeTraits; branch: string; number?: string } | undefined>; + // The branch a PR/MR was opened from, for inferring its issue. + sourceBranch(ref: ReviewRef, directory: string): Promise; +} + +// How long a found PR/MR number is reused. A branch's PR/MR rarely changes, +// but one can close and another open from the same branch. +export const NUMBER_TTL = 10 * 60_000; + +export function createTitleLookups( + forges: ForgeCatalog, + run: Exec, + now: () => number = Date.now, +): TitleLookups { + // Found numbers are kept for NUMBER_TTL. A branch without one is looked up + // again after every turn. + const numbers = new Map(); + + return { + async branch(directory) { + const lookup = await forges.repository(directory); + if (lookup.kind === 'none') return undefined; + const { repository } = lookup; + const forge = forges.forHost(repository.source.host, directory); + if (!forge) return undefined; + const key = JSON.stringify([ + directory, + repository.source.host, + repository.source.path, + repository.sourceBranch, + ]); + const cached = numbers.get(key); + let number = cached && now() - cached.at < NUMBER_TTL ? cached.number : undefined; + // A failed lookup keeps an expired number, which is likelier to be right + // than no number at all. + if (!number) { + try { + number = await forge.findNumberByBranch(repository); + if (number) numbers.set(key, { number, at: now() }); + else numbers.delete(key); + } catch { + number = cached?.number; + } + } + return { forge: forge.traits, branch: repository.branch, number }; + }, + + // Uses the forge the URL names, even on a host the catalog doesn't list. + async sourceBranch(ref, directory) { + return traits(ref.forge) + .open(run, directory) + .sourceBranch(ref) + .catch(() => undefined); + }, + }; +} diff --git a/packages/forge-session/src/options.test.ts b/packages/forge-session/src/options.test.ts new file mode 100644 index 0000000..aacf6c4 --- /dev/null +++ b/packages/forge-session/src/options.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test'; +import { statusOptions } from './options'; + +describe('statusOptions', () => { + test('turns everything on by default', () => { + expect(statusOptions(undefined)).toEqual({ + enabled: true, + pollSeconds: 120, + notifyAutomatedReviews: true, + notifyHumanReviews: true, + resumeSession: true, + }); + }); + + test('reads explicit settings', () => { + expect( + statusOptions({ + reviewStatus: false, + pollSeconds: 45, + notifyAutomatedReviews: false, + notifyHumanReviews: false, + resumeSession: false, + }), + ).toEqual({ + enabled: false, + pollSeconds: 45, + notifyAutomatedReviews: false, + notifyHumanReviews: false, + resumeSession: false, + }); + }); + + test('accepts the former name of notifyAutomatedReviews and ignores invalid intervals', () => { + expect(statusOptions({ notifyDuoReview: false, pollSeconds: -1 })).toMatchObject({ + notifyAutomatedReviews: false, + pollSeconds: 120, + }); + expect( + statusOptions({ notifyDuoReview: false, notifyAutomatedReviews: true }) + .notifyAutomatedReviews, + ).toBe(true); + }); +}); diff --git a/packages/forge-session/src/options.ts b/packages/forge-session/src/options.ts new file mode 100644 index 0000000..4d1e689 --- /dev/null +++ b/packages/forge-session/src/options.ts @@ -0,0 +1,25 @@ +// The PR/MR status options, from the plugin's `opencode.json` entry. The +// server entry point reads them; the CLI only renders what the server sends. +export interface StatusOptions { + // `reviewStatus: false` keeps only session targets and titles. + enabled: boolean; + pollSeconds: number; + notifyAutomatedReviews: boolean; + notifyHumanReviews: boolean; + // Whether a review notification resumes the session, or waits in its + // queue for the next turn. + resumeSession: boolean; +} + +export function statusOptions(options: unknown): StatusOptions { + const source = options && typeof options === 'object' ? (options as Record) : {}; + const poll = Number(source.pollSeconds); + return { + enabled: source.reviewStatus !== false, + pollSeconds: poll > 0 ? poll : 120, + // `notifyDuoReview` is the option's former name. + notifyAutomatedReviews: (source.notifyAutomatedReviews ?? source.notifyDuoReview) !== false, + notifyHumanReviews: source.notifyHumanReviews !== false, + resumeSession: source.resumeSession !== false, + }; +} diff --git a/packages/forge-session/src/rpc.ts b/packages/forge-session/src/rpc.ts new file mode 100644 index 0000000..210eb4d --- /dev/null +++ b/packages/forge-session/src/rpc.ts @@ -0,0 +1,141 @@ +// Contract between the server, which stores each session's targets and +// watches their PRs/MRs, and the CLI, which renders their status. The ID is +// the one that opencode-forge-session-title registered, so callers written +// for that package keep working. +// +// Status keys are session IDs, or "" for the checkout outside a session. A +// key is relative to the location the call or event names, because each +// checkout's server plugin instance watches its own sessions. +const urls = { + url: { type: 'string' }, + issueUrl: { type: 'string' }, +} as const; + +// `url` and `issueUrl` describe the first target, for callers that expect a +// single target. `targets` lists every target in order. +const target = { + type: 'object', + properties: { + ...urls, + targets: { + type: 'array', + items: { type: 'object', properties: urls, required: ['url'], additionalProperties: false }, + }, + }, + additionalProperties: false, +} as const; + +const key = { type: 'string' } as const; +// A `Snapshot` from src/store.ts. +const snapshot = { type: 'object' } as const; +// `enabled` is false when the plugin's `reviewStatus` option is off. +const status = { + type: 'object', + properties: { enabled: { type: 'boolean' }, snapshot }, + required: ['enabled', 'snapshot'], +} as const; + +export const ForgeSessionRpc = { + id: 'opencode-forge-session-title', + methods: { + // The session's explicit targets, or `{}` in automatic branch mode. + target: { + input: { + type: 'object', + properties: { sessionID: { type: 'string' } }, + required: ['sessionID'], + additionalProperties: false, + }, + output: target, + }, + // Renews the CLI's leases on the keys it has shown, and returns their + // snapshots. `visible` is the key on screen, which polls even without + // reviews to watch. Keys the CLI no longer lists lose its lease. + watch: { + input: { + type: 'object', + properties: { + clientID: { type: 'string' }, + keys: { type: 'array', items: key }, + visible: key, + }, + required: ['clientID', 'keys'], + additionalProperties: false, + }, + output: { + type: 'object', + properties: { enabled: { type: 'boolean' }, statuses: { type: 'object' } }, + required: ['enabled', 'statuses'], + }, + }, + // Drops every lease the CLI holds, when it exits. + release: { + input: { + type: 'object', + properties: { clientID: { type: 'string' } }, + required: ['clientID'], + additionalProperties: false, + }, + output: { type: 'object' }, + }, + // Looks the key up now and returns the result. + refresh: { + input: { + type: 'object', + properties: { key }, + required: ['key'], + additionalProperties: false, + }, + output: status, + }, + }, + events: { + // Fires after set_session_target runs. Has only `sessionID` in branch mode. + targetChanged: { + schema: { + type: 'object', + properties: { sessionID: { type: 'string' }, ...target.properties }, + required: ['sessionID'], + additionalProperties: false, + }, + }, + // A key's snapshot changed. + status: { + schema: { + type: 'object', + properties: { directory: { type: 'string' }, key, snapshot }, + required: ['directory', 'key', 'snapshot'], + }, + }, + // A toast for the CLIs, such as after telling a session about a review. + // Without `sessionID`, it concerns no session in particular. + notice: { + schema: { + type: 'object', + properties: { + sessionID: { type: 'string' }, + title: { type: 'string' }, + message: { type: 'string' }, + variant: { type: 'string', enum: ['info', 'error'] }, + }, + required: ['message', 'variant'], + }, + }, + }, +} as const; + +// The status key for the checkout outside a session, such as on the home screen. +export const HOME = ''; +// A CLI renews the leases of the keys it has shown this often, and a lease +// that isn't renewed ends after LEASE_TIME, such as after the CLI exits. +export const HEARTBEAT = 60_000; +export const LEASE_TIME = 3 * HEARTBEAT; + +export type TargetUrls = { url: string; issueUrl?: string }; +export type TargetOutput = { url?: string; issueUrl?: string; targets?: TargetUrls[] }; +export type Notice = { + sessionID?: string; + title?: string; + message: string; + variant: 'info' | 'error'; +}; diff --git a/packages/forge-session/src/server.test.ts b/packages/forge-session/src/server.test.ts new file mode 100644 index 0000000..250f62a --- /dev/null +++ b/packages/forge-session/src/server.test.ts @@ -0,0 +1,1212 @@ +import { describe, expect, test } from 'bun:test'; +import type { Plugin } from '@opencode/plugin'; +import type { + FeedbackSnapshot, + Forge, + GitLabMergeRequest, + ReviewComment, + ReviewRef, + ReviewRequest, +} from './forge'; +import { kinds, traits, type ForgeCatalog } from './forges'; +import type { RemoteProject } from './git'; +import type { TitleLookups } from './lookups'; +import { FETCH_INTERVAL, SETTLE_TIME } from './human-review-watch'; +import { setupServer } from './server'; +import type { Snapshot } from './store'; + +type Tool = { execute: (input: unknown, context: { sessionID: string }) => Promise }; +type Hook = (event: { + sessionID: string; + system: { type: string; text: string }[]; +}) => Promise; + +interface Options { + storage?: Map; + branch?: { current: string; default: string }; + // What the checkout's forge reports for the branch. + found?: { branch: string; number?: string }; + sourceBranches?: Map; + failingSession?: boolean; + pluginOptions?: Record; + // Turns PR/MR status on, with this forge for its lookups. + status?: StatusForge; + // How many synthetic messages fail before they succeed. + failingSynthetic?: number; + // Reads review comments for human feedback, instead of the forge. + feedback?: (request: ReviewRequest) => Promise; + now?: () => number; + // Synthetic messages wait for `releaseSynthetic()`. + holdSynthetic?: boolean; + // Shared with another instance, such as one for another checkout. + sessions?: Map< + string, + { title: string; location: { directory: string }; parentID: string | undefined } + >; + // The instance's checkout. Defaults to /repo. + directory?: string; + // Branch lookups for titles wait for `releaseBranch()`. + holdBranch?: boolean; +} + +// A GitLab forge whose PRs/MRs the test changes between lookups. A gated +// lookup waits for `open()`, then reads the PR/MR as it is at that point. +class StatusForge implements Forge { + readonly traits = traits('gitlab'); + readonly requests = new Map(); + readonly lookups: string[] = []; + branch: GitLabMergeRequest[] = []; + private gates: Array<() => void> = []; + gated = false; + + set(project: string, iid: string, overrides: Partial = {}) { + this.requests.set(`${project}!${iid}`, { + forge: 'gitlab', + iid, + url: `https://gitlab.com/${project}/-/merge_requests/${iid}`, + state: 'opened', + ...overrides, + } as GitLabMergeRequest); + } + + open() { + this.gated = false; + for (const gate of this.gates.splice(0)) gate(); + } + + async findByNumber(projects: readonly RemoteProject[], number: string) { + const key = `${projects[0].path}!${number}`; + this.lookups.push(key); + if (this.gated) await new Promise((resolve) => this.gates.push(resolve)); + return this.requests.get(key); + } + + async findByBranch() { + return this.branch; + } + + async findNumberByBranch() { + return this.branch[0]?.iid; + } + + async sourceBranch() { + return undefined; + } +} + +const catalogFor = (forge: StatusForge): ForgeCatalog => ({ + kinds, + kind: (host) => (host === 'gitlab.com' ? 'gitlab' : undefined), + forHost: (host) => (host === 'gitlab.com' ? forge : undefined), + projects: async () => [{ host: 'gitlab.com', path: 'group/project' }], + repository: async () => ({ + kind: 'repository', + repository: { + head: 'abc', + branch: 'feature', + sourceBranch: 'feature', + source: { host: 'gitlab.com', path: 'group/project' }, + targets: [], + }, + }), +}); + +const defaultSessions = () => + new Map([ + [ + 'one', + { + title: '[#123, !45] Review changes', + location: { directory: '/repo' }, + parentID: undefined as string | undefined, + }, + ], + [ + 'two', + { + title: 'Other session', + location: { directory: '/repo' }, + parentID: undefined as string | undefined, + }, + ], + ]); + +const settle = async () => { + for (let index = 0; index < 5; index++) await Bun.sleep(1); +}; + +// Where the server stores a pending automated-review notification. +const pendingRecord = (sessionID: string, url: string) => + `status/pending/${encodeURIComponent(sessionID)}/${encodeURIComponent(url)}`; + +// Every comment ID that stored feedback records count as announced. +const announcedIn = (storage: Map) => + [...storage] + .filter(([key]) => key.startsWith('status/feedback/')) + .flatMap(([, record]) => (record as { announced: string[] }).announced); + +async function harness(options: Options = {}) { + const storage = options.storage ?? new Map(); + const sessions = options.sessions ?? defaultSessions(); + const directory = options.directory ?? '/repo'; + const sourceLookups: ReviewRef[] = []; + let releaseBranch = () => {}; + const branchGate = options.holdBranch + ? new Promise((resolve) => (releaseBranch = resolve)) + : undefined; + let branchLookups = 0; + const lookups: TitleLookups = { + branch: async () => { + branchLookups++; + await branchGate; + return options.found ? { forge: traits('gitlab'), ...options.found } : undefined; + }, + sourceBranch: async (ref) => { + sourceLookups.push(ref); + return options.sourceBranches?.get(ref.iid); + }, + }; + let tool!: Tool; + let hook!: Hook; + let renames = 0; + let handlers: Record Promise> = {}; + const emitted: Array<{ name: string; data: unknown }> = []; + const disposed: string[] = []; + const synthetic: Array<{ sessionID: string; text: string; resume?: boolean }> = []; + let syntheticFailures = options.failingSynthetic ?? 0; + let releaseSynthetic = () => {}; + const syntheticGate = options.holdSynthetic + ? new Promise((resolve) => (releaseSynthetic = resolve)) + : undefined; + let syntheticStarted = 0; + // What the next session read does, once: wait for a promise, or fail. + let nextRead: Promise | Error | undefined; + // Storage reads of these keys wait, once, for their promise. + const heldGets = new Map>(); + // Events for the plugin's subscription, which ends when it aborts. + const events: unknown[] = []; + let wake = () => {}; + const ctx = { + rpc: { + register: async (_definition: unknown, implementation: typeof handlers) => { + handlers = implementation; + return { + events: { + emit: async (name: string, data: unknown) => void emitted.push({ name, data }), + }, + dispose: async () => void disposed.push('rpc'), + }; + }, + }, + location: { directory }, + options: options.pluginOptions ?? {}, + storage: { + // Copies like durable storage, so later changes in memory don't count as stored. + get: async (key: string) => { + const held = heldGets.get(key); + heldGets.delete(key); + // Reads the value before waiting, like a read that was slow to return. + const value = structuredClone(storage.get(key)); + await held; + return value; + }, + set: async (key: string, value: unknown) => void storage.set(key, structuredClone(value)), + remove: async (key: string) => void storage.delete(key), + scan: async ({ prefix }: { prefix: string }) => ({ + entries: [...storage] + .filter(([key]) => key.startsWith(prefix)) + .map(([key, value]) => ({ key, value: structuredClone(value) })), + }), + }, + session: { + get: async ({ sessionID }: { sessionID: string }) => { + if (options.failingSession) throw new Error('unavailable'); + const next = nextRead; + nextRead = undefined; + if (next instanceof Error) throw next; + await next; + return sessions.get(sessionID); + }, + update: async ({ sessionID, title }: { sessionID: string; title: string }) => { + sessions.get(sessionID)!.title = title; + renames++; + }, + hook: async (_name: string, callback: Hook) => { + hook = callback; + }, + synthetic: async (input: { sessionID: string; text: string; resume?: boolean }) => { + syntheticStarted++; + await syntheticGate; + if (syntheticFailures > 0) { + syntheticFailures--; + throw new Error('session busy'); + } + synthetic.push(input); + }, + }, + tool: { + transform: async (callback: (editor: { add: (value: Tool) => void }) => void) => { + callback({ add: (value) => (tool = value) }); + return { dispose: async () => void disposed.push('tool') }; + }, + }, + vcs: { + get: async () => ({ + data: { branch: options.branch ?? { current: 'main', default: 'main' } }, + }), + }, + event: { + subscribe: async function* ({ signal }: { signal: AbortSignal }) { + while (!signal.aborted) { + const next = events.shift(); + if (next !== undefined) { + yield next; + continue; + } + await new Promise((resolve) => { + wake = resolve; + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + } + }, + }, + }; + const status = options.status + ? { + forges: catalogFor(options.status), + exec: async () => ({ stdout: '', stderr: 'not in tests', code: 1 }), + ...(options.feedback ? { feedback: options.feedback } : {}), + ...(options.now ? { now: options.now } : {}), + } + : undefined; + const cleanup = await setupServer(ctx as unknown as Plugin.Context, lookups, status); + return { + sessions, + storage, + cleanup, + emitted, + disposed, + sourceLookups, + synthetic, + syntheticStarted: () => syntheticStarted, + releaseSynthetic: () => releaseSynthetic(), + // Pauses the next session read until the returned function runs. + holdNextRead: () => { + let release = () => {}; + nextRead = new Promise((resolve) => (release = resolve)); + return release; + }, + failNextRead: (error: Error) => void (nextRead = error), + // Pauses the next storage read of `key` until the returned function runs. + holdNextGet: (key: string) => { + let release = () => {}; + heldGets.set(key, new Promise((resolve) => (release = resolve))); + return release; + }, + // Delivers an event to the plugin's subscription. + emit: (event: unknown) => { + events.push(event); + wake(); + }, + branchLookups: () => branchLookups, + releaseBranch: () => releaseBranch(), + renames: () => renames, + target: (sessionID = 'one') => handlers.target!({ sessionID }), + watch: (keys: string[], visible?: string) => + handlers.watch!({ + clientID: 'cli', + keys, + ...(visible !== undefined ? { visible } : {}), + }) as Promise<{ + enabled: boolean; + statuses: Record; + }>, + refresh: (key = 'one') => + handlers.refresh!({ key }) as Promise<{ enabled: boolean; snapshot: Snapshot }>, + notices: () => emitted.filter((event) => event.name === 'notice').map((event) => event.data), + lastStatus: (key = 'one') => + emitted.findLast( + (event) => event.name === 'status' && (event.data as { key: string }).key === key, + )?.data as { snapshot: Snapshot } | undefined, + set: (input: unknown, sessionID = 'one') => tool.execute(input, { sessionID }), + context: async (sessionID = 'one') => { + const event = { sessionID, system: [] as { type: string; text: string }[] }; + await hook(event); + return event.system.map((part) => part.text).join('\n'); + }, + }; +} + +const mr = 'https://gitlab.com/group/project/-/merge_requests/456'; +const nextMr = 'https://gitlab.com/group/project/-/merge_requests/789'; +const issue = 'https://gitlab.com/group/project/-/issues/42'; + +describe('session targets', () => { + test('registers guidance and replaces branch references even on main', async () => { + const app = await harness(); + expect(await app.context()).toContain('call set_session_target'); + await app.set({ target: mr }); + expect(app.sessions.get('one')?.title).toBe('[!456] Review changes'); + expect(await app.context()).toContain(mr); + expect(await app.context('two')).not.toContain(mr); + expect(app.sessions.get('two')?.title).toBe('Other session'); + await app.cleanup(); + }); + + test('persists the target and owned prefix across plugin reloads', async () => { + const storage = new Map(); + const first = await harness({ storage }); + await first.set({ target: mr, issue_url: issue }); + const title = first.sessions.get('one')!.title; + await first.cleanup(); + + const second = await harness({ storage }); + second.sessions.get('one')!.title = title; + expect(await second.context()).toContain(mr); + await second.set({ target: nextMr }); + expect(second.sessions.get('one')?.title).toBe('[!789] Review changes'); + await second.set({ target: nextMr }); + expect(second.renames()).toBe(1); + await second.cleanup(); + }); + + test('reset removes the prefix on main and restores automatic context', async () => { + const app = await harness(); + await app.set({ target: mr }); + await app.set({ target: 'branch' }); + expect(app.sessions.get('one')?.title).toBe('Review changes'); + expect(await app.context()).toContain('checked-out branch (automatic)'); + await app.cleanup(); + }); + + test('serializes concurrent switches and drops the previous related issue', async () => { + const app = await harness(); + await Promise.all([app.set({ target: mr, issue_url: issue }), app.set({ target: nextMr })]); + expect(app.sessions.get('one')?.title).toBe('[!789] Review changes'); + await app.cleanup(); + }); + + test('rejects invalid targets without changing the current target', async () => { + const app = await harness(); + await app.set({ target: mr }); + await expect(app.set({ target: '!789' })).rejects.toThrow(); + expect(app.sessions.get('one')?.title).toBe('[!456] Review changes'); + expect(await app.context()).toContain(mr); + await app.cleanup(); + }); + + test('targets a newly created MR with its issue', async () => { + const app = await harness(); + expect(await app.context()).toContain('After you create a PR or MR for the current task'); + await app.set({ target: 'https://gitlab.com/group/project/-/issues/123' }); + expect(app.sessions.get('one')?.title).toBe('[#123] Review changes'); + await app.set({ target: mr, issue_url: 'https://gitlab.com/group/project/-/issues/123' }); + expect(app.sessions.get('one')?.title).toBe('[#123, !456] Review changes'); + await app.cleanup(); + }); + + test('infers the related issue from the MR source branch', async () => { + const app = await harness({ sourceBranches: new Map([['456', '321-fix-timeout']]) }); + await app.set({ target: mr }); + expect(app.sourceLookups).toEqual([ + { forge: 'gitlab', host: 'gitlab.com', project: 'group/project', iid: '456' }, + ]); + expect(app.sessions.get('one')?.title).toBe('[#321, !456] Review changes'); + expect(await app.context()).toContain('"branchIssue":"321"'); + await app.set({ target: nextMr }); + expect(app.sessions.get('one')?.title).toBe('[!789] Review changes'); + await app.cleanup(); + }); + + test('prefers an explicit issue and skips lookups for issue targets', async () => { + const app = await harness({ sourceBranches: new Map([['456', '321-fix-timeout']]) }); + await app.set({ target: mr, issue_url: 'https://gitlab.com/group/other/-/issues/42' }); + expect(app.sessions.get('one')?.title).toBe('[#42, !456] Review changes'); + await app.set({ target: 'https://gitlab.com/group/project/-/issues/7' }); + expect(app.sessions.get('one')?.title).toBe('[#7] Review changes'); + expect(app.sourceLookups).toEqual([]); + await app.cleanup(); + }); + + test('exposes the target over RPC and announces changes', async () => { + const storage = new Map(); + const app = await harness({ storage }); + expect(await app.target()).toEqual({}); + await app.set({ target: mr, issue_url: issue }); + expect(await app.target()).toEqual({ + url: mr, + issueUrl: issue, + targets: [{ url: mr, issueUrl: issue }], + }); + expect(await app.target('two')).toEqual({}); + await app.set({ target: 'branch' }); + expect(await app.target()).toEqual({}); + expect(app.emitted).toEqual([ + { + name: 'targetChanged', + data: { + sessionID: 'one', + url: mr, + issueUrl: issue, + targets: [{ url: mr, issueUrl: issue }], + }, + }, + { name: 'targetChanged', data: { sessionID: 'one' } }, + ]); + await app.cleanup(); + expect(app.disposed).toEqual(['tool', 'rpc']); + + const reloaded = await harness({ storage }); + await reloaded.set({ target: nextMr }); + expect(await reloaded.target()).toEqual({ url: nextMr, targets: [{ url: nextMr }] }); + await reloaded.cleanup(); + }); + + test('reads a single target stored before sessions could have several', async () => { + const storage = new Map([ + ['sessions/one', { target: { url: mr, branchIssue: '123' }, prefix: '[#123, !45]' }], + ]); + const app = await harness({ storage, sourceBranches: new Map([['456', '999-other']]) }); + expect(await app.target()).toEqual({ url: mr, targets: [{ url: mr }] }); + await app.set({ target: nextMr, operation: 'add' }); + // The stored target keeps its inferred issue, so only the new one is looked up. + expect(app.sourceLookups.map((ref) => ref.iid)).toEqual(['789']); + expect(storage.get('sessions/one')).toEqual({ + targets: [{ url: mr, branchIssue: '123' }, { url: nextMr }], + prefix: '[!456, !789]', + }); + expect(app.sessions.get('one')?.title).toBe('[!456, !789] Review changes'); + await app.cleanup(); + }); + + test('sets, adds, and removes several targets', async () => { + const app = await harness({ + sourceBranches: new Map([ + ['456', '321-fix-timeout'], + ['101', '5-other'], + ]), + }); + const third = 'https://gitlab.com/group/other/-/merge_requests/101'; + expect(await app.set({ targets: [mr, nextMr] })).toEqual({ + content: `Session targets (2):\n- ${mr}\n- ${nextMr}`, + }); + expect(app.sessions.get('one')?.title).toBe('[!456, !789] Review changes'); + expect(await app.context()).toContain( + `Current session target: 2 targets: [{"url":"${mr}","branchIssue":"321"},{"url":"${nextMr}"}]`, + ); + + await app.set({ target: third, operation: 'add' }); + expect(app.sessions.get('one')?.title).toBe('[!456, !789, !101] Review changes'); + expect(await app.target()).toEqual({ + url: mr, + targets: [{ url: mr }, { url: nextMr }, { url: third }], + }); + expect(app.emitted.at(-1)?.data).toMatchObject({ + sessionID: 'one', + targets: [{ url: mr }, { url: nextMr }, { url: third }], + }); + + expect(await app.set({ targets: [nextMr, issue], operation: 'remove' })).toEqual({ + content: `Session targets (2):\n- ${mr}\n- ${third}\nNot session targets, so not removed: ${issue}`, + }); + expect(app.sessions.get('one')?.title).toBe('[!456, !101] Review changes'); + + // The remaining target shows its inferred issue again, without another lookup. + await app.set({ target: third, operation: 'remove' }); + expect(app.sessions.get('one')?.title).toBe('[#321, !456] Review changes'); + expect(app.sourceLookups.map((ref) => ref.iid)).toEqual(['456', '789', '101']); + + expect(await app.set({ target: mr, operation: 'remove' })).toEqual({ + content: 'Session target: checked-out branch.', + }); + expect(app.sessions.get('one')?.title).toBe('Review changes'); + expect(await app.context()).toContain('checked-out branch (automatic)'); + await app.cleanup(); + }); + + test('tells the agent to set every target of a task, even ones it has to find', async () => { + const app = await harness(); + const guidance = await app.context(); + expect(guidance).toContain('with all of their full URLs in targets'); + expect(guidance).toContain( + 'If the user describes them instead of linking them, find them first', + ); + expect(guidance).toContain('use operation "add" so they are kept'); + expect(guidance).not.toMatch(/weekly|distill/i); + await app.cleanup(); + }); + + test('relates a batch of MRs to one issue and shows it first in the title', async () => { + const app = await harness(); + await app.set({ targets: [mr, nextMr], issue_url: issue }); + expect(app.storage.get('sessions/one')).toMatchObject({ + targets: [ + { url: mr, issueUrl: issue }, + { url: nextMr, issueUrl: issue }, + ], + }); + expect(app.sessions.get('one')?.title).toBe('[#42, !456, !789] Review changes'); + await app.cleanup(); + }); + + test('rejects invalid batches without changing the targets', async () => { + const app = await harness(); + await app.set({ targets: [mr, nextMr] }); + await expect(app.set({ targets: [mr, 'branch'] })).rejects.toThrow(); + await expect(app.set({ targets: [mr, issue], issue_url: issue })).rejects.toThrow(); + expect(app.sessions.get('one')?.title).toBe('[!456, !789] Review changes'); + await app.cleanup(); + }); + + test('skips child sessions and sessions in another location', async () => { + const app = await harness(); + app.sessions.get('one')!.parentID = 'parent'; + expect(await app.context()).toBe(''); + await expect(app.set({ target: mr })).rejects.toThrow('root session'); + app.sessions.get('two')!.location.directory = '/another/location'; + expect(await app.context('two')).toBe(''); + await expect(app.set({ target: mr }, 'two')).rejects.toThrow('root session'); + await app.cleanup(); + }); + + test('keeps targets and titles but serves no status with reviewStatus off', async () => { + const forge = new StatusForge(); + const app = await harness({ status: forge, pluginOptions: { reviewStatus: false } }); + await app.set({ target: mr }); + expect(app.sessions.get('one')?.title).toBe('[!456] Review changes'); + expect(await app.watch(['one'], 'one')).toEqual({ enabled: false, statuses: {} }); + expect((await app.refresh()).enabled).toBe(false); + expect(forge.lookups).toEqual([]); + await app.cleanup(); + }); + + test("never blocks the model request when the session can't be read", async () => { + const app = await harness({ failingSession: true }); + expect(await app.context()).toBe(''); + await app.cleanup(); + }); +}); + +describe('branch-based naming', () => { + const feature = { current: '123-fix-login', default: 'main' }; + + test("prefixes the branch's issue and PR/MR", async () => { + const app = await harness({ + branch: feature, + found: { branch: '123-fix-login', number: '45' }, + }); + await app.set({ target: 'branch' }); + expect(app.sessions.get('one')?.title).toBe('[#123, !45] Review changes'); + await app.cleanup(); + }); + + test('shows a placeholder until the PR/MR exists', async () => { + const app = await harness({ + branch: { current: 'tidy-up', default: 'main' }, + found: { branch: 'tidy-up' }, + }); + await app.set({ target: 'branch' }); + expect(app.sessions.get('one')?.title).toBe('[tidy-up, !N/A] Review changes'); + await app.cleanup(); + }); + + test('leaves the title alone without a forge remote', async () => { + const app = await harness({ branch: feature }); + await app.set({ target: 'branch' }); + expect(app.sessions.get('one')?.title).toBe('Review changes'); + await app.cleanup(); + }); + + test('skips the default branch', async () => { + const app = await harness({ + branch: { current: 'trunk', default: 'trunk' }, + found: { branch: 'trunk', number: '1' }, + }); + await app.set({ target: 'branch' }); + expect(app.sessions.get('one')?.title).toBe('Review changes'); + await app.cleanup(); + }); +}); + +describe('PR/MR status', () => { + const projectA = 'https://gitlab.com/group/a/-/merge_requests/1'; + const projectB = 'https://gitlab.com/group/b/-/merge_requests/1'; + const quiet = { notifyHumanReviews: false }; + + test("serves a shown session's status and publishes changes", async () => { + const forge = new StatusForge(); + forge.set('group/a', '1', { title: 'Add things' }); + const app = await harness({ status: forge, pluginOptions: quiet }); + await app.set({ target: projectA }); + const watched = await app.watch(['one'], 'one'); + expect(watched.enabled).toBe(true); + await settle(); + expect(app.lastStatus()?.snapshot.lookup).toMatchObject({ + kind: 'found', + explicitTarget: true, + requests: [{ iid: '1' }], + }); + expect((await app.watch(['one'], 'one')).statuses.one.lookup).toMatchObject({ + requests: [{ title: 'Add things' }], + }); + await app.cleanup(); + }); + + test('sends only JSON over RPC, even after a lookup clears an error', async () => { + // JSON has no `undefined`, and the RPC rejects outputs that contain it. + const hasUndefined = (value: unknown): boolean => + value === undefined || + (typeof value === 'object' && + value !== null && + Object.values(value).some((entry) => hasUndefined(entry))); + const forge = new StatusForge(); + forge.set('group/a', '1'); + const app = await harness({ status: forge, pluginOptions: quiet }); + await app.set({ target: projectA }); + await app.watch(['one'], 'one'); + await settle(); + const outputs = [ + await app.refresh(), + await app.watch(['one'], 'one'), + ...app.emitted.map((event) => event.data), + ]; + expect(outputs.filter(hasUndefined)).toEqual([]); + await app.cleanup(); + }); + + test('never notifies about a target removed while its lookup was running', async () => { + // Same MR number in two projects, so the title prefix doesn't change. + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEW_STARTED' }); + forge.set('group/b', '1'); + const app = await harness({ status: forge, pluginOptions: quiet }); + await app.set({ target: projectA }); + await app.watch(['one'], 'one'); + await settle(); + + forge.gated = true; + const polling = app.refresh(); + await settle(); + await app.set({ target: projectB }); + const changed = app.emitted.length; + forge.set('group/a', '1', { duoReviewState: 'REVIEWED' }); + forge.open(); + await polling; + await settle(); + + expect(app.synthetic).toEqual([]); + expect(app.lastStatus()?.snapshot.lookup).toMatchObject({ requests: [{ url: projectB }] }); + // The old target's result is discarded, not shown or watched. + const published = app.emitted.slice(changed).filter((event) => event.name === 'status'); + expect(JSON.stringify(published)).not.toContain('"REVIEWED"'); + await app.cleanup(); + }); + + test("tells the session when a target's automated review finishes, with the resume option", async () => { + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEW_STARTED' }); + const app = await harness({ status: forge, pluginOptions: { ...quiet, resumeSession: false } }); + await app.set({ target: projectA }); + await app.watch(['one'], 'one'); + await settle(); + // Hidden after being shown, but still polled because the review is running. + await app.watch(['one'], ''); + forge.set('group/a', '1', { duoReviewState: 'REQUESTED_CHANGES' }); + await app.refresh(); + await settle(); + expect(app.synthetic).toMatchObject([{ sessionID: 'one', resume: false }]); + expect(app.synthetic[0].text).toContain(projectA); + expect(app.notices()).toMatchObject([ + { sessionID: 'one', title: 'GitLab Duo finished reviewing !1', variant: 'info' }, + ]); + await app.cleanup(); + }); + + test('keeps a failed notification pending and reports it', async () => { + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEW_STARTED' }); + const app = await harness({ status: forge, pluginOptions: quiet, failingSynthetic: 1 }); + await app.set({ target: projectA }); + await app.watch(['one'], 'one'); + await settle(); + forge.set('group/a', '1', { duoReviewState: 'REVIEWED' }); + await app.refresh(); + await settle(); + expect(app.synthetic).toEqual([]); + expect(app.notices()).toMatchObject([{ sessionID: 'one', variant: 'error' }]); + expect(app.storage.get(pendingRecord('one', projectA))).toMatchObject({ + sessionID: 'one', + url: projectA, + }); + await app.cleanup(); + }); + + test('follows the branch instead of a branch prefix the plugin wrote', async () => { + const forge = new StatusForge(); + forge.set('group/project', '1', { state: 'closed' }); + forge.branch = [ + { + forge: 'gitlab', + iid: '2', + url: 'https://gitlab.com/group/project/-/merge_requests/2', + state: 'opened', + } as GitLabMergeRequest, + ]; + const storage = new Map([['sessions/two', { prefix: '[!1]' }]]); + const app = await harness({ status: forge, storage, pluginOptions: quiet }); + app.sessions.get('two')!.title = '[!1] Other session'; + await app.watch(['two'], 'two'); + await settle(); + expect(app.lastStatus('two')?.snapshot.lookup).toMatchObject({ requests: [{ iid: '2' }] }); + expect(forge.lookups).toEqual([]); + // A reference the user wrote still counts. + app.sessions.get('two')!.title = '[!1] [!1] Other session'; + expect((await app.refresh('two')).snapshot.lookup).toMatchObject({ + sessionTarget: '!1 (from the session title)', + }); + await app.cleanup(); + }); + + test('stops polling a session that no CLI leases', async () => { + const forge = new StatusForge(); + forge.set('group/a', '1'); + const app = await harness({ status: forge, pluginOptions: quiet }); + await app.set({ target: projectA }); + await app.refresh(); + expect(forge.lookups).toHaveLength(1); + await app.watch([]); + await app.set({ target: projectB }); + await settle(); + // Nothing leases the session, so the target change doesn't trigger a lookup. + expect(forge.lookups).toHaveLength(1); + await app.cleanup(); + }); +}); + +describe('PR/MR status lifecycle', () => { + const projectA = 'https://gitlab.com/group/a/-/merge_requests/1'; + const projectB = 'https://gitlab.com/group/b/-/merge_requests/1'; + const quiet = { notifyHumanReviews: false }; + + // A human feedback reader whose comments the test sets. While `hold` is + // set, reads wait for `release()`. + function feedbackReader() { + let clock = 100 * SETTLE_TIME; + let comments: ReviewComment[] = []; + let gate: Promise | undefined; + let open = () => {}; + let reads = 0; + return { + now: () => clock, + reads: () => reads, + advance: (ms: number) => void (clock += ms), + comment: (id: string): ReviewComment => + ({ + id, + order: Number(id), + url: `${projectA}#note_${id}`, + editedAt: clock - SETTLE_TIME, + resolved: false, + bot: false, + authorID: 'someone', + username: 'alice', + }) as ReviewComment, + set: (next: ReviewComment[]) => void (comments = next), + hold: () => void (gate = new Promise((resolve) => (open = resolve))), + release: () => open(), + read: async (): Promise => { + reads++; + await gate; + return { complete: true, viewerID: 'me', comments }; + }, + }; + } + + async function watchedFeedback(extra: Partial = {}) { + const forge = new StatusForge(); + forge.set('group/a', '1'); + const reader = feedbackReader(); + const app = await harness({ status: forge, feedback: reader.read, now: reader.now, ...extra }); + await app.set({ target: projectA }); + await app.watch(['one'], 'one'); + await settle(); + // The first read records the baseline. + expect(reader.reads()).toBe(1); + reader.advance(FETCH_INTERVAL); + reader.set([reader.comment('7')]); + return { app, reader }; + } + + test('tells the session about settled human feedback', async () => { + const { app } = await watchedFeedback(); + await app.refresh(); + await settle(); + expect(app.synthetic).toMatchObject([{ sessionID: 'one' }]); + expect(app.synthetic[0].text).toContain(`${projectA}#note_7`); + await app.cleanup(); + }); + + test('never tells a session about feedback read after cleanup started', async () => { + const { app, reader } = await watchedFeedback(); + reader.hold(); + await app.refresh(); + await settle(); + expect(reader.reads()).toBe(2); + const stopping = app.cleanup(); + await settle(); + reader.release(); + // Cleanup waits for the running check instead of its 5-second limit. + await stopping; + await settle(); + expect(app.synthetic).toEqual([]); + expect(app.notices()).toEqual([]); + expect(announcedIn(app.storage)).toEqual([]); + }); + + test('looks up a hidden session after a restart, so its reviews stay watched', async () => { + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEW_STARTED' }); + const storage = new Map([['sessions/one', { targets: [{ url: projectA }] }]]); + const app = await harness({ status: forge, storage, pluginOptions: quiet }); + // The CLI renews its lease on the hidden session; another one is on screen. + await app.watch(['one', 'two'], 'two'); + await settle(); + expect(forge.lookups).toContain('group/a!1'); + forge.set('group/a', '1', { duoReviewState: 'REVIEWED' }); + await app.refresh(); + await settle(); + expect(app.synthetic).toMatchObject([{ sessionID: 'one' }]); + await app.cleanup(); + }); + + test("leaves a session that moved to another checkout to that checkout's instance", async () => { + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEW_STARTED' }); + const app = await harness({ status: forge, pluginOptions: quiet, failingSynthetic: 0 }); + await app.set({ target: projectA }); + await app.watch(['one'], 'one'); + await settle(); + app.sessions.get('one')!.location.directory = '/repo-worktree'; + forge.set('group/a', '1', { duoReviewState: 'REVIEWED' }); + // A CLI that still leases the session here doesn't make this instance watch it. + expect((await app.refresh()).snapshot.lookup).toEqual({ + kind: 'none', + reason: 'The session moved to another checkout', + }); + await settle(); + expect(app.synthetic).toEqual([]); + expect([...app.storage.keys()].filter((key) => key.startsWith('status/pending/'))).toEqual([]); + await app.cleanup(); + }); + + test('never tells a session that moved while its feedback was read', async () => { + const { app, reader } = await watchedFeedback(); + reader.hold(); + await app.refresh(); + await settle(); + expect(reader.reads()).toBe(2); + app.sessions.get('one')!.location.directory = '/repo-worktree'; + reader.release(); + await settle(); + expect(app.synthetic).toEqual([]); + expect(app.notices()).toEqual([]); + // The next lookup leaves the session to its new checkout. + expect((await app.refresh()).snapshot.lookup).toEqual({ + kind: 'none', + reason: 'The session moved to another checkout', + }); + await app.cleanup(); + }); + + test('never tells a session that moved while its status was looked up', async () => { + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEW_STARTED' }); + const app = await harness({ status: forge, pluginOptions: quiet }); + await app.set({ target: projectA }); + await app.watch(['one'], 'one'); + await settle(); + forge.gated = true; + const polling = app.refresh(); + await settle(); + app.sessions.get('one')!.location.directory = '/repo-worktree'; + forge.set('group/a', '1', { duoReviewState: 'REVIEWED' }); + forge.open(); + await polling; + await settle(); + expect(app.synthetic).toEqual([]); + expect(app.notices()).toEqual([]); + await app.cleanup(); + }); + + test('stores the outcome of an automated notification that finishes during cleanup', async () => { + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEWED' }); + const storage = new Map([ + ['sessions/one', { targets: [{ url: projectA }] }], + [ + pendingRecord('one', projectA), + { sessionID: 'one', url: projectA, since: Date.now(), retryAt: 0 }, + ], + ]); + const app = await harness({ + status: forge, + storage, + pluginOptions: quiet, + holdSynthetic: true, + }); + await app.watch(['one'], 'one'); + await settle(); + expect(app.syntheticStarted()).toBe(1); + const stopping = app.cleanup(); + await settle(); + app.releaseSynthetic(); + await stopping; + expect(app.synthetic).toHaveLength(1); + // The replacement instance won't send it again. + expect(app.storage.has(pendingRecord('one', projectA))).toBe(false); + }); + + test('stores the comments of a feedback notification that finishes during cleanup', async () => { + const { app } = await watchedFeedback({ holdSynthetic: true }); + await app.refresh(); + await settle(); + expect(app.syntheticStarted()).toBe(1); + const stopping = app.cleanup(); + await settle(); + app.releaseSynthetic(); + await stopping; + expect(app.synthetic).toHaveLength(1); + expect(announcedIn(app.storage)).toEqual(['7']); + }); + + // Runs a lookup in which the targets' automated reviews finish, and arms + // `arm` for the ownership read that the notification starts with. + async function finishDuringRead( + app: Awaited>, + forge: StatusForge, + arm: () => void, + ) { + forge.gated = true; + const polling = app.refresh(); + await settle(); + arm(); + forge.open(); + await polling; + await settle(); + } + + test('never tells a session about a target removed while ownership was read', async () => { + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEW_STARTED' }); + const app = await harness({ status: forge, pluginOptions: quiet }); + await app.set({ target: projectA }); + await app.watch(['one'], 'one'); + await settle(); + forge.set('group/a', '1', { duoReviewState: 'REVIEWED' }); + let release = () => {}; + await finishDuringRead(app, forge, () => (release = app.holdNextRead())); + await app.set({ target: projectB }); + release(); + await settle(); + expect(app.synthetic).toEqual([]); + expect(app.notices()).toEqual([]); + await app.cleanup(); + }); + + test('tells a session only about the targets it still has after ownership was read', async () => { + const projectC = 'https://gitlab.com/group/c/-/merge_requests/1'; + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEW_STARTED' }); + forge.set('group/c', '1', { duoReviewState: 'REVIEW_STARTED' }); + const app = await harness({ status: forge, pluginOptions: quiet }); + await app.set({ targets: [projectA, projectC] }); + await app.watch(['one'], 'one'); + await settle(); + forge.set('group/a', '1', { duoReviewState: 'REVIEWED' }); + forge.set('group/c', '1', { duoReviewState: 'REQUESTED_CHANGES' }); + let release = () => {}; + await finishDuringRead(app, forge, () => (release = app.holdNextRead())); + await app.set({ targets: [projectA], operation: 'remove' }); + release(); + await settle(); + expect(app.synthetic).toHaveLength(1); + expect(app.synthetic[0].text).toContain(projectC); + expect(app.synthetic[0].text).not.toContain(projectA); + expect(app.notices()).toHaveLength(1); + await app.cleanup(); + }); + + test("keeps an automated notification pending when the session can't be read", async () => { + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEWED' }); + const storage = new Map([ + ['sessions/one', { targets: [{ url: projectA }] }], + [ + pendingRecord('one', projectA), + { sessionID: 'one', url: projectA, since: Date.now(), retryAt: 0 }, + ], + ]); + const app = await harness({ status: forge, storage, pluginOptions: quiet }); + forge.gated = true; + await app.watch(['one'], 'one'); + await settle(); + app.failNextRead(new Error('database locked')); + forge.open(); + await settle(); + expect(app.synthetic).toEqual([]); + expect( + (app.storage.get(pendingRecord('one', projectA)) as { retryAt: number }).retryAt, + ).toBeGreaterThan(Date.now()); + expect(app.notices()).toMatchObject([{ sessionID: 'one', variant: 'error' }]); + expect(JSON.stringify(app.notices())).toContain('database locked'); + await app.cleanup(); + }); + + test("doesn't count feedback as announced when the session can't be read", async () => { + const { app, reader } = await watchedFeedback(); + reader.hold(); + await app.refresh(); + await settle(); + app.failNextRead(new Error('database locked')); + reader.release(); + await settle(); + expect(app.synthetic).toEqual([]); + expect(announcedIn(app.storage)).toEqual([]); + expect(JSON.stringify(app.notices())).toContain('database locked'); + await app.cleanup(); + }); +}); + +describe('state shared across instances and reloads', () => { + const projectA = 'https://gitlab.com/group/a/-/merge_requests/1'; + const projectB = 'https://gitlab.com/group/b/-/merge_requests/1'; + const quiet = { notifyHumanReviews: false }; + + // Two sessions in two checkouts, whose instances share storage. + function sharedSessions() { + const sessions = defaultSessions(); + sessions.set('three', { + title: 'Elsewhere', + location: { directory: '/other' }, + parentID: undefined, + }); + return sessions; + } + + test("keeps every instance's pending notifications in shared storage", async () => { + const storage = new Map(); + const sessions = sharedSessions(); + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEW_STARTED' }); + forge.set('group/b', '1', { duoReviewState: 'REVIEW_STARTED' }); + const options = { status: forge, storage, sessions, pluginOptions: quiet, failingSynthetic: 1 }; + const here = await harness(options); + const there = await harness({ ...options, directory: '/other' }); + await here.set({ target: projectA }); + await there.set({ target: projectB }, 'three'); + await here.watch(['one'], 'one'); + await there.watch(['three'], 'three'); + await settle(); + forge.set('group/a', '1', { duoReviewState: 'REVIEWED' }); + forge.set('group/b', '1', { duoReviewState: 'REVIEWED' }); + await here.refresh('one'); + await there.refresh('three'); + await settle(); + expect(storage.get(pendingRecord('one', projectA))).toMatchObject({ url: projectA }); + expect(storage.get(pendingRecord('three', projectB))).toMatchObject({ url: projectB }); + await here.cleanup(); + await there.cleanup(); + }); + + test("never lets an old instance's title update overwrite a target set after a reload", async () => { + const storage = new Map(); + const sessions = defaultSessions(); + const branch = { current: '123-fix-login', default: 'main' }; + const old = await harness({ + storage, + sessions, + branch, + found: { branch: '123-fix-login', number: '45' }, + holdBranch: true, + }); + old.emit({ type: 'session.execution.succeeded', data: { sessionID: 'one' } }); + await settle(); + expect(old.branchLookups()).toBe(1); + const stopping = old.cleanup(); + + const replacement = await harness({ storage, sessions, branch }); + await replacement.set({ target: mr }); + old.releaseBranch(); + await stopping; + await settle(); + expect(storage.get('sessions/one')).toMatchObject({ targets: [{ url: mr }] }); + expect(sessions.get('one')?.title).toBe('[!456] Review changes'); + await replacement.cleanup(); + }); + + test('never restores old targets from a read that overlapped a target change', async () => { + const forge = new StatusForge(); + forge.set('group/a', '1'); + forge.set('group/b', '1'); + const reader = { + clock: 100 * SETTLE_TIME, + gate: undefined as Promise | undefined, + comments: [] as ReviewComment[], + }; + let open = () => {}; + const app = await harness({ + status: forge, + now: () => reader.clock, + feedback: async () => { + await reader.gate; + return { complete: true, viewerID: 'me', comments: reader.comments }; + }, + }); + await app.set({ target: projectA }); + await app.watch(['one'], 'one'); + await settle(); + // A settled human comment on A, whose fetch is still running. + reader.clock += FETCH_INTERVAL; + reader.comments = [ + { + id: '7', + order: 7, + url: `${projectA}#note_7`, + editedAt: reader.clock - SETTLE_TIME, + resolved: false, + bot: false, + authorID: 'someone', + username: 'alice', + } as ReviewComment, + ]; + reader.gate = new Promise((resolve) => (open = resolve)); + await app.refresh(); + await settle(); + + // A slow read of the targets returns A after the change to B. + const release = app.holdNextGet('sessions/one'); + const reading = app.target(); + await settle(); + await app.set({ target: projectB }); + release(); + expect(await reading).toMatchObject({ url: projectA }); + open(); + await settle(); + expect(app.synthetic).toEqual([]); + await app.cleanup(); + }); + + test('never resumes a session for the automated review of a merged MR', async () => { + const forge = new StatusForge(); + forge.set('group/a', '1', { duoReviewState: 'REVIEW_STARTED' }); + const app = await harness({ status: forge, pluginOptions: quiet }); + await app.set({ target: projectA }); + await app.watch(['one'], 'one'); + await settle(); + forge.set('group/a', '1', { duoReviewState: 'REVIEWED', state: 'merged' }); + await app.refresh(); + await settle(); + expect(app.synthetic).toEqual([]); + await app.cleanup(); + }); +}); diff --git a/packages/forge-session/src/server.ts b/packages/forge-session/src/server.ts new file mode 100644 index 0000000..bdd32f3 --- /dev/null +++ b/packages/forge-session/src/server.ts @@ -0,0 +1,338 @@ +import type { Plugin } from '@opencode/plugin'; +import { CONCURRENCY, mapLimit } from './limit'; +import type { TitleLookups } from './lookups'; +import { statusOptions } from './options'; +import { ForgeSessionRpc } from './rpc'; +import { setupStatus, type Status, type StatusDependencies } from './status-server'; +import type { Snapshot } from './store'; +import { + applyTargetChange, + includesRequest, + MAX_TARGETS, + parseTargetChange, + targetOutput, + targetRequest, + targetsPrefix, + type Target, +} from './target'; +import { branchPrefix, extractIssueNumber, reconcileTitle } from './title'; + +const SKIP_BRANCHES = new Set(['master', 'main', 'HEAD', 'develop']); + +// Stored per session. `prefix` is the title prefix this plugin last wrote, so +// it can be replaced without touching text the user added. Before sessions +// could have several targets, the state held a single `target`. +type StoredState = { target?: Target; targets?: Target[]; prefix?: string }; +type SessionState = { targets: Target[]; prefix?: string }; + +const GUIDANCE = [ + 'When the user establishes or changes the primary issue, PR, or MR for this session, call set_session_target with its full URL before starting that work.', + 'When the user asks you to work on several issues, PRs, or MRs together, call set_session_target with all of their full URLs in targets. If the user describes them instead of linking them, find them first, then set them before starting that work. When they are all PRs/MRs for one established issue, pass it as issue_url.', + 'After you create a PR or MR for the current task (for example with gpsup, glab mr create, gh pr create, or a forge tool), call set_session_target with the new PR/MR URL. Pass the current issue as issue_url when the PR/MR was created for it. If the session has several targets that are still part of the task, use operation "add" so they are kept.', + 'When the user drops some of several targets from the task, call set_session_target with operation "remove" and their URLs.', + 'Background references, comparisons, and dependencies do not change the target. Follow-ups without a new primary target keep the current target.', + 'Include issue_url only when its relationship to the target PRs/MRs is established. Never carry over the checked-out branch’s issue to another target.', + 'When the user explicitly returns to work on the checked-out branch, call set_session_target with target "branch".', +]; + +function describeTargets(targets: readonly Target[]): string { + if (targets.length === 0) return 'checked-out branch (automatic)'; + if (targets.length === 1) return JSON.stringify(targets[0]); + return `${targets.length} targets: ${JSON.stringify(targets)}`; +} + +function toolResult(targets: readonly Target[], missing: readonly string[]): string { + const lines = + targets.length === 0 + ? ['Session target: checked-out branch.'] + : targets.length === 1 + ? [`Session target: ${targets[0].url}`] + : [`Session targets (${targets.length}):`, ...targets.map((target) => `- ${target.url}`)]; + if (missing.length) lines.push(`Not session targets, so not removed: ${missing.join(', ')}`); + return lines.join('\n'); +} + +const DISABLED = { enabled: false, snapshot: { loading: false } }; + +// How long cleanup waits for running title updates. They can't write once +// cleanup starts, so this only bounds how long their lookups are kept. +const DRAIN_TIME = 5_000; + +async function drain(tasks: Promise[], limit: number) { + let timer: ReturnType | undefined; + await Promise.race([ + Promise.allSettled(tasks), + new Promise((resolve) => (timer = setTimeout(resolve, limit))), + ]); + clearTimeout(timer); +} + +// RPC values must be JSON, which has no `undefined`, such as a cleared error. +const json = (value: unknown) => JSON.parse(JSON.stringify(value)) as never; + +// Stores each root session's targets, keeps its title prefix current, serves +// the targets to the CLI over RPC, and, given `status`, watches the PRs/MRs +// of the sessions that CLIs show. +export async function setupServer( + ctx: Plugin.Context, + lookups: TitleLookups, + status?: StatusDependencies, +) { + const pending = new Map>(); + const controller = new AbortController(); + // Set when cleanup starts. No title update or target change starts after + // it, and one that is running stops before it writes, so it can't + // overwrite what the replacement instance stores. + let closing = false; + // The last targets read or written per session, for checks that can't wait + // for storage, such as whether a PR/MR is still a target before notifying. + const known = new Map(); + // Counts each session's target writes. A read that overlapped a write may + // return the old targets, so it doesn't update `known`. + const revisions = new Map(); + const revision = (sessionID: string) => revisions.get(sessionID) ?? 0; + const bump = (sessionID: string) => revisions.set(sessionID, revision(sessionID) + 1); + + async function stateFor(sessionID: string): Promise { + const started = revision(sessionID); + const stored = + ((await ctx.storage.get(`sessions/${sessionID}`)) as StoredState | undefined) ?? {}; + const targets = Array.isArray(stored.targets) + ? stored.targets + : stored.target + ? [stored.target] + : []; + if (revision(sessionID) === started) known.set(sessionID, targets); + return { targets, ...(stored.prefix ? { prefix: stored.prefix } : {}) }; + } + + // Bumps the revision before and after the write, so neither a read that + // started before it nor one that started while it ran updates `known`. + const save = async (sessionID: string, state: SessionState) => { + if (closing) throw new Error('The plugin is reloading. Try again.'); + bump(sessionID); + known.set(sessionID, state.targets); + try { + await ctx.storage.set(`sessions/${sessionID}`, { + ...(state.targets.length ? { targets: state.targets } : {}), + ...(state.prefix ? { prefix: state.prefix } : {}), + }); + } finally { + bump(sessionID); + } + }; + + let watcher: Status | undefined; + const rpc = await ctx.rpc.register(ForgeSessionRpc, { + target: async (input) => { + const { sessionID } = input as { sessionID: string }; + return targetOutput((await stateFor(sessionID)).targets); + }, + watch: async (input) => { + const { clientID, keys, visible } = input as { + clientID: string; + keys: string[]; + visible?: string; + }; + return watcher + ? { enabled: true, statuses: json(watcher.service.watch(clientID, keys, visible)) } + : { enabled: false, statuses: {} }; + }, + release: async (input) => { + watcher?.service.release((input as { clientID: string }).clientID); + return {}; + }, + refresh: async (input) => { + const { key } = input as { key: string }; + return watcher + ? { enabled: true, snapshot: json(await watcher.service.refresh(key)) } + : DISABLED; + }, + }); + + const settings = statusOptions(ctx.options); + if (status && settings.enabled) { + watcher = await setupStatus(ctx, settings, status, { + state: stateFor, + isTarget: (sessionID, url) => includesRequest(known.get(sessionID) ?? [], url), + publish: (key: string, snapshot: Snapshot) => + void rpc.events + .emit('status', { directory: ctx.location.directory, key, snapshot: json(snapshot) }) + .catch(() => {}), + notice: (notice) => void rpc.events.emit('notice', json(notice)).catch(() => {}), + }); + } + + async function update(sessionID: string): Promise { + const session = await ctx.session.get({ sessionID }); + if (session.parentID || !session.title) return; + + const directory = session.location.directory; + if (directory !== ctx.location.directory) return; + + const state = await stateFor(sessionID); + let prefix = targetsPrefix(state.targets); + if (state.targets.length === 0) { + const vcs = await ctx.vcs.get({ location: session.location }); + const info = vcs.data?.branch; + const branch = info?.current; + if (branch && !SKIP_BRANCHES.has(branch) && branch !== info?.default) { + const found = await lookups.branch(directory); + if (found) prefix = branchPrefix(found.forge, found.branch, found.number); + } + } + + // A branch lookup can outlast cleanup. Its result must not reach the + // title or storage, which the replacement instance owns by then. + if (closing) return; + const title = reconcileTitle(session.title, prefix, state.prefix); + if (title !== session.title) await ctx.session.update({ sessionID, title }); + await save(sessionID, { targets: state.targets, ...(prefix ? { prefix } : {}) }); + } + + // Runs one update at a time per session, so a target change and a turn + // ending at once can't interleave their reads and writes. + function schedule(sessionID: string, action = () => update(sessionID)): Promise { + if (closing) return Promise.reject(new Error('The plugin is reloading. Try again.')); + const previous = pending.get(sessionID) ?? Promise.resolve(); + const next = previous.then(action); + const settled = next.catch(() => {}); + pending.set(sessionID, settled); + void settled.then(() => { + if (pending.get(sessionID) === settled) pending.delete(sessionID); + }); + return next; + } + + // Infers the related issue of each PR/MR target from its source branch. + // Targets the session already had were looked up when they were added. + async function inferIssues(targets: Target[], previous: readonly Target[]) { + const known = new Set( + previous.filter((target) => !target.issueUrl).map((target) => target.url), + ); + await mapLimit(targets, CONCURRENCY, async (target) => { + if (target.issueUrl || target.branchIssue || known.has(target.url)) return; + const request = targetRequest(target); + const branch = request + ? await lookups.sourceBranch(request, ctx.location.directory) + : undefined; + const issue = branch ? extractIssueNumber(branch) : undefined; + if (issue) target.branchIssue = issue; + }); + } + + const tools = await ctx.tool.transform((editor) => { + editor.add({ + name: 'set_session_target', + description: + 'Set the issues, PRs, or MRs this session works on, for its title and PR/MR status. Use target for one full URL, or "branch" to return to branch-based naming. Use targets for several full URLs. The default operation, "replace", sets exactly the given targets; "add" keeps the current targets, and "remove" drops the given ones. Call it after creating a PR/MR for the current task. Without issue_url, the related issue is inferred from the PR/MR source branch name. This only changes local session metadata.', + input: { + type: 'object', + properties: { + target: { + type: 'string', + description: 'Full GitHub/GitLab issue, PR, or MR URL, or "branch".', + }, + targets: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: MAX_TARGETS, + description: 'Several full GitHub/GitLab issue, PR, or MR URLs, instead of target.', + }, + operation: { + type: 'string', + enum: ['replace', 'add', 'remove'], + description: 'How the given targets change the current ones. Defaults to "replace".', + }, + issue_url: { + type: 'string', + description: + 'Optional full URL of an established related issue for target, or for every PR/MR in targets. Omit when unknown.', + }, + }, + additionalProperties: false, + }, + execute: async (input, { sessionID }) => { + const change = parseTargetChange(input); + let result: { targets: Target[]; missing: string[] } = { targets: [], missing: [] }; + await schedule(sessionID, async () => { + const session = await ctx.session.get({ sessionID }); + if (session.parentID || session.location.directory !== ctx.location.directory) { + throw new Error('Session targets can only be set in the current root session.'); + } + const state = await stateFor(sessionID); + result = applyTargetChange(state.targets, change); + await inferIssues(result.targets, state.targets); + await save(sessionID, { + targets: result.targets, + ...(state.prefix ? { prefix: state.prefix } : {}), + }); + watcher?.targetsChanged(sessionID); + await rpc.events.emit('targetChanged', { sessionID, ...targetOutput(result.targets) }); + await update(sessionID); + }); + return { content: toolResult(result.targets, result.missing) }; + }, + }); + }); + + // A failure here must not block the model request, so it only skips the guidance. + await ctx.session.hook('context', async (event) => { + try { + const session = await ctx.session.get({ sessionID: event.sessionID }); + if (session.parentID || session.location.directory !== ctx.location.directory) return; + const state = await stateFor(event.sessionID); + event.system.push({ + type: 'text', + text: [...GUIDANCE, `Current session target: ${describeTargets(state.targets)}.`].join( + '\n', + ), + }); + } catch { + // The request goes ahead without the guidance. + } + }); + + void (async () => { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + const { type } = event as { type: string }; + const sessionID = (event.data as { sessionID?: string } | undefined)?.sessionID; + if ((type === 'session.renamed' || type === 'session.execution.succeeded') && sessionID) { + void schedule(sessionID).catch(() => {}); + } + if (type === 'session.renamed' && sessionID) + void watcher?.titleChanged(sessionID).catch(() => {}); + if (type === 'session.moved' && sessionID) watcher?.sessionMoved(sessionID); + if (type === 'session.execution.succeeded' || type === 'session.execution.failed') + watcher?.turnEnded(); + if (type === 'vcs.branch.updated') { + const location = (event as { location?: { directory?: string } }).location; + if (!location?.directory || location.directory === ctx.location.directory) + void watcher?.branchChanged().catch(() => {}); + } + } + })().catch((error) => { + // Without events, titles and statuses stop following renames, turns, and + // branch switches until the plugin reloads. + if (controller.signal.aborted) return; + const reason = error instanceof Error ? error.message : String(error); + void rpc.events + .emit('notice', { + message: `Session title and PR/MR status updates stopped: ${reason}`, + variant: 'error', + }) + .catch(() => {}); + }); + + // Stops new work, waits for running title updates and target changes, then + // stops the status watchers. + return async () => { + closing = true; + controller.abort(); + await drain([...pending.values()], DRAIN_TIME); + await watcher?.dispose(); + await tools.dispose(); + await rpc.dispose(); + }; +} diff --git a/packages/forge-session/src/status-client.test.ts b/packages/forge-session/src/status-client.test.ts new file mode 100644 index 0000000..3766604 --- /dev/null +++ b/packages/forge-session/src/status-client.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from 'bun:test'; +import { HOME } from './rpc'; +import { createStatusClient } from './status-client'; +import type { Snapshot } from './store'; + +const found = (iid: string): Snapshot => ({ + loading: false, + lookup: { kind: 'found', requests: [{ iid } as never] }, +}); + +function harness() { + const watches: Array<{ directory: string; keys: string[]; visible?: string }> = []; + const enabled = new Map(); + const statuses = new Map>(); + const released: string[] = []; + let failing = false; + const timers: Array<() => void> = []; + const client = createStatusClient({ + clientID: 'cli', + watch: async (directory, input) => { + if (failing) throw new Error('plugin reloading'); + watches.push({ + directory, + keys: input.keys, + ...(input.visible !== undefined ? { visible: input.visible } : {}), + }); + return { enabled: enabled.get(directory) ?? true, statuses: statuses.get(directory) ?? {} }; + }, + refresh: async (directory, key) => ({ + enabled: enabled.get(directory) ?? true, + snapshot: statuses.get(directory)?.[key], + }), + release: async (directory) => void released.push(directory), + onChange: () => {}, + setTimer: (callback) => timers.push(callback), + clearTimer: () => {}, + }); + return { + client, + watches, + enabled, + statuses, + released, + timers, + fail: (value: boolean) => void (failing = value), + settle: () => Bun.sleep(0), + }; +} + +describe('createStatusClient', () => { + test("moves a session's lease when the session moves to another directory", async () => { + const app = harness(); + app.statuses.set('/a', { ses_1: found('1') }); + app.client.show({ directory: '/a', key: 'ses_1' }); + await app.settle(); + expect(app.client.snapshot({ directory: '/a', key: 'ses_1' })).toEqual(found('1')); + + // The footer hides the old place and shows the new one in one change. + app.client.hide({ directory: '/a', key: 'ses_1' }); + app.client.show({ directory: '/b', key: 'ses_1' }); + await app.settle(); + expect(app.watches.slice(1)).toEqual([ + { directory: '/a', keys: [] }, + { directory: '/b', keys: ['ses_1'], visible: 'ses_1' }, + ]); + expect(app.client.snapshot({ directory: '/a', key: 'ses_1' })).toEqual({ loading: false }); + expect(app.client.hasShown('ses_1')).toBe(true); + + // Later heartbeats renew only the new directory's lease. + app.watches.length = 0; + app.client.heartbeat(); + await app.settle(); + expect(app.watches).toEqual([ + { directory: '/a', keys: [] }, + { directory: '/b', keys: ['ses_1'], visible: 'ses_1' }, + ]); + }); + + test("keeps each directory's home status, which is not a session", async () => { + const app = harness(); + app.client.show({ directory: '/a', key: HOME }); + app.client.show({ directory: '/b', key: HOME }); + await app.settle(); + app.watches.length = 0; + app.client.heartbeat(); + await app.settle(); + expect(app.watches).toEqual([ + { directory: '/a', keys: [HOME] }, + { directory: '/b', keys: [HOME], visible: HOME }, + ]); + }); + + test("follows each directory's own reviewStatus setting", async () => { + const app = harness(); + app.enabled.set('/off', false); + app.client.show({ directory: '/off', key: 'ses_1' }); + app.client.show({ directory: '/on', key: 'ses_2' }); + await app.settle(); + expect(app.client.enabled('/off')).toBe(false); + expect(app.client.enabled('/on')).toBe(true); + // The last answer from another directory doesn't change it. + app.client.heartbeat(); + await app.settle(); + expect(app.client.enabled('/off')).toBe(false); + await app.client.refresh({ directory: '/on', key: 'ses_2' }); + expect(app.client.enabled('/off')).toBe(false); + }); + + test('keeps the last status, marked stale, while the server is unavailable, and retries', async () => { + const app = harness(); + app.statuses.set('/a', { ses_1: found('1') }); + app.client.show({ directory: '/a', key: 'ses_1' }); + await app.settle(); + app.fail(true); + app.client.heartbeat(); + await app.settle(); + expect(app.client.snapshot({ directory: '/a', key: 'ses_1' })).toMatchObject({ + lookup: found('1').lookup, + error: { kind: 'request', message: 'PR/MR status service unavailable: plugin reloading' }, + }); + expect(app.timers).toHaveLength(1); + app.fail(false); + app.timers[0](); + await app.settle(); + expect(app.client.snapshot({ directory: '/a', key: 'ses_1' })).toEqual(found('1')); + }); + + test('keeps the newest snapshot when an older RPC response arrives after an event', async () => { + let answer = (_value: { enabled: boolean; statuses: Record }) => {}; + const client = createStatusClient({ + clientID: 'cli', + watch: () => new Promise((resolve) => (answer = resolve)), + refresh: async () => ({ enabled: true, snapshot: { loading: false } }), + release: async () => {}, + onChange: () => {}, + }); + const place = { directory: '/a', key: 'ses_1' }; + client.show(place); + await Bun.sleep(0); + const done = { ...found('1'), revision: 12 }; + client.receive('/a', 'ses_1', done); + answer({ enabled: true, statuses: { ses_1: { loading: true, revision: 11 } } }); + await Bun.sleep(0); + expect(client.snapshot(place)).toEqual(done); + // A newer response replaces it, and one without a revision never does. + client.receive('/a', 'ses_1', { loading: false }); + expect(client.snapshot(place)).toEqual(done); + client.receive('/a', 'ses_1', { ...found('2'), revision: 13 }); + expect(client.snapshot(place).revision).toBe(13); + }); + + test("ignores status events for keys it hasn't shown, and releases its leases", async () => { + const app = harness(); + app.client.show({ directory: '/a', key: 'ses_1' }); + await app.settle(); + app.client.receive('/a', 'ses_2', found('2')); + app.client.receive('/b', 'ses_1', found('3')); + app.client.receive('/a', 'ses_1', found('1')); + expect(app.client.snapshot({ directory: '/a', key: 'ses_2' })).toEqual({ loading: false }); + expect(app.client.snapshot({ directory: '/b', key: 'ses_1' })).toEqual({ loading: false }); + expect(app.client.snapshot({ directory: '/a', key: 'ses_1' })).toEqual(found('1')); + app.client.dispose(); + expect(app.released).toEqual(['/a']); + }); +}); diff --git a/packages/forge-session/src/status-client.ts b/packages/forge-session/src/status-client.ts new file mode 100644 index 0000000..5d8757e --- /dev/null +++ b/packages/forge-session/src/status-client.ts @@ -0,0 +1,189 @@ +import { HEARTBEAT, HOME } from './rpc'; +import type { Snapshot } from './store'; + +// Where a status lives: the server plugin instance of `directory`, and a +// session ID or HOME within it. +export interface Place { + directory: string; + key: string; +} + +export const samePlace = (a: Place, b: Place) => a.directory === b.directory && a.key === b.key; +const placeID = (place: Place) => JSON.stringify([place.directory, place.key]); + +export interface StatusClientOptions { + clientID: string; + watch: ( + directory: string, + input: { clientID: string; keys: string[]; visible?: string }, + ) => Promise<{ enabled: boolean; statuses: Record }>; + refresh: (directory: string, key: string) => Promise<{ enabled: boolean; snapshot: unknown }>; + release: (directory: string, clientID: string) => Promise; + // Called after any snapshot or setting changes, so the UI can render again. + onChange: () => void; + now?: () => number; + setTimer?: (callback: () => void, delay: number) => unknown; + clearTimer?: (timer: unknown) => void; + defer?: (callback: () => void) => void; +} + +export type StatusClient = ReturnType; + +// The CLI's side of the status service: which keys it has shown, the leases +// that keep the server watching them, and the snapshots the server sent. +// Each directory is its own server plugin instance, with its own options. +export function createStatusClient(options: StatusClientOptions) { + const now = options.now ?? Date.now; + const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay)); + const clearTimer = + options.clearTimer ?? ((timer) => clearTimeout(timer as ReturnType)); + const defer = options.defer ?? queueMicrotask; + + const snapshots = new Map(); + // The keys this CLI has shown since it started, per directory. The server + // keeps watching their reviews while they are hidden. + const shown = new Map>(); + // Whether each directory's server has status on (`reviewStatus`). + const enabled = new Map(); + let visible: Place | undefined; + let disposed = false; + + const isShown = (place: Place) => shown.get(place.directory)?.has(place.key) === true; + + // Snapshots arrive both as RPC responses and as events, in any order, so + // only a newer revision replaces the current one. A snapshot without a + // revision, for a key the server hasn't looked up yet, never replaces one + // with a revision. + function apply(place: Place, snapshot: Snapshot) { + const id = placeID(place); + if ((snapshot.revision ?? -1) >= (snapshots.get(id)?.revision ?? -1)) + snapshots.set(id, snapshot); + } + + // Without the server, such as while its plugin reloads, the last status + // stays, marked stale. The CLI never looks PRs/MRs up itself, because it + // can't know the session's targets. + function unavailable(directory: string, error: unknown) { + const reason = error instanceof Error ? error.message : String(error); + const failure = { + kind: 'request' as const, + message: `PR/MR status service unavailable: ${reason}`, + at: now(), + }; + for (const key of shown.get(directory) ?? []) { + const id = placeID({ directory, key }); + snapshots.set(id, { ...(snapshots.get(id) ?? {}), loading: false, error: failure }); + } + options.onChange(); + } + + // A failed renewal, such as while the server plugin loads, retries sooner + // than the next heartbeat, backing off up to it. + const failures = new Map(); + const retries = new Map(); + function retryLater(directory: string) { + const count = (failures.get(directory) ?? 0) + 1; + failures.set(directory, count); + const timer = retries.get(directory); + if (timer !== undefined) clearTimer(timer); + retries.set( + directory, + setTimer(() => void renew(directory), Math.min(HEARTBEAT, 1000 * 2 ** count)), + ); + } + + async function renew(directory: string) { + if (disposed) return; + const timer = retries.get(directory); + if (timer !== undefined) clearTimer(timer); + retries.delete(directory); + const keys = [...(shown.get(directory) ?? [])]; + try { + const result = await options.watch(directory, { + clientID: options.clientID, + keys, + ...(visible?.directory === directory ? { visible: visible.key } : {}), + }); + failures.delete(directory); + enabled.set(directory, result.enabled); + for (const [key, snapshot] of Object.entries(result.statuses)) { + if (isShown({ directory, key })) apply({ directory, key }, snapshot as Snapshot); + } + options.onChange(); + } catch (error) { + unavailable(directory, error); + retryLater(directory); + } + } + + // Coalesces the renewals of one change, such as a session switch that + // hides one footer and shows another. + const due = new Set(); + function renewSoon(directory: string) { + if (due.has(directory)) return; + due.add(directory); + defer(() => { + due.delete(directory); + void renew(directory); + }); + } + + return { + snapshot: (place: Place): Snapshot => snapshots.get(placeID(place)) ?? { loading: false }, + // Unknown until the directory's server answers, and on by default. + enabled: (directory: string) => enabled.get(directory) ?? true, + // A session lives in one directory. When it moves, such as to another + // worktree, its lease moves too, so the old directory's server stops + // watching it. + show(place: Place) { + if (place.key !== HOME) { + for (const [directory, keys] of shown) { + if (directory === place.directory || !keys.delete(place.key)) continue; + snapshots.delete(placeID({ directory, key: place.key })); + if (visible && samePlace(visible, { directory, key: place.key })) visible = undefined; + renewSoon(directory); + } + } + const keys = shown.get(place.directory) ?? new Set(); + shown.set(place.directory, keys); + keys.add(place.key); + visible = place; + renewSoon(place.directory); + }, + hide(place: Place) { + if (visible && samePlace(visible, place)) visible = undefined; + renewSoon(place.directory); + }, + async refresh(place: Place) { + const id = placeID(place); + snapshots.set(id, { ...(snapshots.get(id) ?? {}), loading: true }); + options.onChange(); + try { + const result = await options.refresh(place.directory, place.key); + enabled.set(place.directory, result.enabled); + apply(place, result.snapshot as Snapshot); + options.onChange(); + } catch (error) { + unavailable(place.directory, error); + } + }, + // Renews every lease. Call it every HEARTBEAT. + heartbeat() { + for (const directory of shown.keys()) void renew(directory); + }, + // A `status` event from a directory's server. + receive(directory: string, key: string, snapshot: Snapshot) { + if (!isShown({ directory, key })) return; + apply({ directory, key }, snapshot); + options.onChange(); + }, + // Whether this CLI has shown the session, so its notices concern this CLI. + hasShown: (sessionID: string) => [...shown.values()].some((keys) => keys.has(sessionID)), + dispose() { + disposed = true; + for (const timer of retries.values()) clearTimer(timer); + for (const directory of shown.keys()) + void options.release(directory, options.clientID).catch(() => {}); + }, + }; +} diff --git a/packages/forge-session/src/status-server.ts b/packages/forge-session/src/status-server.ts new file mode 100644 index 0000000..aa2614e --- /dev/null +++ b/packages/forge-session/src/status-server.ts @@ -0,0 +1,347 @@ +import type { Plugin } from '@opencode/plugin'; +import { + automatedReview, + createAutomatedReviewWatcher, + reviewMessage, + reviewTitle, + reviewToast, + notificationKey, + type PendingDeliveries, + type PendingDelivery, +} from './automated-review-watch'; +import type { Exec } from './exec'; +import type { FeedbackSnapshot, ReviewComment, ReviewRequest } from './forge'; +import { reference, traitsOf, type ForgeCatalog } from './forges'; +import { + applyFeedback, + createFeedbackWatcher, + feedbackMessage, + feedbackToast, + isExpired, + type FeedbackRecord, +} from './human-review-watch'; +import { locate } from './locate'; +import type { StatusOptions } from './options'; +import type { Notice } from './rpc'; +import { createStatusService, HOME } from './status-service'; +import type { Snapshot } from './store'; +import { classifyTargets, targetOutput, type Target } from './target'; + +// Running automated reviews poll at most this often, so a finished review is +// noticed promptly. +const REVIEW_POLL_SECONDS = 30; + +export interface StatusDependencies { + // Forges and commands for status lookups and feedback, which share one + // concurrency limit. + forges: ForgeCatalog; + exec: Exec; + // Reads a PR/MR's review comments. Defaults to its forge's feedback reader. + feedback?: (request: ReviewRequest, directory: string) => Promise; + now?: () => number; +} + +// How long cleanup waits for running review checks before giving up. They +// can't send or write anything after cleanup starts, so this only bounds how +// long a stopped plugin keeps work around. +const DRAIN_TIME = 5_000; + +export interface StatusHost { + // The session's stored targets, and the title prefix this plugin wrote. + state: (sessionID: string) => Promise<{ targets: Target[]; prefix?: string }>; + isTarget: (sessionID: string, url: string) => boolean; + publish: (key: string, snapshot: Snapshot) => void; + notice: (notice: Notice) => void; +} + +// Storage keys. Every checkout's instance shares this plugin's storage, so +// each record has its own key, and instances never overwrite each other's +// records. Encoding keeps IDs and URLs from adding path separators. +const PENDING_PREFIX = 'status/pending/'; +const FEEDBACK_PREFIX = 'status/feedback/'; +const pendingPrefix = (sessionID: string) => `${PENDING_PREFIX}${encodeURIComponent(sessionID)}/`; +const pendingKey = (sessionID: string, url: string) => + `${pendingPrefix(sessionID)}${encodeURIComponent(url)}`; +const feedbackRecordKey = (key: string) => `${FEEDBACK_PREFIX}${encodeURIComponent(key)}`; +// Whole logs from before records had their own keys. They're removed, so the +// first look at each PR/MR records a new baseline instead of replaying. +const LEGACY_KEYS = ['status/humanReviewFeedback', 'status/automatedReviewPending']; + +// The title without the prefix this plugin wrote. +export function userTitle( + title: string | undefined, + prefix: string | undefined, +): string | undefined { + if (!title || !prefix) return title; + if (title === prefix) return ''; + return title.startsWith(`${prefix} `) ? title.slice(prefix.length + 1) : title; +} + +// Watches the PRs/MRs of the sessions that CLIs show, and tells sessions +// about reviews of their targets. +export async function setupStatus( + ctx: Plugin.Context, + settings: StatusOptions, + deps: StatusDependencies, + host: StatusHost, +) { + const directory = ctx.location.directory; + // Cleanup runs in two steps. `closing` is set first: no new fetch or + // delivery starts, so a check that finishes later, such as a feedback + // fetch, can't tell a session anything. Deliveries that already started + // can still finish and store their outcome, so the replacement instance + // doesn't send them again. `closed` is set once those have drained, and + // stops every write and toast. + let closing = false; + let closed = false; + // Sessions that moved to another checkout. That checkout's instance + // watches them, even if a CLI still leases them here. + const foreign = new Set(); + let invalidate: (sessionID: string) => void = () => {}; + + // Storage writes run one at a time, so a record's read and write can't + // interleave with another change to it. A failed write only risks a + // repeated notification, so it's reported once and otherwise ignored. + let writes = Promise.resolve(); + const write = (task: () => Promise) => { + if (closed) return; + writes = writes + .then(task) + .catch((error) => reportOnce('storage', 'Could not store review state', error)); + }; + + async function scanAll(prefix: string) { + const entries: { key: string; value: unknown }[] = []; + let after: string | undefined; + do { + const page = await ctx.storage.scan({ prefix, ...(after ? { after } : {}) }); + entries.push(...page.entries); + after = page.next; + } while (after); + return entries; + } + + // While closing, a delivery that finishes still toasts, but failures, such + // as deliveries that cleanup stopped, don't. + const notice = (value: Notice) => { + if (closed || (closing && value.variant === 'error')) return; + host.notice(value); + }; + + const isTarget = (sessionID: string, url: string) => + !closing && !foreign.has(sessionID) && host.isTarget(sessionID, url); + + // Whether this instance still owns the session. It reads the session again, + // because the session may have moved while a lookup or fetch ran. A session + // that moved is left to its new checkout's instance. + // A failed read rejects, so the delivery stays pending and is retried: only + // a session that was read and is elsewhere counts as moved. + async function owns(sessionID: string) { + const session = await ctx.session.get({ sessionID }); + if (session.location.directory === directory) return true; + markForeign(sessionID); + return false; + } + + function markForeign(sessionID: string) { + if (foreign.has(sessionID)) return; + foreign.add(sessionID); + automated?.forget(sessionID); + invalidate(sessionID); + } + + // Reports a failure once per kind, so a persistent problem doesn't toast on every poll. + const reported = new Set(); + const reportOnce = (kind: string, prefix: string, error: unknown) => { + if (reported.has(kind)) return; + reported.add(kind); + const reason = error instanceof Error ? error.message : String(error); + notice({ message: `${prefix}: ${reason}`, variant: 'error' }); + }; + + // Tells the session about the requests that are still its targets, and + // returns them. The message is composed from those requests only, right + // before sending, because the targets can change while ownership is read. + // A request that is no longer a target, or a session that moved to another + // checkout, ends the delivery without a retry. Rejects when the session + // can't be read or cleanup stopped the delivery, so it stays pending. + async function tell( + sessionID: string, + requests: ReviewRequest[], + compose: (requests: ReviewRequest[]) => { text: string; description: string }, + ): Promise { + if (closing) throw new Error('The plugin stopped'); + if (!(await owns(sessionID))) return []; + if (closing) throw new Error('The plugin stopped'); + const current = requests.filter((request) => isTarget(sessionID, request.url)); + if (current.length === 0) return []; + const { text, description } = compose(current); + await ctx.session.synthetic({ + sessionID, + text, + description, + delivery: 'queue', + resume: settings.resumeSession, + }); + return current; + } + + const readFeedback = + deps.feedback ?? + (async (request: ReviewRequest, cwd: string) => + traitsOf(request).feedback?.fetch(deps.exec, cwd, request)); + + const automated = settings.notifyAutomatedReviews + ? createAutomatedReviewWatcher({ + isTarget, + now: deps.now, + async read(sessionID) { + const deliveries: PendingDeliveries = {}; + for (const { value } of await scanAll(pendingPrefix(sessionID))) { + const delivery = value as PendingDelivery; + deliveries[notificationKey(sessionID, delivery)] = delivery; + } + return deliveries; + }, + write: (sessionID, url, delivery) => + write(() => + delivery + ? ctx.storage.set(pendingKey(sessionID, url), delivery as never) + : ctx.storage.remove(pendingKey(sessionID, url)), + ), + async send(sessionID, requests) { + const sent = await tell(sessionID, requests, (current) => ({ + text: reviewMessage(current), + description: reviewTitle(current), + })); + if (sent.length > 0) notice({ sessionID, ...reviewToast(sent), variant: 'info' }); + }, + onSendError(error, sessionID, requests) { + const reason = error instanceof Error ? error.message : String(error); + const name = automatedReview(requests[0])?.name ?? 'the automated reviewer'; + notice({ + sessionID, + message: `Could not tell the session about ${name}'s review: ${reason}. Retrying in 10 minutes.`, + variant: 'error', + }); + }, + }) + : undefined; + + const human = settings.notifyHumanReviews + ? createFeedbackWatcher({ + record: async (key) => + (await ctx.storage.get(feedbackRecordKey(key))) as FeedbackRecord | undefined, + now: deps.now, + persist: (key, change, at) => + write(async () => { + const stored = (await ctx.storage.get(feedbackRecordKey(key))) as + | FeedbackRecord + | undefined; + await ctx.storage.set( + feedbackRecordKey(key), + applyFeedback(stored, change, at) as never, + ); + }), + fetch: async (_sessionID, request) => + closing ? undefined : readFeedback(request, directory), + current: (sessionID, request) => isTarget(sessionID, request.url), + async send(sessionID: string, request: ReviewRequest, comments: ReviewComment[]) { + const sent = await tell(sessionID, [request], () => ({ + text: feedbackMessage(request, comments), + description: `New review feedback on ${reference(request)}`, + })); + if (sent.length > 0) + notice({ sessionID, ...feedbackToast(request, comments), variant: 'info' }); + }, + onFetchError: (error) => + reportOnce('feedback-fetch', 'Could not check PR/MR review feedback', error), + onSendError: (error) => { + const reason = error instanceof Error ? error.message : String(error); + notice({ + message: `Could not tell the session about review feedback: ${reason}`, + variant: 'error', + }); + }, + }) + : undefined; + + const service = createStatusService({ + async load(key, previous) { + if (key === HOME) return locate(deps.forges, { directory }); + const session = await ctx.session.get({ sessionID: key }); + if (session.location.directory !== directory) { + // Not `markForeign`: invalidating would discard this very lookup. + foreign.add(key); + automated?.forget(key); + return { kind: 'none', reason: 'The session moved to another checkout' }; + } + foreign.delete(key); + const state = await host.state(key); + return locate(deps.forges, { + directory, + targets: classifyTargets(targetOutput(state.targets)), + title: userTitle(session.title, state.prefix), + previous, + }); + }, + isTarget, + owns: (sessionID) => !foreign.has(sessionID), + publish: host.publish, + automated, + human, + onWatchError: (error) => reportOnce('watch', 'PR/MR status watcher failed', error), + interval: settings.pollSeconds * 1000, + activeInterval: Math.min(settings.pollSeconds, REVIEW_POLL_SECONDS) * 1000, + now: deps.now, + }); + invalidate = (sessionID) => service.invalidate(sessionID); + + // Housekeeping, on the write queue so it doesn't hold up setup: removes the + // former whole logs, and feedback records not seen for a long time. + write(async () => { + for (const key of LEGACY_KEYS) await ctx.storage.remove(key); + const at = (deps.now ?? Date.now)(); + for (const { key, value } of await scanAll(FEEDBACK_PREFIX)) { + if (isExpired(value as FeedbackRecord, at)) await ctx.storage.remove(key); + } + }); + + return { + service, + // Discards lookups of the old targets and looks the session up again. + targetsChanged: (sessionID: string) => service.invalidate(sessionID), + // A title reference only counts without targets. + async titleChanged(sessionID: string) { + if (!service.keys().includes(sessionID)) return; + if ((await host.state(sessionID)).targets.length === 0) service.invalidate(sessionID); + }, + // A turn may have pushed a branch or opened a PR/MR. + turnEnded() { + for (const key of service.keys()) service.notify(key); + }, + // Sessions without targets follow the checked-out branch. + async branchChanged() { + for (const key of service.keys()) { + if (key === HOME || (await host.state(key)).targets.length === 0) service.invalidate(key); + } + }, + // A lookup that started before the move describes the old checkout, so + // it's discarded. The next lookup finds out which instance owns the + // session. Deliveries check again before they send. + sessionMoved(sessionID: string) { + if (service.keys().includes(sessionID)) service.invalidate(sessionID); + }, + // Stops new work, lets started deliveries store their outcome, then stops + // writing. A delivery that outlasts DRAIN_TIME can't store its outcome, + // so the replacement instance may send it again. + async dispose() { + closing = true; + await service.dispose(DRAIN_TIME); + closed = true; + await writes; + }, + }; +} + +export type Status = Awaited>; diff --git a/packages/forge-session/src/status-service.test.ts b/packages/forge-session/src/status-service.test.ts new file mode 100644 index 0000000..15ea184 --- /dev/null +++ b/packages/forge-session/src/status-service.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, test } from 'bun:test'; +import type { GitLabMergeRequest, ReviewRequest } from './forge'; +import { createStatusService, HOME } from './status-service'; +import type { Lookup } from './store'; + +const mr = (overrides: Partial = {}): GitLabMergeRequest => + ({ + forge: 'gitlab', + iid: '1', + url: 'https://gitlab.com/g/p/-/merge_requests/1', + state: 'opened', + ...overrides, + }) as GitLabMergeRequest; + +const targets = (...requests: ReviewRequest[]): Lookup => ({ + kind: 'found', + explicitTarget: true, + requests, +}); + +function harness({ + human = false, + pending = () => false, + owns = () => true, +}: { human?: boolean; pending?: () => boolean; owns?: (key: string) => boolean } = {}) { + let clock = 0; + const timers = new Map void }>(); + let nextTimer = 0; + const lookups = new Map(); + const loads: string[] = []; + const checked: string[] = []; + const observed: string[] = []; + const removed = new Set(); + const service = createStatusService({ + load: async (key) => { + loads.push(key); + return lookups.get(key) ?? { kind: 'none', reason: 'No PR/MR' }; + }, + isTarget: (_sessionID, url) => !removed.has(url), + owns, + publish: () => {}, + automated: { + observe: async (sessionID) => void observed.push(sessionID), + hasPending: (sessionID) => pending() && sessionID !== HOME, + }, + ...(human + ? { + human: { + check: (sessionID: string, request: ReviewRequest) => + void checked.push(`${sessionID} ${request.iid}`), + }, + } + : {}), + interval: 100, + activeInterval: 30, + leaseTime: 300, + now: () => clock, + setTimer: (callback, delay) => { + const id = ++nextTimer; + timers.set(id, { at: clock + delay, callback }); + return id; + }, + clearTimer: (id) => timers.delete(id as number), + }); + return { + service, + lookups, + loads, + checked, + observed, + removed, + async advance(ms: number) { + const end = clock + ms; + for (;;) { + const due = [...timers] + .filter(([, timer]) => timer.at <= end) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (!due) break; + clock = due[1].at; + timers.delete(due[0]); + due[1].callback(); + await Bun.sleep(0); + } + clock = end; + await Bun.sleep(0); + }, + }; +} + +describe('createStatusService', () => { + test('polls a visible key, and stops once it is hidden without reviews to watch', async () => { + const app = harness(); + app.lookups.set('ses_1', targets(mr())); + app.service.watch('cli', ['ses_1'], 'ses_1'); + await app.advance(0); + expect(app.loads).toEqual(['ses_1']); + expect(app.service.polling('ses_1')).toBe(true); + await app.advance(100); + expect(app.loads).toHaveLength(2); + + app.service.watch('cli', ['ses_1']); + expect(app.service.polling('ses_1')).toBe(false); + await app.advance(500); + expect(app.loads).toHaveLength(2); + }); + + test('keeps a hidden session polling while its review runs, even after its title or targets change', async () => { + const app = harness(); + app.lookups.set('ses_1', targets(mr({ duoReviewState: 'REVIEW_STARTED' }))); + app.service.watch('cli', ['ses_1'], 'ses_1'); + await app.advance(0); + app.service.watch('cli', ['ses_1'], HOME); + expect(app.service.polling('ses_1')).toBe(true); + + app.service.invalidate('ses_1'); + await app.advance(0); + expect(app.service.polling('ses_1')).toBe(true); + await app.advance(30); + expect(app.loads.length).toBeGreaterThanOrEqual(3); + + app.lookups.set('ses_1', targets(mr({ duoReviewState: 'REVIEWED' }))); + await app.advance(30); + expect(app.service.polling('ses_1')).toBe(false); + }); + + test('watches human feedback on open targets only, and only targets the server still has', async () => { + const app = harness({ human: true }); + app.lookups.set( + 'ses_1', + targets( + mr(), + mr({ iid: '2', url: 'https://gitlab.com/g/p/-/merge_requests/2', state: 'merged' }), + ), + ); + app.service.watch('cli', ['ses_1'], 'ses_1'); + await app.advance(0); + expect(app.checked).toEqual(['ses_1 1']); + + app.removed.add(mr().url); + await app.service.refresh('ses_1'); + await app.advance(0); + expect(app.checked).toEqual(['ses_1 1']); + app.service.watch('cli', ['ses_1']); + expect(app.service.polling('ses_1')).toBe(false); + }); + + test('keeps polling while a notification waits for a retry', async () => { + let pending = true; + const app = harness({ pending: () => pending }); + app.lookups.set('ses_1', targets(mr({ duoReviewState: 'REVIEWED' }))); + app.service.watch('cli', ['ses_1'], 'ses_1'); + await app.advance(0); + app.service.watch('cli', ['ses_1']); + expect(app.service.polling('ses_1')).toBe(true); + pending = false; + await app.advance(100); + expect(app.service.polling('ses_1')).toBe(false); + }); + + test("ends a lease that isn't renewed, and keeps another CLI's lease", async () => { + const app = harness(); + app.lookups.set('ses_1', targets(mr({ duoReviewState: 'REVIEW_STARTED' }))); + app.service.watch('one', ['ses_1'], 'ses_1'); + app.service.watch('two', ['ses_1']); + await app.advance(0); + await app.advance(200); + app.service.watch('two', ['ses_1']); + await app.advance(200); + // CLI "one" stopped renewing, but "two" still leases the running review. + expect(app.service.polling('ses_1')).toBe(true); + await app.advance(400); + expect(app.service.polling('ses_1')).toBe(false); + }); + + test("drops a CLI's leases when it releases them", async () => { + const app = harness(); + app.lookups.set('ses_1', targets(mr())); + app.service.watch('cli', ['ses_1', HOME], 'ses_1'); + await app.advance(0); + app.service.release('cli'); + expect(app.service.polling('ses_1')).toBe(false); + }); + + test('returns the snapshots of the leased keys', async () => { + const app = harness(); + app.lookups.set('ses_1', targets(mr())); + app.service.watch('cli', ['ses_1'], 'ses_1'); + await app.advance(0); + expect(app.service.watch('cli', ['ses_1'], 'ses_1')).toEqual({ + ses_1: expect.objectContaining({ lookup: targets(mr()), loading: false }), + }); + }); + + test('looks up a leased session it has no lookup for, and keeps it only if it has reviews to watch', async () => { + const app = harness({ human: true }); + app.lookups.set('ses_1', targets(mr())); + app.service.watch('cli', ['ses_1', 'ses_2', 'ses_3'], 'ses_3'); + await app.advance(0); + expect(new Set(app.loads)).toEqual(new Set(['ses_1', 'ses_2', 'ses_3'])); + expect(app.checked).toEqual(['ses_1 1']); + expect(app.service.polling('ses_1')).toBe(true); + // Nothing to watch, so the hidden session stops after its first lookup. + expect(app.service.polling('ses_2')).toBe(false); + }); + + test("looks a leased session up again when its targets change, even while it doesn't poll", async () => { + const app = harness(); + app.service.watch('cli', ['ses_1']); + await app.advance(0); + expect(app.service.polling('ses_1')).toBe(false); + app.lookups.set('ses_1', targets(mr({ duoReviewState: 'REVIEW_STARTED' }))); + app.service.invalidate('ses_1'); + await app.advance(0); + expect(app.loads).toEqual(['ses_1', 'ses_1']); + expect(app.service.polling('ses_1')).toBe(true); + }); + + test('only shows a session that another checkout owns', async () => { + const app = harness({ human: true, owns: () => false }); + app.lookups.set('ses_1', targets(mr({ duoReviewState: 'REVIEW_STARTED' }))); + app.service.watch('cli', ['ses_1']); + await app.advance(0); + expect(app.loads).toEqual([]); + app.service.watch('cli', ['ses_1'], 'ses_1'); + await app.advance(0); + expect(app.loads).toEqual(['ses_1']); + expect(app.observed).toEqual([]); + expect(app.checked).toEqual([]); + app.service.watch('cli', ['ses_1']); + expect(app.service.polling('ses_1')).toBe(false); + }); +}); diff --git a/packages/forge-session/src/status-service.ts b/packages/forge-session/src/status-service.ts new file mode 100644 index 0000000..e6d6026 --- /dev/null +++ b/packages/forge-session/src/status-service.ts @@ -0,0 +1,232 @@ +import { + automatedReview, + isReviewRunning, + type AutomatedReviewWatcher, +} from './automated-review-watch'; +import type { ReviewRequest } from './forge'; +import { traitsOf } from './forges'; +import { HOME, LEASE_TIME } from './rpc'; +import { createStatusStore, type Lookup, type Snapshot } from './store'; + +// A status key is a session ID, or HOME for the checkout outside a session. +// Keys don't depend on the title or the targets, so a hidden session keeps +// its subscription when either changes. +export { HOME }; + +export interface StatusServiceOptions { + // Looks up a key's PRs/MRs. `previous` stands in for targets whose lookup fails. + load: (key: string, previous: Lookup | undefined) => Promise; + // Whether the PR/MR is one of the session's targets right now, from the + // server's own state rather than a lookup that may predate a change. + isTarget: (sessionID: string, url: string) => boolean; + // Whether this instance watches the session's reviews. A session that moved + // to another checkout belongs to that checkout's instance, even while a CLI + // still leases it here. + owns?: (sessionID: string) => boolean; + publish: (key: string, snapshot: Snapshot) => void; + automated?: Pick; + human?: { check: (sessionID: string, request: ReviewRequest) => unknown }; + onWatchError?: (error: unknown) => void; + interval?: number; + activeInterval?: number; + leaseTime?: number; + now?: () => number; + setTimer?: (callback: () => void, delay: number) => unknown; + clearTimer?: (timer: unknown) => void; +} + +interface Lease { + until: number; + visible: boolean; +} + +// Polls the PRs/MRs of the keys that CLIs lease, and watches each session's +// targets for reviews. A key polls while a CLI shows it, and while a session +// that a CLI has shown has a running automated review, human feedback to +// watch, or a notification to retry. +export function createStatusService(options: StatusServiceOptions) { + const now = options.now ?? Date.now; + const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay)); + const clearTimer = + options.clearTimer ?? ((timer) => clearTimeout(timer as ReturnType)); + const leaseTime = options.leaseTime ?? LEASE_TIME; + const leases = new Map>(); + const holds = new Map void>(); + // Watcher work in progress, which `dispose` waits for. + const tasks = new Set>(); + const track = (task: Promise) => { + tasks.add(task); + void task.finally(() => tasks.delete(task)); + }; + const owns = (key: string) => key !== HOME && (options.owns?.(key) ?? true); + let sweepTimer: unknown; + let disposed = false; + + const report = (error: unknown) => { + try { + options.onWatchError?.(error); + } catch { + // A failing error handler must not stop the watchers. + } + }; + + const store = createStatusStore({ + load: (key) => options.load(key, store.get(key).lookup), + onChange: (key) => options.publish(key, store.get(key)), + onLoad: (key, previous, next) => onLoad(key, previous, next), + onLoadError: report, + active: isReviewRunning, + interval: options.interval, + activeInterval: options.activeInterval, + now, + setTimer: options.setTimer, + clearTimer: options.clearTimer, + }); + + // The session's current targets among a lookup's PRs/MRs. + const targets = (key: string, lookup: Lookup | undefined): ReviewRequest[] => + owns(key) && lookup?.kind === 'found' && lookup.explicitTarget + ? lookup.requests.filter((request) => options.isTarget(key, request.url)) + : []; + + const watchedForFeedback = (key: string, lookup: Lookup | undefined) => + options.human + ? targets(key, lookup).filter( + (request) => traitsOf(request).feedback && request.state === 'opened', + ) + : []; + + // Whether a session needs polling even when no CLI shows it. + function needsWatch(key: string): boolean { + const lookup = store.get(key).lookup; + if (options.automated) { + if (targets(key, lookup).some((request) => automatedReview(request)?.state === 'running')) + return true; + if (owns(key) && options.automated.hasPending(key)) return true; + } + return watchedForFeedback(key, lookup).length > 0; + } + + // A session without a lookup yet, such as after a restart, may have reviews + // to watch. Only a lookup can tell, so it polls until one succeeds. + const unknown = (key: string) => owns(key) && store.get(key).lookup === undefined; + + const liveLeases = (key: string) => + [...(leases.get(key)?.values() ?? [])].filter((lease) => lease.until > now()); + + // Polls the key while it's wanted, and stops otherwise. + function sync(key: string) { + if (disposed) return; + const live = liveLeases(key); + const wanted = + live.length > 0 && (live.some((lease) => lease.visible) || unknown(key) || needsWatch(key)); + const release = holds.get(key); + if (wanted && !release) holds.set(key, store.acquire(key)); + else if (!wanted && release) { + release(); + holds.delete(key); + } + } + + function onLoad(key: string, previous: Lookup | undefined, next: Lookup) { + if (owns(key) && options.automated) { + track( + options.automated + .observe(key, previous, next) + .catch(report) + .finally(() => sync(key)), + ); + } + for (const request of watchedForFeedback(key, next)) { + track( + Promise.resolve() + .then(() => options.human?.check(key, request)) + .catch(report), + ); + } + sync(key); + } + + function sweep() { + const at = now(); + for (const key of new Set([...leases.keys(), ...holds.keys()])) { + const clients = leases.get(key); + for (const [clientID, lease] of clients ?? []) + if (lease.until <= at) clients?.delete(clientID); + if (clients?.size === 0) leases.delete(key); + sync(key); + } + } + + function scheduleSweep() { + if (disposed || sweepTimer !== undefined || leases.size === 0) return; + sweepTimer = setTimer(() => { + sweepTimer = undefined; + sweep(); + scheduleSweep(); + }, leaseTime / 3); + } + + function forget(clientID: string, keep: ReadonlySet = new Set()) { + const changed: string[] = []; + for (const [key, clients] of leases) { + if (keep.has(key) || !clients.delete(clientID)) continue; + if (clients.size === 0) leases.delete(key); + changed.push(key); + } + return changed; + } + + return { + // Renews a CLI's leases: `keys` are the keys it has shown, and `visible` + // is the one on screen. Keys the CLI no longer lists lose its lease. + watch(clientID: string, keys: readonly string[], visible?: string): Record { + const listed = new Set(keys); + const changed = new Set([...forget(clientID, listed), ...listed]); + const until = now() + leaseTime; + for (const key of listed) { + const clients = leases.get(key) ?? new Map(); + leases.set(key, clients); + clients.set(clientID, { until, visible: key === visible }); + } + for (const key of changed) sync(key); + scheduleSweep(); + return Object.fromEntries([...listed].map((key) => [key, store.get(key)])); + }, + release(clientID: string) { + for (const key of forget(clientID)) sync(key); + }, + get: (key: string) => store.get(key), + refresh: (key: string) => store.refresh(key), + // After a change that the next lookup must see, such as new targets. A + // leased session that doesn't poll is looked up once, because its new + // targets may have reviews to watch. + invalidate(key: string) { + store.invalidate(key); + if (!disposed && !holds.has(key) && liveLeases(key).length > 0) void store.refresh(key); + }, + // After an event that may have changed the PRs/MRs, such as a turn ending. + notify: (key: string) => store.notify(key), + keys: () => store.keys(), + // Whether the key polls, for tests and diagnostics. + polling: (key: string) => holds.has(key), + // Stops polling. The returned promise settles once running watcher work + // has, or after `wait` milliseconds, whichever comes first. + async dispose(wait = 0) { + disposed = true; + if (sweepTimer !== undefined) clearTimer(sweepTimer); + for (const release of holds.values()) release(); + holds.clear(); + store.dispose(); + if (wait <= 0 || tasks.size === 0) return; + let timer: unknown; + await Promise.race([ + Promise.allSettled([...tasks]), + new Promise((resolve) => (timer = setTimer(resolve, wait))), + ]); + clearTimer(timer); + }, + }; +} + +export type StatusService = ReturnType; diff --git a/packages/forge-session/src/store.test.ts b/packages/forge-session/src/store.test.ts new file mode 100644 index 0000000..ee80542 --- /dev/null +++ b/packages/forge-session/src/store.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test } from 'bun:test'; +import type { Repository } from './git'; +import { ForgeError, type ReviewRequest } from './forge'; +import { createStatusStore, type Lookup } from './store'; + +const repository = { + head: '', + branch: 'b', + sourceBranch: 'b', + source: { host: 'h', path: 'a/b' }, + targets: [], +} as Repository; +const found = (count = 1): Lookup => ({ + kind: 'found', + repository, + requests: Array.from({ length: count }, () => ({ iid: '1' }) as ReviewRequest), +}); + +function harness( + load: (directory: string) => Promise, + extra: Partial[0]> = {}, +) { + let clock = 0; + const timers = new Map void }>(); + let nextTimer = 0; + let changes = 0; + const store = createStatusStore({ + ...extra, + load, + onChange: (key) => { + changes++; + extra.onChange?.(key); + }, + now: () => clock, + setTimer: (callback, delay) => { + const id = ++nextTimer; + timers.set(id, { at: clock + delay, callback }); + return id; + }, + clearTimer: (id) => timers.delete(id as number), + interval: 100, + maxBackoff: 400, + minEventInterval: 10, + }); + return { + store, + changes: () => changes, + pending: () => [...timers.values()].map((timer) => timer.at - clock), + async advance(ms: number) { + clock += ms; + for (const [id, timer] of [...timers]) { + if (timer.at > clock) continue; + timers.delete(id); + timer.callback(); + } + await Bun.sleep(0); + }, + }; +} + +describe('createStatusStore', () => { + test('deduplicates concurrent loads and polls while acquired', async () => { + let calls = 0; + const { store, pending, advance } = harness(async () => { + calls++; + return found(); + }); + const release = store.acquire('/a'); + const second = store.acquire('/a'); + await store.refresh('/a'); + expect(calls).toBe(1); + expect(store.get('/a').lookup).toEqual(found()); + expect(pending()).toEqual([100]); + + await advance(100); + expect(calls).toBe(2); + release(); + expect(pending()).toEqual([100]); + second(); + expect(pending()).toEqual([]); + }); + + test('reuses cached data when a session is shown again', async () => { + let calls = 0; + const { store, pending, advance } = harness(async () => { + calls++; + return found(); + }); + const hide = store.acquire('/a'); + await store.refresh('/a'); + hide(); + await advance(40); + const hideAgain = store.acquire('/a'); + expect(calls).toBe(1); + expect(pending()).toEqual([60]); + hideAgain(); + await advance(60); + store.acquire('/a'); + await store.refresh('/a'); + expect(calls).toBe(2); + }); + + test('does not retry a failed lookup on every switch', async () => { + let calls = 0; + const { store, advance } = harness(async () => { + calls++; + throw new ForgeError('auth', 'GitLab authentication failed'); + }); + const hide = store.acquire('/a'); + await store.refresh('/a'); + hide(); + store.acquire('/a')(); + store.acquire('/a')(); + expect(calls).toBe(1); + await advance(200); + store.acquire('/a'); + await store.refresh('/a'); + expect(calls).toBe(2); + }); + + test('does not poll without an MR', async () => { + const { store, pending } = harness(async () => found(0)); + store.acquire('/a'); + await store.refresh('/a'); + expect(pending()).toEqual([]); + }); + + test('manual refresh bypasses the cache and updates subscribers while loading', async () => { + let complete: (lookup: Lookup) => void = () => {}; + let calls = 0; + const { store, changes } = harness(() => { + calls++; + return new Promise((resolve) => (complete = resolve)); + }); + const initial = store.refresh('/a'); + complete(found()); + await initial; + const previousChanges = changes(); + + const refresh = store.refresh('/a'); + expect(calls).toBe(2); + expect(store.get('/a').loading).toBe(true); + expect(store.get('/a').lookup).toEqual(found()); + expect(changes()).toBeGreaterThan(previousChanges); + expect(store.refresh('/a')).toBe(refresh); + + complete(found(2)); + await refresh; + expect(store.get('/a').loading).toBe(false); + expect(store.get('/a').lookup).toEqual(found(2)); + store.dispose(); + }); + + test('manual refresh recovers from a failure without waiting for backoff', async () => { + let fail = true; + const { store } = harness(async () => { + if (fail) throw new ForgeError('request', 'GitLab unavailable'); + return found(); + }); + await store.refresh('/a'); + expect(store.get('/a').error?.message).toBe('GitLab unavailable'); + + fail = false; + await store.refresh('/a'); + expect(store.get('/a').error).toBeUndefined(); + expect(store.get('/a').lookup).toEqual(found()); + store.dispose(); + }); + + test('keeps stale data on failure and backs off', async () => { + let fail = false; + const { store, pending, advance } = harness(async () => { + if (fail) throw new ForgeError('rate-limit', 'GitLab rate limit reached'); + return found(); + }); + store.acquire('/a'); + await store.refresh('/a'); + fail = true; + await advance(100); + await store.refresh('/a'); + const snapshot = store.get('/a'); + expect(snapshot.lookup).toEqual(found()); + expect(snapshot.error).toMatchObject({ kind: 'rate-limit' }); + expect(pending()[0]).toBeGreaterThan(100); + await advance(pending()[0]); + await store.refresh('/a'); + expect(pending()).toEqual([400]); + }); + + test('throttles event refreshes but not invalidations', async () => { + let calls = 0; + const { store, advance } = harness(async () => { + calls++; + return found(); + }); + store.acquire('/a'); + await store.refresh('/a'); + store.notify('/a'); + expect(calls).toBe(1); + await advance(10); + store.notify('/a'); + await store.refresh('/a'); + expect(calls).toBe(2); + store.invalidate('/a'); + await store.refresh('/a'); + expect(calls).toBe(3); + store.notify('/b'); + expect(calls).toBe(3); + }); + + test('polls active lookups at the active interval', async () => { + let active = true; + const { store, pending, advance } = harness(async () => found(), { + active: () => active, + activeInterval: 30, + }); + store.acquire('/a'); + await store.refresh('/a'); + expect(pending()).toEqual([30]); + active = false; + await advance(30); + expect(pending()).toEqual([100]); + }); + + test('reports each load with the lookup it replaced', async () => { + const loads: Array<[string, Lookup | undefined, Lookup]> = []; + let count = 1; + const { store } = harness(async () => found(count++), { + onLoad: (directory, previous, next) => loads.push([directory, previous, next]), + }); + await store.refresh('/a'); + await store.refresh('/a'); + expect(loads).toEqual([ + ['/a', undefined, found(1)], + ['/a', found(1), found(2)], + ]); + }); + + test('keeps polling a lookup acquired from its load callback', async () => { + let calls = 0; + let release: (() => void) | undefined; + const { store, pending, advance } = harness( + async () => { + calls++; + return found(); + }, + { onLoad: (directory) => (release ??= store.acquire(directory)) }, + ); + const hide = store.acquire('/a'); + await store.refresh('/a'); + hide(); + expect(pending()).toEqual([100]); + await advance(100); + expect(calls).toBe(2); + release?.(); + expect(pending()).toEqual([]); + }); + + test('reports a failing load callback and keeps loading', async () => { + const errors: unknown[] = []; + const { store } = harness(async () => found(), { + onLoad: () => { + throw new Error('boom'); + }, + onLoadError: (error) => errors.push(error), + }); + await store.refresh('/a'); + expect(store.get('/a').loading).toBe(false); + expect(errors).toEqual([new Error('boom')]); + await store.refresh('/a'); + expect(store.get('/a').lookup).toEqual(found()); + }); + + test('survives a failing error callback', async () => { + const { store } = harness(async () => found(), { + onLoad: () => { + throw new Error('boom'); + }, + onLoadError: () => { + throw new Error('worse'); + }, + }); + await store.refresh('/a'); + expect(store.get('/a').loading).toBe(false); + }); + + test('discards a load that started before an invalidation, even when nothing polls', async () => { + const resolvers: Array<(lookup: Lookup) => void> = []; + const loaded: Lookup[] = []; + const { store } = harness(() => new Promise((done) => resolvers.push(done)), { + onLoad: (_key, _previous, next) => void loaded.push(next), + }); + const first = store.refresh('/a'); + store.invalidate('/a'); + resolvers[0](found(2)); + await Bun.sleep(0); + expect(loaded).toEqual([]); + expect(store.get('/a').lookup).toBeUndefined(); + resolvers[1](found()); + await first; + expect(loaded).toEqual([found()]); + }); + + test('loads at once when acquired after an invalidation while nothing polled', async () => { + let calls = 0; + const { store, advance } = harness(async () => (calls++, found())); + store.acquire('/a')(); + await advance(0); + expect(calls).toBe(1); + store.invalidate('/a'); + store.acquire('/a'); + await advance(0); + expect(calls).toBe(2); + }); + + test("numbers every change, starting above any earlier store's revisions", async () => { + const { store } = harness(async () => found()); + const loading = store.refresh('/a'); + const first = store.get('/a').revision ?? 0; + await loading; + expect(store.get('/a').revision).toBeGreaterThan(first); + // The harness clock starts at 0. A store created a millisecond later + // starts at 1000, above this one's few changes. + expect(first).toBeLessThan(1000); + }); + + test('reports which key changed', async () => { + const keys: string[] = []; + const { store } = harness(async () => found(), { onChange: (key) => void keys.push(key) }); + await store.refresh('/a'); + expect(new Set(keys)).toEqual(new Set(['/a'])); + }); + + test('reloads after an invalidation during a load', async () => { + let resolve: (lookup: Lookup) => void = () => {}; + let calls = 0; + const { store } = harness(() => { + calls++; + return new Promise((done) => (resolve = done)); + }); + store.acquire('/a'); + const first = store.refresh('/a'); + store.invalidate('/a'); + resolve(found()); + await Bun.sleep(0); + expect(calls).toBe(2); + resolve({ kind: 'none', reason: 'Detached HEAD' }); + await first; + expect(store.get('/a').lookup).toEqual({ kind: 'none', reason: 'Detached HEAD' }); + }); +}); diff --git a/packages/forge-session/src/store.ts b/packages/forge-session/src/store.ts new file mode 100644 index 0000000..c33caee --- /dev/null +++ b/packages/forge-session/src/store.ts @@ -0,0 +1,225 @@ +import { ForgeError, type ErrorKind, type ReviewRequest } from './forge'; +import type { Repository } from './git'; + +export type Lookup = + | { kind: 'none'; reason: string } + // `repository` is set for branch lookups. A session target lookup sets + // `sessionTarget` instead, a display label for where the PR/MR came from, + // because it ignores the checked-out branch. + // `explicitTarget` is set only for PRs/MRs that came from set_session_target. + // `failed` lists explicit targets whose lookup failed. A target that failed + // with an error keeps its previous result in `requests`, if it had one. + | { + kind: 'found'; + repository?: Repository; + sessionTarget?: string; + explicitTarget?: boolean; + requests: ReviewRequest[]; + failed?: { url: string; reason: string }[]; + }; + +// `revision` grows with every change, and across server reloads, so a client +// that gets snapshots over several channels, such as RPC responses and +// events, can keep the newest one. +export interface Snapshot { + revision?: number; + lookup?: Lookup; + fetchedAt?: number; + error?: { kind: ErrorKind; message: string; at: number }; + loading: boolean; +} + +export interface StoreOptions { + load: (key: string) => Promise; + // Receives the key whose snapshot changed. + onChange: (key: string) => void; + // Called after each successful load with the lookup it replaced. + onLoad?: (key: string, previous: Lookup | undefined, next: Lookup) => void; + // Receives errors thrown by `onLoad`, which would otherwise be swallowed. + onLoadError?: (error: unknown) => void; + // Lookups that match poll at `activeInterval` instead of `interval`. + active?: (lookup: Lookup | undefined) => boolean; + activeInterval?: number; + now?: () => number; + setTimer?: (callback: () => void, delay: number) => unknown; + clearTimer?: (timer: unknown) => void; + interval?: number; + maxBackoff?: number; + // Event-driven refreshes within this window after a fetch are skipped. + minEventInterval?: number; +} + +interface Entry { + snapshot: Snapshot; + refs: number; + failures: number; + inflight?: Promise; + // The running load started before a change, so its result is discarded. + invalidated?: boolean; + // A change happened while nothing polled, so the next acquire loads at once. + stale?: boolean; + timer?: unknown; +} + +export function createStatusStore(options: StoreOptions) { + const now = options.now ?? Date.now; + const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay)); + const clearTimer = + options.clearTimer ?? ((timer) => clearTimeout(timer as ReturnType)); + const interval = options.interval ?? 120_000; + const maxBackoff = options.maxBackoff ?? 900_000; + const minEventInterval = options.minEventInterval ?? 15_000; + const entries = new Map(); + let disposed = false; + // Starts at the store's creation time in microseconds, so a store created + // later, such as after a reload, starts above every revision of this one. + let revision = now() * 1000; + + const entry = (key: string) => { + let value = entries.get(key); + if (!value) { + value = { snapshot: { loading: false }, refs: 0, failures: 0 }; + entries.set(key, value); + } + return value; + }; + + const update = (key: string, value: Entry, patch: Partial) => { + value.snapshot = { ...value.snapshot, ...patch, revision: ++revision }; + if (!disposed) options.onChange(key); + }; + + const cancelTimer = (value: Entry) => { + if (value.timer === undefined) return; + clearTimer(value.timer); + value.timer = undefined; + }; + + const baseInterval = (value: Entry) => + options.active?.(value.snapshot.lookup) ? (options.activeInterval ?? interval) : interval; + const pollDelay = (value: Entry) => + Math.min(baseInterval(value) * 2 ** value.failures, maxBackoff); + + // The last load, successful or not, so failed lookups also wait out their backoff. + const lastAttempt = (value: Entry) => + Math.max(value.snapshot.fetchedAt ?? -Infinity, value.snapshot.error?.at ?? -Infinity); + + const schedule = (key: string, value: Entry, delay = pollDelay(value)) => { + cancelTimer(value); + if (disposed || value.refs === 0) return; + const lookup = value.snapshot.lookup; + // Without a PR/MR there is nothing to watch; turn ends, branch changes, and + // the command still look again. + if (!value.snapshot.error && (lookup?.kind !== 'found' || lookup.requests.length === 0)) return; + value.timer = setTimer(() => { + value.timer = undefined; + void refresh(key); + }, delay); + }; + + function refresh(key: string): Promise { + if (disposed) return Promise.resolve({ loading: false }); + const value = entry(key); + if (value.inflight) return value.inflight; + value.stale = false; + update(key, value, { loading: true }); + value.inflight = options + .load(key) + .then( + (lookup) => { + // A load that started before a change, such as new session targets, + // describes the old state. It must not reach the watchers. + if (disposed || value.invalidated) return; + const previous = value.snapshot.lookup; + value.failures = 0; + update(key, value, { lookup, fetchedAt: now(), error: undefined, loading: false }); + // A failing callback must not leave the load in flight forever. + try { + if (!disposed) options.onLoad?.(key, previous, lookup); + } catch (error) { + try { + options.onLoadError?.(error); + } catch { + // A failing error handler must not stop polling. + } + } + }, + (error: unknown) => { + if (disposed || value.invalidated) return; + value.failures++; + const known = error instanceof ForgeError; + update(key, value, { + loading: false, + error: { + kind: known ? error.kind : 'request', + message: error instanceof Error ? error.message : String(error), + at: now(), + }, + }); + }, + ) + .then(() => { + value.inflight = undefined; + if (value.invalidated && !disposed) { + value.invalidated = false; + return refresh(key); + } + schedule(key, value); + return value.snapshot; + }); + return value.inflight; + } + + return { + get(key: string): Snapshot { + return entries.get(key)?.snapshot ?? { loading: false }; + }, + acquire(key: string): () => void { + const value = entry(key); + value.refs++; + // Showing a session again, such as on a tab switch, reuses cached data + // until the next poll would have been due, and resumes that poll schedule. + if (value.refs === 1 && !value.inflight) { + const remaining = lastAttempt(value) + pollDelay(value) - now(); + if (value.stale || remaining <= 0) void refresh(key); + else schedule(key, value, remaining); + } + let released = false; + return () => { + if (released) return; + released = true; + value.refs--; + if (value.refs === 0) cancelTimer(value); + }; + }, + refresh, + keys(): string[] { + return [...entries.keys()]; + }, + // Refreshes a key that something polls, unless it was fetched moments ago. + notify(key: string) { + const value = entries.get(key); + if (!value || value.refs === 0 || value.inflight) return; + const last = Math.max(value.snapshot.fetchedAt ?? 0, value.snapshot.error?.at ?? 0); + if (now() - last < minEventInterval) return; + void refresh(key); + }, + // Forces a refresh after a known state change, such as a branch switch or + // new session targets. A load that started before the change is discarded, + // even when nothing polls the key anymore. + invalidate(key: string) { + const value = entries.get(key); + if (!value) return; + if (value.inflight) value.invalidated = true; + else if (value.refs > 0) void refresh(key); + else value.stale = true; + }, + dispose() { + disposed = true; + for (const value of entries.values()) cancelTimer(value); + entries.clear(); + }, + }; +} + +export type StatusStore = ReturnType; diff --git a/packages/forge-session/src/target.test.ts b/packages/forge-session/src/target.test.ts new file mode 100644 index 0000000..9c6fd86 --- /dev/null +++ b/packages/forge-session/src/target.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, test } from 'bun:test'; +import { githubTraits } from './github'; +import { gitlabTraits } from './gitlab'; +import { + applyTargetChange, + classifyTargets, + parseTarget, + parseTargetChange, + targetOutput, + targetPrefix, + targetRequest, + targetsPrefix, +} from './target'; + +describe('parseTarget', () => { + test.each([ + ['https://gitlab.com/group/project/-/merge_requests/456', '[!456]'], + ['https://gitlab.example.com/group/subgroup/project/-/merge_requests/456', '[!456]'], + ['https://gitlab.com/group/project/-/issues/123', '[#123]'], + ['https://gitlab.com/group/project/-/work_items/123', '[#123]'], + ['https://github.com/owner/repo/pull/456', '[#456]'], + ['https://github.com/owner/repo/issues/123', '[#123]'], + ['https://github.example.com/owner/repo/pull/456', '[#456]'], + ])('formats %s', (url, prefix) => { + const target = parseTarget({ target: url }); + expect(target).toEqual({ url }); + expect(targetPrefix(target!)).toBe(prefix); + }); + + test('normalizes links and accepts an established related issue', () => { + const target = parseTarget({ + target: 'https://gitlab.com/group/project/-/merge_requests/456/?tab=changes#note_1', + issue_url: 'https://gitlab.com/group/other/-/issues/789', + }); + expect(target?.url).toBe('https://gitlab.com/group/project/-/merge_requests/456'); + expect(targetPrefix(target!)).toBe('[#789, !456]'); + }); + + test('supports returning to branch-based naming', () => { + expect(parseTarget({ target: 'branch' })).toBeUndefined(); + }); + + test.each([ + {}, + { target: '!456' }, + { target: 'file:///group/project/-/issues/123' }, + { target: 'https://user:password@gitlab.com/group/project/-/issues/123' }, + { target: 'https://github.com/owner/repo/pull/456/files' }, + { target: 'https://gitlab.com/group/project/-/pipelines/7' }, + { target: 'https://gitlab.com/group/project/issues/7' }, + { target: 'branch', issue_url: 'https://gitlab.com/group/project/-/issues/123' }, + { + target: 'https://gitlab.com/group/project/-/issues/123', + issue_url: 'https://gitlab.com/group/project/-/issues/789', + }, + { + target: 'https://gitlab.com/group/project/-/merge_requests/456', + issue_url: 'https://gitlab.com/group/project/-/merge_requests/789', + }, + { + target: 'https://gitlab.com/group/project/-/merge_requests/456', + issue_url: 'https://github.com/owner/repo/issues/1', + }, + ])('rejects invalid input %j', (input) => { + expect(() => parseTarget(input)).toThrow(); + }); +}); + +describe('target requests and prefixes', () => { + test('identifies pull and merge requests for source branch lookups', () => { + expect( + targetRequest({ url: 'https://gitlab.example.com/group/sub/project/-/merge_requests/456' }), + ).toEqual({ + forge: 'gitlab', + host: 'gitlab.example.com', + project: 'group/sub/project', + iid: '456', + }); + expect(targetRequest({ url: 'https://github.com/owner/repo/pull/7' })).toEqual({ + forge: 'github', + host: 'github.com', + project: 'owner/repo', + iid: '7', + }); + expect(targetRequest({ url: 'https://gitlab.com/group/project/-/issues/1' })).toBeUndefined(); + }); + + test('uses a branch-derived issue unless an explicit issue is set', () => { + const url = 'https://gitlab.com/group/project/-/merge_requests/456'; + expect(targetPrefix({ url, branchIssue: '123' })).toBe('[#123, !456]'); + expect( + targetPrefix({ + url, + branchIssue: '123', + issueUrl: 'https://gitlab.com/group/project/-/issues/9', + }), + ).toBe('[#9, !456]'); + expect( + targetPrefix({ url: 'https://gitlab.com/group/project/-/issues/5', branchIssue: '123' }), + ).toBe('[#5]'); + }); + + test('reports only the URLs over RPC, with the first target at the top level', () => { + expect(targetOutput(undefined)).toEqual({}); + expect(targetOutput([])).toEqual({}); + expect(targetOutput([{ url: 'u', branchIssue: '1' }])).toEqual({ + url: 'u', + targets: [{ url: 'u' }], + }); + expect(targetOutput([{ url: 'u', issueUrl: 'i' }, { url: 'v' }])).toEqual({ + url: 'u', + issueUrl: 'i', + targets: [{ url: 'u', issueUrl: 'i' }, { url: 'v' }], + }); + }); + + const mr = (iid: number) => ({ + url: `https://gitlab.com/group/project/-/merge_requests/${iid}`, + branchIssue: '9', + }); + + test("lists only the targets' own references for several targets", () => { + expect(targetsPrefix([])).toBeUndefined(); + expect(targetsPrefix([mr(1)])).toBe('[#9, !1]'); + expect( + targetsPrefix([ + mr(1), + { url: 'https://github.com/o/r/pull/2' }, + { url: 'https://gitlab.com/g/p/-/issues/3' }, + ]), + ).toBe('[!1, #2, #3]'); + expect(targetsPrefix([mr(1), { ...mr(2), branchIssue: '8' }])).toBe('[!1, !2]'); + expect(targetsPrefix([mr(1), { url: mr(2).url }])).toBe('[!1, !2]'); + }); + + test('puts first the issue that every target shares', () => { + expect(targetsPrefix([1, 2, 3, 4].map(mr))).toBe('[#9, !1, !2, !3, !4]'); + expect(targetsPrefix([1, 2, 3, 4, 5, 6].map(mr))).toBe('[#9, !1, !2, !3, +3]'); + // An explicit issue matches the same issue inferred from a source branch. + const explicit = { url: mr(2).url, issueUrl: 'https://gitlab.com/group/project/-/issues/9' }; + expect(targetsPrefix([mr(1), explicit])).toBe('[#9, !1, !2]'); + // The same number in another project is another issue. + const elsewhere = { + url: 'https://gitlab.com/group/other/-/merge_requests/3', + branchIssue: '9', + }; + expect(targetsPrefix([mr(1), elsewhere])).toBe('[!1, !3]'); + const shared = 'https://gitlab.com/group/project/-/issues/9'; + expect(targetsPrefix([{ ...elsewhere, issueUrl: shared }, explicit])).toBe('[#9, !3, !2]'); + }); +}); + +const a = 'https://gitlab.com/group/project/-/merge_requests/1'; +const b = 'https://gitlab.com/group/project/-/merge_requests/2'; +const c = 'https://gitlab.com/other/project/-/merge_requests/3'; +const issue = 'https://gitlab.com/group/project/-/issues/9'; + +describe('parseTargetChange', () => { + test('defaults to replacing with a single target or branch mode', () => { + expect(parseTargetChange({ target: a, issue_url: issue })).toEqual({ + operation: 'replace', + targets: [{ url: a, issueUrl: issue }], + }); + expect(parseTargetChange({ target: 'branch' })).toEqual({ operation: 'branch' }); + expect(parseTargetChange({ target: 'branch', operation: 'replace' })).toEqual({ + operation: 'branch', + }); + }); + + test('normalizes and deduplicates several targets', () => { + expect( + parseTargetChange({ targets: [a, `${b}?tab=diffs`, `${a}/`], operation: 'add' }), + ).toEqual({ + operation: 'add', + targets: [{ url: a }, { url: b }], + }); + }); + + test('relates every PR/MR in targets to a shared issue_url', () => { + expect( + parseTargetChange({ targets: [a, c], issue_url: `${issue}#note_1`, operation: 'add' }), + ).toEqual({ + operation: 'add', + targets: [ + { url: a, issueUrl: issue }, + { url: c, issueUrl: issue }, + ], + }); + }); + + test.each([ + { targets: [] }, + { targets: 'https://gitlab.com/group/project/-/merge_requests/1' }, + { targets: [a, 7] }, + { targets: [a, 'branch'] }, + { targets: [a], target: b }, + { targets: [a, issue], issue_url: issue }, + { targets: [a, 'https://github.com/o/r/pull/2'], issue_url: issue }, + { targets: [a, b], issue_url: b }, + { targets: [a], operation: 'remove', issue_url: issue }, + { targets: Array.from({ length: 51 }, (_, index) => `${a.slice(0, -1)}${index + 1}`) }, + { target: a, operation: 'merge' }, + { target: a, operation: 'remove', issue_url: issue }, + { target: 'branch', operation: 'add' }, + 'nope', + ])('rejects %j', (input) => { + expect(() => parseTargetChange(input)).toThrow(); + }); +}); + +describe('applyTargetChange', () => { + const current = [ + { url: a, branchIssue: '5' }, + { url: b, issueUrl: issue }, + ]; + + test('replaces the targets, keeping inferred issues of targets it keeps', () => { + expect( + applyTargetChange(current, { + operation: 'replace', + targets: [{ url: c }, { url: a }, { url: b }], + }), + ).toEqual({ + targets: [{ url: c }, { url: a, branchIssue: '5' }, { url: b }], + missing: [], + }); + }); + + test('adds targets after the current ones, keeping their details', () => { + expect( + applyTargetChange(current, { operation: 'add', targets: [{ url: b }, { url: c }] }).targets, + ).toEqual([{ url: a, branchIssue: '5' }, { url: b, issueUrl: issue }, { url: c }]); + expect( + applyTargetChange(current, { operation: 'add', targets: [{ url: a, issueUrl: issue }] }) + .targets[0], + ).toEqual({ + url: a, + issueUrl: issue, + }); + }); + + test("removes targets and reports the ones it didn't have", () => { + expect( + applyTargetChange(current, { operation: 'remove', targets: [{ url: a }, { url: c }] }), + ).toEqual({ + targets: [{ url: b, issueUrl: issue }], + missing: [c], + }); + }); + + test('clears every target in branch mode', () => { + expect(applyTargetChange(current, { operation: 'branch' })).toEqual({ + targets: [], + missing: [], + }); + }); + + test('limits how many targets a session can have', () => { + const many = Array.from({ length: 30 }, (_, index) => ({ + url: `${a.slice(0, -1)}${index + 10}`, + })); + const more = Array.from({ length: 30 }, (_, index) => ({ + url: `${c.slice(0, -1)}${index + 10}`, + })); + expect(() => applyTargetChange(many, { operation: 'add', targets: more })).toThrow( + 'at most 50', + ); + }); +}); + +describe('classifyTargets', () => { + test('recognizes a PR or MR target on any forge', () => { + expect( + classifyTargets({ url: 'https://gitlab.com/group/project/-/merge_requests/42' }), + ).toEqual([ + { + kind: 'merge-request', + ref: { forge: 'gitlab', host: 'gitlab.com', project: 'group/project', iid: '42' }, + url: 'https://gitlab.com/group/project/-/merge_requests/42', + }, + ]); + expect(classifyTargets({ url: 'https://github.com/owner/repo/pull/7' })).toMatchObject([ + { + kind: 'merge-request', + ref: { forge: 'github', host: 'github.com', project: 'owner/repo', iid: '7' }, + }, + ]); + }); + + test.each([ + 'https://gitlab.com/group/project/-/issues/7', + 'https://github.com/owner/repo/issues/7', + ])('marks an issue target as not a PR/MR: %s', (url) => { + expect(classifyTargets({ url })).toEqual([{ kind: 'other', url }]); + }); + + test('prefers the list of targets over the first target', () => { + expect(classifyTargets({ url: a, targets: [{ url: a }, { url: issue }] })).toMatchObject([ + { kind: 'merge-request', url: a }, + { kind: 'other', url: issue }, + ]); + }); + + test('treats a missing target as automatic', () => { + expect(classifyTargets({})).toBeUndefined(); + expect(classifyTargets({ targets: [] })).toBeUndefined(); + expect(classifyTargets({ url: 42 })).toBeUndefined(); + expect(classifyTargets(undefined)).toBeUndefined(); + }); +}); + +describe('parseUrl', () => { + test('parses a GitLab MR URL in a nested group', () => { + expect( + gitlabTraits.parseUrl('https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/42'), + ).toEqual({ + forge: 'gitlab', + host: 'gitlab.com', + project: 'gitlab-org/security/gitlab', + iid: '42', + }); + }); + + test.each([ + 'not a url', + 'https://gitlab.com/group/project/-/issues/42', + 'https://github.com/me/project/pull/42', + 'ftp://gitlab.com/group/project/-/merge_requests/42', + ])('GitLab rejects %p', (value) => { + expect(gitlabTraits.parseUrl(value)).toBeUndefined(); + }); + + test.each([ + 'https://github.com/owner/repo/issues/42', + 'https://github.com/owner/repo/pull/42/files', + 'https://gitlab.com/group/project/-/merge_requests/42', + 'https://github.com/owner/repo/pull/0', + 'https://gitlab.example.com/owner/repo/pull/42', + ])('GitHub rejects %p', (value) => { + expect(githubTraits.parseUrl(value)).toBeUndefined(); + }); +}); + +describe('parseIssueUrl', () => { + test('parses issues and work items, but not PRs/MRs', () => { + expect(gitlabTraits.parseIssueUrl('https://gitlab.com/g/sub/p/-/work_items/3')).toMatchObject({ + project: 'g/sub/p', + iid: '3', + }); + expect(gitlabTraits.parseIssueUrl('https://gitlab.com/g/p/-/merge_requests/3')).toBeUndefined(); + expect(githubTraits.parseIssueUrl('https://github.com/o/r/issues/3')).toMatchObject({ + forge: 'github', + iid: '3', + }); + expect(githubTraits.parseIssueUrl('https://github.com/o/r/pull/3')).toBeUndefined(); + }); +}); diff --git a/packages/forge-session/src/target.ts b/packages/forge-session/src/target.ts new file mode 100644 index 0000000..c20b913 --- /dev/null +++ b/packages/forge-session/src/target.ts @@ -0,0 +1,263 @@ +import type { ReviewRef } from './forge'; +import { parseReference, parseUrl, traits } from './forges'; +import type { TargetOutput } from './rpc'; + +// One of a session's explicit targets, as set_session_target stores it. +// `branchIssue` is the issue number in the PR/MR's source branch name, which +// the title shows when no `issueUrl` was given. +// A type alias rather than an interface, so it is assignable to plugin storage's JSON type. +export type Target = { + url: string; + issueUrl?: string; + branchIssue?: string; +}; + +// The most targets a session can have, so that a runaway call can't make +// every status poll query hundreds of PRs/MRs. +export const MAX_TARGETS = 50; +// A title prefix with more targets lists the first few and counts the rest. +const PREFIX_REFERENCES = 4; + +interface Reference { + url: string; + ref: ReviewRef; + issue: boolean; +} + +const INVALID_URL = 'Use a full HTTP(S) issue, pull request, or merge request URL.'; + +// Validates a target URL and normalizes it, dropping the query, the fragment, +// and a trailing slash, so equivalent links store the same target. +function parseTargetUrl(value: string): Reference { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(INVALID_URL); + } + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) + throw new Error(INVALID_URL); + url.search = ''; + url.hash = ''; + url.pathname = url.pathname.replace(/\/$/, ''); + const parsed = parseReference(url.href); + if (!parsed) throw new Error('Use a GitHub or GitLab issue, pull request, or merge request URL.'); + return { url: url.href, ...parsed }; +} + +// Parses a single `target` and its `issue_url`. Undefined means automatic branch mode. +export function parseTarget(input: unknown): Target | undefined { + if ( + !input || + typeof input !== 'object' || + !('target' in input) || + typeof input.target !== 'string' + ) { + throw new Error('Provide a target URL or "branch".'); + } + const issueUrl = 'issue_url' in input ? input.issue_url : undefined; + if (input.target === 'branch') { + if (issueUrl !== undefined) throw new Error('Branch mode does not accept issue_url.'); + return undefined; + } + return relate(parseTargetUrl(input.target), issueUrl); +} + +// A target with its related issue, if `issueUrl` is given. Only a PR/MR can +// have one, and only from its own forge. +function relate(target: Reference, issueUrl: unknown): Target { + if (issueUrl === undefined) return { url: target.url }; + if (typeof issueUrl !== 'string') throw new Error('issue_url must be a full issue URL.'); + const issue = parseTargetUrl(issueUrl); + if (target.issue || !issue.issue || target.ref.forge !== issue.ref.forge) { + throw new Error('Only a pull or merge request can have a related issue from the same forge.'); + } + return { url: target.url, issueUrl: issue.url }; +} + +export type Operation = 'replace' | 'add' | 'remove'; +export type TargetChange = { operation: Operation; targets: Target[] } | { operation: 'branch' }; + +const OPERATIONS = new Set(['replace', 'add', 'remove']); + +// Keeps the first position of each URL and its last details. +const dedupe = (targets: Target[]) => [ + ...new Map(targets.map((target) => [target.url, target])).values(), +]; + +// Parses set_session_target's input: one `target` or several `targets`, and +// how they change the session's current targets. With `targets`, `issue_url` +// relates every one of them, which must all be PRs/MRs, to the same issue. +export function parseTargetChange(input: unknown): TargetChange { + if (!input || typeof input !== 'object') + throw new Error('Provide a target URL, "branch", or a list of targets.'); + const value = input as Record; + const operation = (value.operation ?? 'replace') as Operation; + if (!OPERATIONS.has(operation)) + throw new Error('operation must be "replace", "add", or "remove".'); + + if (operation === 'remove' && value.issue_url !== undefined) + throw new Error('Removing a target does not accept issue_url.'); + + if (value.targets !== undefined) { + if (value.target !== undefined) throw new Error('Use either target or targets, not both.'); + const urls = value.targets; + if ( + !Array.isArray(urls) || + urls.length === 0 || + !urls.every((url) => typeof url === 'string') + ) { + throw new Error('targets must be a non-empty list of full URLs.'); + } + if (urls.includes('branch')) + throw new Error('Use target: "branch" on its own to return to branch-based naming.'); + if (urls.length > MAX_TARGETS) + throw new Error(`A session can have at most ${MAX_TARGETS} targets.`); + return { + operation, + targets: dedupe(urls.map((url) => relate(parseTargetUrl(url), value.issue_url))), + }; + } + + if (value.target === 'branch' && operation !== 'replace') + throw new Error('"branch" replaces every target, so it takes no operation.'); + const target = parseTarget(value); + return target ? { operation, targets: [target] } : { operation: 'branch' }; +} + +// Applies a change to the session's targets. `missing` lists the URLs that a +// removal named but the session didn't have. +export function applyTargetChange( + current: readonly Target[], + change: TargetChange, +): { targets: Target[]; missing: string[] } { + if (change.operation === 'branch') return { targets: [], missing: [] }; + const known = new Map(current.map((target) => [target.url, target])); + + if (change.operation === 'remove') { + const removed = new Set(change.targets.map((target) => target.url)); + return { + targets: current.filter((target) => !removed.has(target.url)), + missing: [...removed].filter((url) => !known.has(url)), + }; + } + + // A target that the session already has keeps its inferred issue, so its + // source branch isn't looked up again. Adding it again also keeps its + // explicit issue. An explicit issue in the change always wins. + const merge = (target: Target): Target => { + const existing = known.get(target.url); + if (!existing || target.issueUrl) return target; + if (change.operation === 'add') return { ...existing }; + return { + url: target.url, + ...(existing.branchIssue ? { branchIssue: existing.branchIssue } : {}), + }; + }; + const targets = + change.operation === 'replace' + ? change.targets.map(merge) + : dedupe([...current.map((target) => ({ ...target })), ...change.targets.map(merge)]); + if (targets.length > MAX_TARGETS) + throw new Error(`A session can have at most ${MAX_TARGETS} targets.`); + return { targets, missing: [] }; +} + +// The PR/MR a target names, or undefined for an issue. +export function targetRequest(target: Target): ReviewRef | undefined { + const { ref, issue } = parseTargetUrl(target.url); + return issue ? undefined : ref; +} + +// Whether two references name the same PR/MR. Forges ignore the case of +// project paths, so a forge's URL can differ from the target's. +export const sameRef = (a: ReviewRef, b: ReviewRef) => + a.forge === b.forge && + a.host === b.host && + a.iid === b.iid && + a.project.toLowerCase() === b.project.toLowerCase(); + +// Whether a PR/MR URL, as a forge reports it, is one of the targets. +export function includesRequest(targets: readonly Target[], url: string): boolean { + const ref = parseUrl(url); + if (!ref) return false; + return targets.some((target) => { + const request = targetRequest(target); + return request !== undefined && sameRef(request, ref); + }); +} + +// The target's own reference, such as `!45` or `#12`. +function mainReference(target: Target): string { + const { ref, issue } = parseTargetUrl(target.url); + const forge = traits(ref.forge); + return issue ? forge.issueReference(ref.iid) : forge.reference(ref.iid); +} + +// A PR/MR's related issue: the explicit `issueUrl`, or else the issue in its +// source branch name, which is in the PR/MR's own project. `id` tells issues +// with the same number in different projects apart. +function relatedIssue(target: Target): { id: string; reference: string } | undefined { + const { ref, issue } = parseTargetUrl(target.url); + let related: ReviewRef; + if (target.issueUrl) related = parseTargetUrl(target.issueUrl).ref; + else if (!issue && target.branchIssue) related = { ...ref, iid: target.branchIssue }; + else return undefined; + return { + id: `${related.forge} ${related.host} ${related.project.toLowerCase()} ${related.iid}`, + reference: traits(related.forge).issueReference(related.iid), + }; +} + +// The title prefix for a target, such as `[#12, !45]`. +export const targetPrefix = (target: Target): string => targetsPrefix([target])!; + +// The title prefix for a session's targets, such as `[#12, !45, !46]`. The +// related issue comes first when every target has the same one. Otherwise +// the prefix lists only the targets' own references, such as `[!45, #7]`. +// Long lists end with a count, such as `[!45, !46, !47, +3]`. +export function targetsPrefix(targets: readonly Target[]): string | undefined { + if (targets.length === 0) return undefined; + const issues = targets.map(relatedIssue); + const shared = issues.every((issue) => issue && issue.id === issues[0]?.id) + ? issues[0]?.reference + : undefined; + const references = targets.map(mainReference); + const shown = + references.length > PREFIX_REFERENCES + ? [ + ...references.slice(0, PREFIX_REFERENCES - 1), + `+${references.length - PREFIX_REFERENCES + 1}`, + ] + : references; + return `[${[...(shared ? [shared] : []), ...shown].join(', ')}]`; +} + +const urlsOf = (target: Target) => ({ + url: target.url, + ...(target.issueUrl ? { issueUrl: target.issueUrl } : {}), +}); + +// `url` and `issueUrl` describe the first target, for callers that expect one. +export const targetOutput = (targets: readonly Target[] | undefined): TargetOutput => + targets?.length ? { ...urlsOf(targets[0]), targets: targets.map(urlsOf) } : {}; + +export type SessionTarget = + | { kind: 'merge-request'; ref: ReviewRef; url: string } + | { kind: 'other'; url: string }; + +const classify = (url: string): SessionTarget => { + const ref = parseUrl(url); + return ref ? { kind: 'merge-request', ref, url } : { kind: 'other', url }; +}; + +// Classifies the `target` RPC output. Undefined means the session has no +// explicit target; "other" is an explicit target that isn't a PR/MR, such as +// an issue. Output without `targets` comes from a server that predates them. +export function classifyTargets(output: unknown): SessionTarget[] | undefined { + const value = output as { url?: unknown; targets?: unknown } | undefined; + const urls = ( + Array.isArray(value?.targets) ? value.targets.map((target) => target?.url) : [value?.url] + ).filter((url): url is string => typeof url === 'string' && url !== ''); + return urls.length ? urls.map(classify) : undefined; +} diff --git a/packages/forge-session/src/title.test.ts b/packages/forge-session/src/title.test.ts new file mode 100644 index 0000000..57c227e --- /dev/null +++ b/packages/forge-session/src/title.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test'; +import { traits } from './forges'; +import { branchPrefix, extractIssueNumber, reconcileTitle } from './title'; + +describe('extractIssueNumber', () => { + test.each([ + ['feature/123-add-login', '123'], + ['123-fix-typo', '123'], + ['fix-typo-123', '123'], + ['user/123/some-work', '123'], + ['gh-42-improve-perf', '42'], + ['fix-99', '99'], + ['hotfix/501-critical', '501'], + ['no-number-here', undefined], + ])('%s', (branch, expected) => { + expect(extractIssueNumber(branch)).toBe(expected); + }); +}); + +describe('branchPrefix', () => { + test('adds the issue and merge request references', () => { + expect(branchPrefix(traits('gitlab'), '123-fix-login', '45')).toBe('[#123, !45]'); + }); + + test('uses the branch name and a placeholder when nothing is found', () => { + expect(branchPrefix(traits('github'), 'tidy-up', undefined)).toBe('[tidy-up, #N/A]'); + expect(branchPrefix(traits('gitlab'), 'tidy-up', undefined)).toBe('[tidy-up, !N/A]'); + }); +}); + +describe('reconcileTitle', () => { + test('replaces the placeholder once a reference exists', () => { + expect(reconcileTitle('[#123, !N/A] Fix login', '[#123, !45]')).toBe('[#123, !45] Fix login'); + }); + + test('replaces stale legacy references together', () => { + expect(reconcileTitle('[#123, !45] Review changes', '[!456]')).toBe('[!456] Review changes'); + }); + + test('preserves user prefixes', () => { + expect(reconcileTitle('[WIP] Fix login', '[!456]')).toBe('[!456] [WIP] Fix login'); + expect(reconcileTitle('[!456] [WIP] Fix login', '[!789]', '[!456]')).toBe( + '[!789] [WIP] Fix login', + ); + }); + + test('repeated reconciliation is stable', () => { + expect(reconcileTitle('[!456] Review changes', '[!456]', '[!456]')).toBe( + '[!456] Review changes', + ); + }); + + test('removes the managed prefix when resetting on a default branch', () => { + expect(reconcileTitle('[!456] Review changes', undefined, '[!456]')).toBe('Review changes'); + }); + + test('preserves a renamed title', () => { + expect(reconcileTitle('New title', '[!456]', '[!456]')).toBe('[!456] New title'); + }); + + test('keeps a long branch prefix whole, so it can be removed later', () => { + const branch = 'a-very-long-branch-name-that-describes-everything-it-changes-in-detail'; + const prefix = branchPrefix(traits('gitlab'), branch, '45'); + expect(prefix).toBe('[a-very-long-branch-name-that-describes-…, !45]'); + const title = reconcileTitle(`Review ${'x'.repeat(120)}`, prefix); + expect(title).toHaveLength(100); + expect(title.startsWith(`${prefix} Review`)).toBe(true); + // Back on the default branch, the stored prefix still matches the title. + expect(reconcileTitle(title, undefined, prefix).startsWith('Review')).toBe(true); + // A title that is only the prefix loses it entirely. + expect(reconcileTitle(prefix, undefined, prefix)).toBe(''); + expect(reconcileTitle(prefix, '[!46]', prefix)).toBe('[!46]'); + }); + + test('truncates titles to 100 characters', () => { + const result = reconcileTitle('x'.repeat(120), '[#1, #2]'); + expect(result).toHaveLength(100); + expect(result.startsWith('[#1, #2] ')).toBe(true); + }); +}); diff --git a/packages/forge-session/src/title.ts b/packages/forge-session/src/title.ts new file mode 100644 index 0000000..6cff4a2 --- /dev/null +++ b/packages/forge-session/src/title.ts @@ -0,0 +1,54 @@ +import type { ForgeTraits } from './forge'; + +// Prefixes that opencode-forge-session-title wrote before it tracked its own +// prefix, such as `[#12, !45] `. +const LEGACY_PREFIX_RE = /^\[[^[\],]+, [!#](?:\d+|N\/A)\] /; +const MAX_TITLE_LENGTH = 100; +// Leaves room in the title for the user's text after a branch prefix. +const MAX_BRANCH_LABEL = 40; + +const ISSUE_PATTERNS: RegExp[] = [ + /(?:^|[/])(\d+)[-/]/, + /[-/](\d+)$/, + /^(?:issue|gh|bug|fix|feat|feature|hotfix)[-/](\d+)\b/i, +]; + +// The issue number in a branch name, such as `123` in `feature/123-add-login`. +export function extractIssueNumber(branch: string): string | undefined { + for (const pattern of ISSUE_PATTERNS) { + const match = branch.match(pattern); + if (match) return match[1]; + } + return undefined; +} + +// Replaces the prefix this plugin last wrote, or a legacy one, with `prefix`. +// Text the user added, including their own bracketed prefixes, is kept. +// Titles stay within MAX_TITLE_LENGTH by shortening the text after the prefix, +// never the prefix itself, so the stored prefix still matches the title. +export function reconcileTitle(title: string, prefix?: string, previousPrefix?: string): string { + let rest: string; + if (previousPrefix && title === previousPrefix) rest = ''; + else if (previousPrefix && title.startsWith(`${previousPrefix} `)) + rest = title.slice(previousPrefix.length + 1); + else rest = title.replace(LEGACY_PREFIX_RE, ''); + if (!prefix) return rest.slice(0, MAX_TITLE_LENGTH); + const kept = rest.slice(0, Math.max(0, MAX_TITLE_LENGTH - prefix.length - 1)); + return kept ? `${prefix} ${kept}` : prefix; +} + +// The prefix for branch-based naming: the branch's issue, or the branch name +// without one, and its PR/MR, or `!N/A` until one exists. +// A branch name longer than MAX_BRANCH_LABEL is shortened with an ellipsis. +export function branchPrefix( + forge: ForgeTraits, + branch: string, + number: string | undefined, +): string { + const issue = extractIssueNumber(branch); + const label = issue ? forge.issueReference(issue) : shorten(branch, MAX_BRANCH_LABEL); + return `[${label}, ${forge.reference(number ?? 'N/A')}]`; +} + +const shorten = (text: string, length: number) => + text.length > length ? `${text.slice(0, length - 1)}…` : text; diff --git a/packages/forge-session/src/traits.test.ts b/packages/forge-session/src/traits.test.ts new file mode 100644 index 0000000..e6e97e4 --- /dev/null +++ b/packages/forge-session/src/traits.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'bun:test'; +import type { GitHubPullRequest, GitLabMergeRequest } from './forge'; +import { prefixReferences, titlePrefix } from './forge'; +import { reference, traits, traitsOf } from './forges'; +import { footerSegments, responsiveFooterSegments } from './format'; + +const common = { + iid: '7', + title: 'Title', + state: 'opened', + draft: false, + conflicts: false, + conflictsKnown: true, + approved: false, + awaitingReviewers: [], + reviewersComplete: true, + targetProject: 'o/r', + targetBranch: 'main', + unresolvedThreads: 0, + threadsComplete: true, +}; +const merge: GitLabMergeRequest = { + ...common, + forge: 'gitlab', + url: 'https://gitlab.com/o/r/-/merge_requests/7', + hasApprovals: false, + approvalRequirementsSatisfied: null, + duoReviewState: 'REVIEW_STARTED', + mergeStatus: 'CI_MUST_PASS', +}; +const pull: GitHubPullRequest = { + ...common, + forge: 'github', + url: 'https://github.com/o/r/pull/7', + reviewDecision: 'CHANGES_REQUESTED', +}; + +describe('forge traits', () => { + test("format references with each forge's sigil", () => { + expect(reference(merge)).toBe('!7'); + expect(reference(pull)).toBe('#7'); + expect(traits('gitlab').reference('45')).toBe('!45'); + expect(traits('github').reference('45')).toBe('#45'); + }); + + test.each([ + ['[#597600, !259332] Reviewing MRs', ['259332'], ['597600']], + ['[#42, #108] Fix things', [], ['42', '108']], + ['[!42] Title', ['42'], []], + ['[tidy-up, #N/A] Branch without a PR', [], []], + ['Fix !42 and #7 later', [], []], + ['[a!42, b#7] Not references', [], []], + [undefined, [], []], + ])('find title references in %p', (title, gitlab, github) => { + expect(traits('gitlab').titleReferences(title)).toEqual(gitlab); + expect(traits('github').titleReferences(title)).toEqual(github); + }); + + test('read only the managed prefix', () => { + expect(titlePrefix('[#1, !2] Title [!3]')).toBe('[#1, !2]'); + expect(titlePrefix('Title')).toBe(''); + expect(prefixReferences('[$12, $0, $1.5] Title', '$')).toEqual(['12']); + }); + + test('recognize hosts and own requests', () => { + expect(traits('gitlab').recognizes('gitlab.example.com')).toBe(true); + expect(traits('gitlab').recognizes('github.com')).toBe(false); + expect(traits('github').recognizes('github.com')).toBe(false); + expect(traits('gitlab').owns(merge)).toBe(true); + expect(traits('gitlab').owns(pull)).toBe(false); + expect(traits('github').owns(pull)).toBe(true); + }); + + test("resolve the traits of a request's own forge", () => { + expect(traitsOf(merge)).toBe(traits('gitlab')); + expect(traitsOf(pull)).toBe(traits('github')); + expect(traitsOf(merge).details(merge)).toEqual([ + 'Human approvals: no · Approval requirements satisfied: unknown', + 'Merge status: ci must pass', + ]); + expect(traitsOf(pull).details(pull)).toEqual(['Review decision: changes requested']); + }); + + test('contribute forge-specific footer indicators', () => { + const text = (request: GitLabMergeRequest | GitHubPullRequest) => + footerSegments({ loading: false, lookup: { kind: 'found', requests: [request] } }).map( + (segment) => segment.text, + ); + expect(text(merge)).toEqual(['!7', 'no pipeline', '🤖 reviewing']); + expect(text(pull)).toEqual(['#7', 'no checks', 'changes requested']); + const fit = (width: number) => + responsiveFooterSegments( + { loading: false, lookup: { kind: 'found', requests: [pull] } }, + width, + ).map((segment) => segment.text); + expect(fit(30)).toEqual(['#7', 'no CI', 'changes requested']); + expect(fit(29)).toEqual(['#7', 'changes requested']); + }); + + test("name multiple requests with their forge's noun", () => { + const text = (requests: Array) => + footerSegments({ loading: false, lookup: { kind: 'found', requests } }).at(-1)?.text; + expect(text([merge, { ...merge, iid: '8' }])).toBe('2 open MRs, run /mr-status'); + expect(text([pull, { ...pull, iid: '8' }])).toBe('2 open PRs, run /pr-status'); + }); +}); diff --git a/packages/forge-session/src/tui.tsx b/packages/forge-session/src/tui.tsx new file mode 100644 index 0000000..b86e372 --- /dev/null +++ b/packages/forge-session/src/tui.tsx @@ -0,0 +1,410 @@ +/** @jsxImportSource @opentui/solid */ +// OpenCode loads this CLI entry point automatically because the server entry +// point in index.ts is configured in opencode.json. The server looks up and +// watches the PRs/MRs; this entry point renders their status and shows the +// server's notices as toasts. The server watches only sessions that a CLI has +// shown, so this entry point renews a lease on each of them. +import type { Plugin } from '@opencode/plugin/tui'; +import type { BoxRenderable } from '@opentui/core'; +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'; +import { exec } from './exec'; +import { + detailsMessage, + footerSegments, + responsiveFooterSegments, + segmentsWidth, + type Tone, +} from './format'; +import type { ReviewRequest } from './forge'; +import { reference } from './forges'; +import { ForgeSessionRpc, HEARTBEAT, HOME, type Notice } from './rpc'; +import { createStatusClient, samePlace, type Place } from './status-client'; +import type { Snapshot } from './store'; + +export default { + id: 'pedropombeiro.forge-session', + async setup(context) { + const rpc = context.client.rpc(ForgeSessionRpc); + + const [version, setVersion] = createSignal(0); + const client = createStatusClient({ + clientID: crypto.randomUUID(), + watch: async (directory, input) => + (await rpc.watch(input, { location: { directory } })) as { + enabled: boolean; + statuses: Record; + }, + refresh: async (directory, key) => + (await rpc.refresh({ key }, { location: { directory } })) as { + enabled: boolean; + snapshot: unknown; + }, + release: (directory, clientID) => rpc.release({ clientID }, { location: { directory } }), + onChange: () => setVersion((value) => value + 1), + }); + + // The displayed session can live in a different worktree than the one the + // TUI started in, so resolve the directory from the session first. + const directoryFor = (sessionID?: string) => + (sessionID ? context.data.session.get(sessionID)?.location.directory : undefined) ?? + context.location?.directory ?? + context.data.location.default().directory; + + const placeFor = (sessionID?: string): Place => ({ + directory: directoryFor(sessionID), + key: sessionID ?? HOME, + }); + + const currentPlace = () => { + const route = context.ui.router.current(); + return placeFor(route.type === 'session' ? route.sessionID : undefined); + }; + + const snapshotOf = (place: Place): Snapshot => { + version(); + return client.snapshot(place); + }; + + // `reviewStatus: false` on a directory's server turns its footer and + // commands off. Each directory has its own options. + const enabledFor = (directory: string) => { + version(); + return client.enabled(directory); + }; + + const refresh = (place: Place) => client.refresh(place); + const heartbeat = setInterval(() => client.heartbeat(), HEARTBEAT); + + const color = (tone: Tone) => { + const feedback = context.theme.text.feedback; + if (tone === 'success') return feedback.success.base; + if (tone === 'warning') return feedback.warning.base; + if (tone === 'error') return feedback.error.base; + return context.theme.text.muted; + }; + + function Footer(props: { sessionID?: string }) { + const place = createMemo(() => placeFor(props.sessionID), undefined, { equals: samePlace }); + + createEffect(() => { + const current = place(); + client.show(current); + onCleanup(() => client.hide(current)); + }); + + // Keeps the checkout's branch watched, so a branch switch reaches the server. + createEffect(() => { + context.data.location.vcs.sync({ directory: place().directory }).catch(() => {}); + }); + + const snapshot = createMemo(() => snapshotOf(place())); + const fullWidth = createMemo(() => + enabledFor(place().directory) ? segmentsWidth(footerSegments(snapshot())) : 0, + ); + const [availableWidth, setAvailableWidth] = createSignal(0); + const segments = createMemo(() => responsiveFooterSegments(snapshot(), availableWidth())); + + return ( + 0}> + setAvailableWidth(width)); + }} + > + + {(segment, index) => ( + <> + 0}> + + {' · '} + + + showStatus(place())} + > + {segment.text} + + } + > + {(url: () => string) => ( + // OSC 8 makes the text a terminal hyperlink where supported; the + // click handler covers terminals and multiplexers that drop it. + // The underline marks the segment as a link, because terminals + // don't style OSC 8 links consistently. + openUrl(url())} + > + + {segment.text} + + + )} + + + )} + + + + ); + } + + const openUrl = (url: string) => { + const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'; + void exec(opener, [url], process.cwd()).then((result) => { + if (result.code !== 0) + context.ui.toast.show({ message: `Could not open ${url}`, variant: 'error' }); + }); + }; + + const requestsFor = (place: Place): ReviewRequest[] => { + const lookup = snapshotOf(place).lookup; + return lookup?.kind === 'found' ? lookup.requests : []; + }; + + // Commands explain why nothing happens when the place's server has status off. + const statusOff = (place: Place) => { + if (enabledFor(place.directory)) return false; + context.ui.toast.show({ + message: "PR/MR status is off in the plugin's options (reviewStatus: false)", + variant: 'info', + }); + return true; + }; + + function StatusDialog(props: { place: Place }) { + const [terminalHeight, setTerminalHeight] = createSignal(context.renderer.height); + const onResize = () => setTerminalHeight(context.renderer.height); + context.renderer.on('resize', onResize); + onCleanup(() => context.renderer.off('resize', onResize)); + // Leave room for the dialog host, title, gaps, footer, and padding. + // A maxHeight alone lets the scrollbox's content grow the dialog. + const contentHeight = createMemo(() => + Math.max(1, Math.min(24, Math.floor(terminalHeight() * 0.6) - 5)), + ); + const snapshot = createMemo(() => snapshotOf(props.place)); + const reload = () => { + if (!snapshot().loading) void refresh(props.place); + }; + const [activeAction, setActiveAction] = createSignal<'refresh' | 'close'>('refresh'); + const moveAction = () => + setActiveAction((action) => + action === 'refresh' || snapshot().loading ? 'close' : 'refresh', + ); + const close = () => context.ui.dialog.clear(); + + context.keymap.layer(() => ({ + mode: 'modal', + commands: [ + { + id: 'forge.review.status.refresh', + bind: 'ctrl+r', + enabled: () => !snapshot().loading, + run: reload, + }, + { bind: 'tab', title: 'Next dialog action', run: moveAction }, + { bind: 'shift+tab', title: 'Previous dialog action', run: moveAction }, + { bind: 'left', title: 'Previous dialog action', run: moveAction }, + { bind: 'right', title: 'Next dialog action', run: moveAction }, + { + bind: 'return', + title: 'Activate dialog action', + run: () => { + if (activeAction() === 'refresh') reload(); + else close(); + }, + }, + ], + })); + + return ( + + + + PR/MR status + + + esc + + + + {detailsMessage(snapshot())} + + + + {(action) => { + const disabled = () => action === 'refresh' && snapshot().loading; + const active = () => activeAction() === action && !disabled(); + return ( + { + if (!disabled()) setActiveAction(action); + }} + onMouseUp={() => { + if (disabled()) return; + setActiveAction(action); + if (action === 'refresh') reload(); + else close(); + }} + > + + {action === 'close' + ? 'Close' + : snapshot().loading + ? 'Refreshing…' + : 'Refresh'} + + + ); + }} + + + + ); + } + + function showStatus(place = currentPlace()) { + if (statusOff(place)) return; + void refresh(place); + context.ui.dialog.set({ size: 'large', centered: true }); + context.ui.dialog.show(() => ); + } + + async function openRequest() { + const place = currentPlace(); + if (statusOff(place)) return; + let requests = requestsFor(place); + if (requests.length === 0) { + await refresh(place); + requests = requestsFor(place); + } + if (requests.length === 0) { + context.ui.toast.show({ message: 'No PR/MR for this session or branch', variant: 'info' }); + return; + } + if (requests.length === 1) return openUrl(requests[0].url); + const url = await context.ui.dialog.select({ + title: 'Open PR/MR', + actions: [ + { + bind: 'ctrl+w', + title: 'Close', + side: 'right', + selection: 'none', + onTrigger: () => context.ui.dialog.clear(), + }, + ], + options: requests.map((mr) => ({ + title: `${reference(mr)} ${mr.title}`, + value: mr.url, + description: mr.targetProject, + })), + }); + if (url) openUrl(url); + } + + context.ui.slot({ + append: 'prompt.footer.status', + render: (input) =>