diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edc383c..823958d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,10 @@ jobs: - run: bun install --frozen-lockfile + # Lint first — cheapest check. Biome covers formatting + general + # lint; ESLint catches layering violations via eslint-plugin-boundaries. + - run: bun run lint + # Typecheck before tests: `bun test` strips types and won't catch # type errors on its own. Running tsc first short-circuits the job # on type failures instead of wasting the test-run time. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..5dc7484 --- /dev/null +++ b/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": [ + "src/**", + "scripts/**", + "*.{json,ts,js}", + "!src/shared/bitbucket-http/generated.d.ts" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "off", + "noControlCharactersInRegex": "off" + } + } + }, + "javascript": { + "formatter": { "quoteStyle": "double" } + }, + "assist": { + "enabled": true, + "actions": { "source": { "organizeImports": "on" } } + } +} diff --git a/bun.lock b/bun.lock index dbb2539..5ad520b 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,11 @@ "zod": "^4.3.6", }, "devDependencies": { + "@biomejs/biome": "2.4.12", "@types/bun": "latest", + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^10.2.0", + "eslint-plugin-boundaries": "^6.0.2", "msw": "^2.13.3", "openapi-typescript": "^7.13.0", }, @@ -26,8 +30,50 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@biomejs/biome": ["@biomejs/biome@2.4.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.12", "@biomejs/cli-darwin-x64": "2.4.12", "@biomejs/cli-linux-arm64": "2.4.12", "@biomejs/cli-linux-arm64-musl": "2.4.12", "@biomejs/cli-linux-x64": "2.4.12", "@biomejs/cli-linux-x64-musl": "2.4.12", "@biomejs/cli-win32-arm64": "2.4.12", "@biomejs/cli-win32-x64": "2.4.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.12", "", { "os": "win32", "cpu": "x64" }, "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA=="], + + "@boundaries/elements": ["@boundaries/elements@2.0.1", "", { "dependencies": { "eslint-import-resolver-node": "0.3.9", "eslint-module-utils": "2.12.1", "handlebars": "4.7.9", "is-core-module": "2.16.1", "micromatch": "4.0.8" } }, "sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], @@ -54,12 +100,38 @@ "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -68,12 +140,16 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], @@ -92,37 +168,121 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "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=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "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@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@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", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.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", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], + + "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], + + "eslint-plugin-boundaries": ["eslint-plugin-boundaries@6.0.2", "", { "dependencies": { "@boundaries/elements": "2.0.1", "chalk": "4.1.2", "eslint-import-resolver-node": "0.3.9", "eslint-module-utils": "2.12.1", "handlebars": "4.7.9", "micromatch": "4.0.8" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-wSHgiYeMEbziP91lH0UQ9oslgF2djG1x+LV9z/qO19ggMKZaCB8pKIGePHAY91eLF4EAgpsxQk8MRSFGRPfPzw=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "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=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "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-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], "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=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -130,30 +290,62 @@ "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "openapi-fetch": ["openapi-fetch@0.17.0", "", { "dependencies": { "openapi-typescript-helpers": "^0.1.0" } }, "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig=="], "openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="], "openapi-typescript-helpers": ["openapi-typescript-helpers@0.1.0", "", {}, "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], + "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-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "rettime": ["rettime@0.11.7", "", {}, "sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], @@ -164,24 +356,44 @@ "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tldts": ["tldts@7.0.28", "", { "dependencies": { "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw=="], "tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="], + "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=="], + + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -192,12 +404,32 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@redocly/openapi-core/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], + + "@redocly/openapi-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5e6acff --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,47 @@ +// Intentionally narrow: ESLint is here only to enforce the +// commands → backend → shared dependency rule via +// eslint-plugin-boundaries. Formatting and general lint are Biome's job. +// +// See BBC2-34 for the rationale. + +import tsParser from "@typescript-eslint/parser"; +import boundaries from "eslint-plugin-boundaries"; + +export default [ + { + files: ["src/**/*.ts"], + ignores: ["src/shared/bitbucket-http/generated.d.ts", "src/**/*.test.ts"], + languageOptions: { + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module", + }, + plugins: { boundaries }, + settings: { + "boundaries/elements": [ + { type: "commands", pattern: "src/commands/*", mode: "folder" }, + { type: "backend", pattern: "src/backend/*", mode: "folder" }, + { type: "shared", pattern: "src/shared/*", mode: "folder" }, + ], + }, + rules: { + "boundaries/dependencies": [ + "error", + { + default: "disallow", + rules: [ + { from: "commands", allow: ["commands", "backend", "shared"] }, + { + from: "backend", + allow: [ + ["backend", { elementName: "{{from.elementName}}" }], + "shared", + ], + }, + { from: "shared", allow: ["shared"] }, + ], + }, + ], + }, + }, +]; diff --git a/package.json b/package.json index 5725d6d..d4d0ada 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,32 @@ { - "name": "bbcli", - "type": "module", - "private": true, - "bin": { - "bb": "./src/index.ts" - }, - "scripts": { - "generate:api": "bun run scripts/generate-api.ts" - }, - "devDependencies": { - "@types/bun": "latest", - "msw": "^2.13.3", - "openapi-typescript": "^7.13.0" - }, - "peerDependencies": { - "typescript": "^5" - }, - "dependencies": { - "cli-table3": "^0.6.5", - "commander": "^14.0.3", - "openapi-fetch": "^0.17.0", - "picocolors": "^1.1.1", - "zod": "^4.3.6" - } + "name": "bbcli", + "type": "module", + "private": true, + "bin": { + "bb": "./src/index.ts" + }, + "scripts": { + "generate:api": "bun run scripts/generate-api.ts", + "format": "biome format --write .", + "lint": "biome check . && eslint src" + }, + "devDependencies": { + "@biomejs/biome": "2.4.12", + "@types/bun": "latest", + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^10.2.0", + "eslint-plugin-boundaries": "^6.0.2", + "msw": "^2.13.3", + "openapi-typescript": "^7.13.0" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "cli-table3": "^0.6.5", + "commander": "^14.0.3", + "openapi-fetch": "^0.17.0", + "picocolors": "^1.1.1", + "zod": "^4.3.6" + } } diff --git a/scripts/generate-api.ts b/scripts/generate-api.ts index f2b077c..27abe58 100644 --- a/scripts/generate-api.ts +++ b/scripts/generate-api.ts @@ -13,10 +13,18 @@ * Usage: bun run generate:api */ import openapiTS, { astToString, type OpenAPI3 } from "openapi-typescript"; -import { overlay, type OperationOverlay, type OverlayParameter } from "./openapi-overlay.ts"; +import { + type OperationOverlay, + type OverlayParameter, + overlay, +} from "./openapi-overlay.ts"; -const SPEC_URL = "https://dac-static.atlassian.com/cloud/bitbucket/swagger.v3.json"; -const OUTPUT_PATH = new URL("../src/shared/bitbucket-http/generated.d.ts", import.meta.url); +const SPEC_URL = + "https://dac-static.atlassian.com/cloud/bitbucket/swagger.v3.json"; +const OUTPUT_PATH = new URL( + "../src/shared/bitbucket-http/generated.d.ts", + import.meta.url, +); const HEADER = `/** * AUTO-GENERATED — do not edit by hand. @@ -29,7 +37,7 @@ const HEADER = `/** console.log(`Fetching OpenAPI spec from ${SPEC_URL}...`); const response = await fetch(SPEC_URL); if (!response.ok) { - throw new Error(`Failed to fetch spec: HTTP ${response.status}`); + throw new Error(`Failed to fetch spec: HTTP ${response.status}`); } const spec = (await response.json()) as Record; @@ -41,51 +49,56 @@ const ast = await openapiTS(spec as OpenAPI3); const contents = HEADER + astToString(ast); await Bun.write(OUTPUT_PATH, contents); -console.log(`Wrote ${contents.length.toLocaleString()} bytes to ${OUTPUT_PATH.pathname}`); +console.log( + `Wrote ${contents.length.toLocaleString()} bytes to ${OUTPUT_PATH.pathname}`, +); function applyOverlay( - spec: Record, - overlay: Record>, + spec: Record, + overlay: Record>, ): void { - const paths = spec["paths"] as Record | undefined; - if (!paths) throw new Error("Spec has no `paths` object — refusing to apply overlay."); + const paths = spec.paths as Record | undefined; + if (!paths) + throw new Error("Spec has no `paths` object — refusing to apply overlay."); - for (const [path, methods] of Object.entries(overlay)) { - const pathItem = paths[path]; - if (!pathItem) { - // Loud warning: an overlay entry that doesn't match the spec is - // either a typo or a sign Bitbucket renamed something. Either way - // the maintainer needs to know. - console.warn(` ! overlay path not found in spec: ${path}`); - continue; - } - for (const [method, mods] of Object.entries(methods)) { - const op = pathItem[method]; - if (!op) { - console.warn(` ! overlay method not found: ${method.toUpperCase()} ${path}`); - continue; - } - op.parameters ??= []; - const params = op.parameters as OverlayParameter[]; + for (const [path, methods] of Object.entries(overlay)) { + const pathItem = paths[path]; + if (!pathItem) { + // Loud warning: an overlay entry that doesn't match the spec is + // either a typo or a sign Bitbucket renamed something. Either way + // the maintainer needs to know. + console.warn(` ! overlay path not found in spec: ${path}`); + continue; + } + for (const [method, mods] of Object.entries(methods)) { + const op = pathItem[method]; + if (!op) { + console.warn( + ` ! overlay method not found: ${method.toUpperCase()} ${path}`, + ); + continue; + } + op.parameters ??= []; + const params = op.parameters as OverlayParameter[]; - if (mods.replaceParameters) { - for (const replacement of mods.replaceParameters) { - const idx = params.findIndex( - (p) => p.name === replacement.name && p.in === replacement.in, - ); - if (idx >= 0) params[idx] = replacement; - else params.push(replacement); - } - } - if (mods.addParameters) { - for (const addition of mods.addParameters) { - const exists = params.some( - (p) => p.name === addition.name && p.in === addition.in, - ); - if (!exists) params.push(addition); - } - } - console.log(` ✓ overlaid ${method.toUpperCase()} ${path}`); - } - } + if (mods.replaceParameters) { + for (const replacement of mods.replaceParameters) { + const idx = params.findIndex( + (p) => p.name === replacement.name && p.in === replacement.in, + ); + if (idx >= 0) params[idx] = replacement; + else params.push(replacement); + } + } + if (mods.addParameters) { + for (const addition of mods.addParameters) { + const exists = params.some( + (p) => p.name === addition.name && p.in === addition.in, + ); + if (!exists) params.push(addition); + } + } + console.log(` ✓ overlaid ${method.toUpperCase()} ${path}`); + } + } } diff --git a/scripts/openapi-overlay.ts b/scripts/openapi-overlay.ts index 271ceed..0be3e75 100644 --- a/scripts/openapi-overlay.ts +++ b/scripts/openapi-overlay.ts @@ -12,92 +12,92 @@ */ type ParameterSchema = { - type?: string; - enum?: readonly string[]; - items?: ParameterSchema; - minimum?: number; - maximum?: number; + type?: string; + enum?: readonly string[]; + items?: ParameterSchema; + minimum?: number; + maximum?: number; }; export type OverlayParameter = { - name: string; - in: "query"; - description?: string; - required?: boolean; - explode?: boolean; - schema: ParameterSchema; + name: string; + in: "query"; + description?: string; + required?: boolean; + explode?: boolean; + schema: ParameterSchema; }; export type OperationOverlay = { - /** Append params (skipped if already present by name). */ - addParameters?: OverlayParameter[]; - /** Replace existing params by name (or add if missing). */ - replaceParameters?: OverlayParameter[]; + /** Append params (skipped if already present by name). */ + addParameters?: OverlayParameter[]; + /** Replace existing params by name (or add if missing). */ + replaceParameters?: OverlayParameter[]; }; export type Overlay = { - [path: string]: { - [method: string]: OperationOverlay; - }; + [path: string]: { + [method: string]: OperationOverlay; + }; }; const PAGINATION: OverlayParameter[] = [ - { - name: "page", - in: "query", - description: "Page number (1-based) for paginated results.", - schema: { type: "integer", minimum: 1 }, - }, - { - name: "pagelen", - in: "query", - description: - "Number of items per page. Default 10; maximum varies per endpoint (typically 50 or 100).", - schema: { type: "integer", minimum: 1, maximum: 100 }, - }, + { + name: "page", + in: "query", + description: "Page number (1-based) for paginated results.", + schema: { type: "integer", minimum: 1 }, + }, + { + name: "pagelen", + in: "query", + description: + "Number of items per page. Default 10; maximum varies per endpoint (typically 50 or 100).", + schema: { type: "integer", minimum: 1, maximum: 100 }, + }, ]; const FILTER_AND_SORT: OverlayParameter[] = [ - { - name: "q", - in: "query", - description: - "BBQL filter expression. See https://developer.atlassian.com/cloud/bitbucket/rest/intro/#filtering", - schema: { type: "string" }, - }, - { - name: "sort", - in: "query", - description: - "Field to sort by. Prefix with '-' for descending. See https://developer.atlassian.com/cloud/bitbucket/rest/intro/#sorting", - schema: { type: "string" }, - }, + { + name: "q", + in: "query", + description: + "BBQL filter expression. See https://developer.atlassian.com/cloud/bitbucket/rest/intro/#filtering", + schema: { type: "string" }, + }, + { + name: "sort", + in: "query", + description: + "Field to sort by. Prefix with '-' for descending. See https://developer.atlassian.com/cloud/bitbucket/rest/intro/#sorting", + schema: { type: "string" }, + }, ]; const PR_STATES = ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"] as const; export const overlay: Overlay = { - "/user/workspaces": { - get: { - addParameters: [...PAGINATION, ...FILTER_AND_SORT], - }, - }, - "/repositories/{workspace}/{repo_slug}/pullrequests": { - get: { - addParameters: [...PAGINATION, ...FILTER_AND_SORT], - replaceParameters: [ - { - name: "state", - in: "query", - description: - "Only return pull requests in these states. Repeat the param to combine.", - explode: true, - schema: { - type: "array", - items: { type: "string", enum: PR_STATES }, - }, - }, - ], - }, - }, + "/user/workspaces": { + get: { + addParameters: [...PAGINATION, ...FILTER_AND_SORT], + }, + }, + "/repositories/{workspace}/{repo_slug}/pullrequests": { + get: { + addParameters: [...PAGINATION, ...FILTER_AND_SORT], + replaceParameters: [ + { + name: "state", + in: "query", + description: + "Only return pull requests in these states. Repeat the param to combine.", + explode: true, + schema: { + type: "array", + items: { type: "string", enum: PR_STATES }, + }, + }, + ], + }, + }, }; diff --git a/src/backend/auth/index.test.ts b/src/backend/auth/index.test.ts index b895d46..e7d91fd 100644 --- a/src/backend/auth/index.test.ts +++ b/src/backend/auth/index.test.ts @@ -1,8 +1,8 @@ -import { test, expect, describe } from "bun:test"; -import { http, HttpResponse } from "msw"; -import { verifyCredentials, BitbucketAuthError } from "./index.ts"; +import { describe, expect, test } from "bun:test"; +import { HttpResponse, http } from "msw"; import type { components } from "../../shared/bitbucket-http/generated"; import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; +import { BitbucketAuthError, verifyCredentials } from "./index.ts"; type Account = components["schemas"]["account"]; type BitbucketError = components["schemas"]["error"]; @@ -12,77 +12,75 @@ setupMsw(); const creds = { email: "a@b.co", token: "t" }; describe("verifyCredentials", () => { - test("returns the account on HTTP 200", async () => { - const account: Account = { - type: "account", - display_name: "Alice Example", - uuid: "{abc-123}", - }; - server.use( - http.get(`${BITBUCKET_BASE}/user`, () => HttpResponse.json(account)), - ); + test("returns the account on HTTP 200", async () => { + const account: Account = { + type: "account", + display_name: "Alice Example", + uuid: "{abc-123}", + }; + server.use( + http.get(`${BITBUCKET_BASE}/user`, () => HttpResponse.json(account)), + ); - const user = await verifyCredentials(creds); - expect(user).toMatchObject(account); - }); + const user = await verifyCredentials(creds); + expect(user).toMatchObject(account); + }); - test("throws BitbucketAuthError with status 401 on HTTP 401", async () => { - const body: BitbucketError = { - type: "error", - error: { message: "Bad credentials" }, - }; - server.use( - http.get(`${BITBUCKET_BASE}/user`, () => - HttpResponse.json(body, { status: 401 }), - ), - ); + test("throws BitbucketAuthError with status 401 on HTTP 401", async () => { + const body: BitbucketError = { + type: "error", + error: { message: "Bad credentials" }, + }; + server.use( + http.get(`${BITBUCKET_BASE}/user`, () => + HttpResponse.json(body, { status: 401 }), + ), + ); - const err = await verifyCredentials(creds).catch((e) => e); - expect(err).toBeInstanceOf(BitbucketAuthError); - expect((err as BitbucketAuthError).status).toBe(401); - expect((err as BitbucketAuthError).message).toContain("rejected"); - }); + const err = await verifyCredentials(creds).catch((e) => e); + expect(err).toBeInstanceOf(BitbucketAuthError); + expect((err as BitbucketAuthError).status).toBe(401); + expect((err as BitbucketAuthError).message).toContain("rejected"); + }); - test("throws BitbucketAuthError with status 403 on HTTP 403", async () => { - const body: BitbucketError = { - type: "error", - error: { message: "Insufficient scopes" }, - }; - server.use( - http.get(`${BITBUCKET_BASE}/user`, () => - HttpResponse.json(body, { status: 403 }), - ), - ); + test("throws BitbucketAuthError with status 403 on HTTP 403", async () => { + const body: BitbucketError = { + type: "error", + error: { message: "Insufficient scopes" }, + }; + server.use( + http.get(`${BITBUCKET_BASE}/user`, () => + HttpResponse.json(body, { status: 403 }), + ), + ); - const err = await verifyCredentials(creds).catch((e) => e); - expect(err).toBeInstanceOf(BitbucketAuthError); - expect((err as BitbucketAuthError).status).toBe(403); - expect((err as BitbucketAuthError).message).toContain("account:read"); - }); + const err = await verifyCredentials(creds).catch((e) => e); + expect(err).toBeInstanceOf(BitbucketAuthError); + expect((err as BitbucketAuthError).status).toBe(403); + expect((err as BitbucketAuthError).message).toContain("account:read"); + }); - test("throws BitbucketAuthError on unexpected HTTP 500", async () => { - const body: BitbucketError = { - type: "error", - error: { message: "Internal server error" }, - }; - server.use( - http.get(`${BITBUCKET_BASE}/user`, () => - HttpResponse.json(body, { status: 500 }), - ), - ); + test("throws BitbucketAuthError on unexpected HTTP 500", async () => { + const body: BitbucketError = { + type: "error", + error: { message: "Internal server error" }, + }; + server.use( + http.get(`${BITBUCKET_BASE}/user`, () => + HttpResponse.json(body, { status: 500 }), + ), + ); - const err = await verifyCredentials(creds).catch((e) => e); - expect(err).toBeInstanceOf(BitbucketAuthError); - expect((err as BitbucketAuthError).status).toBe(500); - expect((err as BitbucketAuthError).message).toContain("500"); - }); + const err = await verifyCredentials(creds).catch((e) => e); + expect(err).toBeInstanceOf(BitbucketAuthError); + expect((err as BitbucketAuthError).status).toBe(500); + expect((err as BitbucketAuthError).message).toContain("500"); + }); - test("propagates network errors", async () => { - server.use( - http.get(`${BITBUCKET_BASE}/user`, () => HttpResponse.error()), - ); + test("propagates network errors", async () => { + server.use(http.get(`${BITBUCKET_BASE}/user`, () => HttpResponse.error())); - const err = await verifyCredentials(creds).catch((e) => e); - expect(err).toBeInstanceOf(Error); - }); + const err = await verifyCredentials(creds).catch((e) => e); + expect(err).toBeInstanceOf(Error); + }); }); diff --git a/src/backend/auth/index.ts b/src/backend/auth/index.ts index f9318e9..4b2e8d8 100644 --- a/src/backend/auth/index.ts +++ b/src/backend/auth/index.ts @@ -1,19 +1,19 @@ +import type { components } from "../../shared/bitbucket-http/generated"; import { - createBitbucketClient, - type Credentials, + type Credentials, + createBitbucketClient, } from "../../shared/bitbucket-http/index.ts"; -import type { components } from "../../shared/bitbucket-http/generated"; export type BitbucketAccount = components["schemas"]["account"]; export class BitbucketAuthError extends Error { - readonly status: number | undefined; + readonly status: number | undefined; - constructor(message: string, status?: number) { - super(message); - this.name = "BitbucketAuthError"; - this.status = status; - } + constructor(message: string, status?: number) { + super(message); + this.name = "BitbucketAuthError"; + this.status = status; + } } /** @@ -22,28 +22,28 @@ export class BitbucketAuthError extends Error { * non-2xx response with a message suitable for direct user display. */ export async function verifyCredentials( - credentials: Credentials, + credentials: Credentials, ): Promise { - const client = createBitbucketClient(credentials); - const { data, response } = await client.GET("/user"); + const client = createBitbucketClient(credentials); + const { data, response } = await client.GET("/user"); - if (response.status === 401) { - throw new BitbucketAuthError( - "Bitbucket rejected the credentials (HTTP 401). Check the email and API token.", - 401, - ); - } - if (response.status === 403) { - throw new BitbucketAuthError( - "Bitbucket returned HTTP 403. The API token may be missing the `account:read` scope.", - 403, - ); - } - if (!response.ok || !data) { - throw new BitbucketAuthError( - `Unexpected response from Bitbucket: HTTP ${response.status}.`, - response.status, - ); - } - return data; + if (response.status === 401) { + throw new BitbucketAuthError( + "Bitbucket rejected the credentials (HTTP 401). Check the email and API token.", + 401, + ); + } + if (response.status === 403) { + throw new BitbucketAuthError( + "Bitbucket returned HTTP 403. The API token may be missing the `account:read` scope.", + 403, + ); + } + if (!response.ok || !data) { + throw new BitbucketAuthError( + `Unexpected response from Bitbucket: HTTP ${response.status}.`, + response.status, + ); + } + return data; } diff --git a/src/backend/pullrequests/index.test.ts b/src/backend/pullrequests/index.test.ts index e9f3984..14de0f1 100644 --- a/src/backend/pullrequests/index.test.ts +++ b/src/backend/pullrequests/index.test.ts @@ -1,15 +1,15 @@ -import { test, expect, describe } from "bun:test"; -import { http, HttpResponse } from "msw"; +import { describe, expect, test } from "bun:test"; +import { HttpResponse, http } from "msw"; +import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; import { - createPullRequest, - findOpenPullRequestForBranch, - getPullRequest, - listPullRequests, - PullRequestError, - type PullRequest, - type PullRequestDetail, + createPullRequest, + findOpenPullRequestForBranch, + getPullRequest, + listPullRequests, + type PullRequest, + type PullRequestDetail, + PullRequestError, } from "./index.ts"; -import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; setupMsw(); @@ -18,58 +18,64 @@ const ref = { workspace: "ws", slug: "repo" }; const PR_LIST_PATH = `${BITBUCKET_BASE}/repositories/ws/repo/pullrequests`; const PR_DETAIL_PATH = (id: number) => - `${BITBUCKET_BASE}/repositories/ws/repo/pullrequests/${id}`; - -function makePr(overrides: Record = {}): Record { - return { - id: 1, - title: "A PR", - state: "OPEN", - author: { uuid: "{alice-uuid}", display_name: "Alice", nickname: "alice" }, - created_on: "2026-04-10T00:00:00Z", - updated_on: "2026-04-13T00:00:00Z", - links: { html: { href: "https://bitbucket.org/ws/repo/pull-requests/1" } }, - ...overrides, - }; + `${BITBUCKET_BASE}/repositories/ws/repo/pullrequests/${id}`; + +function makePr( + overrides: Record = {}, +): Record { + return { + id: 1, + title: "A PR", + state: "OPEN", + author: { uuid: "{alice-uuid}", display_name: "Alice", nickname: "alice" }, + created_on: "2026-04-10T00:00:00Z", + updated_on: "2026-04-13T00:00:00Z", + links: { html: { href: "https://bitbucket.org/ws/repo/pull-requests/1" } }, + ...overrides, + }; } -function makePrDetail(overrides: Record = {}): Record { - return { - id: 42, - title: "Rework auth", - state: "OPEN", - author: { uuid: "{alice}", display_name: "Alice", nickname: "alice" }, - created_on: "2026-04-10T00:00:00Z", - updated_on: "2026-04-13T00:00:00Z", - summary: { raw: "A detailed PR description.\n\n- fix thing\n- fix other thing" }, - source: { branch: { name: "feature/auth" } }, - destination: { branch: { name: "main" } }, - links: { html: { href: "https://bitbucket.org/ws/repo/pull-requests/42" } }, - participants: [ - { - role: "REVIEWER", - user: { uuid: "{bob}", display_name: "Bob", nickname: "bob" }, - state: "approved", - }, - { - role: "REVIEWER", - user: { uuid: "{carol}", display_name: "Carol", nickname: "carol" }, - state: "changes_requested", - }, - { - role: "REVIEWER", - user: { uuid: "{dave}", display_name: "Dave", nickname: "dave" }, - state: null, - }, - // non-reviewer participants (plain commenters) should be dropped - { - role: "PARTICIPANT", - user: { uuid: "{eve}", display_name: "Eve", nickname: "eve" }, - state: null, - }, - ], - ...overrides, - }; +function makePrDetail( + overrides: Record = {}, +): Record { + return { + id: 42, + title: "Rework auth", + state: "OPEN", + author: { uuid: "{alice}", display_name: "Alice", nickname: "alice" }, + created_on: "2026-04-10T00:00:00Z", + updated_on: "2026-04-13T00:00:00Z", + summary: { + raw: "A detailed PR description.\n\n- fix thing\n- fix other thing", + }, + source: { branch: { name: "feature/auth" } }, + destination: { branch: { name: "main" } }, + links: { html: { href: "https://bitbucket.org/ws/repo/pull-requests/42" } }, + participants: [ + { + role: "REVIEWER", + user: { uuid: "{bob}", display_name: "Bob", nickname: "bob" }, + state: "approved", + }, + { + role: "REVIEWER", + user: { uuid: "{carol}", display_name: "Carol", nickname: "carol" }, + state: "changes_requested", + }, + { + role: "REVIEWER", + user: { uuid: "{dave}", display_name: "Dave", nickname: "dave" }, + state: null, + }, + // non-reviewer participants (plain commenters) should be dropped + { + role: "PARTICIPANT", + user: { uuid: "{eve}", display_name: "Eve", nickname: "eve" }, + state: null, + }, + ], + ...overrides, + }; } /** @@ -78,439 +84,426 @@ function makePrDetail(overrides: Record = {}): Record Response | Promise, + responder: (req: Request) => Response | Promise, ): URLSearchParams[] { - const calls: URLSearchParams[] = []; - server.use( - http.get(PR_LIST_PATH, async ({ request }) => { - calls.push(new URL(request.url).searchParams); - return responder(request); - }), - ); - return calls; + const calls: URLSearchParams[] = []; + server.use( + http.get(PR_LIST_PATH, async ({ request }) => { + calls.push(new URL(request.url).searchParams); + return responder(request); + }), + ); + return calls; } describe("listPullRequests", () => { - test("default query: state=OPEN, sort=-updated_on", async () => { - const calls = captureListRequests(() => - HttpResponse.json({ values: [makePr({ id: 42, title: "fix bug" })] }), - ); - - const result = await listPullRequests(creds, ref, { - state: "open", - limit: 30, - }); - - expect(calls).toHaveLength(1); - const params = calls[0]!; - expect(params.getAll("state")).toEqual(["OPEN"]); - expect(params.get("sort")).toBe("-updated_on"); - expect(params.has("q")).toBe(false); - expect(result).toHaveLength(1); - expect(result[0]!.id).toBe(42); - expect(result[0]!.title).toBe("fix bug"); - }); - - test("state=all expands to repeated state params", async () => { - const calls = captureListRequests(() => - HttpResponse.json({ values: [] }), - ); - - await listPullRequests(creds, ref, { state: "all", limit: 30 }); - - expect(calls[0]!.getAll("state")).toEqual([ - "OPEN", - "MERGED", - "DECLINED", - "SUPERSEDED", - ]); - }); - - test("author @me builds BBQL with uuid", async () => { - const calls = captureListRequests(() => - HttpResponse.json({ values: [] }), - ); - - await listPullRequests(creds, ref, { - state: "open", - limit: 30, - author: { kind: "me" }, - currentUserUuid: "{uuid-1}", - }); - - expect(calls[0]!.get("q")).toBe( - 'state="OPEN" AND author.uuid="{uuid-1}"', - ); - }); - - test("author nickname builds BBQL with nickname", async () => { - const calls = captureListRequests(() => - HttpResponse.json({ values: [] }), - ); - - await listPullRequests(creds, ref, { - state: "open", - limit: 30, - author: { kind: "nickname", value: "jsmith" }, - }); - - expect(calls[0]!.get("q")).toBe( - 'state="OPEN" AND author.nickname="jsmith"', - ); - }); - - test("author and reviewer are combined with AND", async () => { - const calls = captureListRequests(() => - HttpResponse.json({ values: [] }), - ); - - await listPullRequests(creds, ref, { - state: "open", - limit: 30, - author: { kind: "nickname", value: "alice" }, - reviewer: { kind: "nickname", value: "bob" }, - }); - - expect(calls[0]!.get("q")).toBe( - 'state="OPEN" AND author.nickname="alice" AND reviewers.nickname="bob"', - ); - }); - - test("state filter is folded into q when a user filter is present", async () => { - // Bitbucket ignores the `state=` query param when `q` is set, so the - // state constraint has to live inside the BBQL expression. - const calls = captureListRequests(() => - HttpResponse.json({ values: [] }), - ); - - await listPullRequests(creds, ref, { - state: "open", - limit: 30, - reviewer: { kind: "me" }, - currentUserUuid: "{me-uuid}", - }); - - expect(calls[0]!.getAll("state")).toEqual([]); - expect(calls[0]!.get("q")).toBe( - 'state="OPEN" AND reviewers.uuid="{me-uuid}"', - ); - }); - - test("multi-state folds into q as OR group when combined with a filter", async () => { - const calls = captureListRequests(() => - HttpResponse.json({ values: [] }), - ); - - await listPullRequests(creds, ref, { - state: "all", - limit: 30, - author: { kind: "nickname", value: "alice" }, - }); - - expect(calls[0]!.getAll("state")).toEqual([]); - expect(calls[0]!.get("q")).toBe( - '(state="OPEN" OR state="MERGED" OR state="DECLINED" OR state="SUPERSEDED") AND author.nickname="alice"', - ); - }); - - test("follows next cursor until limit reached", async () => { - const calls = captureListRequests(({ url }) => { - const page = new URL(url).searchParams.get("page") ?? "1"; - const values = page === "1" - ? [makePr({ id: 1 }), makePr({ id: 2 })] - : [makePr({ id: 3 }), makePr({ id: 4 })]; - const next = page === "1" - ? `${PR_LIST_PATH}?page=2&state=OPEN&sort=-updated_on&pagelen=50` - : undefined; - return HttpResponse.json({ values, next }); - }); - - const result = await listPullRequests(creds, ref, { - state: "open", - limit: 3, - }); - - expect(result.map((r) => r.id)).toEqual([1, 2, 3]); - expect(calls).toHaveLength(2); - expect(calls[1]!.get("page")).toBe("2"); - }); - - test("stops paging when Bitbucket omits next even if under limit", async () => { - const calls = captureListRequests(({ url }) => { - const page = new URL(url).searchParams.get("page") ?? "1"; - if (page === "1") { - return HttpResponse.json({ - values: [makePr({ id: 1 }), makePr({ id: 2 })], - next: `${PR_LIST_PATH}?page=2`, - }); - } - return HttpResponse.json({ values: [makePr({ id: 3 })] }); - }); - - const result = await listPullRequests(creds, ref, { - state: "open", - limit: 100, - }); - - expect(result.map((r) => r.id)).toEqual([1, 2, 3]); - expect(calls).toHaveLength(2); - }); - - test("next cursor preserves repeated state params and sort", async () => { - const calls = captureListRequests(({ url }) => { - const page = new URL(url).searchParams.get("page"); - if (!page) { - return HttpResponse.json({ - values: [makePr({ id: 1 })], - next: `${PR_LIST_PATH}?page=2&state=OPEN&state=MERGED&sort=-updated_on`, - }); - } - return HttpResponse.json({ values: [] }); - }); - - await listPullRequests(creds, ref, { state: "all", limit: 100 }); - - expect(calls[1]!.getAll("state")).toEqual(["OPEN", "MERGED"]); - expect(calls[1]!.get("sort")).toBe("-updated_on"); - expect(calls[1]!.get("page")).toBe("2"); - }); - - test("maps API fields to summary shape", async () => { - server.use( - http.get(PR_LIST_PATH, () => - HttpResponse.json({ - values: [ - makePr({ - id: 7, - title: "Refactor auth", - state: "MERGED", - author: { - uuid: "{alice-uuid}", - display_name: "Alice A.", - nickname: "alice", - }, - created_on: "2026-04-01T10:00:00Z", - updated_on: "2026-04-12T10:00:00Z", - links: { html: { href: "https://bitbucket.org/ws/repo/pull-requests/7" } }, - }), - ], - }), - ), - ); - - const result = await listPullRequests(creds, ref, { - state: "all", - limit: 10, - }); - - expect(result[0]).toEqual({ - id: 7, - title: "Refactor auth", - state: "MERGED", - author: { - uuid: "{alice-uuid}", - displayName: "Alice A.", - nickname: "alice", - }, - createdOn: "2026-04-01T10:00:00Z", - updatedOn: "2026-04-12T10:00:00Z", - url: "https://bitbucket.org/ws/repo/pull-requests/7", - }); - }); - - test("author is null when raw has no uuid (deleted user)", async () => { - server.use( - http.get(PR_LIST_PATH, () => - HttpResponse.json({ values: [makePr({ id: 9, author: null })] }), - ), - ); - - const result = await listPullRequests(creds, ref, { - state: "all", - limit: 10, - }); - - expect(result[0]!.author).toBeNull(); - }); - - test("throws PullRequestError on non-ok response", async () => { - server.use( - http.get(PR_LIST_PATH, () => - HttpResponse.json({ type: "error" }, { status: 404 }), - ), - ); - - const err = await listPullRequests(creds, ref, { - state: "open", - limit: 30, - }).catch((e) => e); - - expect(err).toBeInstanceOf(PullRequestError); - expect((err as PullRequestError).status).toBe(404); - }); + test("default query: state=OPEN, sort=-updated_on", async () => { + const calls = captureListRequests(() => + HttpResponse.json({ values: [makePr({ id: 42, title: "fix bug" })] }), + ); + + const result = await listPullRequests(creds, ref, { + state: "open", + limit: 30, + }); + + expect(calls).toHaveLength(1); + const params = calls[0]!; + expect(params.getAll("state")).toEqual(["OPEN"]); + expect(params.get("sort")).toBe("-updated_on"); + expect(params.has("q")).toBe(false); + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe(42); + expect(result[0]?.title).toBe("fix bug"); + }); + + test("state=all expands to repeated state params", async () => { + const calls = captureListRequests(() => HttpResponse.json({ values: [] })); + + await listPullRequests(creds, ref, { state: "all", limit: 30 }); + + expect(calls[0]?.getAll("state")).toEqual([ + "OPEN", + "MERGED", + "DECLINED", + "SUPERSEDED", + ]); + }); + + test("author @me builds BBQL with uuid", async () => { + const calls = captureListRequests(() => HttpResponse.json({ values: [] })); + + await listPullRequests(creds, ref, { + state: "open", + limit: 30, + author: { kind: "me" }, + currentUserUuid: "{uuid-1}", + }); + + expect(calls[0]?.get("q")).toBe('state="OPEN" AND author.uuid="{uuid-1}"'); + }); + + test("author nickname builds BBQL with nickname", async () => { + const calls = captureListRequests(() => HttpResponse.json({ values: [] })); + + await listPullRequests(creds, ref, { + state: "open", + limit: 30, + author: { kind: "nickname", value: "jsmith" }, + }); + + expect(calls[0]?.get("q")).toBe( + 'state="OPEN" AND author.nickname="jsmith"', + ); + }); + + test("author and reviewer are combined with AND", async () => { + const calls = captureListRequests(() => HttpResponse.json({ values: [] })); + + await listPullRequests(creds, ref, { + state: "open", + limit: 30, + author: { kind: "nickname", value: "alice" }, + reviewer: { kind: "nickname", value: "bob" }, + }); + + expect(calls[0]?.get("q")).toBe( + 'state="OPEN" AND author.nickname="alice" AND reviewers.nickname="bob"', + ); + }); + + test("state filter is folded into q when a user filter is present", async () => { + // Bitbucket ignores the `state=` query param when `q` is set, so the + // state constraint has to live inside the BBQL expression. + const calls = captureListRequests(() => HttpResponse.json({ values: [] })); + + await listPullRequests(creds, ref, { + state: "open", + limit: 30, + reviewer: { kind: "me" }, + currentUserUuid: "{me-uuid}", + }); + + expect(calls[0]?.getAll("state")).toEqual([]); + expect(calls[0]?.get("q")).toBe( + 'state="OPEN" AND reviewers.uuid="{me-uuid}"', + ); + }); + + test("multi-state folds into q as OR group when combined with a filter", async () => { + const calls = captureListRequests(() => HttpResponse.json({ values: [] })); + + await listPullRequests(creds, ref, { + state: "all", + limit: 30, + author: { kind: "nickname", value: "alice" }, + }); + + expect(calls[0]?.getAll("state")).toEqual([]); + expect(calls[0]?.get("q")).toBe( + '(state="OPEN" OR state="MERGED" OR state="DECLINED" OR state="SUPERSEDED") AND author.nickname="alice"', + ); + }); + + test("follows next cursor until limit reached", async () => { + const calls = captureListRequests(({ url }) => { + const page = new URL(url).searchParams.get("page") ?? "1"; + const values = + page === "1" + ? [makePr({ id: 1 }), makePr({ id: 2 })] + : [makePr({ id: 3 }), makePr({ id: 4 })]; + const next = + page === "1" + ? `${PR_LIST_PATH}?page=2&state=OPEN&sort=-updated_on&pagelen=50` + : undefined; + return HttpResponse.json({ values, next }); + }); + + const result = await listPullRequests(creds, ref, { + state: "open", + limit: 3, + }); + + expect(result.map((r) => r.id)).toEqual([1, 2, 3]); + expect(calls).toHaveLength(2); + expect(calls[1]?.get("page")).toBe("2"); + }); + + test("stops paging when Bitbucket omits next even if under limit", async () => { + const calls = captureListRequests(({ url }) => { + const page = new URL(url).searchParams.get("page") ?? "1"; + if (page === "1") { + return HttpResponse.json({ + values: [makePr({ id: 1 }), makePr({ id: 2 })], + next: `${PR_LIST_PATH}?page=2`, + }); + } + return HttpResponse.json({ values: [makePr({ id: 3 })] }); + }); + + const result = await listPullRequests(creds, ref, { + state: "open", + limit: 100, + }); + + expect(result.map((r) => r.id)).toEqual([1, 2, 3]); + expect(calls).toHaveLength(2); + }); + + test("next cursor preserves repeated state params and sort", async () => { + const calls = captureListRequests(({ url }) => { + const page = new URL(url).searchParams.get("page"); + if (!page) { + return HttpResponse.json({ + values: [makePr({ id: 1 })], + next: `${PR_LIST_PATH}?page=2&state=OPEN&state=MERGED&sort=-updated_on`, + }); + } + return HttpResponse.json({ values: [] }); + }); + + await listPullRequests(creds, ref, { state: "all", limit: 100 }); + + expect(calls[1]?.getAll("state")).toEqual(["OPEN", "MERGED"]); + expect(calls[1]?.get("sort")).toBe("-updated_on"); + expect(calls[1]?.get("page")).toBe("2"); + }); + + test("maps API fields to summary shape", async () => { + server.use( + http.get(PR_LIST_PATH, () => + HttpResponse.json({ + values: [ + makePr({ + id: 7, + title: "Refactor auth", + state: "MERGED", + author: { + uuid: "{alice-uuid}", + display_name: "Alice A.", + nickname: "alice", + }, + created_on: "2026-04-01T10:00:00Z", + updated_on: "2026-04-12T10:00:00Z", + links: { + html: { href: "https://bitbucket.org/ws/repo/pull-requests/7" }, + }, + }), + ], + }), + ), + ); + + const result = await listPullRequests(creds, ref, { + state: "all", + limit: 10, + }); + + expect(result[0]).toEqual({ + id: 7, + title: "Refactor auth", + state: "MERGED", + author: { + uuid: "{alice-uuid}", + displayName: "Alice A.", + nickname: "alice", + }, + createdOn: "2026-04-01T10:00:00Z", + updatedOn: "2026-04-12T10:00:00Z", + url: "https://bitbucket.org/ws/repo/pull-requests/7", + }); + }); + + test("author is null when raw has no uuid (deleted user)", async () => { + server.use( + http.get(PR_LIST_PATH, () => + HttpResponse.json({ values: [makePr({ id: 9, author: null })] }), + ), + ); + + const result = await listPullRequests(creds, ref, { + state: "all", + limit: 10, + }); + + expect(result[0]?.author).toBeNull(); + }); + + test("throws PullRequestError on non-ok response", async () => { + server.use( + http.get(PR_LIST_PATH, () => + HttpResponse.json({ type: "error" }, { status: 404 }), + ), + ); + + const err = await listPullRequests(creds, ref, { + state: "open", + limit: 30, + }).catch((e) => e); + + expect(err).toBeInstanceOf(PullRequestError); + expect((err as PullRequestError).status).toBe(404); + }); }); describe("getPullRequest", () => { - test("fetches and maps a single PR to PullRequestDetail", async () => { - let seenPath = null as string | null; - server.use( - http.get(PR_DETAIL_PATH(42), ({ request }) => { - seenPath = new URL(request.url).pathname; - return HttpResponse.json(makePrDetail()); - }), - ); - - const result = await getPullRequest(creds, ref, 42); - - expect(seenPath!).toBe("/2.0/repositories/ws/repo/pullrequests/42"); - - expect(result).toEqual({ - id: 42, - title: "Rework auth", - state: "OPEN", - author: { uuid: "{alice}", displayName: "Alice", nickname: "alice" }, - createdOn: "2026-04-10T00:00:00Z", - updatedOn: "2026-04-13T00:00:00Z", - url: "https://bitbucket.org/ws/repo/pull-requests/42", - description: "A detailed PR description.\n\n- fix thing\n- fix other thing", - sourceBranch: "feature/auth", - destinationBranch: "main", - reviewers: [ - { - account: { uuid: "{bob}", displayName: "Bob", nickname: "bob" }, - state: "approved", - }, - { - account: { uuid: "{carol}", displayName: "Carol", nickname: "carol" }, - state: "changes_requested", - }, - { - account: { uuid: "{dave}", displayName: "Dave", nickname: "dave" }, - state: "pending", - }, - ], - }); - }); - - test("throws PullRequestError on 404", async () => { - server.use( - http.get(PR_DETAIL_PATH(99), () => - HttpResponse.json({ type: "error" }, { status: 404 }), - ), - ); - - const err = await getPullRequest(creds, ref, 99).catch((e) => e); - expect(err).toBeInstanceOf(PullRequestError); - expect((err as PullRequestError).status).toBe(404); - }); - - test("handles a PR with no participants", async () => { - server.use( - http.get(PR_DETAIL_PATH(42), () => - HttpResponse.json(makePrDetail({ participants: undefined })), - ), - ); - - const result = await getPullRequest(creds, ref, 42); - expect(result.reviewers).toEqual([]); - }); + test("fetches and maps a single PR to PullRequestDetail", async () => { + let seenPath = null as string | null; + server.use( + http.get(PR_DETAIL_PATH(42), ({ request }) => { + seenPath = new URL(request.url).pathname; + return HttpResponse.json(makePrDetail()); + }), + ); + + const result = await getPullRequest(creds, ref, 42); + + expect(seenPath!).toBe("/2.0/repositories/ws/repo/pullrequests/42"); + + expect(result).toEqual({ + id: 42, + title: "Rework auth", + state: "OPEN", + author: { uuid: "{alice}", displayName: "Alice", nickname: "alice" }, + createdOn: "2026-04-10T00:00:00Z", + updatedOn: "2026-04-13T00:00:00Z", + url: "https://bitbucket.org/ws/repo/pull-requests/42", + description: + "A detailed PR description.\n\n- fix thing\n- fix other thing", + sourceBranch: "feature/auth", + destinationBranch: "main", + reviewers: [ + { + account: { uuid: "{bob}", displayName: "Bob", nickname: "bob" }, + state: "approved", + }, + { + account: { uuid: "{carol}", displayName: "Carol", nickname: "carol" }, + state: "changes_requested", + }, + { + account: { uuid: "{dave}", displayName: "Dave", nickname: "dave" }, + state: "pending", + }, + ], + }); + }); + + test("throws PullRequestError on 404", async () => { + server.use( + http.get(PR_DETAIL_PATH(99), () => + HttpResponse.json({ type: "error" }, { status: 404 }), + ), + ); + + const err = await getPullRequest(creds, ref, 99).catch((e) => e); + expect(err).toBeInstanceOf(PullRequestError); + expect((err as PullRequestError).status).toBe(404); + }); + + test("handles a PR with no participants", async () => { + server.use( + http.get(PR_DETAIL_PATH(42), () => + HttpResponse.json(makePrDetail({ participants: undefined })), + ), + ); + + const result = await getPullRequest(creds, ref, 42); + expect(result.reviewers).toEqual([]); + }); }); describe("findOpenPullRequestForBranch", () => { - test("queries with BBQL filter on source branch and open state", async () => { - const calls = captureListRequests(() => - HttpResponse.json({ values: [makePrDetail({ id: 7 })] }), - ); - - const result = await findOpenPullRequestForBranch( - creds, - ref, - "feature/auth", - ); - - expect(calls).toHaveLength(1); - expect(calls[0]!.get("q")).toBe( - 'state="OPEN" AND source.branch.name="feature/auth"', - ); - expect(calls[0]!.get("pagelen")).toBe("1"); - expect(result?.id).toBe(7); - }); - - test("returns null when no open PR matches", async () => { - server.use( - http.get(PR_LIST_PATH, () => HttpResponse.json({ values: [] })), - ); - - const result = await findOpenPullRequestForBranch( - creds, - ref, - "feature/auth", - ); - expect(result).toBeNull(); - }); - - test("escapes quotes in branch names", async () => { - const calls = captureListRequests(() => - HttpResponse.json({ values: [] }), - ); - - await findOpenPullRequestForBranch(creds, ref, 'weird"branch'); - - expect(calls[0]!.get("q")).toBe( - 'state="OPEN" AND source.branch.name="weird\\"branch"', - ); - }); + test("queries with BBQL filter on source branch and open state", async () => { + const calls = captureListRequests(() => + HttpResponse.json({ values: [makePrDetail({ id: 7 })] }), + ); + + const result = await findOpenPullRequestForBranch( + creds, + ref, + "feature/auth", + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.get("q")).toBe( + 'state="OPEN" AND source.branch.name="feature/auth"', + ); + expect(calls[0]?.get("pagelen")).toBe("1"); + expect(result?.id).toBe(7); + }); + + test("returns null when no open PR matches", async () => { + server.use(http.get(PR_LIST_PATH, () => HttpResponse.json({ values: [] }))); + + const result = await findOpenPullRequestForBranch( + creds, + ref, + "feature/auth", + ); + expect(result).toBeNull(); + }); + + test("escapes quotes in branch names", async () => { + const calls = captureListRequests(() => HttpResponse.json({ values: [] })); + + await findOpenPullRequestForBranch(creds, ref, 'weird"branch'); + + expect(calls[0]?.get("q")).toBe( + 'state="OPEN" AND source.branch.name="weird\\"branch"', + ); + }); }); describe("createPullRequest", () => { - test("POSTs title, description, and source/destination branches", async () => { - let seenBody: Record | null = null; - server.use( - http.post(PR_LIST_PATH, async ({ request }) => { - seenBody = (await request.json()) as Record; - return HttpResponse.json( - makePrDetail({ id: 100, title: "Add login" }), - { status: 201 }, - ); - }), - ); - - const result = await createPullRequest(creds, ref, { - title: "Add login", - description: "Wires up auth middleware.", - sourceBranch: "feature/login", - destinationBranch: "main", - }); - - expect(seenBody!).toEqual({ - type: "pullrequest", - title: "Add login", - description: "Wires up auth middleware.", - source: { branch: { name: "feature/login" } }, - destination: { branch: { name: "main" } }, - }); - expect(result.id).toBe(100); - expect(result.title).toBe("Add login"); - }); - - test("throws PullRequestError on 400 (validation failure)", async () => { - server.use( - http.post(PR_LIST_PATH, () => - HttpResponse.json( - { type: "error", error: { message: "Invalid source branch" } }, - { status: 400 }, - ), - ), - ); - - const err = await createPullRequest(creds, ref, { - title: "x", - description: "", - sourceBranch: "nope", - destinationBranch: "main", - }).catch((e) => e); - - expect(err).toBeInstanceOf(PullRequestError); - expect((err as PullRequestError).status).toBe(400); - }); + test("POSTs title, description, and source/destination branches", async () => { + let seenBody: Record | null = null; + server.use( + http.post(PR_LIST_PATH, async ({ request }) => { + seenBody = (await request.json()) as Record; + return HttpResponse.json( + makePrDetail({ id: 100, title: "Add login" }), + { status: 201 }, + ); + }), + ); + + const result = await createPullRequest(creds, ref, { + title: "Add login", + description: "Wires up auth middleware.", + sourceBranch: "feature/login", + destinationBranch: "main", + }); + + expect(seenBody!).toEqual({ + type: "pullrequest", + title: "Add login", + description: "Wires up auth middleware.", + source: { branch: { name: "feature/login" } }, + destination: { branch: { name: "main" } }, + }); + expect(result.id).toBe(100); + expect(result.title).toBe("Add login"); + }); + + test("throws PullRequestError on 400 (validation failure)", async () => { + server.use( + http.post(PR_LIST_PATH, () => + HttpResponse.json( + { type: "error", error: { message: "Invalid source branch" } }, + { status: 400 }, + ), + ), + ); + + const err = await createPullRequest(creds, ref, { + title: "x", + description: "", + sourceBranch: "nope", + destinationBranch: "main", + }).catch((e) => e); + + expect(err).toBeInstanceOf(PullRequestError); + expect((err as PullRequestError).status).toBe(400); + }); }); diff --git a/src/backend/pullrequests/index.ts b/src/backend/pullrequests/index.ts index 42bd80b..f789274 100644 --- a/src/backend/pullrequests/index.ts +++ b/src/backend/pullrequests/index.ts @@ -1,197 +1,195 @@ +import type { components } from "../../shared/bitbucket-http/generated"; import { - createBitbucketClient, - type Credentials, + type Credentials, + createBitbucketClient, } from "../../shared/bitbucket-http/index.ts"; import { - withPagination, - PaginationError, + PaginationError, + withPagination, } from "../../shared/bitbucket-http/paginate.ts"; -import type { components } from "../../shared/bitbucket-http/generated"; type RawPullRequest = components["schemas"]["pullrequest"]; type RawParticipant = components["schemas"]["participant"]; export type PullRequestStateFilter = "open" | "merged" | "declined" | "all"; -export type PullRequestApiState = - | "OPEN" - | "MERGED" - | "DECLINED" - | "SUPERSEDED"; +export type PullRequestApiState = "OPEN" | "MERGED" | "DECLINED" | "SUPERSEDED"; -export type UserFilter = - | { kind: "me" } - | { kind: "nickname"; value: string }; +export type UserFilter = { kind: "me" } | { kind: "nickname"; value: string }; export type PullRequestState = - | "OPEN" - | "DRAFT" - | "QUEUED" - | "MERGED" - | "DECLINED" - | "SUPERSEDED"; + | "OPEN" + | "DRAFT" + | "QUEUED" + | "MERGED" + | "DECLINED" + | "SUPERSEDED"; export type PullRequestAuthor = { - uuid: string; - displayName: string; - nickname: string; + uuid: string; + displayName: string; + nickname: string; }; export type PullRequest = { - id: number; - title: string; - state: PullRequestState; - author: PullRequestAuthor | null; - createdOn: string; - updatedOn: string; - url: string; + id: number; + title: string; + state: PullRequestState; + author: PullRequestAuthor | null; + createdOn: string; + updatedOn: string; + url: string; }; export type ReviewState = "approved" | "changes_requested" | "pending"; export type Reviewer = { - account: PullRequestAuthor; - state: ReviewState; + account: PullRequestAuthor; + state: ReviewState; }; export type PullRequestDetail = PullRequest & { - description: string; - sourceBranch: string; - destinationBranch: string; - reviewers: Reviewer[]; + description: string; + sourceBranch: string; + destinationBranch: string; + reviewers: Reviewer[]; }; export type ListPullRequestsOptions = { - state: PullRequestStateFilter; - author?: UserFilter; - reviewer?: UserFilter; - limit: number; - /** Pre-resolved uuid of the authenticated user; required only when an @me filter is used. */ - currentUserUuid?: string; + state: PullRequestStateFilter; + author?: UserFilter; + reviewer?: UserFilter; + limit: number; + /** Pre-resolved uuid of the authenticated user; required only when an @me filter is used. */ + currentUserUuid?: string; }; export class PullRequestError extends Error { - readonly status: number | undefined; + readonly status: number | undefined; - constructor(message: string, status?: number) { - super(message); - this.name = "PullRequestError"; - this.status = status; - } + constructor(message: string, status?: number) { + super(message); + this.name = "PullRequestError"; + this.status = status; + } } const STATE_MAP: Record = { - open: ["OPEN"], - merged: ["MERGED"], - declined: ["DECLINED"], - all: ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"], + open: ["OPEN"], + merged: ["MERGED"], + declined: ["DECLINED"], + all: ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"], }; const PAGELEN = 50; export async function listPullRequests( - credentials: Credentials, - ref: { workspace: string; slug: string }, - options: ListPullRequestsOptions, + credentials: Credentials, + ref: { workspace: string; slug: string }, + options: ListPullRequestsOptions, ): Promise { - const client = createBitbucketClient(credentials); - const states = STATE_MAP[options.state]; - const filterBbql = buildBbql(options); - - // Bitbucket ignores the `state=` query param when `q` is also set, so - // when we have a BBQL filter the state constraint has to live inside it. - const query = filterBbql - ? { - sort: "-updated_on", - pagelen: PAGELEN, - q: `${stateBbql(states)} AND ${filterBbql}`, - } - : { sort: "-updated_on", pagelen: PAGELEN, state: states }; - - try { - const raw = await withPagination( - () => - client.GET("/repositories/{workspace}/{repo_slug}/pullrequests", { - params: { - path: { workspace: ref.workspace, repo_slug: ref.slug }, - query, - }, - }), - credentials, - { limit: options.limit }, - ); - return raw.map(toPullRequest); - } catch (err) { - if (err instanceof PaginationError) { - // Re-wrap as a domain error so the command layer only needs to know - // about PullRequestError. - throw new PullRequestError(err.message, err.status); - } - throw err; - } + const client = createBitbucketClient(credentials); + const states = STATE_MAP[options.state]; + const filterBbql = buildBbql(options); + + // Bitbucket ignores the `state=` query param when `q` is also set, so + // when we have a BBQL filter the state constraint has to live inside it. + const query = filterBbql + ? { + sort: "-updated_on", + pagelen: PAGELEN, + q: `${stateBbql(states)} AND ${filterBbql}`, + } + : { sort: "-updated_on", pagelen: PAGELEN, state: states }; + + try { + const raw = await withPagination( + () => + client.GET("/repositories/{workspace}/{repo_slug}/pullrequests", { + params: { + path: { workspace: ref.workspace, repo_slug: ref.slug }, + query, + }, + }), + credentials, + { limit: options.limit }, + ); + return raw.map(toPullRequest); + } catch (err) { + if (err instanceof PaginationError) { + // Re-wrap as a domain error so the command layer only needs to know + // about PullRequestError. + throw new PullRequestError(err.message, err.status); + } + throw err; + } } function toPullRequest(pr: RawPullRequest): PullRequest { - const raw = pr as Record; - return { - id: Number(raw.id ?? 0), - title: String(raw.title ?? ""), - state: String(raw.state ?? "") as PullRequestState, - author: toAuthor(raw.author), - createdOn: String(raw.created_on ?? ""), - updatedOn: String(raw.updated_on ?? ""), - url: String(raw.links?.html?.href ?? ""), - }; + const raw = pr as Record; + return { + id: Number(raw.id ?? 0), + title: String(raw.title ?? ""), + state: String(raw.state ?? "") as PullRequestState, + author: toAuthor(raw.author), + createdOn: String(raw.created_on ?? ""), + updatedOn: String(raw.updated_on ?? ""), + url: String(raw.links?.html?.href ?? ""), + }; } function toAuthor(raw: unknown): PullRequestAuthor | null { - if (!raw || typeof raw !== "object") return null; - const a = raw as Record; - const uuid = typeof a["uuid"] === "string" ? a["uuid"] : ""; - if (!uuid) return null; - return { - uuid, - displayName: typeof a["display_name"] === "string" ? a["display_name"] : "", - nickname: typeof a["nickname"] === "string" ? a["nickname"] : "", - }; + if (!raw || typeof raw !== "object") return null; + const a = raw as Record; + const uuid = typeof a.uuid === "string" ? a.uuid : ""; + if (!uuid) return null; + return { + uuid, + displayName: typeof a.display_name === "string" ? a.display_name : "", + nickname: typeof a.nickname === "string" ? a.nickname : "", + }; } function stateBbql(states: PullRequestApiState[]): string { - if (states.length === 1) return `state="${states[0]}"`; - return `(${states.map((s) => `state="${s}"`).join(" OR ")})`; + if (states.length === 1) return `state="${states[0]}"`; + return `(${states.map((s) => `state="${s}"`).join(" OR ")})`; } function buildBbql(options: ListPullRequestsOptions): string | undefined { - const parts: string[] = []; - if (options.author) { - parts.push(userFilterToBbql("author", options.author, options.currentUserUuid)); - } - if (options.reviewer) { - parts.push(userFilterToBbql("reviewers", options.reviewer, options.currentUserUuid)); - } - return parts.length > 0 ? parts.join(" AND ") : undefined; + const parts: string[] = []; + if (options.author) { + parts.push( + userFilterToBbql("author", options.author, options.currentUserUuid), + ); + } + if (options.reviewer) { + parts.push( + userFilterToBbql("reviewers", options.reviewer, options.currentUserUuid), + ); + } + return parts.length > 0 ? parts.join(" AND ") : undefined; } function userFilterToBbql( - field: string, - filter: UserFilter, - meUuid: string | undefined, + field: string, + filter: UserFilter, + meUuid: string | undefined, ): string { - if (filter.kind === "me") { - return `${field}.uuid="${meUuid}"`; - } - return `${field}.nickname="${escapeBbql(filter.value)}"`; + if (filter.kind === "me") { + return `${field}.uuid="${meUuid}"`; + } + return `${field}.nickname="${escapeBbql(filter.value)}"`; } function escapeBbql(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } export type CreatePullRequestInput = { - title: string; - description: string; - sourceBranch: string; - destinationBranch: string; + title: string; + description: string; + sourceBranch: string; + destinationBranch: string; }; /** @@ -201,35 +199,35 @@ export type CreatePullRequestInput = { * reviewers based on code-owner settings. */ export async function createPullRequest( - credentials: Credentials, - ref: { workspace: string; slug: string }, - input: CreatePullRequestInput, + credentials: Credentials, + ref: { workspace: string; slug: string }, + input: CreatePullRequestInput, ): Promise { - const client = createBitbucketClient(credentials); - const { data, response } = await client.POST( - "/repositories/{workspace}/{repo_slug}/pullrequests", - { - params: { - path: { workspace: ref.workspace, repo_slug: ref.slug }, - }, - body: { - type: "pullrequest", - title: input.title, - description: input.description, - source: { branch: { name: input.sourceBranch } }, - destination: { branch: { name: input.destinationBranch } }, - }, - }, - ); - - if (!response.ok || !data) { - throw new PullRequestError( - `Failed to create pull request: HTTP ${response.status}.`, - response.status, - ); - } - - return toPullRequestDetail(data as RawPullRequest); + const client = createBitbucketClient(credentials); + const { data, response } = await client.POST( + "/repositories/{workspace}/{repo_slug}/pullrequests", + { + params: { + path: { workspace: ref.workspace, repo_slug: ref.slug }, + }, + body: { + type: "pullrequest", + title: input.title, + description: input.description, + source: { branch: { name: input.sourceBranch } }, + destination: { branch: { name: input.destinationBranch } }, + }, + }, + ); + + if (!response.ok || !data) { + throw new PullRequestError( + `Failed to create pull request: HTTP ${response.status}.`, + response.status, + ); + } + + return toPullRequestDetail(data as RawPullRequest); } /** @@ -238,32 +236,32 @@ export async function createPullRequest( * openapi-fetch's client rather than dropping to raw fetch. */ export async function getPullRequest( - credentials: Credentials, - ref: { workspace: string; slug: string }, - id: number, + credentials: Credentials, + ref: { workspace: string; slug: string }, + id: number, ): Promise { - const client = createBitbucketClient(credentials); - const { data, response } = await client.GET( - "/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}", - { - params: { - path: { - workspace: ref.workspace, - repo_slug: ref.slug, - pull_request_id: id, - }, - }, - }, - ); - - if (!response.ok || !data) { - throw new PullRequestError( - `Failed to fetch pull request #${id}: HTTP ${response.status}.`, - response.status, - ); - } - - return toPullRequestDetail(data as RawPullRequest); + const client = createBitbucketClient(credentials); + const { data, response } = await client.GET( + "/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}", + { + params: { + path: { + workspace: ref.workspace, + repo_slug: ref.slug, + pull_request_id: id, + }, + }, + }, + ); + + if (!response.ok || !data) { + throw new PullRequestError( + `Failed to fetch pull request #${id}: HTTP ${response.status}.`, + response.status, + ); + } + + return toPullRequestDetail(data as RawPullRequest); } /** @@ -275,63 +273,63 @@ export async function getPullRequest( * don't want to silently surface a stale one as "the PR for this branch." */ export async function findOpenPullRequestForBranch( - credentials: Credentials, - ref: { workspace: string; slug: string }, - branch: string, + credentials: Credentials, + ref: { workspace: string; slug: string }, + branch: string, ): Promise { - const client = createBitbucketClient(credentials); - const { data, response } = await client.GET( - "/repositories/{workspace}/{repo_slug}/pullrequests", - { - params: { - path: { workspace: ref.workspace, repo_slug: ref.slug }, - query: { - q: `state="OPEN" AND source.branch.name="${escapeBbql(branch)}"`, - pagelen: 1, - sort: "-updated_on", - }, - }, - }, - ); - - if (!response.ok || !data) { - throw new PullRequestError( - `Failed to search pull requests: HTTP ${response.status}.`, - response.status, - ); - } - - const first = data.values?.[0]; - return first ? toPullRequest(first as RawPullRequest) : null; + const client = createBitbucketClient(credentials); + const { data, response } = await client.GET( + "/repositories/{workspace}/{repo_slug}/pullrequests", + { + params: { + path: { workspace: ref.workspace, repo_slug: ref.slug }, + query: { + q: `state="OPEN" AND source.branch.name="${escapeBbql(branch)}"`, + pagelen: 1, + sort: "-updated_on", + }, + }, + }, + ); + + if (!response.ok || !data) { + throw new PullRequestError( + `Failed to search pull requests: HTTP ${response.status}.`, + response.status, + ); + } + + const first = data.values?.[0]; + return first ? toPullRequest(first as RawPullRequest) : null; } function toPullRequestDetail(pr: RawPullRequest): PullRequestDetail { - const base = toPullRequest(pr); - const raw = pr as Record; - return { - ...base, - description: String(raw.summary?.raw ?? raw.description ?? ""), - sourceBranch: String(raw.source?.branch?.name ?? ""), - destinationBranch: String(raw.destination?.branch?.name ?? ""), - reviewers: toReviewers(raw.participants), - }; + const base = toPullRequest(pr); + const raw = pr as Record; + return { + ...base, + description: String(raw.summary?.raw ?? raw.description ?? ""), + sourceBranch: String(raw.source?.branch?.name ?? ""), + destinationBranch: String(raw.destination?.branch?.name ?? ""), + reviewers: toReviewers(raw.participants), + }; } function toReviewers(raw: unknown): Reviewer[] { - if (!Array.isArray(raw)) return []; - const out: Reviewer[] = []; - for (const p of raw as RawParticipant[]) { - const pp = p as Record; - if (pp.role !== "REVIEWER") continue; - const account = toAuthor(pp.user); - if (!account) continue; - out.push({ account, state: toReviewState(pp.state) }); - } - return out; + if (!Array.isArray(raw)) return []; + const out: Reviewer[] = []; + for (const p of raw as RawParticipant[]) { + const pp = p as Record; + if (pp.role !== "REVIEWER") continue; + const account = toAuthor(pp.user); + if (!account) continue; + out.push({ account, state: toReviewState(pp.state) }); + } + return out; } function toReviewState(raw: unknown): ReviewState { - if (raw === "approved") return "approved"; - if (raw === "changes_requested") return "changes_requested"; - return "pending"; + if (raw === "approved") return "approved"; + if (raw === "changes_requested") return "changes_requested"; + return "pending"; } diff --git a/src/backend/user/index.test.ts b/src/backend/user/index.test.ts index d31066c..7210986 100644 --- a/src/backend/user/index.test.ts +++ b/src/backend/user/index.test.ts @@ -1,44 +1,44 @@ -import { test, expect, describe } from "bun:test"; -import { http, HttpResponse } from "msw"; -import { getCurrentUser, UserError } from "./index.ts"; +import { describe, expect, test } from "bun:test"; +import { HttpResponse, http } from "msw"; import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; +import { getCurrentUser, UserError } from "./index.ts"; setupMsw(); const creds = { email: "a@b.co", token: "t" }; describe("getCurrentUser", () => { - test("returns uuid and display name", async () => { - server.use( - http.get(`${BITBUCKET_BASE}/user`, () => - HttpResponse.json({ uuid: "{abc-123}", display_name: "Alice" }), - ), - ); - - const result = await getCurrentUser(creds); - expect(result).toEqual({ uuid: "{abc-123}", displayName: "Alice" }); - }); - - test("falls back to empty display name when absent", async () => { - server.use( - http.get(`${BITBUCKET_BASE}/user`, () => - HttpResponse.json({ uuid: "{abc-123}" }), - ), - ); - - const result = await getCurrentUser(creds); - expect(result.displayName).toBe(""); - }); - - test("throws UserError on failure", async () => { - server.use( - http.get(`${BITBUCKET_BASE}/user`, () => - HttpResponse.json({ type: "error" }, { status: 401 }), - ), - ); - - const err = await getCurrentUser(creds).catch((e) => e); - expect(err).toBeInstanceOf(UserError); - expect((err as UserError).status).toBe(401); - }); + test("returns uuid and display name", async () => { + server.use( + http.get(`${BITBUCKET_BASE}/user`, () => + HttpResponse.json({ uuid: "{abc-123}", display_name: "Alice" }), + ), + ); + + const result = await getCurrentUser(creds); + expect(result).toEqual({ uuid: "{abc-123}", displayName: "Alice" }); + }); + + test("falls back to empty display name when absent", async () => { + server.use( + http.get(`${BITBUCKET_BASE}/user`, () => + HttpResponse.json({ uuid: "{abc-123}" }), + ), + ); + + const result = await getCurrentUser(creds); + expect(result.displayName).toBe(""); + }); + + test("throws UserError on failure", async () => { + server.use( + http.get(`${BITBUCKET_BASE}/user`, () => + HttpResponse.json({ type: "error" }, { status: 401 }), + ), + ); + + const err = await getCurrentUser(creds).catch((e) => e); + expect(err).toBeInstanceOf(UserError); + expect((err as UserError).status).toBe(401); + }); }); diff --git a/src/backend/user/index.ts b/src/backend/user/index.ts index 3e9571d..660a296 100644 --- a/src/backend/user/index.ts +++ b/src/backend/user/index.ts @@ -1,21 +1,21 @@ import { - createBitbucketClient, - type Credentials, + type Credentials, + createBitbucketClient, } from "../../shared/bitbucket-http/index.ts"; export type CurrentUser = { - uuid: string; - displayName: string; + uuid: string; + displayName: string; }; export class UserError extends Error { - readonly status: number | undefined; + readonly status: number | undefined; - constructor(message: string, status?: number) { - super(message); - this.name = "UserError"; - this.status = status; - } + constructor(message: string, status?: number) { + super(message); + this.name = "UserError"; + this.status = status; + } } /** @@ -23,21 +23,21 @@ export class UserError extends Error { * stable identifier (uuid) for BBQL queries. */ export async function getCurrentUser( - credentials: Credentials, + credentials: Credentials, ): Promise { - const client = createBitbucketClient(credentials); + const client = createBitbucketClient(credentials); - const { data, response } = await client.GET("/user"); + const { data, response } = await client.GET("/user"); - if (!response.ok || !data || !data.uuid) { - throw new UserError( - `Failed to fetch current user: HTTP ${response.status}.`, - response.status, - ); - } + if (!response.ok || !data?.uuid) { + throw new UserError( + `Failed to fetch current user: HTTP ${response.status}.`, + response.status, + ); + } - return { - uuid: data.uuid, - displayName: data.display_name ?? "", - }; + return { + uuid: data.uuid, + displayName: data.display_name ?? "", + }; } diff --git a/src/backend/workspaces/index.test.ts b/src/backend/workspaces/index.test.ts index 487374c..9532a2d 100644 --- a/src/backend/workspaces/index.test.ts +++ b/src/backend/workspaces/index.test.ts @@ -1,67 +1,73 @@ -import { test, expect, describe } from "bun:test"; -import { http, HttpResponse } from "msw"; -import { listWorkspaces, WorkspaceError } from "./index.ts"; +import { describe, expect, test } from "bun:test"; +import { HttpResponse, http } from "msw"; import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; +import { listWorkspaces, WorkspaceError } from "./index.ts"; setupMsw(); const creds = { email: "a@b.co", token: "t" }; describe("listWorkspaces", () => { - test("returns workspaces with slug and admin flag", async () => { - server.use( - http.get(`${BITBUCKET_BASE}/user/workspaces`, () => - HttpResponse.json({ - values: [ - { workspace: { slug: "team-a", uuid: "{aaa}" }, administrator: true }, - { workspace: { slug: "team-b", uuid: "{bbb}" }, administrator: false }, - ], - }), - ), - ); + test("returns workspaces with slug and admin flag", async () => { + server.use( + http.get(`${BITBUCKET_BASE}/user/workspaces`, () => + HttpResponse.json({ + values: [ + { + workspace: { slug: "team-a", uuid: "{aaa}" }, + administrator: true, + }, + { + workspace: { slug: "team-b", uuid: "{bbb}" }, + administrator: false, + }, + ], + }), + ), + ); - const result = await listWorkspaces(creds); - expect(result).toEqual([ - { slug: "team-a", administrator: true }, - { slug: "team-b", administrator: false }, - ]); - }); + const result = await listWorkspaces(creds); + expect(result).toEqual([ + { slug: "team-a", administrator: true }, + { slug: "team-b", administrator: false }, + ]); + }); - test("returns empty array when user has no workspaces", async () => { - server.use( - http.get(`${BITBUCKET_BASE}/user/workspaces`, () => - HttpResponse.json({ values: [] }), - ), - ); + test("returns empty array when user has no workspaces", async () => { + server.use( + http.get(`${BITBUCKET_BASE}/user/workspaces`, () => + HttpResponse.json({ values: [] }), + ), + ); - const result = await listWorkspaces(creds); - expect(result).toEqual([]); - }); + const result = await listWorkspaces(creds); + expect(result).toEqual([]); + }); - test("throws WorkspaceError on failure", async () => { - server.use( - http.get(`${BITBUCKET_BASE}/user/workspaces`, () => - HttpResponse.json({ type: "error" }, { status: 401 }), - ), - ); + test("throws WorkspaceError on failure", async () => { + server.use( + http.get(`${BITBUCKET_BASE}/user/workspaces`, () => + HttpResponse.json({ type: "error" }, { status: 401 }), + ), + ); - const err = await listWorkspaces(creds).catch((e) => e); - expect(err).toBeInstanceOf(WorkspaceError); - expect((err as WorkspaceError).status).toBe(401); - }); + const err = await listWorkspaces(creds).catch((e) => e); + expect(err).toBeInstanceOf(WorkspaceError); + expect((err as WorkspaceError).status).toBe(401); + }); - test("sends pagelen=100 on the request", async () => { - // New assertion capability msw gives us: inspect the actual outgoing - // request rather than hand-rolling URL parsing in the mock. - let seenPagelen = null as string | null; - server.use( - http.get(`${BITBUCKET_BASE}/user/workspaces`, ({ request }) => { - seenPagelen = new URL(request.url).searchParams.get("pagelen"); - return HttpResponse.json({ values: [] }); - }), - ); + test("sends pagelen=100 on the request", async () => { + // New assertion capability msw gives us: inspect the actual outgoing + // request rather than hand-rolling URL parsing in the mock. + let seenPagelen = null as string | null; + server.use( + http.get(`${BITBUCKET_BASE}/user/workspaces`, ({ request }) => { + seenPagelen = new URL(request.url).searchParams.get("pagelen"); + return HttpResponse.json({ values: [] }); + }), + ); - await listWorkspaces(creds); - expect(seenPagelen!).toBe("100"); - }); + await listWorkspaces(creds); + expect(seenPagelen!).toBe("100"); + }); }); diff --git a/src/backend/workspaces/index.ts b/src/backend/workspaces/index.ts index c6222e9..3e58559 100644 --- a/src/backend/workspaces/index.ts +++ b/src/backend/workspaces/index.ts @@ -1,21 +1,21 @@ import { - createBitbucketClient, - type Credentials, + type Credentials, + createBitbucketClient, } from "../../shared/bitbucket-http/index.ts"; export type WorkspaceInfo = { - slug: string; - administrator: boolean; + slug: string; + administrator: boolean; }; export class WorkspaceError extends Error { - readonly status: number | undefined; + readonly status: number | undefined; - constructor(message: string, status?: number) { - super(message); - this.name = "WorkspaceError"; - this.status = status; - } + constructor(message: string, status?: number) { + super(message); + this.name = "WorkspaceError"; + this.status = status; + } } /** @@ -23,23 +23,25 @@ export class WorkspaceError extends Error { * Fetches a single page of up to 100 results (the Bitbucket API max). */ export async function listWorkspaces( - credentials: Credentials, + credentials: Credentials, ): Promise { - const client = createBitbucketClient(credentials); + const client = createBitbucketClient(credentials); - const { data, response } = await client.GET("/user/workspaces", { - params: { query: { pagelen: 100 } }, - }); + const { data, response } = await client.GET("/user/workspaces", { + params: { query: { pagelen: 100 } }, + }); - if (!response.ok || !data) { - throw new WorkspaceError( - `Failed to list workspaces: HTTP ${response.status}.`, - response.status, - ); - } + if (!response.ok || !data) { + throw new WorkspaceError( + `Failed to list workspaces: HTTP ${response.status}.`, + response.status, + ); + } - return data.values?.map(value => ({ - slug: value.workspace?.slug ?? "", - administrator: value.administrator ?? false, - })) ?? [] + return ( + data.values?.map((value) => ({ + slug: value.workspace?.slug ?? "", + administrator: value.administrator ?? false, + })) ?? [] + ); } diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index 4bdf95e..774f137 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -3,12 +3,12 @@ import { withRenderer } from "../../shared/renderer/commander.ts"; import { runAuthStatus } from "./status.ts"; export function registerAuthCommands(program: Command): void { - const auth = program - .command("auth") - .description("Authenticate bbcli against Bitbucket Cloud"); + const auth = program + .command("auth") + .description("Authenticate bbcli against Bitbucket Cloud"); - auth - .command("status") - .description("Verify that the configured Bitbucket credentials work") - .action(withRenderer(runAuthStatus)); + auth + .command("status") + .description("Verify that the configured Bitbucket credentials work") + .action(withRenderer(runAuthStatus)); } diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index f0c5278..33a3d89 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -1,28 +1,28 @@ import { - BitbucketAuthError, - verifyCredentials, - type BitbucketAccount, + type BitbucketAccount, + BitbucketAuthError, + verifyCredentials, } from "../../backend/auth/index.ts"; import { loadConfigOrExit } from "../../shared/config/index.ts"; import type { Renderer } from "../../shared/renderer/index.ts"; export async function runAuthStatus(renderer: Renderer): Promise { - const config = await loadConfigOrExit(renderer); + const config = await loadConfigOrExit(renderer); - try { - const account = await verifyCredentials(config); - renderer.detail( - { ...account, email: config.email }, - [ - { label: "Account", value: (a) => a.display_name ?? "(no name)" }, - { label: "Email", value: (a) => a.email }, - ], - ); - } catch (err) { - if (err instanceof BitbucketAuthError) { - renderer.error(err.message); - process.exit(1); - } - throw err; - } + try { + const account = await verifyCredentials(config); + renderer.detail( + { ...account, email: config.email }, + [ + { label: "Account", value: (a) => a.display_name ?? "(no name)" }, + { label: "Email", value: (a) => a.email }, + ], + ); + } catch (err) { + if (err instanceof BitbucketAuthError) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } } diff --git a/src/commands/pullrequest/create.ts b/src/commands/pullrequest/create.ts index 371a070..1bf82e6 100644 --- a/src/commands/pullrequest/create.ts +++ b/src/commands/pullrequest/create.ts @@ -1,127 +1,130 @@ import { - createPullRequest, - PullRequestError, + createPullRequest, + PullRequestError, } from "../../backend/pullrequests/index.ts"; import { loadConfigOrExit } from "../../shared/config/index.ts"; -import { openEditor, EditorError } from "../../shared/editor/index.ts"; +import { EditorError, openEditor } from "../../shared/editor/index.ts"; import type { Renderer } from "../../shared/renderer/index.ts"; import { - resolveRepository, - RepositoryResolutionError, - defaultGitRunner, + defaultGitRunner, + RepositoryResolutionError, + resolveRepository, } from "../../shared/repository/index.ts"; export type PullRequestCreateOptions = { - repository?: string; - title?: string; - body?: string; - bodyFile?: string; - base?: string; + repository?: string; + title?: string; + body?: string; + bodyFile?: string; + base?: string; }; export async function runPullRequestCreate( - renderer: Renderer, - options: PullRequestCreateOptions, + renderer: Renderer, + options: PullRequestCreateOptions, ): Promise { - const config = await loadConfigOrExit(renderer); + const config = await loadConfigOrExit(renderer); - if (!options.title) { - renderer.error("--title is required."); - process.exit(1); - } - if (options.body !== undefined && options.bodyFile !== undefined) { - renderer.error("Pass either --body or --body-file, not both."); - process.exit(1); - } + if (!options.title) { + renderer.error("--title is required."); + process.exit(1); + } + if (options.body !== undefined && options.bodyFile !== undefined) { + renderer.error("Pass either --body or --body-file, not both."); + process.exit(1); + } - try { - const ref = await resolveRepository({ override: options.repository }); - const cwd = process.cwd(); + try { + const ref = await resolveRepository({ override: options.repository }); + const cwd = process.cwd(); - const branch = await defaultGitRunner.getCurrentBranch(cwd); - if (!branch) { - renderer.error( - "Could not determine the current branch (detached HEAD or not a git repo).", - ); - process.exit(1); - } + const branch = await defaultGitRunner.getCurrentBranch(cwd); + if (!branch) { + renderer.error( + "Could not determine the current branch (detached HEAD or not a git repo).", + ); + process.exit(1); + } - await assertBranchPushedAndInSync(renderer, cwd, branch); + await assertBranchPushedAndInSync(renderer, cwd, branch); - const destination = options.base ?? await defaultBase(renderer, cwd); + const destination = options.base ?? (await defaultBase(renderer, cwd)); - const body = await resolveBody(renderer, options); + const body = await resolveBody(renderer, options); - const pr = await createPullRequest(config, ref, { - title: options.title, - description: body, - sourceBranch: branch, - destinationBranch: destination, - }); + const pr = await createPullRequest(config, ref, { + title: options.title, + description: body, + sourceBranch: branch, + destinationBranch: destination, + }); - renderer.message(pr.url); - } catch (err) { - if ( - err instanceof RepositoryResolutionError || - err instanceof PullRequestError || - err instanceof EditorError - ) { - renderer.error(err.message); - process.exit(1); - } - throw err; - } + renderer.message(pr.url); + } catch (err) { + if ( + err instanceof RepositoryResolutionError || + err instanceof PullRequestError || + err instanceof EditorError + ) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } } async function assertBranchPushedAndInSync( - renderer: Renderer, - cwd: string, - branch: string, + renderer: Renderer, + cwd: string, + branch: string, ): Promise { - const remoteSha = await defaultGitRunner.getRemoteBranchSha( - cwd, - "origin", - branch, - ); - if (!remoteSha) { - renderer.error( - `Branch '${branch}' is not on origin. Push it first: git push -u origin ${branch}`, - ); - process.exit(1); - } - const localSha = await defaultGitRunner.getSha(cwd, "HEAD"); - if (localSha && localSha !== remoteSha) { - renderer.error( - `Branch '${branch}' has unpushed commits (local ${localSha.slice(0, 7)} vs remote ${remoteSha.slice(0, 7)}). Push them first.`, - ); - process.exit(1); - } + const remoteSha = await defaultGitRunner.getRemoteBranchSha( + cwd, + "origin", + branch, + ); + if (!remoteSha) { + renderer.error( + `Branch '${branch}' is not on origin. Push it first: git push -u origin ${branch}`, + ); + process.exit(1); + } + const localSha = await defaultGitRunner.getSha(cwd, "HEAD"); + if (localSha && localSha !== remoteSha) { + renderer.error( + `Branch '${branch}' has unpushed commits (local ${localSha.slice(0, 7)} vs remote ${remoteSha.slice(0, 7)}). Push them first.`, + ); + process.exit(1); + } } async function defaultBase(renderer: Renderer, cwd: string): Promise { - const branch = await defaultGitRunner.getDefaultBranchFromRemote(cwd, "origin"); - if (!branch) { - renderer.error( - "Could not determine the default branch. Run 'git remote set-head origin --auto' or pass --base explicitly.", - ); - process.exit(1); - } - return branch; + const branch = await defaultGitRunner.getDefaultBranchFromRemote( + cwd, + "origin", + ); + if (!branch) { + renderer.error( + "Could not determine the default branch. Run 'git remote set-head origin --auto' or pass --base explicitly.", + ); + process.exit(1); + } + return branch; } async function resolveBody( - renderer: Renderer, - options: PullRequestCreateOptions, + renderer: Renderer, + options: PullRequestCreateOptions, ): Promise { - if (options.body !== undefined) return options.body; - if (options.bodyFile !== undefined) { - const file = Bun.file(options.bodyFile); - if (!(await file.exists())) { - renderer.error(`--body-file '${options.bodyFile}' does not exist.`); - process.exit(1); - } - return await file.text(); - } - // No flag: drop into the user's editor with a blank file. - return await openEditor(); + if (options.body !== undefined) return options.body; + if (options.bodyFile !== undefined) { + const file = Bun.file(options.bodyFile); + if (!(await file.exists())) { + renderer.error(`--body-file '${options.bodyFile}' does not exist.`); + process.exit(1); + } + return await file.text(); + } + // No flag: drop into the user's editor with a blank file. + return await openEditor(); } diff --git a/src/commands/pullrequest/index.ts b/src/commands/pullrequest/index.ts index 59f721a..920c2ae 100644 --- a/src/commands/pullrequest/index.ts +++ b/src/commands/pullrequest/index.ts @@ -5,60 +5,53 @@ import { runPullRequestList } from "./list.ts"; import { runPullRequestView } from "./view.ts"; export function registerPullRequestCommands(program: Command): void { - const pr = program - .command("pullrequest") - .alias("pr") - .description("Work with Bitbucket pull requests"); + const pr = program + .command("pullrequest") + .alias("pr") + .description("Work with Bitbucket pull requests"); - pr - .command("list") - .description("List pull requests in a repository") - .option( - "-R, --repository ", - "Override repository detection", - ) - .option( - "-s, --state ", - "Filter by state: open, merged, declined, all", - "open", - ) - .option( - "-a, --author ", - "Filter by author (nickname, or @me)", - ) - .option( - "-r, --reviewer ", - "Filter by reviewer (nickname, or @me)", - ) - .option("-L, --limit ", "Maximum results", "30") - .action(withRenderer(runPullRequestList)); + pr.command("list") + .description("List pull requests in a repository") + .option( + "-R, --repository ", + "Override repository detection", + ) + .option( + "-s, --state ", + "Filter by state: open, merged, declined, all", + "open", + ) + .option("-a, --author ", "Filter by author (nickname, or @me)") + .option("-r, --reviewer ", "Filter by reviewer (nickname, or @me)") + .option("-L, --limit ", "Maximum results", "30") + .action(withRenderer(runPullRequestList)); - pr - .command("view") - .description("Show a pull request's details (defaults to the PR for the current branch)") - .argument("[id]", "Pull request number") - .option( - "-R, --repository ", - "Override repository detection", - ) - .action(withRenderer(runPullRequestView)); + pr.command("view") + .description( + "Show a pull request's details (defaults to the PR for the current branch)", + ) + .argument("[id]", "Pull request number") + .option( + "-R, --repository ", + "Override repository detection", + ) + .action(withRenderer(runPullRequestView)); - pr - .command("create") - .description("Open a pull request from the current branch") - .option( - "-R, --repository ", - "Override repository detection", - ) - .option("-t, --title ", "Pull request title (required)") - .option("-b, --body <body>", "Pull request description") - .option( - "-F, --body-file <path>", - "Read description from a file ('-' for stdin support deferred)", - ) - .option( - "--base <branch>", - "Destination branch (defaults to the remote's default branch)", - ) - .action(withRenderer(runPullRequestCreate)); + pr.command("create") + .description("Open a pull request from the current branch") + .option( + "-R, --repository <workspace/repo>", + "Override repository detection", + ) + .option("-t, --title <title>", "Pull request title (required)") + .option("-b, --body <body>", "Pull request description") + .option( + "-F, --body-file <path>", + "Read description from a file ('-' for stdin support deferred)", + ) + .option( + "--base <branch>", + "Destination branch (defaults to the remote's default branch)", + ) + .action(withRenderer(runPullRequestCreate)); } diff --git a/src/commands/pullrequest/list.ts b/src/commands/pullrequest/list.ts index 0b5323c..02f58fc 100644 --- a/src/commands/pullrequest/list.ts +++ b/src/commands/pullrequest/list.ts @@ -1,126 +1,127 @@ import { - listPullRequests, - PullRequestError, - type PullRequestStateFilter, - type UserFilter, + listPullRequests, + PullRequestError, + type PullRequestStateFilter, + type UserFilter, } from "../../backend/pullrequests/index.ts"; import { getCurrentUser, UserError } from "../../backend/user/index.ts"; import { loadConfigOrExit } from "../../shared/config/index.ts"; import type { Renderer } from "../../shared/renderer/index.ts"; import { - resolveRepository, - RepositoryResolutionError, + RepositoryResolutionError, + resolveRepository, } from "../../shared/repository/index.ts"; import { formatRelativeTime } from "../../shared/time/index.ts"; export type PullRequestListOptions = { - repository?: string; - state?: string; - author?: string; - reviewer?: string; - limit?: string; + repository?: string; + state?: string; + author?: string; + reviewer?: string; + limit?: string; }; const VALID_STATES: readonly PullRequestStateFilter[] = [ - "open", - "merged", - "declined", - "all", + "open", + "merged", + "declined", + "all", ] as const; const DEFAULT_LIMIT = 30; export async function runPullRequestList( - renderer: Renderer, - options: PullRequestListOptions, + renderer: Renderer, + options: PullRequestListOptions, ): Promise<void> { - const config = await loadConfigOrExit(renderer); + const config = await loadConfigOrExit(renderer); - const state = parseState(options.state); - if (!state) { - renderer.error( - `Invalid --state '${options.state}'. Expected one of: ${VALID_STATES.join(", ")}.`, - ); - process.exit(1); - } + const state = parseState(options.state); + if (!state) { + renderer.error( + `Invalid --state '${options.state}'. Expected one of: ${VALID_STATES.join(", ")}.`, + ); + process.exit(1); + } - const limit = parseLimit(options.limit); - if (limit === null) { - renderer.error(`Invalid --limit '${options.limit}'. Expected a positive integer.`); - process.exit(1); - } + const limit = parseLimit(options.limit); + if (limit === null) { + renderer.error( + `Invalid --limit '${options.limit}'. Expected a positive integer.`, + ); + process.exit(1); + } - const author = parseUserFilter(options.author); - const reviewer = parseUserFilter(options.reviewer); - const needsMe = - (author?.kind === "me") || (reviewer?.kind === "me"); + const author = parseUserFilter(options.author); + const reviewer = parseUserFilter(options.reviewer); + const needsMe = author?.kind === "me" || reviewer?.kind === "me"; - try { - const ref = await resolveRepository({ override: options.repository }); + try { + const ref = await resolveRepository({ override: options.repository }); - let currentUserUuid: string | undefined; - if (needsMe) { - const me = await getCurrentUser(config); - currentUserUuid = me.uuid; - } + let currentUserUuid: string | undefined; + if (needsMe) { + const me = await getCurrentUser(config); + currentUserUuid = me.uuid; + } - const prs = await listPullRequests(config, ref, { - state, - author, - reviewer, - limit, - currentUserUuid, - }); + const prs = await listPullRequests(config, ref, { + state, + author, + reviewer, + limit, + currentUserUuid, + }); - if (prs.length === 0) { - renderer.message("No pull requests found."); - return; - } + if (prs.length === 0) { + renderer.message("No pull requests found."); + return; + } - renderer.list(prs, [ - { header: "#", value: (pr) => String(pr.id) }, - { header: "TITLE", value: (pr) => pr.title, flex: true }, - { - header: "AUTHOR", - value: (pr) => pr.author?.displayName ?? pr.author?.nickname ?? "", - style: "muted", - }, - { header: "STATE", value: (pr) => pr.state }, - { - header: "UPDATED", - value: (pr) => formatRelativeTime(pr.updatedOn), - style: "muted", - }, - ]); - } catch (err) { - if ( - err instanceof RepositoryResolutionError || - err instanceof PullRequestError || - err instanceof UserError - ) { - renderer.error(err.message); - process.exit(1); - } - throw err; - } + renderer.list(prs, [ + { header: "#", value: (pr) => String(pr.id) }, + { header: "TITLE", value: (pr) => pr.title, flex: true }, + { + header: "AUTHOR", + value: (pr) => pr.author?.displayName ?? pr.author?.nickname ?? "", + style: "muted", + }, + { header: "STATE", value: (pr) => pr.state }, + { + header: "UPDATED", + value: (pr) => formatRelativeTime(pr.updatedOn), + style: "muted", + }, + ]); + } catch (err) { + if ( + err instanceof RepositoryResolutionError || + err instanceof PullRequestError || + err instanceof UserError + ) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } } function parseState(raw: string | undefined): PullRequestStateFilter | null { - const value = (raw ?? "open").toLowerCase(); - return (VALID_STATES as readonly string[]).includes(value) - ? (value as PullRequestStateFilter) - : null; + const value = (raw ?? "open").toLowerCase(); + return (VALID_STATES as readonly string[]).includes(value) + ? (value as PullRequestStateFilter) + : null; } function parseLimit(raw: string | undefined): number | null { - if (raw === undefined) return DEFAULT_LIMIT; - const n = Number(raw); - if (!Number.isInteger(n) || n <= 0) return null; - return n; + if (raw === undefined) return DEFAULT_LIMIT; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) return null; + return n; } function parseUserFilter(raw: string | undefined): UserFilter | undefined { - if (!raw) return undefined; - if (raw === "@me") return { kind: "me" }; - return { kind: "nickname", value: raw }; + if (!raw) return undefined; + if (raw === "@me") return { kind: "me" }; + return { kind: "nickname", value: raw }; } diff --git a/src/commands/pullrequest/view.ts b/src/commands/pullrequest/view.ts index fd7b03f..a2d70d9 100644 --- a/src/commands/pullrequest/view.ts +++ b/src/commands/pullrequest/view.ts @@ -1,135 +1,134 @@ import { - findOpenPullRequestForBranch, - getPullRequest, - PullRequestError, - type PullRequestDetail, - type ReviewState, + findOpenPullRequestForBranch, + getPullRequest, + type PullRequestDetail, + PullRequestError, + type ReviewState, } from "../../backend/pullrequests/index.ts"; import { loadConfigOrExit } from "../../shared/config/index.ts"; import type { Renderer } from "../../shared/renderer/index.ts"; import { - resolveRepository, - RepositoryResolutionError, - defaultGitRunner, + defaultGitRunner, + RepositoryResolutionError, + resolveRepository, } from "../../shared/repository/index.ts"; import { formatRelativeTime } from "../../shared/time/index.ts"; export type PullRequestViewOptions = { - repository?: string; + repository?: string; }; const REVIEW_GLYPH: Record<ReviewState, string> = { - approved: "✓", - changes_requested: "✗", - pending: "…", + approved: "✓", + changes_requested: "✗", + pending: "…", }; const REVIEW_LABEL: Record<ReviewState, string> = { - approved: "approved", - changes_requested: "changes requested", - pending: "pending", + approved: "approved", + changes_requested: "changes requested", + pending: "pending", }; export async function runPullRequestView( - renderer: Renderer, - idArg: string | undefined, - options: PullRequestViewOptions, + renderer: Renderer, + idArg: string | undefined, + options: PullRequestViewOptions, ): Promise<void> { - const config = await loadConfigOrExit(renderer); + const config = await loadConfigOrExit(renderer); - let id: number | undefined; - if (idArg !== undefined) { - const parsed = parseId(idArg); - if (parsed === null) { - renderer.error(`Invalid PR id '${idArg}'. Expected a positive integer.`); - process.exit(1); - } - id = parsed; - } + let id: number | undefined; + if (idArg !== undefined) { + const parsed = parseId(idArg); + if (parsed === null) { + renderer.error(`Invalid PR id '${idArg}'. Expected a positive integer.`); + process.exit(1); + } + id = parsed; + } - try { - const ref = await resolveRepository({ override: options.repository }); + try { + const ref = await resolveRepository({ override: options.repository }); - if (id === undefined) { - const branch = await defaultGitRunner.getCurrentBranch(process.cwd()); - if (!branch) { - renderer.error( - "Could not determine the current branch (detached HEAD or not a git repo). Pass a PR number explicitly: bb pr view <n>.", - ); - process.exit(1); - } - const summary = await findOpenPullRequestForBranch(config, ref, branch); - if (!summary) { - renderer.error( - `No open pull request for branch '${branch}'. Pass a PR number explicitly (bb pr view <n>) or list available PRs (bb pr list).`, - ); - process.exit(1); - } - id = summary.id; - } + if (id === undefined) { + const branch = await defaultGitRunner.getCurrentBranch(process.cwd()); + if (!branch) { + renderer.error( + "Could not determine the current branch (detached HEAD or not a git repo). Pass a PR number explicitly: bb pr view <n>.", + ); + process.exit(1); + } + const summary = await findOpenPullRequestForBranch(config, ref, branch); + if (!summary) { + renderer.error( + `No open pull request for branch '${branch}'. Pass a PR number explicitly (bb pr view <n>) or list available PRs (bb pr list).`, + ); + process.exit(1); + } + id = summary.id; + } - const pr = await getPullRequest(config, ref, id); - render(renderer, pr); - } catch (err) { - if ( - err instanceof RepositoryResolutionError || - err instanceof PullRequestError - ) { - renderer.error(err.message); - process.exit(1); - } - throw err; - } + const pr = await getPullRequest(config, ref, id); + render(renderer, pr); + } catch (err) { + if ( + err instanceof RepositoryResolutionError || + err instanceof PullRequestError + ) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } } function render(renderer: Renderer, pr: PullRequestDetail): void { - renderer.detail(pr, [ - { label: "#", value: (p) => String(p.id) }, - { label: "TITLE", value: (p) => p.title, style: "bold" }, - { label: "STATE", value: (p) => p.state }, - { - label: "AUTHOR", - value: (p) => - p.author?.displayName ?? p.author?.nickname ?? "(unknown)", - style: "muted", - }, - { - label: "BRANCH", - value: (p) => `${p.sourceBranch} → ${p.destinationBranch}`, - }, - { - label: "CREATED", - value: (p) => formatRelativeTime(p.createdOn), - style: "muted", - }, - { - label: "UPDATED", - value: (p) => formatRelativeTime(p.updatedOn), - style: "muted", - }, - { label: "URL", value: (p) => p.url, style: "muted" }, - ]); + renderer.detail(pr, [ + { label: "#", value: (p) => String(p.id) }, + { label: "TITLE", value: (p) => p.title, style: "bold" }, + { label: "STATE", value: (p) => p.state }, + { + label: "AUTHOR", + value: (p) => p.author?.displayName ?? p.author?.nickname ?? "(unknown)", + style: "muted", + }, + { + label: "BRANCH", + value: (p) => `${p.sourceBranch} → ${p.destinationBranch}`, + }, + { + label: "CREATED", + value: (p) => formatRelativeTime(p.createdOn), + style: "muted", + }, + { + label: "UPDATED", + value: (p) => formatRelativeTime(p.updatedOn), + style: "muted", + }, + { label: "URL", value: (p) => p.url, style: "muted" }, + ]); - renderer.message(""); - renderer.message("DESCRIPTION"); - renderer.message(pr.description.trim() || "(no description)"); + renderer.message(""); + renderer.message("DESCRIPTION"); + renderer.message(pr.description.trim() || "(no description)"); - renderer.message(""); - renderer.message("REVIEWERS"); - if (pr.reviewers.length === 0) { - renderer.message(" (none)"); - return; - } - for (const r of pr.reviewers) { - const name = r.account.displayName || r.account.nickname; - renderer.message( - ` ${REVIEW_GLYPH[r.state]} ${name} (${REVIEW_LABEL[r.state]})`, - ); - } + renderer.message(""); + renderer.message("REVIEWERS"); + if (pr.reviewers.length === 0) { + renderer.message(" (none)"); + return; + } + for (const r of pr.reviewers) { + const name = r.account.displayName || r.account.nickname; + renderer.message( + ` ${REVIEW_GLYPH[r.state]} ${name} (${REVIEW_LABEL[r.state]})`, + ); + } } function parseId(raw: string): number | null { - const n = Number(raw); - if (!Number.isInteger(n) || n <= 0) return null; - return n; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) return null; + return n; } diff --git a/src/commands/repo/current.ts b/src/commands/repo/current.ts index cd39a31..1845ee3 100644 --- a/src/commands/repo/current.ts +++ b/src/commands/repo/current.ts @@ -1,26 +1,26 @@ +import type { Renderer } from "../../shared/renderer/index.ts"; import { - resolveRepository, - RepositoryResolutionError, + RepositoryResolutionError, + resolveRepository, } from "../../shared/repository/index.ts"; -import type { Renderer } from "../../shared/renderer/index.ts"; export type CurrentOptions = { repository?: string }; export async function runRepoCurrent( - renderer: Renderer, - options: CurrentOptions, + renderer: Renderer, + options: CurrentOptions, ): Promise<void> { - try { - const ref = await resolveRepository({ override: options.repository }); - renderer.detail(ref, [ - { label: "WORKSPACE", value: (r) => r.workspace }, - { label: "REPO", value: (r) => r.slug }, - ]); - } catch (err) { - if (err instanceof RepositoryResolutionError) { - renderer.error(err.message); - process.exit(1); - } - throw err; - } + try { + const ref = await resolveRepository({ override: options.repository }); + renderer.detail(ref, [ + { label: "WORKSPACE", value: (r) => r.workspace }, + { label: "REPO", value: (r) => r.slug }, + ]); + } catch (err) { + if (err instanceof RepositoryResolutionError) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } } diff --git a/src/commands/repo/index.ts b/src/commands/repo/index.ts index 3cce7e7..64a607f 100644 --- a/src/commands/repo/index.ts +++ b/src/commands/repo/index.ts @@ -3,16 +3,16 @@ import { withRenderer } from "../../shared/renderer/commander.ts"; import { runRepoCurrent } from "./current.ts"; export function registerRepoCommands(program: Command): void { - const repo = program - .command("repo") - .description("Work with Bitbucket repositories"); + const repo = program + .command("repo") + .description("Work with Bitbucket repositories"); - repo - .command("current") - .description("Print the repository bbcli would act on") - .option( - "-R, --repository <workspace/repo>", - "Override repository detection", - ) - .action(withRenderer(runRepoCurrent)); + repo + .command("current") + .description("Print the repository bbcli would act on") + .option( + "-R, --repository <workspace/repo>", + "Override repository detection", + ) + .action(withRenderer(runRepoCurrent)); } diff --git a/src/commands/workspace/index.ts b/src/commands/workspace/index.ts index 6df59f0..7a5972a 100644 --- a/src/commands/workspace/index.ts +++ b/src/commands/workspace/index.ts @@ -3,13 +3,13 @@ import { withRenderer } from "../../shared/renderer/commander.ts"; import { runWorkspaceList } from "./list.ts"; export function registerWorkspaceCommands(program: Command): void { - const workspace = program - .command("workspace") - .alias("ws") - .description("Manage Bitbucket workspaces"); + const workspace = program + .command("workspace") + .alias("ws") + .description("Manage Bitbucket workspaces"); - workspace - .command("list") - .description("List workspaces you have access to") - .action(withRenderer(runWorkspaceList)); + workspace + .command("list") + .description("List workspaces you have access to") + .action(withRenderer(runWorkspaceList)); } diff --git a/src/commands/workspace/list.ts b/src/commands/workspace/list.ts index f6dbf06..8015eab 100644 --- a/src/commands/workspace/list.ts +++ b/src/commands/workspace/list.ts @@ -1,34 +1,34 @@ import { - listWorkspaces, - WorkspaceError, + listWorkspaces, + WorkspaceError, } from "../../backend/workspaces/index.ts"; import { loadConfigOrExit } from "../../shared/config/index.ts"; import type { Renderer } from "../../shared/renderer/index.ts"; export async function runWorkspaceList(renderer: Renderer): Promise<void> { - const config = await loadConfigOrExit(renderer); + const config = await loadConfigOrExit(renderer); - try { - const workspaces = await listWorkspaces(config); + try { + const workspaces = await listWorkspaces(config); - if (workspaces.length === 0) { - renderer.message("No workspaces found."); - return; - } + if (workspaces.length === 0) { + renderer.message("No workspaces found."); + return; + } - renderer.list(workspaces, [ - { header: "SLUG", value: (w) => w.slug }, - { - header: "ROLE", - value: (w) => (w.administrator ? "admin" : "member"), - style: "muted", - }, - ]); - } catch (err) { - if (err instanceof WorkspaceError) { - renderer.error(err.message); - process.exit(1); - } - throw err; - } + renderer.list(workspaces, [ + { header: "SLUG", value: (w) => w.slug }, + { + header: "ROLE", + value: (w) => (w.administrator ? "admin" : "member"), + style: "muted", + }, + ]); + } catch (err) { + if (err instanceof WorkspaceError) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } } diff --git a/src/index.ts b/src/index.ts index 0b42e62..1c7619d 100755 --- a/src/index.ts +++ b/src/index.ts @@ -8,10 +8,10 @@ import { registerWorkspaceCommands } from "./commands/workspace/index.ts"; const program = new Command(); program - .name("bb") - .description("CLI for interacting with Bitbucket Cloud") - .version("0.0.1") - .option("--json", "Output machine-readable JSON"); + .name("bb") + .description("CLI for interacting with Bitbucket Cloud") + .version("0.0.1") + .option("--json", "Output machine-readable JSON"); registerAuthCommands(program); registerPullRequestCommands(program); diff --git a/src/shared/bitbucket-http/index.ts b/src/shared/bitbucket-http/index.ts index be5760d..5bfd118 100644 --- a/src/shared/bitbucket-http/index.ts +++ b/src/shared/bitbucket-http/index.ts @@ -4,8 +4,8 @@ import type { paths } from "./generated"; export type BitbucketClient = Client<paths>; export type Credentials = { - email: string; - token: string; + email: string; + token: string; }; export const BASE_URL = "https://api.bitbucket.org/2.0"; @@ -16,7 +16,7 @@ export const BASE_URL = "https://api.bitbucket.org/2.0"; * follow opaque `next` URLs) can attach the same auth. */ export function basicAuthHeader(credentials: Credentials): string { - return `Basic ${btoa(`${credentials.email}:${credentials.token}`)}`; + return `Basic ${btoa(`${credentials.email}:${credentials.token}`)}`; } /** @@ -24,13 +24,13 @@ export function basicAuthHeader(credentials: Credentials): string { * Tests intercept the global `fetch` via msw; no injection seam needed. */ export function createBitbucketClient( - credentials: Credentials, + credentials: Credentials, ): BitbucketClient { - return createClient<paths>({ - baseUrl: BASE_URL, - headers: { - Authorization: basicAuthHeader(credentials), - Accept: "application/json", - }, - }); + return createClient<paths>({ + baseUrl: BASE_URL, + headers: { + Authorization: basicAuthHeader(credentials), + Accept: "application/json", + }, + }); } diff --git a/src/shared/bitbucket-http/paginate.test.ts b/src/shared/bitbucket-http/paginate.test.ts index b0ddd14..1a8dc96 100644 --- a/src/shared/bitbucket-http/paginate.test.ts +++ b/src/shared/bitbucket-http/paginate.test.ts @@ -1,11 +1,11 @@ -import { test, expect, describe } from "bun:test"; -import { http, HttpResponse } from "msw"; +import { describe, expect, test } from "bun:test"; +import { HttpResponse, http } from "msw"; +import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; import { - withPagination, - PaginationError, - type PaginatedResponse, + type PaginatedResponse, + PaginationError, + withPagination, } from "./paginate.ts"; -import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts"; setupMsw(); @@ -18,7 +18,7 @@ const NEXT_2 = `${FOLLOW_PATH}?page=2`; const NEXT_3 = `${FOLLOW_PATH}?page=3`; function pageBody(ids: number[], next?: string): PaginatedResponse<Item> { - return { values: ids.map((id) => ({ id })), ...(next ? { next } : {}) }; + return { values: ids.map((id) => ({ id })), ...(next ? { next } : {}) }; } /** @@ -28,148 +28,151 @@ function pageBody(ids: number[], next?: string): PaginatedResponse<Item> { * status explicitly. */ function mockFirstCall( - body: PaginatedResponse<Item>, - { status }: { status: number }, + body: PaginatedResponse<Item>, + { status }: { status: number }, ): () => Promise<{ data?: PaginatedResponse<Item>; response: Response }> { - return async () => ({ - data: status >= 200 && status < 300 ? body : undefined, - response: new Response(null, { status }), - }); + return async () => ({ + data: status >= 200 && status < 300 ? body : undefined, + response: new Response(null, { status }), + }); } describe("withPagination", () => { - test("returns items from the first page when no `next`", async () => { - const result = await withPagination( - mockFirstCall(pageBody([1, 2, 3]), { status: 200 }), - creds, - { limit: 100 }, - ); - expect(result.map((r) => r.id)).toEqual([1, 2, 3]); - }); - - test("truncates the first page when `limit` is smaller", async () => { - const result = await withPagination( - mockFirstCall(pageBody([1, 2, 3, 4, 5]), { status: 200 }), - creds, - { limit: 2 }, - ); - expect(result.map((r) => r.id)).toEqual([1, 2]); - }); - - test("follows `next` cursor and concatenates pages", async () => { - const seen: string[] = []; - server.use( - http.get(FOLLOW_PATH, ({ request }) => { - const url = new URL(request.url); - seen.push(url.toString()); - const pageNum = url.searchParams.get("page"); - if (pageNum === "2") return HttpResponse.json(pageBody([3, 4], NEXT_3)); - if (pageNum === "3") return HttpResponse.json(pageBody([5])); - return HttpResponse.json({ error: "unexpected" }, { status: 500 }); - }), - ); - - const result = await withPagination( - mockFirstCall(pageBody([1, 2], NEXT_2), { status: 200 }), - creds, - { limit: 100 }, - ); - - expect(result.map((r) => r.id)).toEqual([1, 2, 3, 4, 5]); - expect(seen).toEqual([NEXT_2, NEXT_3]); - }); - - test("stops fetching once limit is reached mid-page", async () => { - const seen: string[] = []; - server.use( - http.get(FOLLOW_PATH, ({ request }) => { - const url = new URL(request.url); - seen.push(url.toString()); - const pageNum = url.searchParams.get("page"); - if (pageNum === "2") return HttpResponse.json(pageBody([3, 4, 5], NEXT_3)); - return HttpResponse.json({ error: "should not have requested" }, { status: 500 }); - }), - ); - - const result = await withPagination( - mockFirstCall(pageBody([1, 2], NEXT_2), { status: 200 }), - creds, - { limit: 4 }, - ); - - expect(result.map((r) => r.id)).toEqual([1, 2, 3, 4]); - expect(seen).toEqual([NEXT_2]); - }); - - test("attaches Basic auth on cursor-follow requests", async () => { - let seenAuth = null as string | null; - let seenAccept = null as string | null; - server.use( - http.get(FOLLOW_PATH, ({ request }) => { - seenAuth = request.headers.get("authorization"); - seenAccept = request.headers.get("accept"); - return HttpResponse.json(pageBody([2])); - }), - ); - - await withPagination( - mockFirstCall(pageBody([1], NEXT_2), { status: 200 }), - creds, - { limit: 100 }, - ); - - expect(seenAuth!).toBe("Basic " + btoa("a@b.co:t")); - expect(seenAccept!).toBe("application/json"); - }); - - test("refuses to follow a cross-origin `next` URL", async () => { - const err = await withPagination( - mockFirstCall( - pageBody([1], "https://evil.example.com/2.0/foo"), - { status: 200 }, - ), - creds, - { limit: 100 }, - ).catch((e) => e); - - expect(err).toBeInstanceOf(PaginationError); - expect((err as Error).message).toContain("evil.example.com"); - }); - - test("throws PaginationError when first call returns a non-ok response", async () => { - const err = await withPagination( - mockFirstCall({ values: [] }, { status: 500 }), - creds, - { limit: 100 }, - ).catch((e) => e); - - expect(err).toBeInstanceOf(PaginationError); - expect((err as PaginationError).status).toBe(500); - }); - - test("throws PaginationError when cursor-follow returns a non-ok response", async () => { - server.use( - http.get(FOLLOW_PATH, () => - HttpResponse.json({ type: "error" }, { status: 500 }), - ), - ); - - const err = await withPagination( - mockFirstCall(pageBody([1], NEXT_2), { status: 200 }), - creds, - { limit: 100 }, - ).catch((e) => e); - - expect(err).toBeInstanceOf(PaginationError); - expect((err as PaginationError).status).toBe(500); - }); - - test("handles a missing `values` array as empty", async () => { - const result = await withPagination( - mockFirstCall({}, { status: 200 }), - creds, - { limit: 100 }, - ); - expect(result).toEqual([]); - }); + test("returns items from the first page when no `next`", async () => { + const result = await withPagination( + mockFirstCall(pageBody([1, 2, 3]), { status: 200 }), + creds, + { limit: 100 }, + ); + expect(result.map((r) => r.id)).toEqual([1, 2, 3]); + }); + + test("truncates the first page when `limit` is smaller", async () => { + const result = await withPagination( + mockFirstCall(pageBody([1, 2, 3, 4, 5]), { status: 200 }), + creds, + { limit: 2 }, + ); + expect(result.map((r) => r.id)).toEqual([1, 2]); + }); + + test("follows `next` cursor and concatenates pages", async () => { + const seen: string[] = []; + server.use( + http.get(FOLLOW_PATH, ({ request }) => { + const url = new URL(request.url); + seen.push(url.toString()); + const pageNum = url.searchParams.get("page"); + if (pageNum === "2") return HttpResponse.json(pageBody([3, 4], NEXT_3)); + if (pageNum === "3") return HttpResponse.json(pageBody([5])); + return HttpResponse.json({ error: "unexpected" }, { status: 500 }); + }), + ); + + const result = await withPagination( + mockFirstCall(pageBody([1, 2], NEXT_2), { status: 200 }), + creds, + { limit: 100 }, + ); + + expect(result.map((r) => r.id)).toEqual([1, 2, 3, 4, 5]); + expect(seen).toEqual([NEXT_2, NEXT_3]); + }); + + test("stops fetching once limit is reached mid-page", async () => { + const seen: string[] = []; + server.use( + http.get(FOLLOW_PATH, ({ request }) => { + const url = new URL(request.url); + seen.push(url.toString()); + const pageNum = url.searchParams.get("page"); + if (pageNum === "2") + return HttpResponse.json(pageBody([3, 4, 5], NEXT_3)); + return HttpResponse.json( + { error: "should not have requested" }, + { status: 500 }, + ); + }), + ); + + const result = await withPagination( + mockFirstCall(pageBody([1, 2], NEXT_2), { status: 200 }), + creds, + { limit: 4 }, + ); + + expect(result.map((r) => r.id)).toEqual([1, 2, 3, 4]); + expect(seen).toEqual([NEXT_2]); + }); + + test("attaches Basic auth on cursor-follow requests", async () => { + let seenAuth = null as string | null; + let seenAccept = null as string | null; + server.use( + http.get(FOLLOW_PATH, ({ request }) => { + seenAuth = request.headers.get("authorization"); + seenAccept = request.headers.get("accept"); + return HttpResponse.json(pageBody([2])); + }), + ); + + await withPagination( + mockFirstCall(pageBody([1], NEXT_2), { status: 200 }), + creds, + { limit: 100 }, + ); + + expect(seenAuth!).toBe(`Basic ${btoa("a@b.co:t")}`); + expect(seenAccept!).toBe("application/json"); + }); + + test("refuses to follow a cross-origin `next` URL", async () => { + const err = await withPagination( + mockFirstCall(pageBody([1], "https://evil.example.com/2.0/foo"), { + status: 200, + }), + creds, + { limit: 100 }, + ).catch((e) => e); + + expect(err).toBeInstanceOf(PaginationError); + expect((err as Error).message).toContain("evil.example.com"); + }); + + test("throws PaginationError when first call returns a non-ok response", async () => { + const err = await withPagination( + mockFirstCall({ values: [] }, { status: 500 }), + creds, + { limit: 100 }, + ).catch((e) => e); + + expect(err).toBeInstanceOf(PaginationError); + expect((err as PaginationError).status).toBe(500); + }); + + test("throws PaginationError when cursor-follow returns a non-ok response", async () => { + server.use( + http.get(FOLLOW_PATH, () => + HttpResponse.json({ type: "error" }, { status: 500 }), + ), + ); + + const err = await withPagination( + mockFirstCall(pageBody([1], NEXT_2), { status: 200 }), + creds, + { limit: 100 }, + ).catch((e) => e); + + expect(err).toBeInstanceOf(PaginationError); + expect((err as PaginationError).status).toBe(500); + }); + + test("handles a missing `values` array as empty", async () => { + const result = await withPagination( + mockFirstCall({}, { status: 200 }), + creds, + { limit: 100 }, + ); + expect(result).toEqual([]); + }); }); diff --git a/src/shared/bitbucket-http/paginate.ts b/src/shared/bitbucket-http/paginate.ts index 8dd18e8..727e366 100644 --- a/src/shared/bitbucket-http/paginate.ts +++ b/src/shared/bitbucket-http/paginate.ts @@ -1,16 +1,12 @@ -import { - BASE_URL, - basicAuthHeader, - type Credentials, -} from "./index.ts"; +import { BASE_URL, basicAuthHeader, type Credentials } from "./index.ts"; /** * Shape Bitbucket returns for every paginated list endpoint. Generic over * the item type so callers keep type safety on `values`. */ export type PaginatedResponse<T> = { - values?: T[]; - next?: string; + values?: T[]; + next?: string; }; /** @@ -19,18 +15,18 @@ export type PaginatedResponse<T> = { * `() => client.GET(...)` directly; T is inferred from the GET's response. */ type FirstCallResult<T> = { - data?: PaginatedResponse<T>; - response: Response; + data?: PaginatedResponse<T>; + response: Response; }; export class PaginationError extends Error { - readonly status: number | undefined; + readonly status: number | undefined; - constructor(message: string, status?: number) { - super(message); - this.name = "PaginationError"; - this.status = status; - } + constructor(message: string, status?: number) { + super(message); + this.name = "PaginationError"; + this.status = status; + } } const ALLOWED_ORIGIN = new URL(BASE_URL).origin; @@ -55,66 +51,62 @@ const ALLOWED_ORIGIN = new URL(BASE_URL).origin; * Bitbucket's API host. */ export async function withPagination<T>( - firstCall: () => Promise<FirstCallResult<T>>, - credentials: Credentials, - opts: { limit: number }, + firstCall: () => Promise<FirstCallResult<T>>, + credentials: Credentials, + opts: { limit: number }, ): Promise<T[]> { - const { data, response } = await firstCall(); + const { data, response } = await firstCall(); - if (!response.ok || !data) { - throw new PaginationError( - `Failed to fetch first page: HTTP ${response.status}.`, - response.status, - ); - } + if (!response.ok || !data) { + throw new PaginationError( + `Failed to fetch first page: HTTP ${response.status}.`, + response.status, + ); + } - const taken = (data.values ?? []).slice(0, opts.limit); + const taken = (data.values ?? []).slice(0, opts.limit); - if (taken.length < opts.limit && data.next) { - const rest = await followCursor<T>( - data.next, - credentials, - { limit: opts.limit - taken.length }, - ); - return [...taken, ...rest]; - } - return taken; + if (taken.length < opts.limit && data.next) { + const rest = await followCursor<T>(data.next, credentials, { + limit: opts.limit - taken.length, + }); + return [...taken, ...rest]; + } + return taken; } async function followCursor<T>( - url: string, - credentials: Credentials, - opts: { limit: number }, + url: string, + credentials: Credentials, + opts: { limit: number }, ): Promise<T[]> { - if (new URL(url).origin !== ALLOWED_ORIGIN) { - throw new PaginationError( - `Refusing to follow next URL outside ${ALLOWED_ORIGIN}: ${url}`, - ); - } + if (new URL(url).origin !== ALLOWED_ORIGIN) { + throw new PaginationError( + `Refusing to follow next URL outside ${ALLOWED_ORIGIN}: ${url}`, + ); + } - const response = await fetch(url, { - headers: { - Authorization: basicAuthHeader(credentials), - Accept: "application/json", - }, - }); - if (!response.ok) { - throw new PaginationError( - `Failed to fetch next page: HTTP ${response.status}.`, - response.status, - ); - } + const response = await fetch(url, { + headers: { + Authorization: basicAuthHeader(credentials), + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new PaginationError( + `Failed to fetch next page: HTTP ${response.status}.`, + response.status, + ); + } - const page = (await response.json()) as PaginatedResponse<T>; - const taken = (page.values ?? []).slice(0, opts.limit); + const page = (await response.json()) as PaginatedResponse<T>; + const taken = (page.values ?? []).slice(0, opts.limit); - if (taken.length < opts.limit && page.next) { - const rest = await followCursor<T>( - page.next, - credentials, - { limit: opts.limit - taken.length }, - ); - return [...taken, ...rest]; - } - return taken; + if (taken.length < opts.limit && page.next) { + const rest = await followCursor<T>(page.next, credentials, { + limit: opts.limit - taken.length, + }); + return [...taken, ...rest]; + } + return taken; } diff --git a/src/shared/config/fixtures/bad-command-type.json b/src/shared/config/fixtures/bad-command-type.json index c50815c..9b90e07 100644 --- a/src/shared/config/fixtures/bad-command-type.json +++ b/src/shared/config/fixtures/bad-command-type.json @@ -1,4 +1,4 @@ { - "email": "alice@example.com", - "token_command": ["echo", 123] + "email": "alice@example.com", + "token_command": ["echo", 123] } diff --git a/src/shared/config/fixtures/both-set.json b/src/shared/config/fixtures/both-set.json index 16aac30..05c29fb 100644 --- a/src/shared/config/fixtures/both-set.json +++ b/src/shared/config/fixtures/both-set.json @@ -1,5 +1,5 @@ { - "email": "alice@example.com", - "token": "t", - "token_command": ["echo", "x"] + "email": "alice@example.com", + "token": "t", + "token_command": ["echo", "x"] } diff --git a/src/shared/config/fixtures/command-empty-output.json b/src/shared/config/fixtures/command-empty-output.json index 4dfcdbc..38b1ffa 100644 --- a/src/shared/config/fixtures/command-empty-output.json +++ b/src/shared/config/fixtures/command-empty-output.json @@ -1,4 +1,4 @@ { - "email": "alice@example.com", - "token_command": ["true"] + "email": "alice@example.com", + "token_command": ["true"] } diff --git a/src/shared/config/fixtures/command-exits-nonzero.json b/src/shared/config/fixtures/command-exits-nonzero.json index e9d34f6..53e258a 100644 --- a/src/shared/config/fixtures/command-exits-nonzero.json +++ b/src/shared/config/fixtures/command-exits-nonzero.json @@ -1,4 +1,4 @@ { - "email": "alice@example.com", - "token_command": ["false"] + "email": "alice@example.com", + "token_command": ["false"] } diff --git a/src/shared/config/fixtures/command-not-found.json b/src/shared/config/fixtures/command-not-found.json index ca9cda9..9693973 100644 --- a/src/shared/config/fixtures/command-not-found.json +++ b/src/shared/config/fixtures/command-not-found.json @@ -1,4 +1,4 @@ { - "email": "alice@example.com", - "token_command": ["bbcli-definitely-not-a-real-binary-xyz"] + "email": "alice@example.com", + "token_command": ["bbcli-definitely-not-a-real-binary-xyz"] } diff --git a/src/shared/config/fixtures/empty-email.json b/src/shared/config/fixtures/empty-email.json index de51ffa..a5fb2b4 100644 --- a/src/shared/config/fixtures/empty-email.json +++ b/src/shared/config/fixtures/empty-email.json @@ -1,4 +1,4 @@ { - "email": "", - "token": "t" + "email": "", + "token": "t" } diff --git a/src/shared/config/fixtures/missing-email.json b/src/shared/config/fixtures/missing-email.json index f3cb608..398bcc4 100644 --- a/src/shared/config/fixtures/missing-email.json +++ b/src/shared/config/fixtures/missing-email.json @@ -1,3 +1,3 @@ { - "token": "t" + "token": "t" } diff --git a/src/shared/config/fixtures/neither-set.json b/src/shared/config/fixtures/neither-set.json index 17acdf4..c09f201 100644 --- a/src/shared/config/fixtures/neither-set.json +++ b/src/shared/config/fixtures/neither-set.json @@ -1,3 +1,3 @@ { - "email": "alice@example.com" + "email": "alice@example.com" } diff --git a/src/shared/config/fixtures/valid-command.json b/src/shared/config/fixtures/valid-command.json index 1ab43b2..25c5f99 100644 --- a/src/shared/config/fixtures/valid-command.json +++ b/src/shared/config/fixtures/valid-command.json @@ -1,4 +1,4 @@ { - "email": "alice@example.com", - "token_command": ["echo", "from-command"] + "email": "alice@example.com", + "token_command": ["echo", "from-command"] } diff --git a/src/shared/config/fixtures/valid-token.json b/src/shared/config/fixtures/valid-token.json index 8085428..4258303 100644 --- a/src/shared/config/fixtures/valid-token.json +++ b/src/shared/config/fixtures/valid-token.json @@ -1,4 +1,4 @@ { - "email": "alice@example.com", - "token": "test-token" + "email": "alice@example.com", + "token": "test-token" } diff --git a/src/shared/config/index.test.ts b/src/shared/config/index.test.ts index caba97f..c43efa4 100644 --- a/src/shared/config/index.test.ts +++ b/src/shared/config/index.test.ts @@ -1,102 +1,112 @@ -import { test, expect, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { join } from "node:path"; -import { loadConfig, ConfigError } from "./index.ts"; +import { ConfigError, loadConfig } from "./index.ts"; const fixturesDir = join(import.meta.dir, "fixtures"); const fixture = (name: string) => join(fixturesDir, name); -const stripPath = (msg: string) => msg.replaceAll(fixturesDir + "/", ""); +const stripPath = (msg: string) => msg.replaceAll(`${fixturesDir}/`, ""); async function expectConfigError(fixtureName: string): Promise<string> { - const err = await loadConfig(fixture(fixtureName)).catch((e) => e); - expect(err).toBeInstanceOf(ConfigError); - return stripPath((err as ConfigError).message); + const err = await loadConfig(fixture(fixtureName)).catch((e) => e); + expect(err).toBeInstanceOf(ConfigError); + return stripPath((err as ConfigError).message); } describe("loadConfig", () => { - // --- happy paths --- + // --- happy paths --- - test("loads plaintext token", async () => { - const cfg = await loadConfig(fixture("valid-token.json")); - expect(cfg).toMatchInlineSnapshot(` + test("loads plaintext token", async () => { + const cfg = await loadConfig(fixture("valid-token.json")); + expect(cfg).toMatchInlineSnapshot(` { "email": "alice@example.com", "token": "test-token", } `); - }); + }); - test("resolves token via token_command and trims trailing whitespace", async () => { - const cfg = await loadConfig(fixture("valid-command.json")); - expect(cfg).toMatchInlineSnapshot(` + test("resolves token via token_command and trims trailing whitespace", async () => { + const cfg = await loadConfig(fixture("valid-command.json")); + expect(cfg).toMatchInlineSnapshot(` { "email": "alice@example.com", "token": "from-command", } `); - }); + }); - // --- config file errors --- + // --- config file errors --- - test("rejects missing config file", async () => { - const msg = await expectConfigError("does-not-exist.json"); - expect(msg).toMatchInlineSnapshot(`"No config file found at does-not-exist.json."`); - }); + test("rejects missing config file", async () => { + const msg = await expectConfigError("does-not-exist.json"); + expect(msg).toMatchInlineSnapshot( + `"No config file found at does-not-exist.json."`, + ); + }); - test("rejects malformed JSON", async () => { - const msg = await expectConfigError("not-valid.txt"); - expect(msg).toMatchInlineSnapshot(`"not-valid.txt is not valid JSON."`); - }); + test("rejects malformed JSON", async () => { + const msg = await expectConfigError("not-valid.txt"); + expect(msg).toMatchInlineSnapshot(`"not-valid.txt is not valid JSON."`); + }); - // --- schema validation errors --- + // --- schema validation errors --- - test("rejects missing email", async () => { - const msg = await expectConfigError("missing-email.json"); - expect(msg).toMatchInlineSnapshot(` + test("rejects missing email", async () => { + const msg = await expectConfigError("missing-email.json"); + expect(msg).toMatchInlineSnapshot(` "Invalid config in missing-email.json: ✖ Invalid input: expected string, received undefined → at email" `); - }); + }); - test("rejects empty email", async () => { - const msg = await expectConfigError("empty-email.json"); - expect(msg).toMatchInlineSnapshot(` + test("rejects empty email", async () => { + const msg = await expectConfigError("empty-email.json"); + expect(msg).toMatchInlineSnapshot(` "Invalid config in empty-email.json: ✖ Too small: expected string to have >=1 characters → at email" `); - }); - - test("rejects both token and token_command", async () => { - const msg = await expectConfigError("both-set.json"); - expect(msg).toMatchInlineSnapshot(`"Invalid config in both-set.json: ✖ Exactly one of "token" or "token_command" must be set."`); - }); - - test("rejects neither token nor token_command", async () => { - const msg = await expectConfigError("neither-set.json"); - expect(msg).toMatchInlineSnapshot(`"Invalid config in neither-set.json: ✖ Exactly one of "token" or "token_command" must be set."`); - }); - - test("rejects token_command with non-string elements", async () => { - const msg = await expectConfigError("bad-command-type.json"); - expect(msg).toMatchInlineSnapshot(` + }); + + test("rejects both token and token_command", async () => { + const msg = await expectConfigError("both-set.json"); + expect(msg).toMatchInlineSnapshot( + `"Invalid config in both-set.json: ✖ Exactly one of "token" or "token_command" must be set."`, + ); + }); + + test("rejects neither token nor token_command", async () => { + const msg = await expectConfigError("neither-set.json"); + expect(msg).toMatchInlineSnapshot( + `"Invalid config in neither-set.json: ✖ Exactly one of "token" or "token_command" must be set."`, + ); + }); + + test("rejects token_command with non-string elements", async () => { + const msg = await expectConfigError("bad-command-type.json"); + expect(msg).toMatchInlineSnapshot(` "Invalid config in bad-command-type.json: ✖ Invalid input: expected string, received number → at token_command[1]" `); - }); - - // --- token_command execution errors --- - - test("rejects token_command that exits non-zero", async () => { - const msg = await expectConfigError("command-exits-nonzero.json"); - expect(msg).toMatchInlineSnapshot(`"token_command exited with code 1: (no output)"`); - }); - - test("rejects token_command that produces empty output", async () => { - const msg = await expectConfigError("command-empty-output.json"); - expect(msg).toMatchInlineSnapshot(`"token_command produced no output."`); - }); - - test("rejects token_command with nonexistent binary", async () => { - const err = await loadConfig(fixture("command-not-found.json")).catch((e) => e); - expect(err).toBeInstanceOf(ConfigError); - }); + }); + + // --- token_command execution errors --- + + test("rejects token_command that exits non-zero", async () => { + const msg = await expectConfigError("command-exits-nonzero.json"); + expect(msg).toMatchInlineSnapshot( + `"token_command exited with code 1: (no output)"`, + ); + }); + + test("rejects token_command that produces empty output", async () => { + const msg = await expectConfigError("command-empty-output.json"); + expect(msg).toMatchInlineSnapshot(`"token_command produced no output."`); + }); + + test("rejects token_command with nonexistent binary", async () => { + const err = await loadConfig(fixture("command-not-found.json")).catch( + (e) => e, + ); + expect(err).toBeInstanceOf(ConfigError); + }); }); diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts index 29f19ac..5575c28 100644 --- a/src/shared/config/index.ts +++ b/src/shared/config/index.ts @@ -1,122 +1,124 @@ -import { z } from "zod/v4"; import { homedir } from "node:os"; import { join } from "node:path"; +import { z } from "zod/v4"; import type { Renderer } from "../renderer/index.ts"; export type Config = { email: string; token: string }; export class ConfigError extends Error { - override name = "ConfigError"; + override name = "ConfigError"; } export function defaultConfigPath(): string { - const base = process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"); - return join(base, "bbcli", "config.json"); + const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); + return join(base, "bbcli", "config.json"); } const configSchema = z - .object({ - email: z.string().min(1), - token: z.string().min(1).optional(), - token_command: z.array(z.string()).min(1).optional(), - }) - .refine( - (c) => Boolean(c.token) !== Boolean(c.token_command), - 'Exactly one of "token" or "token_command" must be set.', - ); + .object({ + email: z.string().min(1), + token: z.string().min(1).optional(), + token_command: z.array(z.string()).min(1).optional(), + }) + .refine( + (c) => Boolean(c.token) !== Boolean(c.token_command), + 'Exactly one of "token" or "token_command" must be set.', + ); export async function loadConfig(path = defaultConfigPath()): Promise<Config> { - const file = Bun.file(path); - if (!(await file.exists())) - throw new ConfigError(`No config file found at ${path}.`); - - let raw: unknown; - try { - raw = await file.json(); - } catch { - throw new ConfigError(`${path} is not valid JSON.`); - } - - const result = configSchema.safeParse(raw); - if (!result.success) - throw new ConfigError(`Invalid config in ${path}: ${z.prettifyError(result.error)}`); - - const { email, token, token_command } = result.data; - const resolvedToken = token ?? (await runTokenCommand(token_command!)); - - return { email, token: resolvedToken }; + const file = Bun.file(path); + if (!(await file.exists())) + throw new ConfigError(`No config file found at ${path}.`); + + let raw: unknown; + try { + raw = await file.json(); + } catch { + throw new ConfigError(`${path} is not valid JSON.`); + } + + const result = configSchema.safeParse(raw); + if (!result.success) + throw new ConfigError( + `Invalid config in ${path}: ${z.prettifyError(result.error)}`, + ); + + const { email, token, token_command } = result.data; + const resolvedToken = token ?? (await runTokenCommand(token_command!)); + + return { email, token: resolvedToken }; } const TOKEN_URL = "https://id.atlassian.com/manage-profile/security/api-tokens"; export async function loadConfigOrExit(renderer: Renderer): Promise<Config> { - try { - return await loadConfig(); - } catch (err) { - if (err instanceof ConfigError) { - renderer.error(buildSetupInstructions(err.message)); - process.exit(1); - } - throw err; - } + try { + return await loadConfig(); + } catch (err) { + if (err instanceof ConfigError) { + renderer.error(buildSetupInstructions(err.message)); + process.exit(1); + } + throw err; + } } function buildSetupInstructions(reason: string): string { - const lines = [ - reason, - "", - "To configure bbcli:", - "", - ` 1. Create an API token at ${TOKEN_URL}`, - "", - ` 2. Create ${defaultConfigPath()} with your Atlassian email and a way`, - " to retrieve the token. The recommended shape fetches the token", - " from your system keyring via a command:", - "", - " {", - ' "email": "you@example.com",', - ' "token_command": [', - ' "secret-tool", "lookup",', - ' "service", "bbcli",', - ' "account", "bitbucket_api_token"', - " ]", - " }", - "", - " Then store the token in your keyring:", - "", - " secret-tool store --label='bbcli' \\", - " service bbcli account bitbucket_api_token", - "", - ' Alternatively, put the token directly in the config as a "token"', - " field. If you do, chmod 600 the config file.", - "", - " 3. Re-run `bb auth status` to verify.", - ]; - return lines.join("\n"); + const lines = [ + reason, + "", + "To configure bbcli:", + "", + ` 1. Create an API token at ${TOKEN_URL}`, + "", + ` 2. Create ${defaultConfigPath()} with your Atlassian email and a way`, + " to retrieve the token. The recommended shape fetches the token", + " from your system keyring via a command:", + "", + " {", + ' "email": "you@example.com",', + ' "token_command": [', + ' "secret-tool", "lookup",', + ' "service", "bbcli",', + ' "account", "bitbucket_api_token"', + " ]", + " }", + "", + " Then store the token in your keyring:", + "", + " secret-tool store --label='bbcli' \\", + " service bbcli account bitbucket_api_token", + "", + ' Alternatively, put the token directly in the config as a "token"', + " field. If you do, chmod 600 the config file.", + "", + " 3. Re-run `bb auth status` to verify.", + ]; + return lines.join("\n"); } async function runTokenCommand(argv: string[]): Promise<string> { - let proc; - try { - proc = Bun.spawn({ cmd: argv, stdout: "pipe", stderr: "pipe" }); - } catch (err) { - throw new ConfigError( - `Failed to run token_command: ${(err as Error).message}`, - ); - } - - const [stdout, stderr, code] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - - if (code !== 0) - throw new ConfigError( - `token_command exited with code ${code}: ${(stderr || stdout).trim() || "(no output)"}`, - ); - - const result = stdout.trimEnd(); - if (!result) throw new ConfigError("token_command produced no output."); - return result; + let proc: Bun.Subprocess<"ignore", "pipe", "pipe">; + try { + proc = Bun.spawn({ cmd: argv, stdout: "pipe", stderr: "pipe" }); + } catch (err) { + throw new ConfigError( + `Failed to run token_command: ${(err as Error).message}`, + ); + } + + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (code !== 0) + throw new ConfigError( + `token_command exited with code ${code}: ${(stderr || stdout).trim() || "(no output)"}`, + ); + + const result = stdout.trimEnd(); + if (!result) throw new ConfigError("token_command produced no output."); + return result; } diff --git a/src/shared/editor/index.test.ts b/src/shared/editor/index.test.ts index ded6cc9..7417bf8 100644 --- a/src/shared/editor/index.test.ts +++ b/src/shared/editor/index.test.ts @@ -1,9 +1,16 @@ -import { test, expect, describe, afterEach, beforeAll, afterAll } from "bun:test"; -import { $ } from "bun"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { openEditor, EditorError } from "./index.ts"; +import { $ } from "bun"; +import { EditorError, openEditor } from "./index.ts"; /** * Integration tests. Real editors are interactive; we point `$EDITOR` at a @@ -16,67 +23,70 @@ let visualScript: string; let failScript: string; beforeAll(async () => { - scriptDir = await mkdtemp(join(tmpdir(), "bbcli-editor-test-")); - - appendScript = join(scriptDir, "append.sh"); - await Bun.write(appendScript, '#!/bin/sh\necho "hello from editor" >> "$1"\n'); - await $`chmod +x ${appendScript}`.quiet(); - - visualScript = join(scriptDir, "visual.sh"); - await Bun.write(visualScript, '#!/bin/sh\necho "VISUAL_ran" >> "$1"\n'); - await $`chmod +x ${visualScript}`.quiet(); - - failScript = join(scriptDir, "fail.sh"); - await Bun.write(failScript, "#!/bin/sh\nexit 2\n"); - await $`chmod +x ${failScript}`.quiet(); + scriptDir = await mkdtemp(join(tmpdir(), "bbcli-editor-test-")); + + appendScript = join(scriptDir, "append.sh"); + await Bun.write( + appendScript, + '#!/bin/sh\necho "hello from editor" >> "$1"\n', + ); + await $`chmod +x ${appendScript}`.quiet(); + + visualScript = join(scriptDir, "visual.sh"); + await Bun.write(visualScript, '#!/bin/sh\necho "VISUAL_ran" >> "$1"\n'); + await $`chmod +x ${visualScript}`.quiet(); + + failScript = join(scriptDir, "fail.sh"); + await Bun.write(failScript, "#!/bin/sh\nexit 2\n"); + await $`chmod +x ${failScript}`.quiet(); }); afterAll(async () => { - if (scriptDir) await rm(scriptDir, { recursive: true, force: true }); + if (scriptDir) await rm(scriptDir, { recursive: true, force: true }); }); -const originalEditor = process.env["EDITOR"]; -const originalVisual = process.env["VISUAL"]; +const originalEditor = process.env.EDITOR; +const originalVisual = process.env.VISUAL; afterEach(() => { - if (originalEditor === undefined) delete process.env["EDITOR"]; - else process.env["EDITOR"] = originalEditor; - if (originalVisual === undefined) delete process.env["VISUAL"]; - else process.env["VISUAL"] = originalVisual; + if (originalEditor === undefined) delete process.env.EDITOR; + else process.env.EDITOR = originalEditor; + if (originalVisual === undefined) delete process.env.VISUAL; + else process.env.VISUAL = originalVisual; }); describe("openEditor", () => { - test("invokes $EDITOR and returns the file contents after exit", async () => { - process.env["EDITOR"] = appendScript; - delete process.env["VISUAL"]; - - const result = await openEditor("seed line\n"); - expect(result).toBe("seed line\nhello from editor\n"); - }); - - test("prefers $VISUAL over $EDITOR", async () => { - process.env["EDITOR"] = appendScript; - process.env["VISUAL"] = visualScript; - - const result = await openEditor(); - expect(result).toContain("VISUAL_ran"); - expect(result).not.toContain("hello from editor"); - }); - - test("throws EditorError when neither env var is set", async () => { - delete process.env["EDITOR"]; - delete process.env["VISUAL"]; - - const err = await openEditor().catch((e) => e); - expect(err).toBeInstanceOf(EditorError); - }); - - test("throws EditorError when the editor exits non-zero", async () => { - process.env["EDITOR"] = failScript; - delete process.env["VISUAL"]; - - const err = await openEditor().catch((e) => e); - expect(err).toBeInstanceOf(EditorError); - expect((err as Error).message).toContain("code 2"); - }); + test("invokes $EDITOR and returns the file contents after exit", async () => { + process.env.EDITOR = appendScript; + delete process.env.VISUAL; + + const result = await openEditor("seed line\n"); + expect(result).toBe("seed line\nhello from editor\n"); + }); + + test("prefers $VISUAL over $EDITOR", async () => { + process.env.EDITOR = appendScript; + process.env.VISUAL = visualScript; + + const result = await openEditor(); + expect(result).toContain("VISUAL_ran"); + expect(result).not.toContain("hello from editor"); + }); + + test("throws EditorError when neither env var is set", async () => { + delete process.env.EDITOR; + delete process.env.VISUAL; + + const err = await openEditor().catch((e) => e); + expect(err).toBeInstanceOf(EditorError); + }); + + test("throws EditorError when the editor exits non-zero", async () => { + process.env.EDITOR = failScript; + delete process.env.VISUAL; + + const err = await openEditor().catch((e) => e); + expect(err).toBeInstanceOf(EditorError); + expect((err as Error).message).toContain("code 2"); + }); }); diff --git a/src/shared/editor/index.ts b/src/shared/editor/index.ts index 7305d32..282b070 100644 --- a/src/shared/editor/index.ts +++ b/src/shared/editor/index.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; export class EditorError extends Error { - override name = "EditorError"; + override name = "EditorError"; } /** @@ -15,38 +15,36 @@ export class EditorError extends Error { * vim/nvim/nano/etc.). On non-zero exit the tempfile is still cleaned up. */ export async function openEditor(initialContents = ""): Promise<string> { - const raw = process.env["VISUAL"] ?? process.env["EDITOR"]; - if (!raw || !raw.trim()) { - throw new EditorError( - "No editor configured. Set $VISUAL or $EDITOR (e.g. export EDITOR=nvim).", - ); - } + const raw = process.env.VISUAL ?? process.env.EDITOR; + if (!raw?.trim()) { + throw new EditorError( + "No editor configured. Set $VISUAL or $EDITOR (e.g. export EDITOR=nvim).", + ); + } - // Env var may carry args (`code --wait`, `nvim -c 'set ft=markdown'`), so - // split on whitespace rather than exec'ing the whole string as one binary. - // This doesn't honor shell quoting, but matches how git and gh parse it. - const [exe, ...args] = raw.trim().split(/\s+/); + // Env var may carry args (`code --wait`, `nvim -c 'set ft=markdown'`), so + // split on whitespace rather than exec'ing the whole string as one binary. + // This doesn't honor shell quoting, but matches how git and gh parse it. + const [exe, ...args] = raw.trim().split(/\s+/); - const dir = await mkdtemp(join(tmpdir(), "bbcli-editor-")); - const path = join(dir, "message.md"); + const dir = await mkdtemp(join(tmpdir(), "bbcli-editor-")); + const path = join(dir, "message.md"); - try { - await Bun.write(path, initialContents); + try { + await Bun.write(path, initialContents); - const proc = Bun.spawn([exe!, ...args, path], { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }); - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new EditorError( - `Editor (${raw}) exited with code ${exitCode}.`, - ); - } + const proc = Bun.spawn([exe!, ...args, path], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new EditorError(`Editor (${raw}) exited with code ${exitCode}.`); + } - return await Bun.file(path).text(); - } finally { - await rm(dir, { recursive: true, force: true }); - } + return await Bun.file(path).text(); + } finally { + await rm(dir, { recursive: true, force: true }); + } } diff --git a/src/shared/renderer/commander.ts b/src/shared/renderer/commander.ts index 94f8d05..48f0589 100644 --- a/src/shared/renderer/commander.ts +++ b/src/shared/renderer/commander.ts @@ -7,8 +7,8 @@ import type { Renderer } from "./types.ts"; * `--json` flag. */ export function rendererFrom(cmd: Command): Renderer { - const json = Boolean(cmd.optsWithGlobals().json); - return createRenderer({ json }); + const json = Boolean(cmd.optsWithGlobals().json); + return createRenderer({ json }); } /** @@ -19,11 +19,11 @@ export function rendererFrom(cmd: Command): Renderer { * at the registration site. */ export function withRenderer<A extends unknown[]>( - runner: (renderer: Renderer, ...rest: A) => void | Promise<void>, + runner: (renderer: Renderer, ...rest: A) => void | Promise<void>, ): (...args: unknown[]) => Promise<void> { - return async (...args: unknown[]) => { - const cmd = args[args.length - 1] as Command; - const rest = args.slice(0, -1) as A; - await runner(rendererFrom(cmd), ...rest); - }; + return async (...args: unknown[]) => { + const cmd = args[args.length - 1] as Command; + const rest = args.slice(0, -1) as A; + await runner(rendererFrom(cmd), ...rest); + }; } diff --git a/src/shared/renderer/index.ts b/src/shared/renderer/index.ts index 55e7849..075b0d4 100644 --- a/src/shared/renderer/index.ts +++ b/src/shared/renderer/index.ts @@ -5,5 +5,5 @@ import type { Renderer } from "./types.ts"; export type { Column, Field, Renderer, Style } from "./types.ts"; export function createRenderer({ json }: { json: boolean }): Renderer { - return json ? createJsonRenderer() : createTextRenderer(); + return json ? createJsonRenderer() : createTextRenderer(); } diff --git a/src/shared/renderer/json.test.ts b/src/shared/renderer/json.test.ts index 89fe636..bc0ffb7 100644 --- a/src/shared/renderer/json.test.ts +++ b/src/shared/renderer/json.test.ts @@ -1,57 +1,55 @@ -import { test, expect, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { createJsonRenderer } from "./json.ts"; function capture() { - const out: string[] = []; - const err: string[] = []; - const renderer = createJsonRenderer({ - stdout: (s) => out.push(s), - stderr: (s) => err.push(s), - }); - return { renderer, out: () => out.join(""), err: () => err.join("") }; + const out: string[] = []; + const err: string[] = []; + const renderer = createJsonRenderer({ + stdout: (s) => out.push(s), + stderr: (s) => err.push(s), + }); + return { renderer, out: () => out.join(""), err: () => err.join("") }; } describe("JsonRenderer", () => { - test("message is a no-op (avoids noise in JSON pipelines)", () => { - const c = capture(); - c.renderer.message("hello"); - expect(c.out()).toBe(""); - expect(c.err()).toBe(""); - }); + test("message is a no-op (avoids noise in JSON pipelines)", () => { + const c = capture(); + c.renderer.message("hello"); + expect(c.out()).toBe(""); + expect(c.err()).toBe(""); + }); - test("error writes plain text to stderr (no JSON envelope)", () => { - const c = capture(); - c.renderer.error("something broke"); - expect(c.err()).toBe("something broke\n"); - expect(c.out()).toBe(""); - }); + test("error writes plain text to stderr (no JSON envelope)", () => { + const c = capture(); + c.renderer.error("something broke"); + expect(c.err()).toBe("something broke\n"); + expect(c.out()).toBe(""); + }); - test("list emits raw array to stdout, ignoring column definitions", () => { - const c = capture(); - const items = [ - { slug: "a", admin: true }, - { slug: "b", admin: false }, - ]; - c.renderer.list(items, [ - { header: "SLUG", value: (w) => w.slug }, - // value functions are intentionally not invoked for JSON mode. - { header: "ROLE", value: (w) => (w.admin ? "admin" : "member") }, - ]); - expect(JSON.parse(c.out())).toEqual(items); - }); + test("list emits raw array to stdout, ignoring column definitions", () => { + const c = capture(); + const items = [ + { slug: "a", admin: true }, + { slug: "b", admin: false }, + ]; + c.renderer.list(items, [ + { header: "SLUG", value: (w) => w.slug }, + // value functions are intentionally not invoked for JSON mode. + { header: "ROLE", value: (w) => (w.admin ? "admin" : "member") }, + ]); + expect(JSON.parse(c.out())).toEqual(items); + }); - test("detail emits raw object to stdout", () => { - const c = capture(); - const item = { name: "Alice", email: "alice@example.com" }; - c.renderer.detail(item, [ - { label: "Account", value: (a) => a.name }, - ]); - expect(JSON.parse(c.out())).toEqual(item); - }); + test("detail emits raw object to stdout", () => { + const c = capture(); + const item = { name: "Alice", email: "alice@example.com" }; + c.renderer.detail(item, [{ label: "Account", value: (a) => a.name }]); + expect(JSON.parse(c.out())).toEqual(item); + }); - test("empty list emits an empty JSON array", () => { - const c = capture(); - c.renderer.list([], [{ header: "X", value: () => "" }]); - expect(JSON.parse(c.out())).toEqual([]); - }); + test("empty list emits an empty JSON array", () => { + const c = capture(); + c.renderer.list([], [{ header: "X", value: () => "" }]); + expect(JSON.parse(c.out())).toEqual([]); + }); }); diff --git a/src/shared/renderer/json.ts b/src/shared/renderer/json.ts index 678d6cb..53002ac 100644 --- a/src/shared/renderer/json.ts +++ b/src/shared/renderer/json.ts @@ -1,13 +1,13 @@ import type { Renderer } from "./types.ts"; type Streams = { - stdout: (s: string) => void; - stderr: (s: string) => void; + stdout: (s: string) => void; + stderr: (s: string) => void; }; const defaultStreams: Streams = { - stdout: (s) => process.stdout.write(s), - stderr: (s) => process.stderr.write(s), + stdout: (s) => process.stdout.write(s), + stderr: (s) => process.stderr.write(s), }; /** @@ -15,22 +15,24 @@ const defaultStreams: Streams = { * and writes errors as plain text to stderr (scripts inspect exit codes, not * the shape of error output). */ -export function createJsonRenderer(streams: Streams = defaultStreams): Renderer { - return { - message() { - // Info output is noise in a JSON pipeline — swallow it. - }, +export function createJsonRenderer( + streams: Streams = defaultStreams, +): Renderer { + return { + message() { + // Info output is noise in a JSON pipeline — swallow it. + }, - error(text) { - streams.stderr(text + "\n"); - }, + error(text) { + streams.stderr(`${text}\n`); + }, - list(items) { - streams.stdout(JSON.stringify(items) + "\n"); - }, + list(items) { + streams.stdout(`${JSON.stringify(items)}\n`); + }, - detail(item) { - streams.stdout(JSON.stringify(item) + "\n"); - }, - }; + detail(item) { + streams.stdout(`${JSON.stringify(item)}\n`); + }, + }; } diff --git a/src/shared/renderer/text.test.ts b/src/shared/renderer/text.test.ts index 493a4c7..41537f7 100644 --- a/src/shared/renderer/text.test.ts +++ b/src/shared/renderer/text.test.ts @@ -1,140 +1,137 @@ -import { test, expect, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { createTextRenderer } from "./text.ts"; function capture(terminalWidth?: number) { - const out: string[] = []; - const err: string[] = []; - const renderer = createTextRenderer({ - stdout: (s) => out.push(s), - stderr: (s) => err.push(s), - terminalWidth, - }); - return { renderer, out: () => out.join(""), err: () => err.join("") }; + const out: string[] = []; + const err: string[] = []; + const renderer = createTextRenderer({ + stdout: (s) => out.push(s), + stderr: (s) => err.push(s), + terminalWidth, + }); + return { renderer, out: () => out.join(""), err: () => err.join("") }; } // Strip ANSI escapes so assertions aren't hostage to the color palette. const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ""); describe("TextRenderer", () => { - test("message writes a line to stdout", () => { - const c = capture(); - c.renderer.message("hello"); - expect(c.out()).toBe("hello\n"); - expect(c.err()).toBe(""); - }); + test("message writes a line to stdout", () => { + const c = capture(); + c.renderer.message("hello"); + expect(c.out()).toBe("hello\n"); + expect(c.err()).toBe(""); + }); - test("error writes to stderr with a red prefix", () => { - const c = capture(); - c.renderer.error("something broke"); - expect(strip(c.err())).toBe("error: something broke\n"); - expect(c.out()).toBe(""); - }); + test("error writes to stderr with a red prefix", () => { + const c = capture(); + c.renderer.error("something broke"); + expect(strip(c.err())).toBe("error: something broke\n"); + expect(c.out()).toBe(""); + }); - test("list prints aligned columns with header", () => { - const c = capture(); - c.renderer.list( - [ - { slug: "short", admin: true }, - { slug: "much-longer-slug", admin: false }, - ], - [ - { header: "SLUG", value: (w) => w.slug }, - { header: "ROLE", value: (w) => (w.admin ? "admin" : "member") }, - ], - ); - expect(strip(c.out())).toMatchInlineSnapshot(` + test("list prints aligned columns with header", () => { + const c = capture(); + c.renderer.list( + [ + { slug: "short", admin: true }, + { slug: "much-longer-slug", admin: false }, + ], + [ + { header: "SLUG", value: (w) => w.slug }, + { header: "ROLE", value: (w) => (w.admin ? "admin" : "member") }, + ], + ); + expect(strip(c.out())).toMatchInlineSnapshot(` "SLUG ROLE short admin much-longer-slug member " `); - }); + }); - test("list with no items writes nothing", () => { - const c = capture(); - c.renderer.list([], [{ header: "X", value: () => "" }]); - expect(c.out()).toBe(""); - }); + test("list with no items writes nothing", () => { + const c = capture(); + c.renderer.list([], [{ header: "X", value: () => "" }]); + expect(c.out()).toBe(""); + }); - test("detail prints label-value pairs aligned", () => { - const c = capture(); - c.renderer.detail( - { name: "Alice", email: "alice@example.com" }, - [ - { label: "Account", value: (a) => a.name }, - { label: "Email", value: (a) => a.email }, - ], - ); - expect(strip(c.out())).toMatchInlineSnapshot(` + test("detail prints label-value pairs aligned", () => { + const c = capture(); + c.renderer.detail({ name: "Alice", email: "alice@example.com" }, [ + { label: "Account", value: (a) => a.name }, + { label: "Email", value: (a) => a.email }, + ]); + expect(strip(c.out())).toMatchInlineSnapshot(` "Account: Alice Email : alice@example.com " `); - }); + }); - test("truncates flex column with … when terminal width is exceeded", () => { - const c = capture(40); - c.renderer.list( - [ - { id: 1, title: "this title is way too long to fit on one line" }, - { id: 2, title: "short" }, - ], - [ - { header: "ID", value: (r) => String(r.id) }, - { header: "TITLE", value: (r) => r.title, flex: true }, - ], - ); - const lines = strip(c.out()).trimEnd().split("\n"); - // every rendered row must fit within the requested terminal width - for (const line of lines) { - expect(line.length).toBeLessThanOrEqual(40); - } - // …and the long title got truncated with the ellipsis cli-table3 uses - expect(lines.some((l) => l.includes("…"))).toBe(true); - }); + test("truncates flex column with … when terminal width is exceeded", () => { + const c = capture(40); + c.renderer.list( + [ + { id: 1, title: "this title is way too long to fit on one line" }, + { id: 2, title: "short" }, + ], + [ + { header: "ID", value: (r) => String(r.id) }, + { header: "TITLE", value: (r) => r.title, flex: true }, + ], + ); + const lines = strip(c.out()).trimEnd().split("\n"); + // every rendered row must fit within the requested terminal width + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(40); + } + // …and the long title got truncated with the ellipsis cli-table3 uses + expect(lines.some((l) => l.includes("…"))).toBe(true); + }); - test("does not truncate when terminal width is unknown (piped output)", () => { - const c = capture(undefined); - c.renderer.list( - [{ title: "this is a fairly long title that should not be truncated" }], - [{ header: "TITLE", value: (r) => r.title, flex: true }], - ); - expect(strip(c.out())).toContain( - "this is a fairly long title that should not be truncated", - ); - }); + test("does not truncate when terminal width is unknown (piped output)", () => { + const c = capture(undefined); + c.renderer.list( + [{ title: "this is a fairly long title that should not be truncated" }], + [{ header: "TITLE", value: (r) => r.title, flex: true }], + ); + expect(strip(c.out())).toContain( + "this is a fairly long title that should not be truncated", + ); + }); - test("does not truncate when no column is marked flex", () => { - const c = capture(20); - c.renderer.list( - [{ a: "aaaaaaaaaaaa", b: "bbbbbbbbbbbb" }], - [ - { header: "A", value: (r) => r.a }, - { header: "B", value: (r) => r.b }, - ], - ); - expect(strip(c.out())).toContain("aaaaaaaaaaaa"); - expect(strip(c.out())).toContain("bbbbbbbbbbbb"); - }); + test("does not truncate when no column is marked flex", () => { + const c = capture(20); + c.renderer.list( + [{ a: "aaaaaaaaaaaa", b: "bbbbbbbbbbbb" }], + [ + { header: "A", value: (r) => r.a }, + { header: "B", value: (r) => r.b }, + ], + ); + expect(strip(c.out())).toContain("aaaaaaaaaaaa"); + expect(strip(c.out())).toContain("bbbbbbbbbbbb"); + }); - test("applies per-column style without corrupting text", () => { - // picocolors auto-disables colors when stdout isn't a TTY (as in tests), - // so we can't assert on ANSI codes here. Instead, verify the styled - // output still contains the correct underlying text — guards against - // accidentally dropping cells, double-wrapping, etc. - const c = capture(); - c.renderer.list( - [{ slug: "team-a", admin: true }], - [ - { header: "SLUG", value: (w) => w.slug }, - { - header: "ROLE", - value: (w) => (w.admin ? "admin" : "member"), - style: "muted", - }, - ], - ); - expect(strip(c.out())).toContain("team-a"); - expect(strip(c.out())).toContain("admin"); - }); + test("applies per-column style without corrupting text", () => { + // picocolors auto-disables colors when stdout isn't a TTY (as in tests), + // so we can't assert on ANSI codes here. Instead, verify the styled + // output still contains the correct underlying text — guards against + // accidentally dropping cells, double-wrapping, etc. + const c = capture(); + c.renderer.list( + [{ slug: "team-a", admin: true }], + [ + { header: "SLUG", value: (w) => w.slug }, + { + header: "ROLE", + value: (w) => (w.admin ? "admin" : "member"), + style: "muted", + }, + ], + ); + expect(strip(c.out())).toContain("team-a"); + expect(strip(c.out())).toContain("admin"); + }); }); diff --git a/src/shared/renderer/text.ts b/src/shared/renderer/text.ts index 2bf8257..c879365 100644 --- a/src/shared/renderer/text.ts +++ b/src/shared/renderer/text.ts @@ -1,11 +1,6 @@ import Table from "cli-table3"; import pc from "picocolors"; -import type { - Column, - Field, - Renderer, - Style, -} from "./types.ts"; +import type { Column, Field, Renderer, Style } from "./types.ts"; /** * gh-style borderless config for cli-table3: no box-drawing characters, no @@ -13,109 +8,116 @@ import type { * matches the original hand-rolled layout. */ const BORDERLESS_CHARS = { - top: "", "top-mid": "", "top-left": "", "top-right": "", - bottom: "", "bottom-mid": "", "bottom-left": "", "bottom-right": "", - left: "", "left-mid": "", mid: "", "mid-mid": "", - right: "", "right-mid": "", middle: " ", + top: "", + "top-mid": "", + "top-left": "", + "top-right": "", + bottom: "", + "bottom-mid": "", + "bottom-left": "", + "bottom-right": "", + left: "", + "left-mid": "", + mid: "", + "mid-mid": "", + right: "", + "right-mid": "", + middle: " ", }; const BORDERLESS_STYLE = { - "padding-left": 0, - "padding-right": 0, - head: [] as string[], - border: [] as string[], + "padding-left": 0, + "padding-right": 0, + head: [] as string[], + border: [] as string[], }; type Streams = { - stdout: (s: string) => void; - stderr: (s: string) => void; - /** - * Terminal width in columns. `undefined` means don't truncate — either - * stdout isn't a TTY (piped output should preserve full text) or the - * caller doesn't know the width. - */ - terminalWidth?: number | undefined; + stdout: (s: string) => void; + stderr: (s: string) => void; + /** + * Terminal width in columns. `undefined` means don't truncate — either + * stdout isn't a TTY (piped output should preserve full text) or the + * caller doesn't know the width. + */ + terminalWidth?: number | undefined; }; const defaultStreams: Streams = { - stdout: (s) => process.stdout.write(s), - stderr: (s) => process.stderr.write(s), - terminalWidth: process.stdout.isTTY ? process.stdout.columns : undefined, + stdout: (s) => process.stdout.write(s), + stderr: (s) => process.stderr.write(s), + terminalWidth: process.stdout.isTTY ? process.stdout.columns : undefined, }; const COLUMN_GAP = 2; const MIN_FLEX_WIDTH = 10; function trimTrailing(line: string): string { - return line.replace(/\s+$/, ""); + return line.replace(/\s+$/, ""); } function applyStyle(style: Style | undefined, text: string): string { - switch (style) { - case "muted": - return pc.gray(text); - case "bold": - return pc.bold(text); - case "success": - return pc.green(text); - case "failure": - return pc.red(text); - case "default": - case undefined: - return text; - } + switch (style) { + case "muted": + return pc.gray(text); + case "bold": + return pc.bold(text); + case "success": + return pc.green(text); + case "failure": + return pc.red(text); + case "default": + case undefined: + return text; + } } -export function createTextRenderer(streams: Streams = defaultStreams): Renderer { - return { - message(text) { - streams.stdout(text + "\n"); - }, - - error(text) { - streams.stderr(pc.red("error:") + " " + text + "\n"); - }, - - list<T>(items: T[], columns: Column<T>[]) { - if (items.length === 0) return; - - const colWidths = computeColWidths( - items, - columns, - streams.terminalWidth, - ); - - const table = new Table({ - head: columns.map((c) => pc.bold(c.header)), - chars: { ...BORDERLESS_CHARS }, - style: { ...BORDERLESS_STYLE }, - // cli-table3 reads colWidths[i] for every cell and crashes if the - // option is set but undefined — only pass the property when sized. - ...(colWidths ? { colWidths } : {}), - }); - - for (const item of items) { - table.push( - columns.map((c) => applyStyle(c.style, c.value(item))), - ); - } - - // cli-table3 pads the last column, leaving trailing whitespace per line. - // Match gh's output style by stripping it. - const output = table.toString().split("\n").map(trimTrailing).join("\n"); - streams.stdout(output + "\n"); - }, - - detail<T>(item: T, fields: Field<T>[]) { - // detail is label-per-line; terminal width doesn't affect layout here. - const labelWidth = Math.max(...fields.map((f) => f.label.length)); - for (const field of fields) { - const label = pc.bold(field.label.padEnd(labelWidth)) + ":"; - const value = applyStyle(field.style, field.value(item)); - streams.stdout(`${label} ${value}\n`); - } - }, - }; +export function createTextRenderer( + streams: Streams = defaultStreams, +): Renderer { + return { + message(text) { + streams.stdout(`${text}\n`); + }, + + error(text) { + streams.stderr(`${pc.red("error:")} ${text}\n`); + }, + + list<T>(items: T[], columns: Column<T>[]) { + if (items.length === 0) return; + + const colWidths = computeColWidths(items, columns, streams.terminalWidth); + + const table = new Table({ + head: columns.map((c) => pc.bold(c.header)), + chars: { ...BORDERLESS_CHARS }, + style: { ...BORDERLESS_STYLE }, + // cli-table3 reads colWidths[i] for every cell and crashes if the + // option is set but undefined — only pass the property when sized. + ...(colWidths ? { colWidths } : {}), + }); + + for (const item of items) { + table.push(columns.map((c) => applyStyle(c.style, c.value(item)))); + } + + // cli-table3 pads the last column, leaving trailing whitespace per line. + // Match gh's output style by stripping it. + const output = table.toString().split("\n").map(trimTrailing).join("\n"); + streams.stdout(`${output}\n`); + }, + + detail<T>(item: T, fields: Field<T>[]) { + // detail is label-per-line; terminal width doesn't affect layout here. + const labelWidth = Math.max(...fields.map((f) => f.label.length)); + for (const field of fields) { + const label = `${pc.bold(field.label.padEnd(labelWidth))}:`; + const value = applyStyle(field.style, field.value(item)); + streams.stdout(`${label} ${value}\n`); + } + }, + }; } /** @@ -126,32 +128,32 @@ export function createTextRenderer(streams: Streams = defaultStreams): Renderer * cli-table3 truncates the content with `…`. */ function computeColWidths<T>( - items: T[], - columns: Column<T>[], - terminalWidth: number | undefined, + items: T[], + columns: Column<T>[], + terminalWidth: number | undefined, ): number[] | undefined { - if (terminalWidth === undefined) return undefined; - - const natural = columns.map((c) => { - let max = visualLength(c.header); - for (const item of items) { - const len = visualLength(c.value(item)); - if (len > max) max = len; - } - return max; - }); - - const total = - natural.reduce((a, b) => a + b, 0) + COLUMN_GAP * (columns.length - 1); - if (total <= terminalWidth) return undefined; - - const flexIdx = columns.findIndex((c) => c.flex); - if (flexIdx === -1) return undefined; - - const over = total - terminalWidth; - const widths = [...natural]; - widths[flexIdx] = Math.max(MIN_FLEX_WIDTH, widths[flexIdx]! - over); - return widths; + if (terminalWidth === undefined) return undefined; + + const natural = columns.map((c) => { + let max = visualLength(c.header); + for (const item of items) { + const len = visualLength(c.value(item)); + if (len > max) max = len; + } + return max; + }); + + const total = + natural.reduce((a, b) => a + b, 0) + COLUMN_GAP * (columns.length - 1); + if (total <= terminalWidth) return undefined; + + const flexIdx = columns.findIndex((c) => c.flex); + if (flexIdx === -1) return undefined; + + const over = total - terminalWidth; + const widths = [...natural]; + widths[flexIdx] = Math.max(MIN_FLEX_WIDTH, widths[flexIdx]! - over); + return widths; } /** @@ -160,5 +162,5 @@ function computeColWidths<T>( * may or may not inject escapes depending on TTY detection. */ function visualLength(s: string): number { - return s.replace(/\x1b\[[0-9;]*m/g, "").length; + return s.replace(/\x1b\[[0-9;]*m/g, "").length; } diff --git a/src/shared/renderer/types.ts b/src/shared/renderer/types.ts index 146da0a..d46666c 100644 --- a/src/shared/renderer/types.ts +++ b/src/shared/renderer/types.ts @@ -1,31 +1,31 @@ export type Style = "default" | "muted" | "bold" | "success" | "failure"; export type Column<T> = { - header: string; - value: (item: T) => string; - style?: Style; - /** - * When the table would overflow the terminal width, the first column - * marked `flex: true` gets truncated with `…` to make the row fit on - * one line. Columns that carry data the user needs verbatim (ids, urls, - * states) should leave this off. - */ - flex?: boolean; + header: string; + value: (item: T) => string; + style?: Style; + /** + * When the table would overflow the terminal width, the first column + * marked `flex: true` gets truncated with `…` to make the row fit on + * one line. Columns that carry data the user needs verbatim (ids, urls, + * states) should leave this off. + */ + flex?: boolean; }; export type Field<T> = { - label: string; - value: (item: T) => string; - style?: Style; + label: string; + value: (item: T) => string; + style?: Style; }; export interface Renderer { - /** Info output. Stdout in text mode, suppressed in JSON mode. */ - message(text: string): void; - /** Errors. Always stderr, plain text regardless of mode. */ - error(text: string): void; - /** Collection. Text mode: aligned columns. JSON mode: raw array. */ - list<T>(items: T[], columns: Column<T>[]): void; - /** Single item. Text mode: labeled fields. JSON mode: raw object. */ - detail<T>(item: T, fields: Field<T>[]): void; + /** Info output. Stdout in text mode, suppressed in JSON mode. */ + message(text: string): void; + /** Errors. Always stderr, plain text regardless of mode. */ + error(text: string): void; + /** Collection. Text mode: aligned columns. JSON mode: raw array. */ + list<T>(items: T[], columns: Column<T>[]): void; + /** Single item. Text mode: labeled fields. JSON mode: raw object. */ + detail<T>(item: T, fields: Field<T>[]): void; } diff --git a/src/shared/repository/git.integration.test.ts b/src/shared/repository/git.integration.test.ts index 6b7bda7..b7b572b 100644 --- a/src/shared/repository/git.integration.test.ts +++ b/src/shared/repository/git.integration.test.ts @@ -1,13 +1,10 @@ -import { test, expect, describe, beforeAll, afterAll } from "bun:test"; -import { $ } from "bun"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { $ } from "bun"; import { defaultGitRunner } from "./git.ts"; -import { - resolveRepository, - RepositoryResolutionError, -} from "./resolve.ts"; +import { RepositoryResolutionError, resolveRepository } from "./resolve.ts"; /** * Integration tests: exercise the real `Bun.$`-backed git runner against a @@ -20,160 +17,173 @@ let nestedDir: string; let nonRepoDir: string; beforeAll(async () => { - const root = await mkdtemp(join(tmpdir(), "bbcli-repo-test-")); - repoDir = join(root, "repo"); - nestedDir = join(repoDir, "src", "deep", "nested"); - nonRepoDir = join(root, "plain"); - await $`mkdir -p ${repoDir} ${nestedDir} ${nonRepoDir}`.quiet(); - await $`git -C ${repoDir} init -q -b main`.quiet(); - // Set identity locally on this test repo so `git commit` works on CI - // runners that don't have a global user.email/user.name configured. - await $`git -C ${repoDir} config user.email test@bbcli.local`.quiet(); - await $`git -C ${repoDir} config user.name Test`.quiet(); - await $`git -C ${repoDir} remote add origin git@bitbucket.org:acme/widgets.git`.quiet(); - await $`git -C ${repoDir} remote add upstream https://bitbucket.org/other/widgets.git`.quiet(); + const root = await mkdtemp(join(tmpdir(), "bbcli-repo-test-")); + repoDir = join(root, "repo"); + nestedDir = join(repoDir, "src", "deep", "nested"); + nonRepoDir = join(root, "plain"); + await $`mkdir -p ${repoDir} ${nestedDir} ${nonRepoDir}`.quiet(); + await $`git -C ${repoDir} init -q -b main`.quiet(); + // Set identity locally on this test repo so `git commit` works on CI + // runners that don't have a global user.email/user.name configured. + await $`git -C ${repoDir} config user.email test@bbcli.local`.quiet(); + await $`git -C ${repoDir} config user.name Test`.quiet(); + await $`git -C ${repoDir} remote add origin git@bitbucket.org:acme/widgets.git`.quiet(); + await $`git -C ${repoDir} remote add upstream https://bitbucket.org/other/widgets.git`.quiet(); }); afterAll(async () => { - if (repoDir) { - const parent = join(repoDir, ".."); - await rm(parent, { recursive: true, force: true }); - } + if (repoDir) { + const parent = join(repoDir, ".."); + await rm(parent, { recursive: true, force: true }); + } }); describe("defaultGitRunner", () => { - test("detects work tree", async () => { - expect(await defaultGitRunner.isInsideWorkTree(repoDir)).toBe(true); - expect(await defaultGitRunner.isInsideWorkTree(nonRepoDir)).toBe(false); - }); - - test("lists remotes in config order", async () => { - const remotes = await defaultGitRunner.listRemotes(repoDir); - expect(remotes).toContain("origin"); - expect(remotes).toContain("upstream"); - }); - - test("returns remote URLs", async () => { - expect(await defaultGitRunner.getRemoteUrl(repoDir, "origin")).toBe( - "git@bitbucket.org:acme/widgets.git", - ); - expect(await defaultGitRunner.getRemoteUrl(repoDir, "nope")).toBeUndefined(); - }); - - test("returns current branch name", async () => { - // The repo was init'd with `-b main`; HEAD points at `main` even with no - // commits yet (symbolic-ref reads HEAD without requiring the branch to - // exist as a ref). - expect(await defaultGitRunner.getCurrentBranch(repoDir)).toBe("main"); - }); - - test("returns undefined on detached HEAD", async () => { - // Create a commit and a second branch, then detach HEAD by checking out - // the commit hash. symbolic-ref exits non-zero in that state. - await $`git -C ${repoDir} commit --allow-empty -m init`.quiet(); - const sha = ( - await $`git -C ${repoDir} rev-parse HEAD`.quiet() - ).stdout.toString().trim(); - await $`git -C ${repoDir} checkout --detach ${sha}`.quiet(); - try { - expect(await defaultGitRunner.getCurrentBranch(repoDir)).toBeUndefined(); - } finally { - await $`git -C ${repoDir} checkout main`.quiet(); - } - }); - - test("returns undefined outside a git repo", async () => { - expect(await defaultGitRunner.getCurrentBranch(nonRepoDir)).toBeUndefined(); - }); + test("detects work tree", async () => { + expect(await defaultGitRunner.isInsideWorkTree(repoDir)).toBe(true); + expect(await defaultGitRunner.isInsideWorkTree(nonRepoDir)).toBe(false); + }); + + test("lists remotes in config order", async () => { + const remotes = await defaultGitRunner.listRemotes(repoDir); + expect(remotes).toContain("origin"); + expect(remotes).toContain("upstream"); + }); + + test("returns remote URLs", async () => { + expect(await defaultGitRunner.getRemoteUrl(repoDir, "origin")).toBe( + "git@bitbucket.org:acme/widgets.git", + ); + expect( + await defaultGitRunner.getRemoteUrl(repoDir, "nope"), + ).toBeUndefined(); + }); + + test("returns current branch name", async () => { + // The repo was init'd with `-b main`; HEAD points at `main` even with no + // commits yet (symbolic-ref reads HEAD without requiring the branch to + // exist as a ref). + expect(await defaultGitRunner.getCurrentBranch(repoDir)).toBe("main"); + }); + + test("returns undefined on detached HEAD", async () => { + // Create a commit and a second branch, then detach HEAD by checking out + // the commit hash. symbolic-ref exits non-zero in that state. + await $`git -C ${repoDir} commit --allow-empty -m init`.quiet(); + const sha = (await $`git -C ${repoDir} rev-parse HEAD`.quiet()).stdout + .toString() + .trim(); + await $`git -C ${repoDir} checkout --detach ${sha}`.quiet(); + try { + expect(await defaultGitRunner.getCurrentBranch(repoDir)).toBeUndefined(); + } finally { + await $`git -C ${repoDir} checkout main`.quiet(); + } + }); + + test("returns undefined outside a git repo", async () => { + expect(await defaultGitRunner.getCurrentBranch(nonRepoDir)).toBeUndefined(); + }); }); describe("defaultGitRunner against a local bare remote", () => { - // We set up a separate clone with a real (local-filesystem) remote so - // `ls-remote` actually talks to something. The shared `repoDir` above - // has `origin` pointing at a bogus bitbucket URL — useful for URL-parse - // tests but unusable for network-reaching operations. - - let bareDir: string; - let cloneDir: string; - let initialSha: string; - - beforeAll(async () => { - const root = await mkdtemp(join(tmpdir(), "bbcli-bareremote-test-")); - bareDir = join(root, "origin.git"); - cloneDir = join(root, "clone"); - - await $`git init --bare -q -b main ${bareDir}`.quiet(); - - // Seed the bare remote with a single commit on main, then clone it. - const seedDir = join(root, "seed"); - await $`git init -q -b main ${seedDir}`.quiet(); - await $`git -C ${seedDir} -c user.email=t@b.co -c user.name=Test commit --allow-empty -m initial`.quiet(); - await $`git -C ${seedDir} push -q ${bareDir} main`.quiet(); - - await $`git clone -q ${bareDir} ${cloneDir}`.quiet(); - - initialSha = ( - await $`git -C ${cloneDir} rev-parse HEAD`.quiet() - ).stdout.toString().trim(); - }); - - afterAll(async () => { - if (bareDir) { - await rm(join(bareDir, ".."), { recursive: true, force: true }); - } - }); - - test("getSha resolves HEAD", async () => { - expect(await defaultGitRunner.getSha(cloneDir, "HEAD")).toBe(initialSha); - }); - - test("getSha returns undefined for an unknown rev", async () => { - expect(await defaultGitRunner.getSha(cloneDir, "does-not-exist")) - .toBeUndefined(); - }); - - test("getRemoteBranchSha returns the remote sha for an existing branch", async () => { - const sha = await defaultGitRunner.getRemoteBranchSha(cloneDir, "origin", "main"); - expect(sha).toBe(initialSha); - }); - - test("getRemoteBranchSha returns undefined for a branch that isn't on the remote", async () => { - const sha = await defaultGitRunner.getRemoteBranchSha( - cloneDir, - "origin", - "never-pushed", - ); - expect(sha).toBeUndefined(); - }); - - test("getDefaultBranchFromRemote returns the default branch after clone", async () => { - expect(await defaultGitRunner.getDefaultBranchFromRemote(cloneDir, "origin")) - .toBe("main"); - }); - - test("getDefaultBranchFromRemote returns undefined when the symbolic ref is missing", async () => { - // A fresh `init`'d repo with a remote added by hand won't have - // refs/remotes/<remote>/HEAD set; it's only populated by `clone`. - // Reuse the main shared repoDir which was set up that way. - expect(await defaultGitRunner.getDefaultBranchFromRemote(repoDir, "origin")) - .toBeUndefined(); - }); + // We set up a separate clone with a real (local-filesystem) remote so + // `ls-remote` actually talks to something. The shared `repoDir` above + // has `origin` pointing at a bogus bitbucket URL — useful for URL-parse + // tests but unusable for network-reaching operations. + + let bareDir: string; + let cloneDir: string; + let initialSha: string; + + beforeAll(async () => { + const root = await mkdtemp(join(tmpdir(), "bbcli-bareremote-test-")); + bareDir = join(root, "origin.git"); + cloneDir = join(root, "clone"); + + await $`git init --bare -q -b main ${bareDir}`.quiet(); + + // Seed the bare remote with a single commit on main, then clone it. + const seedDir = join(root, "seed"); + await $`git init -q -b main ${seedDir}`.quiet(); + await $`git -C ${seedDir} -c user.email=t@b.co -c user.name=Test commit --allow-empty -m initial`.quiet(); + await $`git -C ${seedDir} push -q ${bareDir} main`.quiet(); + + await $`git clone -q ${bareDir} ${cloneDir}`.quiet(); + + initialSha = (await $`git -C ${cloneDir} rev-parse HEAD`.quiet()).stdout + .toString() + .trim(); + }); + + afterAll(async () => { + if (bareDir) { + await rm(join(bareDir, ".."), { recursive: true, force: true }); + } + }); + + test("getSha resolves HEAD", async () => { + expect(await defaultGitRunner.getSha(cloneDir, "HEAD")).toBe(initialSha); + }); + + test("getSha returns undefined for an unknown rev", async () => { + expect( + await defaultGitRunner.getSha(cloneDir, "does-not-exist"), + ).toBeUndefined(); + }); + + test("getRemoteBranchSha returns the remote sha for an existing branch", async () => { + const sha = await defaultGitRunner.getRemoteBranchSha( + cloneDir, + "origin", + "main", + ); + expect(sha).toBe(initialSha); + }); + + test("getRemoteBranchSha returns undefined for a branch that isn't on the remote", async () => { + const sha = await defaultGitRunner.getRemoteBranchSha( + cloneDir, + "origin", + "never-pushed", + ); + expect(sha).toBeUndefined(); + }); + + test("getDefaultBranchFromRemote returns the default branch after clone", async () => { + expect( + await defaultGitRunner.getDefaultBranchFromRemote(cloneDir, "origin"), + ).toBe("main"); + }); + + test("getDefaultBranchFromRemote returns undefined when the symbolic ref is missing", async () => { + // A fresh `init`'d repo with a remote added by hand won't have + // refs/remotes/<remote>/HEAD set; it's only populated by `clone`. + // Reuse the main shared repoDir which was set up that way. + expect( + await defaultGitRunner.getDefaultBranchFromRemote(repoDir, "origin"), + ).toBeUndefined(); + }); }); describe("resolveRepository with real git", () => { - test("resolves origin from a real repo", async () => { - const ref = await resolveRepository({ cwd: repoDir }); - expect(ref).toEqual({ workspace: "acme", slug: "widgets" }); - }); - - test("resolves origin when invoked from a subdirectory", async () => { - const ref = await resolveRepository({ cwd: nestedDir }); - expect(ref).toEqual({ workspace: "acme", slug: "widgets" }); - }); - - test("fails outside a git repo", async () => { - const err = await resolveRepository({ cwd: nonRepoDir }).catch((e: unknown) => e); - expect(err).toBeInstanceOf(RepositoryResolutionError); - expect((err as RepositoryResolutionError).failure.kind).toBe("not-a-git-repo"); - }); + test("resolves origin from a real repo", async () => { + const ref = await resolveRepository({ cwd: repoDir }); + expect(ref).toEqual({ workspace: "acme", slug: "widgets" }); + }); + + test("resolves origin when invoked from a subdirectory", async () => { + const ref = await resolveRepository({ cwd: nestedDir }); + expect(ref).toEqual({ workspace: "acme", slug: "widgets" }); + }); + + test("fails outside a git repo", async () => { + const err = await resolveRepository({ cwd: nonRepoDir }).catch( + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(RepositoryResolutionError); + expect((err as RepositoryResolutionError).failure.kind).toBe( + "not-a-git-repo", + ); + }); }); diff --git a/src/shared/repository/git.ts b/src/shared/repository/git.ts index 76f4e83..628d10d 100644 --- a/src/shared/repository/git.ts +++ b/src/shared/repository/git.ts @@ -6,116 +6,118 @@ import { $ } from "bun"; * the default implementation backed by `git -C <cwd>`. */ export interface GitRunner { - isInsideWorkTree(cwd: string): Promise<boolean>; - /** Returns remote names in the order git reports them. */ - listRemotes(cwd: string): Promise<string[]>; - /** Returns the URL of a remote, or undefined if the remote does not exist. */ - getRemoteUrl(cwd: string, name: string): Promise<string | undefined>; - /** - * Returns the current branch name, or undefined on detached HEAD / empty - * repo. Used to default `bb pr view` to the PR for the current branch. - */ - getCurrentBranch(cwd: string): Promise<string | undefined>; - /** - * Returns the local commit sha of the given rev (`HEAD`, a branch name, - * etc.), or undefined if git can't resolve it. - */ - getSha(cwd: string, rev: string): Promise<string | undefined>; - /** - * Queries the remote over the network for the sha a branch points at. - * Returns undefined when the branch doesn't exist on the remote. Used - * for "is this branch pushed?" checks without trusting possibly-stale - * local remote-tracking refs. - */ - getRemoteBranchSha( - cwd: string, - remote: string, - branch: string, - ): Promise<string | undefined>; - /** - * Returns the default branch the remote is set up to point at (via - * `refs/remotes/<remote>/HEAD`), or undefined if the symbolic ref - * isn't set locally. Reflects remote state at clone time — may be - * stale if the repo has renamed its default branch since. - */ - getDefaultBranchFromRemote( - cwd: string, - remote: string, - ): Promise<string | undefined>; + isInsideWorkTree(cwd: string): Promise<boolean>; + /** Returns remote names in the order git reports them. */ + listRemotes(cwd: string): Promise<string[]>; + /** Returns the URL of a remote, or undefined if the remote does not exist. */ + getRemoteUrl(cwd: string, name: string): Promise<string | undefined>; + /** + * Returns the current branch name, or undefined on detached HEAD / empty + * repo. Used to default `bb pr view` to the PR for the current branch. + */ + getCurrentBranch(cwd: string): Promise<string | undefined>; + /** + * Returns the local commit sha of the given rev (`HEAD`, a branch name, + * etc.), or undefined if git can't resolve it. + */ + getSha(cwd: string, rev: string): Promise<string | undefined>; + /** + * Queries the remote over the network for the sha a branch points at. + * Returns undefined when the branch doesn't exist on the remote. Used + * for "is this branch pushed?" checks without trusting possibly-stale + * local remote-tracking refs. + */ + getRemoteBranchSha( + cwd: string, + remote: string, + branch: string, + ): Promise<string | undefined>; + /** + * Returns the default branch the remote is set up to point at (via + * `refs/remotes/<remote>/HEAD`), or undefined if the symbolic ref + * isn't set locally. Reflects remote state at clone time — may be + * stale if the repo has renamed its default branch since. + */ + getDefaultBranchFromRemote( + cwd: string, + remote: string, + ): Promise<string | undefined>; } export const defaultGitRunner: GitRunner = { - async isInsideWorkTree(cwd) { - const result = await $`git -C ${cwd} rev-parse --is-inside-work-tree` - .nothrow() - .quiet(); - if (result.exitCode !== 0) return false; - return result.stdout.toString().trim() === "true"; - }, + async isInsideWorkTree(cwd) { + const result = await $`git -C ${cwd} rev-parse --is-inside-work-tree` + .nothrow() + .quiet(); + if (result.exitCode !== 0) return false; + return result.stdout.toString().trim() === "true"; + }, - async listRemotes(cwd) { - const result = await $`git -C ${cwd} remote`.nothrow().quiet(); - if (result.exitCode !== 0) return []; - return result.stdout - .toString() - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - }, + async listRemotes(cwd) { + const result = await $`git -C ${cwd} remote`.nothrow().quiet(); + if (result.exitCode !== 0) return []; + return result.stdout + .toString() + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + }, - async getRemoteUrl(cwd, name) { - const result = await $`git -C ${cwd} remote get-url ${name}` - .nothrow() - .quiet(); - if (result.exitCode !== 0) return undefined; - const url = result.stdout.toString().trim(); - return url || undefined; - }, + async getRemoteUrl(cwd, name) { + const result = await $`git -C ${cwd} remote get-url ${name}` + .nothrow() + .quiet(); + if (result.exitCode !== 0) return undefined; + const url = result.stdout.toString().trim(); + return url || undefined; + }, - async getCurrentBranch(cwd) { - // `symbolic-ref --short HEAD` returns the branch name, or exits non-zero - // on detached HEAD. We distinguish that from "not a git repo" via the - // separate isInsideWorkTree check callers already do. - const result = await $`git -C ${cwd} symbolic-ref --short HEAD` - .nothrow() - .quiet(); - if (result.exitCode !== 0) return undefined; - const branch = result.stdout.toString().trim(); - return branch || undefined; - }, + async getCurrentBranch(cwd) { + // `symbolic-ref --short HEAD` returns the branch name, or exits non-zero + // on detached HEAD. We distinguish that from "not a git repo" via the + // separate isInsideWorkTree check callers already do. + const result = await $`git -C ${cwd} symbolic-ref --short HEAD` + .nothrow() + .quiet(); + if (result.exitCode !== 0) return undefined; + const branch = result.stdout.toString().trim(); + return branch || undefined; + }, - async getSha(cwd, rev) { - const result = await $`git -C ${cwd} rev-parse ${rev}`.nothrow().quiet(); - if (result.exitCode !== 0) return undefined; - const sha = result.stdout.toString().trim(); - return sha || undefined; - }, + async getSha(cwd, rev) { + const result = await $`git -C ${cwd} rev-parse ${rev}`.nothrow().quiet(); + if (result.exitCode !== 0) return undefined; + const sha = result.stdout.toString().trim(); + return sha || undefined; + }, - async getRemoteBranchSha(cwd, remote, branch) { - // `ls-remote` talks to the network, so this is authoritative — unlike - // `rev-parse <remote>/<branch>` which relies on the locally-cached - // remote-tracking ref being fresh. Output: '<sha>\trefs/heads/<branch>' - // one line per ref (empty when the branch doesn't exist). - const result = await $`git -C ${cwd} ls-remote ${remote} ${`refs/heads/${branch}`}` - .nothrow() - .quiet(); - if (result.exitCode !== 0) return undefined; - const line = result.stdout.toString().trim().split("\n")[0]; - if (!line) return undefined; - const sha = line.split(/\s+/)[0]; - return sha || undefined; - }, + async getRemoteBranchSha(cwd, remote, branch) { + // `ls-remote` talks to the network, so this is authoritative — unlike + // `rev-parse <remote>/<branch>` which relies on the locally-cached + // remote-tracking ref being fresh. Output: '<sha>\trefs/heads/<branch>' + // one line per ref (empty when the branch doesn't exist). + const result = + await $`git -C ${cwd} ls-remote ${remote} ${`refs/heads/${branch}`}` + .nothrow() + .quiet(); + if (result.exitCode !== 0) return undefined; + const line = result.stdout.toString().trim().split("\n")[0]; + if (!line) return undefined; + const sha = line.split(/\s+/)[0]; + return sha || undefined; + }, - async getDefaultBranchFromRemote(cwd, remote) { - // `refs/remotes/<remote>/HEAD` is a local symbolic ref set at clone - // time; exits non-zero if unset. Value looks like `<remote>/main`. - const result = await $`git -C ${cwd} symbolic-ref --short ${`refs/remotes/${remote}/HEAD`}` - .nothrow() - .quiet(); - if (result.exitCode !== 0) return undefined; - const full = result.stdout.toString().trim(); - if (!full) return undefined; - const prefix = `${remote}/`; - return full.startsWith(prefix) ? full.slice(prefix.length) : full; - }, + async getDefaultBranchFromRemote(cwd, remote) { + // `refs/remotes/<remote>/HEAD` is a local symbolic ref set at clone + // time; exits non-zero if unset. Value looks like `<remote>/main`. + const result = + await $`git -C ${cwd} symbolic-ref --short ${`refs/remotes/${remote}/HEAD`}` + .nothrow() + .quiet(); + if (result.exitCode !== 0) return undefined; + const full = result.stdout.toString().trim(); + if (!full) return undefined; + const prefix = `${remote}/`; + return full.startsWith(prefix) ? full.slice(prefix.length) : full; + }, }; diff --git a/src/shared/repository/index.ts b/src/shared/repository/index.ts index b669d01..f6bb27a 100644 --- a/src/shared/repository/index.ts +++ b/src/shared/repository/index.ts @@ -1,10 +1,10 @@ -export type { RepositoryRef } from "./parse-url.ts"; -export { parseBitbucketRemoteUrl } from "./parse-url.ts"; export type { GitRunner } from "./git.ts"; export { defaultGitRunner } from "./git.ts"; +export type { RepositoryRef } from "./parse-url.ts"; +export { parseBitbucketRemoteUrl } from "./parse-url.ts"; export { - resolveRepository, - RepositoryResolutionError, - type ResolutionFailure, - type ResolveOptions, + RepositoryResolutionError, + type ResolutionFailure, + type ResolveOptions, + resolveRepository, } from "./resolve.ts"; diff --git a/src/shared/repository/parse-url.test.ts b/src/shared/repository/parse-url.test.ts index 2dc0906..98da4ca 100644 --- a/src/shared/repository/parse-url.test.ts +++ b/src/shared/repository/parse-url.test.ts @@ -1,45 +1,45 @@ -import { test, expect, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { parseBitbucketRemoteUrl } from "./parse-url.ts"; describe("parseBitbucketRemoteUrl", () => { - const accepted: [string, string, string][] = [ - ["git@bitbucket.org:ws/repo.git", "ws", "repo"], - ["git@bitbucket.org:ws/repo", "ws", "repo"], - ["https://bitbucket.org/ws/repo.git", "ws", "repo"], - ["https://bitbucket.org/ws/repo", "ws", "repo"], - ["https://bitbucket.org/ws/repo/", "ws", "repo"], - ["https://user@bitbucket.org/ws/repo.git", "ws", "repo"], - ["ssh://git@bitbucket.org/ws/repo.git", "ws", "repo"], - // case insensitivity on host + slug normalization - ["https://BITBUCKET.ORG/WS/Repo.git", "ws", "repo"], - // trailing whitespace - [" git@bitbucket.org:ws/repo.git ", "ws", "repo"], - ]; + const accepted: [string, string, string][] = [ + ["git@bitbucket.org:ws/repo.git", "ws", "repo"], + ["git@bitbucket.org:ws/repo", "ws", "repo"], + ["https://bitbucket.org/ws/repo.git", "ws", "repo"], + ["https://bitbucket.org/ws/repo", "ws", "repo"], + ["https://bitbucket.org/ws/repo/", "ws", "repo"], + ["https://user@bitbucket.org/ws/repo.git", "ws", "repo"], + ["ssh://git@bitbucket.org/ws/repo.git", "ws", "repo"], + // case insensitivity on host + slug normalization + ["https://BITBUCKET.ORG/WS/Repo.git", "ws", "repo"], + // trailing whitespace + [" git@bitbucket.org:ws/repo.git ", "ws", "repo"], + ]; - for (const [url, workspace, slug] of accepted) { - test(`accepts ${url}`, () => { - expect(parseBitbucketRemoteUrl(url)).toEqual({ workspace, slug }); - }); - } + for (const [url, workspace, slug] of accepted) { + test(`accepts ${url}`, () => { + expect(parseBitbucketRemoteUrl(url)).toEqual({ workspace, slug }); + }); + } - const rejected = [ - "", - "not a url", - "git@github.com:ws/repo.git", - "https://github.com/ws/repo.git", - "https://bitbucket.org/", - "https://bitbucket.org/ws", - "https://bitbucket.org/ws/repo/extra", - "https://api.bitbucket.org/ws/repo", - "https://foo.bitbucket.org/ws/repo", - "ftp://bitbucket.org/ws/repo", - "git@bitbucket.org:ws", - "git@bitbucket.org:/repo.git", - ]; + const rejected = [ + "", + "not a url", + "git@github.com:ws/repo.git", + "https://github.com/ws/repo.git", + "https://bitbucket.org/", + "https://bitbucket.org/ws", + "https://bitbucket.org/ws/repo/extra", + "https://api.bitbucket.org/ws/repo", + "https://foo.bitbucket.org/ws/repo", + "ftp://bitbucket.org/ws/repo", + "git@bitbucket.org:ws", + "git@bitbucket.org:/repo.git", + ]; - for (const url of rejected) { - test(`rejects ${JSON.stringify(url)}`, () => { - expect(parseBitbucketRemoteUrl(url)).toBeNull(); - }); - } + for (const url of rejected) { + test(`rejects ${JSON.stringify(url)}`, () => { + expect(parseBitbucketRemoteUrl(url)).toBeNull(); + }); + } }); diff --git a/src/shared/repository/parse-url.ts b/src/shared/repository/parse-url.ts index e811e7f..61ff630 100644 --- a/src/shared/repository/parse-url.ts +++ b/src/shared/repository/parse-url.ts @@ -11,35 +11,35 @@ export type RepositoryRef = { workspace: string; slug: string }; * rejected. Returns null when the URL doesn't match (caller decides the error). */ export function parseBitbucketRemoteUrl(url: string): RepositoryRef | null { - const trimmed = url.trim(); - if (!trimmed) return null; + const trimmed = url.trim(); + if (!trimmed) return null; - // scp-like SSH: git@bitbucket.org:ws/repo(.git)? - const scp = /^git@bitbucket\.org:([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(trimmed); - if (scp) return normalize(scp[1]!, scp[2]!); + // scp-like SSH: git@bitbucket.org:ws/repo(.git)? + const scp = /^git@bitbucket\.org:([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(trimmed); + if (scp) return normalize(scp[1]!, scp[2]!); - // URL-shaped: ssh://, https://, http:// with optional user info. - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - return null; - } + // URL-shaped: ssh://, https://, http:// with optional user info. + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } - if (parsed.hostname.toLowerCase() !== "bitbucket.org") return null; - if (!["https:", "http:", "ssh:"].includes(parsed.protocol)) return null; + if (parsed.hostname.toLowerCase() !== "bitbucket.org") return null; + if (!["https:", "http:", "ssh:"].includes(parsed.protocol)) return null; - const segments = parsed.pathname.split("/").filter(Boolean); - if (segments.length !== 2) return null; + const segments = parsed.pathname.split("/").filter(Boolean); + if (segments.length !== 2) return null; - const workspace = segments[0]!; - const slug = segments[1]!.replace(/\.git$/, ""); - return normalize(workspace, slug); + const workspace = segments[0]!; + const slug = segments[1]!.replace(/\.git$/, ""); + return normalize(workspace, slug); } function normalize(workspace: string, slug: string): RepositoryRef | null { - const ws = workspace.trim().toLowerCase(); - const sl = slug.trim().toLowerCase(); - if (!ws || !sl) return null; - return { workspace: ws, slug: sl }; + const ws = workspace.trim().toLowerCase(); + const sl = slug.trim().toLowerCase(); + if (!ws || !sl) return null; + return { workspace: ws, slug: sl }; } diff --git a/src/shared/repository/resolve.test.ts b/src/shared/repository/resolve.test.ts index 8e3a7e6..dd78455 100644 --- a/src/shared/repository/resolve.test.ts +++ b/src/shared/repository/resolve.test.ts @@ -1,212 +1,211 @@ -import { test, expect, describe } from "bun:test"; -import { - resolveRepository, - RepositoryResolutionError, -} from "./resolve.ts"; +import { describe, expect, test } from "bun:test"; import type { GitRunner } from "./git.ts"; +import { RepositoryResolutionError, resolveRepository } from "./resolve.ts"; type Setup = { - insideWorkTree?: boolean; - remotes?: string[]; - remoteUrls?: Record<string, string>; - currentBranch?: string; + insideWorkTree?: boolean; + remotes?: string[]; + remoteUrls?: Record<string, string>; + currentBranch?: string; }; function fakeGit(s: Setup = {}): GitRunner { - return { - async isInsideWorkTree() { - return s.insideWorkTree ?? true; - }, - async listRemotes() { - return s.remotes ?? []; - }, - async getRemoteUrl(_cwd, name) { - return s.remoteUrls?.[name]; - }, - async getCurrentBranch() { - return s.currentBranch; - }, - async getSha() { - return undefined; - }, - async getRemoteBranchSha() { - return undefined; - }, - async getDefaultBranchFromRemote() { - return undefined; - }, - }; + return { + async isInsideWorkTree() { + return s.insideWorkTree ?? true; + }, + async listRemotes() { + return s.remotes ?? []; + }, + async getRemoteUrl(_cwd, name) { + return s.remoteUrls?.[name]; + }, + async getCurrentBranch() { + return s.currentBranch; + }, + async getSha() { + return undefined; + }, + async getRemoteBranchSha() { + return undefined; + }, + async getDefaultBranchFromRemote() { + return undefined; + }, + }; } async function failureOf(promise: Promise<unknown>) { - const err = await promise.catch((e: unknown) => e); - expect(err).toBeInstanceOf(RepositoryResolutionError); - return (err as RepositoryResolutionError).failure; + const err = await promise.catch((e: unknown) => e); + expect(err).toBeInstanceOf(RepositoryResolutionError); + return (err as RepositoryResolutionError).failure; } -async function errorOf(promise: Promise<unknown>): Promise<RepositoryResolutionError> { - const err = await promise.catch((e: unknown) => e); - expect(err).toBeInstanceOf(RepositoryResolutionError); - return err as RepositoryResolutionError; +async function errorOf( + promise: Promise<unknown>, +): Promise<RepositoryResolutionError> { + const err = await promise.catch((e: unknown) => e); + expect(err).toBeInstanceOf(RepositoryResolutionError); + return err as RepositoryResolutionError; } describe("resolveRepository — override", () => { - test("returns parsed override when well-formed", async () => { - const ref = await resolveRepository( - { override: "MyWs/MyRepo" }, - fakeGit(), - ); - expect(ref).toEqual({ workspace: "myws", slug: "myrepo" }); - }); - - test("does not consult git when override is given", async () => { - let called = false; - const git: GitRunner = { - async isInsideWorkTree() { - called = true; - return true; - }, - async listRemotes() { - called = true; - return []; - }, - async getRemoteUrl() { - called = true; - return undefined; - }, - async getCurrentBranch() { - called = true; - return undefined; - }, - async getSha() { - called = true; - return undefined; - }, - async getRemoteBranchSha() { - called = true; - return undefined; - }, - async getDefaultBranchFromRemote() { - called = true; - return undefined; - }, - }; - await resolveRepository({ override: "ws/repo" }, git); - expect(called).toBe(false); - }); - - test("rejects malformed override", async () => { - const cases = ["", "ws", "ws/", "/repo", "ws/repo/extra", "ws repo"]; - for (const value of cases) { - const failure = await failureOf( - resolveRepository({ override: value }, fakeGit()), - ); - expect(failure).toEqual({ kind: "override-invalid", value }); - } - }); + test("returns parsed override when well-formed", async () => { + const ref = await resolveRepository({ override: "MyWs/MyRepo" }, fakeGit()); + expect(ref).toEqual({ workspace: "myws", slug: "myrepo" }); + }); + + test("does not consult git when override is given", async () => { + let called = false; + const git: GitRunner = { + async isInsideWorkTree() { + called = true; + return true; + }, + async listRemotes() { + called = true; + return []; + }, + async getRemoteUrl() { + called = true; + return undefined; + }, + async getCurrentBranch() { + called = true; + return undefined; + }, + async getSha() { + called = true; + return undefined; + }, + async getRemoteBranchSha() { + called = true; + return undefined; + }, + async getDefaultBranchFromRemote() { + called = true; + return undefined; + }, + }; + await resolveRepository({ override: "ws/repo" }, git); + expect(called).toBe(false); + }); + + test("rejects malformed override", async () => { + const cases = ["", "ws", "ws/", "/repo", "ws/repo/extra", "ws repo"]; + for (const value of cases) { + const failure = await failureOf( + resolveRepository({ override: value }, fakeGit()), + ); + expect(failure).toEqual({ kind: "override-invalid", value }); + } + }); }); describe("resolveRepository — git detection", () => { - test("fails when cwd is not a git repo", async () => { - const failure = await failureOf( - resolveRepository( - { cwd: "/tmp/not-a-repo" }, - fakeGit({ insideWorkTree: false }), - ), - ); - expect(failure).toEqual({ - kind: "not-a-git-repo", - cwd: "/tmp/not-a-repo", - }); - }); - - test("fails when repo has no remotes", async () => { - const failure = await failureOf( - resolveRepository({ cwd: "/r" }, fakeGit({ remotes: [] })), - ); - expect(failure).toEqual({ kind: "no-remotes" }); - }); - - test("fails when origin is missing, listing available remotes", async () => { - const failure = await failureOf( - resolveRepository( - { cwd: "/r" }, - fakeGit({ remotes: ["upstream", "fork"] }), - ), - ); - expect(failure).toEqual({ - kind: "no-origin", - remotes: ["upstream", "fork"], - }); - }); - - test("fails with unparseable when origin URL is empty", async () => { - const failure = await failureOf( - resolveRepository( - { cwd: "/r" }, - fakeGit({ remotes: ["origin"], remoteUrls: {} }), - ), - ); - expect(failure).toEqual({ kind: "origin-unparseable", url: "" }); - }); - - test("fails with origin-not-bitbucket for non-Bitbucket hosts", async () => { - const failure = await failureOf( - resolveRepository( - { cwd: "/r" }, - fakeGit({ - remotes: ["origin"], - remoteUrls: { origin: "git@github.com:ws/repo.git" }, - }), - ), - ); - expect(failure).toEqual({ - kind: "origin-not-bitbucket", - url: "git@github.com:ws/repo.git", - }); - }); - - test("fails with origin-unparseable for garbage URLs", async () => { - const failure = await failureOf( - resolveRepository( - { cwd: "/r" }, - fakeGit({ - remotes: ["origin"], - remoteUrls: { origin: "not a url at all" }, - }), - ), - ); - expect(failure).toEqual({ - kind: "origin-unparseable", - url: "not a url at all", - }); - }); - - test("returns parsed ref for valid Bitbucket origin", async () => { - const ref = await resolveRepository( - { cwd: "/r" }, - fakeGit({ - remotes: ["origin", "upstream"], - remoteUrls: { origin: "git@bitbucket.org:acme/widgets.git" }, - }), - ); - expect(ref).toEqual({ workspace: "acme", slug: "widgets" }); - }); + test("fails when cwd is not a git repo", async () => { + const failure = await failureOf( + resolveRepository( + { cwd: "/tmp/not-a-repo" }, + fakeGit({ insideWorkTree: false }), + ), + ); + expect(failure).toEqual({ + kind: "not-a-git-repo", + cwd: "/tmp/not-a-repo", + }); + }); + + test("fails when repo has no remotes", async () => { + const failure = await failureOf( + resolveRepository({ cwd: "/r" }, fakeGit({ remotes: [] })), + ); + expect(failure).toEqual({ kind: "no-remotes" }); + }); + + test("fails when origin is missing, listing available remotes", async () => { + const failure = await failureOf( + resolveRepository( + { cwd: "/r" }, + fakeGit({ remotes: ["upstream", "fork"] }), + ), + ); + expect(failure).toEqual({ + kind: "no-origin", + remotes: ["upstream", "fork"], + }); + }); + + test("fails with unparseable when origin URL is empty", async () => { + const failure = await failureOf( + resolveRepository( + { cwd: "/r" }, + fakeGit({ remotes: ["origin"], remoteUrls: {} }), + ), + ); + expect(failure).toEqual({ kind: "origin-unparseable", url: "" }); + }); + + test("fails with origin-not-bitbucket for non-Bitbucket hosts", async () => { + const failure = await failureOf( + resolveRepository( + { cwd: "/r" }, + fakeGit({ + remotes: ["origin"], + remoteUrls: { origin: "git@github.com:ws/repo.git" }, + }), + ), + ); + expect(failure).toEqual({ + kind: "origin-not-bitbucket", + url: "git@github.com:ws/repo.git", + }); + }); + + test("fails with origin-unparseable for garbage URLs", async () => { + const failure = await failureOf( + resolveRepository( + { cwd: "/r" }, + fakeGit({ + remotes: ["origin"], + remoteUrls: { origin: "not a url at all" }, + }), + ), + ); + expect(failure).toEqual({ + kind: "origin-unparseable", + url: "not a url at all", + }); + }); + + test("returns parsed ref for valid Bitbucket origin", async () => { + const ref = await resolveRepository( + { cwd: "/r" }, + fakeGit({ + remotes: ["origin", "upstream"], + remoteUrls: { origin: "git@bitbucket.org:acme/widgets.git" }, + }), + ); + expect(ref).toEqual({ workspace: "acme", slug: "widgets" }); + }); }); describe("RepositoryResolutionError messages", () => { - test("include the override hint", async () => { - const err = await errorOf( - resolveRepository({ cwd: "/r" }, fakeGit({ insideWorkTree: false })), - ); - expect(err.message).toContain("-R <workspace>/<repo>"); - expect(err.message).toContain("/r"); - }); - - test("no-origin message lists remotes", async () => { - const err = await errorOf( - resolveRepository({ cwd: "/r" }, fakeGit({ remotes: ["upstream", "fork"] })), - ); - expect(err.message).toContain("upstream, fork"); - }); + test("include the override hint", async () => { + const err = await errorOf( + resolveRepository({ cwd: "/r" }, fakeGit({ insideWorkTree: false })), + ); + expect(err.message).toContain("-R <workspace>/<repo>"); + expect(err.message).toContain("/r"); + }); + + test("no-origin message lists remotes", async () => { + const err = await errorOf( + resolveRepository( + { cwd: "/r" }, + fakeGit({ remotes: ["upstream", "fork"] }), + ), + ); + expect(err.message).toContain("upstream, fork"); + }); }); diff --git a/src/shared/repository/resolve.ts b/src/shared/repository/resolve.ts index cbde1aa..e621ab5 100644 --- a/src/shared/repository/resolve.ts +++ b/src/shared/repository/resolve.ts @@ -1,115 +1,115 @@ -import { parseBitbucketRemoteUrl, type RepositoryRef } from "./parse-url.ts"; import { defaultGitRunner, type GitRunner } from "./git.ts"; +import { parseBitbucketRemoteUrl, type RepositoryRef } from "./parse-url.ts"; export type ResolutionFailure = - | { kind: "override-invalid"; value: string } - | { kind: "not-a-git-repo"; cwd: string } - | { kind: "no-remotes" } - | { kind: "no-origin"; remotes: string[] } - | { kind: "origin-not-bitbucket"; url: string } - | { kind: "origin-unparseable"; url: string }; + | { kind: "override-invalid"; value: string } + | { kind: "not-a-git-repo"; cwd: string } + | { kind: "no-remotes" } + | { kind: "no-origin"; remotes: string[] } + | { kind: "origin-not-bitbucket"; url: string } + | { kind: "origin-unparseable"; url: string }; const OVERRIDE_HINT = - "Pass -R <workspace>/<repo> to specify a repository explicitly."; + "Pass -R <workspace>/<repo> to specify a repository explicitly."; export class RepositoryResolutionError extends Error { - override name = "RepositoryResolutionError"; - readonly failure: ResolutionFailure; + override name = "RepositoryResolutionError"; + readonly failure: ResolutionFailure; - constructor(failure: ResolutionFailure) { - super(formatFailure(failure)); - this.failure = failure; - } + constructor(failure: ResolutionFailure) { + super(formatFailure(failure)); + this.failure = failure; + } } export type ResolveOptions = { - override?: string | undefined; - cwd?: string; + override?: string | undefined; + cwd?: string; }; export async function resolveRepository( - options: ResolveOptions, - git: GitRunner = defaultGitRunner, + options: ResolveOptions, + git: GitRunner = defaultGitRunner, ): Promise<RepositoryRef> { - if (options.override !== undefined) { - const parsed = parseOverride(options.override); - if (!parsed) { - throw new RepositoryResolutionError({ - kind: "override-invalid", - value: options.override, - }); - } - return parsed; - } - - const cwd = options.cwd ?? process.cwd(); - - if (!(await git.isInsideWorkTree(cwd))) { - throw new RepositoryResolutionError({ kind: "not-a-git-repo", cwd }); - } - - const remotes = await git.listRemotes(cwd); - if (remotes.length === 0) { - throw new RepositoryResolutionError({ kind: "no-remotes" }); - } - - if (!remotes.includes("origin")) { - throw new RepositoryResolutionError({ kind: "no-origin", remotes }); - } - - const url = await git.getRemoteUrl(cwd, "origin"); - // listRemotes reported 'origin', so a missing URL here is a genuinely - // broken local config — treat it the same as an unparseable URL. - if (!url) { - throw new RepositoryResolutionError({ - kind: "origin-unparseable", - url: "", - }); - } - - const parsed = parseBitbucketRemoteUrl(url); - if (parsed) return parsed; - - // Distinguish "well-formed URL for a different host" from "garbage". - if (looksLikeKnownHost(url)) { - throw new RepositoryResolutionError({ kind: "origin-not-bitbucket", url }); - } - throw new RepositoryResolutionError({ kind: "origin-unparseable", url }); + if (options.override !== undefined) { + const parsed = parseOverride(options.override); + if (!parsed) { + throw new RepositoryResolutionError({ + kind: "override-invalid", + value: options.override, + }); + } + return parsed; + } + + const cwd = options.cwd ?? process.cwd(); + + if (!(await git.isInsideWorkTree(cwd))) { + throw new RepositoryResolutionError({ kind: "not-a-git-repo", cwd }); + } + + const remotes = await git.listRemotes(cwd); + if (remotes.length === 0) { + throw new RepositoryResolutionError({ kind: "no-remotes" }); + } + + if (!remotes.includes("origin")) { + throw new RepositoryResolutionError({ kind: "no-origin", remotes }); + } + + const url = await git.getRemoteUrl(cwd, "origin"); + // listRemotes reported 'origin', so a missing URL here is a genuinely + // broken local config — treat it the same as an unparseable URL. + if (!url) { + throw new RepositoryResolutionError({ + kind: "origin-unparseable", + url: "", + }); + } + + const parsed = parseBitbucketRemoteUrl(url); + if (parsed) return parsed; + + // Distinguish "well-formed URL for a different host" from "garbage". + if (looksLikeKnownHost(url)) { + throw new RepositoryResolutionError({ kind: "origin-not-bitbucket", url }); + } + throw new RepositoryResolutionError({ kind: "origin-unparseable", url }); } function parseOverride(value: string): RepositoryRef | null { - const trimmed = value.trim(); - const match = /^([^/\s]+)\/([^/\s]+)$/.exec(trimmed); - if (!match) return null; - return { workspace: match[1]!.toLowerCase(), slug: match[2]!.toLowerCase() }; + const trimmed = value.trim(); + const match = /^([^/\s]+)\/([^/\s]+)$/.exec(trimmed); + if (!match) return null; + return { workspace: match[1]!.toLowerCase(), slug: match[2]!.toLowerCase() }; } function looksLikeKnownHost(url: string): boolean { - // scp-like: user@host:path - if (/^[\w.-]+@[\w.-]+:/.test(url)) return true; - try { - const parsed = new URL(url); - return Boolean(parsed.hostname); - } catch { - return false; - } + // scp-like: user@host:path + if (/^[\w.-]+@[\w.-]+:/.test(url)) return true; + try { + const parsed = new URL(url); + return Boolean(parsed.hostname); + } catch { + return false; + } } function formatFailure(failure: ResolutionFailure): string { - switch (failure.kind) { - case "override-invalid": - return `Invalid --repository value '${failure.value}'. Expected format: workspace/repo.`; - case "not-a-git-repo": - return `Not inside a git repository (cwd: ${failure.cwd}). ${OVERRIDE_HINT}`; - case "no-remotes": - return `This git repository has no remotes configured. ${OVERRIDE_HINT}`; - case "no-origin": - return `No 'origin' remote found. Available remotes: ${failure.remotes.join(", ")}. ${OVERRIDE_HINT}`; - case "origin-not-bitbucket": - return `Remote 'origin' (${failure.url}) is not a Bitbucket Cloud URL. ${OVERRIDE_HINT}`; - case "origin-unparseable": - return failure.url - ? `Could not parse remote 'origin' URL '${failure.url}'. ${OVERRIDE_HINT}` - : `Remote 'origin' has no URL configured. ${OVERRIDE_HINT}`; - } + switch (failure.kind) { + case "override-invalid": + return `Invalid --repository value '${failure.value}'. Expected format: workspace/repo.`; + case "not-a-git-repo": + return `Not inside a git repository (cwd: ${failure.cwd}). ${OVERRIDE_HINT}`; + case "no-remotes": + return `This git repository has no remotes configured. ${OVERRIDE_HINT}`; + case "no-origin": + return `No 'origin' remote found. Available remotes: ${failure.remotes.join(", ")}. ${OVERRIDE_HINT}`; + case "origin-not-bitbucket": + return `Remote 'origin' (${failure.url}) is not a Bitbucket Cloud URL. ${OVERRIDE_HINT}`; + case "origin-unparseable": + return failure.url + ? `Could not parse remote 'origin' URL '${failure.url}'. ${OVERRIDE_HINT}` + : `Remote 'origin' has no URL configured. ${OVERRIDE_HINT}`; + } } diff --git a/src/shared/time/index.test.ts b/src/shared/time/index.test.ts index 4cd544a..f7ac043 100644 --- a/src/shared/time/index.test.ts +++ b/src/shared/time/index.test.ts @@ -1,38 +1,38 @@ -import { test, expect, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { formatRelativeTime } from "./index.ts"; const NOW = new Date("2026-04-13T12:00:00Z"); describe("formatRelativeTime", () => { - test("returns 'just now' for sub-minute diffs", () => { - expect(formatRelativeTime("2026-04-13T11:59:30Z", NOW)).toBe("just now"); - }); + test("returns 'just now' for sub-minute diffs", () => { + expect(formatRelativeTime("2026-04-13T11:59:30Z", NOW)).toBe("just now"); + }); - test("future timestamps collapse to 'just now'", () => { - expect(formatRelativeTime("2026-04-13T12:00:30Z", NOW)).toBe("just now"); - }); + test("future timestamps collapse to 'just now'", () => { + expect(formatRelativeTime("2026-04-13T12:00:30Z", NOW)).toBe("just now"); + }); - test("formats minutes", () => { - expect(formatRelativeTime("2026-04-13T11:55:00Z", NOW)).toBe("5m ago"); - }); + test("formats minutes", () => { + expect(formatRelativeTime("2026-04-13T11:55:00Z", NOW)).toBe("5m ago"); + }); - test("formats hours", () => { - expect(formatRelativeTime("2026-04-13T10:00:00Z", NOW)).toBe("2h ago"); - }); + test("formats hours", () => { + expect(formatRelativeTime("2026-04-13T10:00:00Z", NOW)).toBe("2h ago"); + }); - test("formats days", () => { - expect(formatRelativeTime("2026-04-10T12:00:00Z", NOW)).toBe("3d ago"); - }); + test("formats days", () => { + expect(formatRelativeTime("2026-04-10T12:00:00Z", NOW)).toBe("3d ago"); + }); - test("formats months", () => { - expect(formatRelativeTime("2026-02-01T12:00:00Z", NOW)).toBe("2mo ago"); - }); + test("formats months", () => { + expect(formatRelativeTime("2026-02-01T12:00:00Z", NOW)).toBe("2mo ago"); + }); - test("formats years", () => { - expect(formatRelativeTime("2024-01-01T12:00:00Z", NOW)).toBe("2y ago"); - }); + test("formats years", () => { + expect(formatRelativeTime("2024-01-01T12:00:00Z", NOW)).toBe("2y ago"); + }); - test("returns empty string for invalid input", () => { - expect(formatRelativeTime("not-a-date", NOW)).toBe(""); - }); + test("returns empty string for invalid input", () => { + expect(formatRelativeTime("not-a-date", NOW)).toBe(""); + }); }); diff --git a/src/shared/time/index.ts b/src/shared/time/index.ts index bdd9724..bd5cbb6 100644 --- a/src/shared/time/index.ts +++ b/src/shared/time/index.ts @@ -9,14 +9,17 @@ const YEAR = 365 * DAY; * ("2h ago", "3d ago") rather than prose. Future timestamps (clock skew) * collapse to "just now" rather than returning a negative duration. */ -export function formatRelativeTime(iso: string, now: Date = new Date()): string { - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return ""; - const diff = now.getTime() - then; - if (diff < MINUTE) return "just now"; - if (diff < HOUR) return `${Math.floor(diff / MINUTE)}m ago`; - if (diff < DAY) return `${Math.floor(diff / HOUR)}h ago`; - if (diff < MONTH) return `${Math.floor(diff / DAY)}d ago`; - if (diff < YEAR) return `${Math.floor(diff / MONTH)}mo ago`; - return `${Math.floor(diff / YEAR)}y ago`; +export function formatRelativeTime( + iso: string, + now: Date = new Date(), +): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const diff = now.getTime() - then; + if (diff < MINUTE) return "just now"; + if (diff < HOUR) return `${Math.floor(diff / MINUTE)}m ago`; + if (diff < DAY) return `${Math.floor(diff / HOUR)}h ago`; + if (diff < MONTH) return `${Math.floor(diff / DAY)}d ago`; + if (diff < YEAR) return `${Math.floor(diff / MONTH)}mo ago`; + return `${Math.floor(diff / YEAR)}y ago`; } diff --git a/src/test/msw/server.ts b/src/test/msw/server.ts index b6992bd..4f85519 100644 --- a/src/test/msw/server.ts +++ b/src/test/msw/server.ts @@ -1,4 +1,4 @@ -import { beforeAll, afterEach, afterAll } from "bun:test"; +import { afterAll, afterEach, beforeAll } from "bun:test"; import { setupServer } from "msw/node"; /** @@ -12,9 +12,9 @@ import { setupServer } from "msw/node"; export const server = setupServer(); export function setupMsw(): void { - beforeAll(() => server.listen({ onUnhandledRequest: "error" })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); } export const BITBUCKET_BASE = "https://api.bitbucket.org/2.0"; diff --git a/tsconfig.json b/tsconfig.json index bfa0fea..146fe4e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,29 +1,29 @@ { - "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } }