diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000..e9a83f03 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers-contrib/features/bun:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers-extra/features/bun@sha256:0624284ecaead9dd4c6654616a7f939cfa4ebcbc60593700a74e35b1767befa5", + "integrity": "sha256:0624284ecaead9dd4c6654616a7f939cfa4ebcbc60593700a74e35b1767befa5" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..fd431bf8 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,8 @@ +{ + "name": "opencode-dev", + "image": "mcr.microsoft.com/devcontainers/typescript-node:latest", + "features": { + "ghcr.io/devcontainers-contrib/features/bun:1": {} + }, + "postCreateCommand": "bun --version" +} diff --git a/bun.lock b/bun.lock index 1e3a6425..47da2b1f 100644 --- a/bun.lock +++ b/bun.lock @@ -1442,8 +1442,6 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="], "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.12.0", "", { "dependencies": { "@bufbuild/protobuf": "2.12.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow=="], @@ -3002,8 +3000,6 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], - "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="], @@ -3124,8 +3120,6 @@ "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="], - "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -3610,8 +3604,6 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="], @@ -4132,12 +4124,6 @@ "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], - "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], - - "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], - - "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], - "iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="], "iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="], @@ -4306,8 +4292,6 @@ "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], - "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], - "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=="], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], @@ -6122,10 +6106,6 @@ "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], - "@standard-community/standard-json/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], - - "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], - "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6154,10 +6134,6 @@ "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], - - "@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], @@ -6208,8 +6184,6 @@ "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], "astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -6300,8 +6274,6 @@ "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], - "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -6352,8 +6324,6 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], - "istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], - "js-beautify/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=="], "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -6960,18 +6930,12 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], - "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], - "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], - "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/script/raw-changelog.test.ts b/script/raw-changelog.test.ts new file mode 100644 index 00000000..dae045a0 --- /dev/null +++ b/script/raw-changelog.test.ts @@ -0,0 +1,182 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test" +import { $ } from "bun" +import { mkdtempSync, writeFileSync, mkdirSync, chmodSync, rmSync } from "fs" +import { tmpdir } from "os" +import path from "path" +import { commits } from "./raw-changelog" + + + +/** + * IGNORE: Comment to re-run test suite + * commits(from, to) has two dependencies: + * 1. the GitHub CLI (`gh api .../compare/...`), used to look up each + * commit's author username and full message + * 2. the local git repo (`git log`, `git diff-tree`), used to figure out + * which commits touched which package paths + * + * Bun's `$` shell tag can't be intercepted with `mock.module("bun", ...)` + * (it isn't resolved through the normal module registry), so instead of + * mocking, this test builds a real temporary git repo and shims a fake `gh` + * executable onto PATH that returns fixed data for the one API call + * commits() actually makes. + * + * I have used Claude to write these tests and help me come up with a working mechanism. + */ + +let repoDir: string +let binDir: string +let originalCwd: string +let originalPath: string | undefined + +let baseSha = "" +let coreFeatSha = "" +let tuiFixSha = "" +let choreSha = "" +let tempAddSha = "" +let tempRevertSha = "" +let mysterySha = "" + +async function commit(message: string, files: Record) { + for (const [file, content] of Object.entries(files)) { + const full = path.join(repoDir, file) + mkdirSync(path.dirname(full), { recursive: true }) + writeFileSync(full, content) + await $`git -C ${repoDir} add ${file}`.quiet() + } + await $`git -C ${repoDir} commit -m ${message} --allow-empty`.quiet() + return (await $`git -C ${repoDir} rev-parse HEAD`.text()).trim() +} + +beforeAll(async () => { + originalCwd = process.cwd() + originalPath = process.env.PATH + + repoDir = mkdtempSync(path.join(tmpdir(), "raw-changelog-repo-")) + binDir = mkdtempSync(path.join(tmpdir(), "raw-changelog-bin-")) + + await $`git -C ${repoDir} init -q -b main` + await $`git -C ${repoDir} config user.email test@example.com` + await $`git -C ${repoDir} config user.name "Test Bot"` + + // 1. baseline commit -- this is the "from" ref + baseSha = await commit("chore: init", { + "packages/opencode/src/init.ts": "// init\n", + }) + + // 2. a feature touching core (packages/opencode/**, outside cli/cmd) -- should appear, Core/Improvements + coreFeatSha = await commit("feat: add opencode feature", { + "packages/opencode/src/foo.ts": "export const foo = 1\n", + }) + + // 3. a fix touching the TUI area (packages/opencode/src/cli/cmd/**) -- should appear, TUI/Bugfixes + tuiFixSha = await commit("fix: tui rendering bug", { + "packages/opencode/src/cli/cmd/bar.ts": "export const bar = 1\n", + }) + + // 4. a chore -- present in the gh data and touches a tracked path, but must be + // filtered out by the `chore:` message-prefix check + choreSha = await commit("chore: bump deps", { + "packages/opencode/package.json": "{}\n", + }) + + // 5. a docs commit touching only an untracked path (repo root) -- git log's + // pathspec should exclude it entirely, so it never reaches the gh lookup + await commit("docs: update readme", { + "README.md": "hello\n", + }) + + // 6/7. a commit and its exact revert (packages/sdk/**) -- both should cancel out + tempAddSha = await commit("feat: add temp thing", { + "packages/sdk/temp.ts": "export const temp = 1\n", + }) + tempRevertSha = await commit('Revert "feat: add temp thing"', { + "packages/sdk/temp.ts": "// reverted\n", + }) + + // 8. touches a tracked path, but deliberately left OUT of the fake gh response + // below -- exercises the `if (!item) continue` branch when a commit hash + // from `git log` has no matching entry from the GitHub API + mysterySha = await commit("feat: mystery commit missing from gh data", { + "packages/opencode/src/mystery.ts": "export const mystery = 1\n", + }) + + const ghData: Record = { + [coreFeatSha]: { login: "alice-dev", message: "feat: add opencode feature" }, + [tuiFixSha]: { login: "bob-dev", message: "fix: tui rendering bug" }, + [choreSha]: { login: "carol-dev", message: "chore: bump deps" }, + [tempAddSha]: { login: "erin-dev", message: "feat: add temp thing" }, + [tempRevertSha]: { login: "erin-dev", message: 'Revert "feat: add temp thing"' }, + // mysterySha intentionally omitted + } + + const jsonl = + Object.entries(ghData) + .map(([sha, v]) => JSON.stringify({ sha, login: v.login, message: v.message })) + .join("\n") + "\n" + + const ghScript = `#!/usr/bin/env bash +cat <<'GHDATA' +${jsonl}GHDATA +` + writeFileSync(path.join(binDir, "gh"), ghScript) + chmodSync(path.join(binDir, "gh"), 0o755) + + process.env.PATH = `${binDir}:${originalPath}` + process.chdir(repoDir) +}) + +afterAll(() => { + process.chdir(originalCwd) + process.env.PATH = originalPath + rmSync(repoDir, { recursive: true, force: true }) + rmSync(binDir, { recursive: true, force: true }) +}) + +describe("commits", () => { + test("includes commits that touch tracked package paths, with correct hash/author/message", async () => { + const list = await commits(baseSha, "HEAD") + const feat = list.find((c) => c.message === "feat: add opencode feature") + expect(feat).toBeDefined() + expect(feat!.hash).toBe(coreFeatSha.slice(0, 7)) + expect(feat!.author).toBe("alice-dev") + expect(feat!.areas).toEqual(new Set(["core"])) + }) + + test("assigns the tui area to files under packages/opencode/src/cli/cmd/", async () => { + const list = await commits(baseSha, "HEAD") + const fix = list.find((c) => c.message === "fix: tui rendering bug") + expect(fix).toBeDefined() + expect(fix!.author).toBe("bob-dev") + expect(fix!.areas).toEqual(new Set(["tui"])) + }) + + test("excludes commits whose message starts with an ignored prefix (chore:)", async () => { + const list = await commits(baseSha, "HEAD") + expect(list.find((c) => c.message === "chore: bump deps")).toBeUndefined() + }) + + test("excludes commits that never touch a tracked package path", async () => { + const list = await commits(baseSha, "HEAD") + expect(list.find((c) => c.message === "docs: update readme")).toBeUndefined() + }) + + test("cancels out a commit and its exact revert", async () => { + const list = await commits(baseSha, "HEAD") + expect(list.find((c) => c.message === "feat: add temp thing")).toBeUndefined() + expect(list.find((c) => c.message.startsWith("Revert "))).toBeUndefined() + }) + + test("skips commits present in git log but missing from the gh API data", async () => { + const list = await commits(baseSha, "HEAD") + // check by hash, not message: if the `!item` guard is dropped, the commit + // still gets added (with an empty message), so a message-based assertion + // would miss the regression entirely + expect(list.find((c) => c.hash === mysterySha.slice(0, 7))).toBeUndefined() + }) + + test("returns nothing for an empty range", async () => { + const list = await commits(baseSha, baseSha) + expect(list).toEqual([]) + }) +}) \ No newline at end of file diff --git a/script/raw-changelog.ts b/script/raw-changelog.ts index 2ea76247..6c837801 100644 --- a/script/raw-changelog.ts +++ b/script/raw-changelog.ts @@ -16,6 +16,8 @@ type Commit = { } type User = Map> + +// This is the shape returned by the GitHub API type Diff = { sha: string login: string | null @@ -57,17 +59,33 @@ async function latest() { return release.tag_name.replace(/^v/, "") } +/** + * Outputs which commits exist on head that do not exist on base + * @param base Base ref (could be commit or branch) + * @param head End ref (could be commit or branch) + * @returns List of Diff objects (representing commits? ) + */ async function diff(base: string, head: string) { const list: Diff[] = [] + // loop forever or until we actually page for (let page = 1; ; page++) { + // get the diff using github api, will be formatted in json const text = await $`gh api "/repos/${repo}/compare/${base}...${head}?per_page=100&page=${page}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text() + + // convert text to a list of Diff type json objects const batch = text .split("\n") .filter(Boolean) .map((line) => JSON.parse(line) as Diff) + + // if this is the last page and it's empty, return the list if (batch.length === 0) break + + // append every item in batch to the list variable list.push(...batch) + + // if there is less than 100 elements, then it is the last page, so break and return list if (batch.length < 100) break } return list @@ -88,12 +106,17 @@ function type(message: string) { function reverted(commits: Commit[]) { const seen = new Map() + // map of message -> commit containing the message for (const commit of commits) { const match = commit.message.match(/^Revert "(.+)"$/) if (match) { const msg = match[1]! + + // if this commit reverts some previous commit, remove that previous commit if (seen.has(msg)) seen.delete(msg) + + // else add that commit to the seen map else seen.set(commit.message, commit) continue } @@ -110,38 +133,66 @@ function reverted(commits: Commit[]) { return [...seen.values()] } -async function commits(from: string, to: string) { + +function find_areas(diff: string){ + const areas = new Set() + for (const file of diff.split("\n").filter(Boolean)) { + if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui") + else if (file.startsWith("packages/opencode/")) areas.add("core") + else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri") + else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app") + else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk") + else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode") + } + + return areas; +} + + +/** + * Most common use of this is to get all the commits starting from a certain + * release (in which case v is appended) to the HEAD, which would be the latest + * commit on the branch. + * + * @param from Base ref + * @param to End ref + * @returns List of commits + */ +export async function commits(from: string, to: string) { + // append v or return HEAD const base = ref(from) const head = ref(to) + // data: {sha: string -> {login: string, message: string}} const data = new Map() + + // for each Diff object in from the base to the head for (const item of await diff(base, head)) { data.set(item.sha, { login: item.login, message: item.message.split("\n")[0] ?? "" }) } const log = await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text() + // log is a string that lists all the commit hashes from base to head + const list: Commit[] = [] + + // for all non-empty and non-chore commit hashes for (const hash of log.split("\n").filter(Boolean)) { const item = data.get(hash) if (!item) continue if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue + // which files were changed by a specific commit const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text() - const areas = new Set() - - for (const file of diff.split("\n").filter(Boolean)) { - if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui") - else if (file.startsWith("packages/opencode/")) areas.add("core") - else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri") - else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app") - else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk") - else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode") - } + const areas: Set = find_areas(diff) + // if the commit did not have any changed files then skip the entire for loop + // although why would it be a commit at all then? if (areas.size === 0) continue + // create a commit-like object; areas - which abstract areas of the codebase were changed list.push({ hash: hash.slice(0, 7), author: item.login, @@ -150,6 +201,7 @@ async function commits(from: string, to: string) { }) } + // return a clean list excluding 1) commits that were reverted 2) commits that revert those return reverted(list) }