From ce87477b80eac5670239135689cd3b618a7ff300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20N=C3=B8rg=C3=A5rd?= Date: Sat, 9 May 2026 10:26:51 +0200 Subject: [PATCH 1/6] chore: install effect --- package.json | 4 +- pnpm-lock.yaml | 1379 +++++++++++++++++++++++-------------------- pnpm-workspace.yaml | 4 + 3 files changed, 752 insertions(+), 635 deletions(-) diff --git a/package.json b/package.json index cca3103..c27a892 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "tinyexec": "catalog:runtime" }, "devDependencies": { + "@effect/platform-node": "catalog:effect", "@luxass/msw-utils": "catalog:dev", "@types/node": "catalog:types", "@types/prompts": "catalog:types", "@types/semver": "catalog:types", + "effect": "catalog:effect", "eta": "catalog:dev", "msw": "catalog:dev", "oxfmt": "catalog:dev", @@ -58,7 +60,7 @@ "vitest": "catalog:dev", "vitest-testdirs": "catalog:dev" }, - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@11.0.9", "inlinedDependencies": { "eta": "4.5.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33e0426..b827506 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,13 @@ catalogs: vitest-testdirs: specifier: 4.4.3 version: 4.4.3 + effect: + '@effect/platform-node': + specifier: 4.0.0-beta.60 + version: 4.0.0-beta.60 + effect: + specifier: 4.0.0-beta.60 + version: 4.0.0-beta.60 runtime: '@luxass/utils': specifier: 2.7.3 @@ -92,6 +99,9 @@ importers: specifier: catalog:runtime version: 1.0.4 devDependencies: + '@effect/platform-node': + specifier: catalog:effect + version: 4.0.0-beta.60(effect@4.0.0-beta.60)(ioredis@5.10.1) '@luxass/msw-utils': specifier: catalog:dev version: 0.6.2(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2)) @@ -104,6 +114,9 @@ importers: '@types/semver': specifier: catalog:types version: 7.7.1 + effect: + specifier: catalog:effect + version: 4.0.0-beta.60 eta: specifier: catalog:dev version: 4.5.1 @@ -121,16 +134,16 @@ importers: version: 0.19.0 tsdown: specifier: catalog:dev - version: 0.21.7(synckit@0.11.12)(typescript@6.0.2) + version: 0.21.7(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(typescript@6.0.2) typescript: specifier: catalog:dev version: 6.0.2 vitest: specifier: catalog:dev - version: 4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@7.1.12(@types/node@22.19.14)(jiti@2.6.1)(yaml@2.8.2)) + version: 4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4)) vitest-testdirs: specifier: catalog:dev - version: 4.4.3(vitest@4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@7.1.12(@types/node@22.19.14)(jiti@2.6.1)(yaml@2.8.2))) + version: 4.4.3(vitest@4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4))) packages: @@ -138,8 +151,8 @@ packages: resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==} engines: {node: ^20.19.0 || >=22.12.0} - '@babel/helper-string-parser@8.0.0-rc.3': - resolution: {integrity: sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==} + '@babel/helper-string-parser@8.0.0-rc.4': + resolution: {integrity: sha512-dluR3v287dp6YPF57kyKKrHPKffUeuxH1zQcF1WD30TeFzWXhDiVi1U6PkqaDB0++H1PeCwRhmYl4DvoerlPIw==} engines: {node: ^20.19.0 || >=22.12.0} '@babel/helper-validator-identifier@8.0.0-rc.3': @@ -155,170 +168,27 @@ packages: resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==} engines: {node: ^20.19.0 || >=22.12.0} - '@emnapi/core@1.7.1': - resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} - - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] + '@effect/platform-node-shared@4.0.0-beta.60': + resolution: {integrity: sha512-I/4g1B9Nb2qeUAJmRiEnd3bFOx2LU3FaBGi3O6iUGlRe+MAReqYP+1JBntLoCBkH8kVUM2viQRZONzopb/8Bzw==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-beta.60 - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] + '@effect/platform-node@4.0.0-beta.60': + resolution: {integrity: sha512-vA9isPNtPg6riXevAgZS1LXPiijQ7TYAdUFWzZwZL1qAqVtDbBFd3Lvfm9p6UKokAiUSeeFvamndmp6R1hkKFQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-beta.60 + ioredis: ^5.7.0 - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} @@ -355,6 +225,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -377,12 +250,45 @@ packages: resolution: {integrity: sha512-+1ZqFLZAmPjtBhEjotGuXSwh/tGPn2Q03L+gUY3RM0psLYpnLpec3SFNOLOoEJDtX+9+5dInuD8GFrCqWgw4Mw==} engines: {node: '>=20'} - '@mswjs/interceptors@0.41.3': - resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + + '@mswjs/interceptors@0.41.8': + resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==} engines: {node: '>=18'} - '@napi-rs/wasm-runtime@1.1.1': - resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -396,6 +302,9 @@ packages: '@oxc-project/types@0.122.0': resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@oxfmt/binding-android-arm-eabi@0.43.0': resolution: {integrity: sha512-CgU2s+/9hHZgo0IxVxrbMPrMj+tJ6VM3mD7Mr/4oiz4FNTISLoCvRmB5nk4wAAle045RtRjd86m673jwPyb1OQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -670,10 +579,6 @@ packages: cpu: [x64] os: [win32] - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -683,30 +588,60 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.12': resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -714,6 +649,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -721,6 +663,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -728,6 +677,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} @@ -735,6 +691,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -742,6 +705,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} @@ -749,158 +719,70 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} engines: {node: '>=14.0.0'} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.12': - resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} - - '@rollup/rollup-android-arm-eabi@4.52.5': - resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.52.5': - resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.52.5': - resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.52.5': - resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.52.5': - resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.52.5': - resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.52.5': - resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.52.5': - resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.52.5': - resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.52.5': - resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.52.5': - resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-gnu@4.52.5': - resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-gnu@4.52.5': - resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.52.5': - resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.52.5': - resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.52.5': - resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.52.5': - resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openharmony-arm64@4.52.5': - resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.52.5': - resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.52.5': - resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==} - cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.52.5': - resolution: {integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==} - cpu: [x64] - os: [win32] + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} - '@rollup/rollup-win32-x64-msvc@4.52.5': - resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==} - cpu: [x64] - os: [win32] + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -926,6 +808,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@4.1.2': resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} @@ -994,6 +879,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1011,8 +900,25 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - defu@6.1.4: - resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} dts-resolver@2.1.3: resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} @@ -1023,6 +929,9 @@ packages: oxc-resolver: optional: true + effect@4.0.0-beta.60: + resolution: {integrity: sha512-OkrCKT+aBFIti4ryuxKfIozNx2SMxmFZ8uWB53gGzdjQzaJ7RVf0s6nJQ4JubJM//R24DtSYNOdmPRSK+qoRww==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1030,13 +939,8 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} @@ -1057,6 +961,10 @@ packages: resolution: {integrity: sha512-rHj+XLOnEJ44miIXJ2W68GKnys5TYQgGhpClfbSzdpKAcYpwdjJjDJMjzj9uLVP243fszLaKDgDFwC89YB37cg==} engines: {node: '>=20'} + fast-check@4.7.0: + resolution: {integrity: sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==} + engines: {node: '>=12.17.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1066,6 +974,9 @@ packages: picomatch: optional: true + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1075,49 +986,151 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-tsconfig@4.13.7: - resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + graphql@16.13.2: + resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + + import-without-cache@0.2.5: + resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} + engines: {node: '>=20.19.0'} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-network-error@1.3.1: + resolution: {integrity: sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==} + engines: {node: '>=16'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] - graphql@16.13.2: - resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} - engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] - headers-polyfill@4.0.3: - resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] - hookable@6.1.0: - resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] - import-without-cache@0.2.5: - resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} - engines: {node: '>=20.19.0'} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - is-network-error@1.3.0: - resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==} - engines: {node: '>=16'} + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} - is-node-process@1.2.0: - resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} hasBin: true - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + msgpackr@1.11.12: + resolution: {integrity: sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==} msw@2.12.14: resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==} @@ -1129,15 +1142,22 @@ packages: typescript: optional: true + multipasta@0.2.7: + resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -1180,17 +1200,28 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1225,9 +1256,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.52.5: - resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true semver@7.7.4: @@ -1252,12 +1283,15 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -1270,16 +1304,12 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} - engines: {node: ^14.18.0 || >=16.0.0} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} - termenv@1.0.2: - resolution: {integrity: sha512-Qhgg8HwPAwvvzvl8xUXH13R0dLcXqgOCHMfFejf3vGYYxmZfMUwN5jTdB+lDEtlJCxgwH8o349L5O7hU2QxPOA==} + termenv@1.0.4: + resolution: {integrity: sha512-9sOjqn8s9BrBAuVjOS9ZqN5zfI+n0qSJeep1+a+ZRWZfpjXZCnbCBVsWtjQZK6/OTCIrYW5plN3JwP71o0htuw==} engines: {node: '>=20'} testdirs@4.0.2: @@ -1293,8 +1323,8 @@ packages: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} tinypool@2.1.0: @@ -1305,13 +1335,17 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldts-core@7.0.27: - resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} - tldts@7.0.27: - resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} hasBin: true + toml@4.1.1: + resolution: {integrity: sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==} + engines: {node: '>=20'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -1351,8 +1385,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - type-fest@5.5.0: - resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} typescript@6.0.2: @@ -1366,8 +1400,12 @@ packages: undici-types@6.24.0: resolution: {integrity: sha512-7sP0dVSecE8+Kylk0j81SymYqJwdFL6pPlIJqYjv5V7r4o8su/Vi50kBDuuV9X9uC8Mt/RfFFCFVvr/nzuPonw==} - unrun@0.2.34: - resolution: {integrity: sha512-LyaghRBR++r7svhDK6tnDz2XaYHWdneBOA0jbS8wnRsHerI9MFljX4fIiTgbbNbEVzZ0C9P1OjWLLe1OqoaaEw==} + undici@8.2.0: + resolution: {integrity: sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==} + engines: {node: '>=22.19.0'} + + unrun@0.2.37: + resolution: {integrity: sha512-AA7vDuYsgeSYVzJMm16UKA+aXFKhy7nFqW9z5l7q44K4ppFWZAMqYS58ePRZbugMLPH0fwwMzD5A8nP0avxwZQ==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: @@ -1379,15 +1417,20 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} - vite@7.1.12: - resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==} + uuid@13.0.2: + resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} + hasBin: true + + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 - lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' @@ -1398,12 +1441,14 @@ packages: peerDependenciesMeta: '@types/node': optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -1473,12 +1518,24 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} engines: {node: '>= 14.6'} hasBin: true @@ -1494,8 +1551,8 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: @@ -1508,7 +1565,7 @@ snapshots: '@types/jsesc': 2.5.1 jsesc: 3.1.0 - '@babel/helper-string-parser@8.0.0-rc.3': {} + '@babel/helper-string-parser@8.0.0-rc.4': {} '@babel/helper-validator-identifier@8.0.0-rc.3': {} @@ -1518,101 +1575,43 @@ snapshots: '@babel/types@8.0.0-rc.3': dependencies: - '@babel/helper-string-parser': 8.0.0-rc.3 + '@babel/helper-string-parser': 8.0.0-rc.4 '@babel/helper-validator-identifier': 8.0.0-rc.3 - '@emnapi/core@1.7.1': + '@effect/platform-node-shared@4.0.0-beta.60(effect@4.0.0-beta.60)': dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 - optional: true + '@types/ws': 8.18.1 + effect: 4.0.0-beta.60 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate - '@emnapi/runtime@1.7.1': + '@effect/platform-node@4.0.0-beta.60(effect@4.0.0-beta.60)(ioredis@5.10.1)': dependencies: - tslib: 2.8.1 - optional: true + '@effect/platform-node-shared': 4.0.0-beta.60(effect@4.0.0-beta.60) + effect: 4.0.0-beta.60 + ioredis: 5.10.1 + mime: 4.1.0 + undici: 8.2.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate - '@emnapi/wasi-threads@1.1.0': + '@emnapi/core@1.10.0': dependencies: + '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.25.12': + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 optional: true - '@esbuild/win32-x64@0.25.12': + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 optional: true '@inquirer/ansi@1.0.2': {} @@ -1643,6 +1642,8 @@ snapshots: optionalDependencies: '@types/node': 22.19.14 + '@ioredis/commands@1.5.1': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1665,7 +1666,25 @@ snapshots: dependencies: p-retry: 7.1.1 - '@mswjs/interceptors@0.41.3': + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@mswjs/interceptors@0.41.8': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -1674,11 +1693,11 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@napi-rs/wasm-runtime@1.1.1': + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.7.1 - '@emnapi/runtime': 1.7.1 - '@tybys/wasm-util': 0.10.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 optional: true '@open-draft/deferred-promise@2.2.0': {} @@ -1692,6 +1711,8 @@ snapshots: '@oxc-project/types@0.122.0': {} + '@oxc-project/types@0.127.0': {} + '@oxfmt/binding-android-arm-eabi@0.43.0': optional: true @@ -1824,9 +1845,6 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.58.0': optional: true - '@pkgr/core@0.2.9': - optional: true - '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -1834,121 +1852,109 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-rc.12': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + '@rolldown/binding-darwin-x64@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + '@rolldown/binding-darwin-x64@1.0.0-rc.17': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': - dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': optional: true - '@rolldown/pluginutils@1.0.0-rc.12': {} - - '@rollup/rollup-android-arm-eabi@4.52.5': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-android-arm64@4.52.5': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': optional: true - '@rollup/rollup-darwin-arm64@4.52.5': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': optional: true - '@rollup/rollup-darwin-x64@4.52.5': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': optional: true - '@rollup/rollup-freebsd-arm64@4.52.5': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-freebsd-x64@4.52.5': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.52.5': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.52.5': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-arm64-gnu@4.52.5': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-arm64-musl@4.52.5': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-loong64-gnu@4.52.5': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.52.5': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.52.5': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': optional: true - '@rollup/rollup-linux-riscv64-musl@4.52.5': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': optional: true - '@rollup/rollup-linux-s390x-gnu@4.52.5': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true - '@rollup/rollup-linux-x64-gnu@4.52.5': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rollup/rollup-linux-x64-musl@4.52.5': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': optional: true - '@rollup/rollup-openharmony-arm64@4.52.5': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': optional: true - '@rollup/rollup-win32-arm64-msvc@4.52.5': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': optional: true - '@rollup/rollup-win32-ia32-msvc@4.52.5': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': optional: true - '@rollup/rollup-win32-x64-gnu@4.52.5': - optional: true + '@rolldown/pluginutils@1.0.0-rc.12': {} - '@rollup/rollup-win32-x64-msvc@4.52.5': - optional: true + '@rolldown/pluginutils@1.0.0-rc.17': {} '@standard-schema/spec@1.1.0': {} - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true @@ -1977,6 +1983,10 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.14 + '@vitest/expect@4.1.2': dependencies: '@standard-schema/spec': 1.1.0 @@ -1986,14 +1996,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.2(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@7.1.12(@types/node@22.19.14)(jiti@2.6.1)(yaml@2.8.2))': + '@vitest/mocker@4.1.2(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.14(@types/node@22.19.14)(typescript@6.0.2) - vite: 7.1.12(@types/node@22.19.14)(jiti@2.6.1)(yaml@2.8.2) + vite: 8.0.10(@types/node@22.19.14)(yaml@2.8.4) '@vitest/pretty-format@4.1.2': dependencies: @@ -2049,6 +2059,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2063,44 +2075,36 @@ snapshots: cookie@1.1.1: {} - defu@6.1.4: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + defu@6.1.7: {} + + denque@2.1.0: {} + + detect-libc@2.1.2: {} dts-resolver@2.1.3: {} + effect@4.0.0-beta.60: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.7.0 + find-my-way-ts: 0.1.6 + ini: 6.0.0 + kubernetes-types: 1.30.0 + msgpackr: 1.11.12 + multipasta: 0.2.7 + toml: 4.1.1 + uuid: 13.0.2 + yaml: 2.8.4 + emoji-regex@8.0.0: {} empathic@2.0.0: {} - es-module-lexer@2.0.0: {} - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 + es-module-lexer@2.1.0: {} escalade@3.2.0: {} @@ -2114,18 +2118,24 @@ snapshots: farver@1.0.0-beta.1: dependencies: - termenv: 1.0.2 + termenv: 1.0.4 + + fast-check@4.7.0: + dependencies: + pure-rand: 8.4.0 fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + find-my-way-ts@0.1.6: {} + fsevents@2.3.3: optional: true get-caller-file@2.0.5: {} - get-tsconfig@4.13.7: + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -2133,31 +2143,119 @@ snapshots: headers-polyfill@4.0.3: {} - hookable@6.1.0: {} + hookable@6.1.1: {} import-without-cache@0.2.5: {} + ini@6.0.0: {} + + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-fullwidth-code-point@3.0.0: {} - is-network-error@1.3.0: {} + is-network-error@1.3.1: {} is-node-process@1.2.0: {} - jiti@2.6.1: - optional: true - jsesc@3.1.0: {} kleur@3.0.3: {} + kubernetes-types@1.30.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mime@4.1.0: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.12: + optionalDependencies: + msgpackr-extract: 3.0.3 + msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2): dependencies: '@inquirer/confirm': 5.1.21(@types/node@22.19.14) - '@mswjs/interceptors': 0.41.3 + '@mswjs/interceptors': 0.41.8 '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 cookie: 1.1.1 @@ -2171,7 +2269,7 @@ snapshots: statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.1 - type-fest: 5.5.0 + type-fest: 5.6.0 until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: @@ -2179,9 +2277,16 @@ snapshots: transitivePeerDependencies: - '@types/node' + multipasta@0.2.7: {} + mute-stream@2.0.0: {} - nanoid@3.3.11: {} + nanoid@3.3.12: {} + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true obug@2.1.1: {} @@ -2245,7 +2350,7 @@ snapshots: p-retry@7.1.1: dependencies: - is-network-error: 1.3.0 + is-network-error: 1.3.1 path-to-regexp@6.3.0: {} @@ -2255,9 +2360,9 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.6: + postcss@8.5.14: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2266,15 +2371,23 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + pure-rand@8.4.0: {} + quansync@1.0.0: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + require-directory@2.1.1: {} resolve-pkg-maps@1.0.0: {} rettime@0.10.1: {} - rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.12)(typescript@6.0.2): + rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@6.0.2): dependencies: '@babel/generator': 8.0.0-rc.3 '@babel/helper-validator-identifier': 8.0.0-rc.3 @@ -2283,16 +2396,16 @@ snapshots: ast-kit: 3.0.0-beta.1 birpc: 4.0.0 dts-resolver: 2.1.3 - get-tsconfig: 4.13.7 + get-tsconfig: 4.14.0 obug: 2.1.1 picomatch: 4.0.4 - rolldown: 1.0.0-rc.12 + rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: - oxc-resolver - rolldown@1.0.0-rc.12: + rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: '@oxc-project/types': 0.122.0 '@rolldown/pluginutils': 1.0.0-rc.12 @@ -2309,37 +2422,33 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - rollup@4.52.5: + rolldown@1.0.0-rc.17: dependencies: - '@types/estree': 1.0.8 + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.52.5 - '@rollup/rollup-android-arm64': 4.52.5 - '@rollup/rollup-darwin-arm64': 4.52.5 - '@rollup/rollup-darwin-x64': 4.52.5 - '@rollup/rollup-freebsd-arm64': 4.52.5 - '@rollup/rollup-freebsd-x64': 4.52.5 - '@rollup/rollup-linux-arm-gnueabihf': 4.52.5 - '@rollup/rollup-linux-arm-musleabihf': 4.52.5 - '@rollup/rollup-linux-arm64-gnu': 4.52.5 - '@rollup/rollup-linux-arm64-musl': 4.52.5 - '@rollup/rollup-linux-loong64-gnu': 4.52.5 - '@rollup/rollup-linux-ppc64-gnu': 4.52.5 - '@rollup/rollup-linux-riscv64-gnu': 4.52.5 - '@rollup/rollup-linux-riscv64-musl': 4.52.5 - '@rollup/rollup-linux-s390x-gnu': 4.52.5 - '@rollup/rollup-linux-x64-gnu': 4.52.5 - '@rollup/rollup-linux-x64-musl': 4.52.5 - '@rollup/rollup-openharmony-arm64': 4.52.5 - '@rollup/rollup-win32-arm64-msvc': 4.52.5 - '@rollup/rollup-win32-ia32-msvc': 4.52.5 - '@rollup/rollup-win32-x64-gnu': 4.52.5 - '@rollup/rollup-win32-x64-msvc': 4.52.5 - fsevents: 2.3.3 + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 semver@7.7.4: {} @@ -2353,9 +2462,11 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} - std-env@4.0.0: {} + std-env@4.1.0: {} strict-event-emitter@0.5.1: {} @@ -2369,24 +2480,19 @@ snapshots: dependencies: ansi-regex: 5.0.1 - synckit@0.11.12: - dependencies: - '@pkgr/core': 0.2.9 - optional: true - tagged-tag@1.0.0: {} - termenv@1.0.2: {} + termenv@1.0.4: {} testdirs@4.0.2: dependencies: - zod: 4.3.6 + zod: 4.4.3 tinybench@2.9.0: {} tinyexec@1.0.4: {} - tinyglobby@0.2.15: + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 @@ -2395,39 +2501,43 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.0.27: {} + tldts-core@7.0.30: {} - tldts@7.0.27: + tldts@7.0.30: dependencies: - tldts-core: 7.0.27 + tldts-core: 7.0.30 + + toml@4.1.1: {} tough-cookie@6.0.1: dependencies: - tldts: 7.0.27 + tldts: 7.0.30 tree-kill@1.2.2: {} - tsdown@0.21.7(synckit@0.11.12)(typescript@6.0.2): + tsdown@0.21.7(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(typescript@6.0.2): dependencies: ansis: 4.2.0 cac: 7.0.0 - defu: 6.1.4 + defu: 6.1.7 empathic: 2.0.0 - hookable: 6.1.0 + hookable: 6.1.1 import-without-cache: 0.2.5 obug: 2.1.1 picomatch: 4.0.4 - rolldown: 1.0.0-rc.12 - rolldown-plugin-dts: 0.23.2(rolldown@1.0.0-rc.12)(typescript@6.0.2) + rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + rolldown-plugin-dts: 0.23.2(rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@6.0.2) semver: 7.7.4 tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tree-kill: 1.2.2 unconfig-core: 7.5.0 - unrun: 0.2.34(synckit@0.11.12) + unrun: 0.2.37 optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - '@ts-macro/tsc' - '@typescript/native-preview' - oxc-resolver @@ -2437,7 +2547,7 @@ snapshots: tslib@2.8.1: optional: true - type-fest@5.5.0: + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -2450,55 +2560,55 @@ snapshots: undici-types@6.24.0: {} - unrun@0.2.34(synckit@0.11.12): + undici@8.2.0: {} + + unrun@0.2.37: dependencies: - rolldown: 1.0.0-rc.12 - optionalDependencies: - synckit: 0.11.12 + rolldown: 1.0.0-rc.17 until-async@3.0.2: {} - vite@7.1.12(@types/node@22.19.14)(jiti@2.6.1)(yaml@2.8.2): + uuid@13.0.2: {} + + vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4): dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) + lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.6 - rollup: 4.52.5 - tinyglobby: 0.2.15 + postcss: 8.5.14 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 optionalDependencies: '@types/node': 22.19.14 fsevents: 2.3.3 - jiti: 2.6.1 - yaml: 2.8.2 + yaml: 2.8.4 - vitest-testdirs@4.4.3(vitest@4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@7.1.12(@types/node@22.19.14)(jiti@2.6.1)(yaml@2.8.2))): + vitest-testdirs@4.4.3(vitest@4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4))): dependencies: testdirs: 4.0.2 - vitest: 4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@7.1.12(@types/node@22.19.14)(jiti@2.6.1)(yaml@2.8.2)) - zod: 4.3.6 + vitest: 4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4)) + zod: 4.4.3 - vitest@4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@7.1.12(@types/node@22.19.14)(jiti@2.6.1)(yaml@2.8.2)): + vitest@4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@7.1.12(@types/node@22.19.14)(jiti@2.6.1)(yaml@2.8.2)) + '@vitest/mocker': 4.1.2(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.2 '@vitest/runner': 4.1.2 '@vitest/snapshot': 4.1.2 '@vitest/spy': 4.1.2 '@vitest/utils': 4.1.2 - es-module-lexer: 2.0.0 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 4.0.0 + std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.1.12(@types/node@22.19.14)(jiti@2.6.1)(yaml@2.8.2) + vite: 8.0.10(@types/node@22.19.14)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.14 @@ -2522,10 +2632,11 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + ws@8.20.0: {} + y18n@5.0.8: {} - yaml@2.8.2: - optional: true + yaml@2.8.4: {} yargs-parser@21.1.1: {} @@ -2541,4 +2652,4 @@ snapshots: yoctocolors-cjs@2.1.3: {} - zod@4.3.6: {} + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f5cd0c1..383e0e8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,10 @@ overrides: undici-types: 6.24.0 catalogs: + effect: + effect: 4.0.0-beta.60 + "@effect/platform-node": 4.0.0-beta.60 + dev: "@luxass/msw-utils": 0.6.2 eta: 4.5.1 From 09fac884b750ecea2e80c78b47a52e4d9ffc3ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20N=C3=B8rg=C3=A5rd?= Date: Mon, 11 May 2026 17:47:54 +0200 Subject: [PATCH 2/6] chore: migrate release workflows to effect services --- package.json | 1 + pnpm-lock.yaml | 17 + pnpm-workspace.yaml | 1 + src/core/changelog.ts | 292 ------ src/core/git.ts | 815 ----------------- src/core/github.ts | 456 ---------- src/core/npm.ts | 256 ------ src/core/prompts.ts | 219 ----- src/core/workspace.ts | 189 ---- src/index.ts | 97 +- src/operations/branch.ts | 114 --- src/operations/calculate.ts | 72 -- src/operations/pr.ts | 62 -- src/options.ts | 9 +- src/release/branch.ts | 93 ++ src/release/calculate.ts | 59 ++ src/release/pr.ts | 52 ++ src/release/prepare.ts | 376 ++++++++ src/{workflows => release}/publish.ts | 276 +++--- src/release/verify.ts | 184 ++++ src/services/changelog.ts | 304 +++++++ src/services/git.ts | 860 ++++++++++++++++++ src/services/github.ts | 514 +++++++++++ src/services/npm.ts | 267 ++++++ src/services/prompts.ts | 289 ++++++ src/services/workspace.ts | 215 +++++ .../changelog-format.ts | 0 src/{operations => shared}/semver.ts | 0 src/shared/types.ts | 2 +- src/shared/utils.ts | 155 +++- src/{operations => shared}/version.ts | 4 +- src/types.ts | 28 - src/versioning/commits.ts | 131 +-- src/versioning/package.ts | 8 +- src/versioning/version.ts | 94 +- src/workflows/prepare.ts | 375 -------- src/workflows/verify.ts | 177 ---- test/_shared.ts | 27 +- test/core/changelog.authors.test.ts | 40 +- test/core/changelog.test.ts | 115 ++- test/core/git.test.ts | 329 +++---- test/core/github.test.ts | 482 ++-------- test/core/npm.test.ts | 152 ++-- test/core/types.test.ts | 27 +- test/operations/branch.test.ts | 107 ++- test/operations/changelog-format.test.ts | 2 +- test/operations/pr.test.ts | 82 +- test/operations/semver.test.ts | 2 +- test/operations/version.test.ts | 2 +- test/shared/runtime.test.ts | 48 + test/shared/utils.test.ts | 64 +- test/types/result.test.ts | 21 - test/versioning/commits.test.ts | 2 +- test/versioning/package-graph.test.ts | 2 +- .../version-dependent-updates.test.ts | 72 +- 55 files changed, 4394 insertions(+), 4245 deletions(-) delete mode 100644 src/core/changelog.ts delete mode 100644 src/core/git.ts delete mode 100644 src/core/github.ts delete mode 100644 src/core/npm.ts delete mode 100644 src/core/prompts.ts delete mode 100644 src/core/workspace.ts delete mode 100644 src/operations/branch.ts delete mode 100644 src/operations/calculate.ts delete mode 100644 src/operations/pr.ts create mode 100644 src/release/branch.ts create mode 100644 src/release/calculate.ts create mode 100644 src/release/pr.ts create mode 100644 src/release/prepare.ts rename src/{workflows => release}/publish.ts (52%) create mode 100644 src/release/verify.ts create mode 100644 src/services/changelog.ts create mode 100644 src/services/git.ts create mode 100644 src/services/github.ts create mode 100644 src/services/npm.ts create mode 100644 src/services/prompts.ts create mode 100644 src/services/workspace.ts rename src/{operations => shared}/changelog-format.ts (100%) rename src/{operations => shared}/semver.ts (100%) rename src/{operations => shared}/version.ts (91%) delete mode 100644 src/workflows/prepare.ts delete mode 100644 src/workflows/verify.ts create mode 100644 test/shared/runtime.test.ts delete mode 100644 test/types/result.test.ts diff --git a/package.json b/package.json index c27a892..393855b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ }, "devDependencies": { "@effect/platform-node": "catalog:effect", + "@effect/vitest": "catalog:effect", "@luxass/msw-utils": "catalog:dev", "@types/node": "catalog:types", "@types/prompts": "catalog:types", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b827506..63b743c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ catalogs: '@effect/platform-node': specifier: 4.0.0-beta.60 version: 4.0.0-beta.60 + '@effect/vitest': + specifier: 4.0.0-beta.60 + version: 4.0.0-beta.60 effect: specifier: 4.0.0-beta.60 version: 4.0.0-beta.60 @@ -102,6 +105,9 @@ importers: '@effect/platform-node': specifier: catalog:effect version: 4.0.0-beta.60(effect@4.0.0-beta.60)(ioredis@5.10.1) + '@effect/vitest': + specifier: catalog:effect + version: 4.0.0-beta.60(effect@4.0.0-beta.60)(vitest@4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4))) '@luxass/msw-utils': specifier: catalog:dev version: 0.6.2(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2)) @@ -181,6 +187,12 @@ packages: effect: ^4.0.0-beta.60 ioredis: ^5.7.0 + '@effect/vitest@4.0.0-beta.60': + resolution: {integrity: sha512-taPLD3X5IQnrWsCGOg/VAu3e2o1Ou8YQKAosR00y6vwcXadiqeDrQTyyGPdDByXggODEqB5ygx0gHuptW+hHVA==} + peerDependencies: + effect: ^4.0.0-beta.60 + vitest: ^3.0.0 || ^4.0.0 + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -1598,6 +1610,11 @@ snapshots: - bufferutil - utf-8-validate + '@effect/vitest@4.0.0-beta.60(effect@4.0.0-beta.60)(vitest@4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4)))': + dependencies: + effect: 4.0.0-beta.60 + vitest: 4.1.2(@types/node@22.19.14)(msw@2.12.14(@types/node@22.19.14)(typescript@6.0.2))(vite@8.0.10(@types/node@22.19.14)(yaml@2.8.4)) + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 383e0e8..2223785 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,7 @@ catalogs: effect: effect: 4.0.0-beta.60 "@effect/platform-node": 4.0.0-beta.60 + "@effect/vitest": 4.0.0-beta.60 dev: "@luxass/msw-utils": 0.6.2 diff --git a/src/core/changelog.ts b/src/core/changelog.ts deleted file mode 100644 index 07f8831..0000000 --- a/src/core/changelog.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import { join, relative } from "node:path"; - -import { buildTemplateGroups } from "#operations/changelog-format"; -import type { AuthorInfo, CommitTypeRule } from "#shared/types"; -import { logger } from "#shared/utils"; -import type { GitCommit } from "commit-parser"; -import { Eta } from "eta"; - -import type { NormalizedReleaseScriptsOptions } from "../options"; -import { DEFAULT_CHANGELOG_TEMPLATE } from "../options"; -import { readFileFromGit } from "./git"; -import type { GitHubClient } from "./github"; -import type { WorkspacePackage } from "./workspace"; - -const CHANGELOG_VERSION_RE = /##\s+(?:)?\[?([^\](\s<]+)/; - -const excludeAuthors = [/\[bot\]/i, /dependabot/i, /\(bot\)/i]; - -export async function generateChangelogEntry(options: { - packageName: string; - version: string; - previousVersion?: string; - date: string; - commits: GitCommit[]; - owner: string; - repo: string; - types: Record; - template?: string; - githubClient: GitHubClient; -}): Promise { - const { - packageName, - version, - previousVersion, - date, - commits, - owner, - repo, - types, - template, - githubClient, - } = options; - - // Build compare URL - const compareUrl = - previousVersion && previousVersion !== version - ? `https://github.com/${owner}/${repo}/compare/${packageName}@${previousVersion}...${packageName}@${version}` - : undefined; - - const commitAuthors = await resolveCommitAuthors(commits, githubClient); - const templateGroups = buildTemplateGroups({ - commits, - owner, - repo, - types, - commitAuthors, - }); - - const templateData = { - packageName, - version, - previousVersion, - date, - compareUrl, - owner, - repo, - groups: templateGroups, - }; - - const eta = new Eta(); - const templateToUse = template || DEFAULT_CHANGELOG_TEMPLATE; - - return eta.renderString(templateToUse, templateData).trim(); -} - -export async function updateChangelog(options: { - normalizedOptions: NormalizedReleaseScriptsOptions; - workspacePackage: WorkspacePackage; - version: string; - previousVersion?: string; - commits: GitCommit[]; - date: string; - githubClient: GitHubClient; -}): Promise { - const { - version, - previousVersion, - commits, - date, - normalizedOptions, - workspacePackage, - githubClient, - } = options; - - if (previousVersion === version) { - logger.verbose( - `Skipping changelog update for ${workspacePackage.name}: version unchanged (${version})`, - ); - return; - } - - const changelogPath = join(workspacePackage.path, "CHANGELOG.md"); - - const changelogRelativePath = relative( - normalizedOptions.workspaceRoot, - join(workspacePackage.path, "CHANGELOG.md"), - ); - - // Read the changelog from the default branch to get clean state without unreleased entries - // This ensures that if a previous release PR was abandoned, we don't keep the old entry - const existingContent = await readFileFromGit( - normalizedOptions.workspaceRoot, - normalizedOptions.branch.default, - changelogRelativePath, - ); - - logger.verbose("Existing content found: ", existingContent.ok && Boolean(existingContent.value)); - - // Generate the new changelog entry - const newEntry = await generateChangelogEntry({ - packageName: workspacePackage.name, - version, - previousVersion, - date, - commits, - owner: normalizedOptions.owner, - repo: normalizedOptions.repo, - types: normalizedOptions.types, - template: normalizedOptions.changelog?.template, - githubClient, - }); - - let updatedContent: string; - - if (!existingContent.ok || !existingContent.value) { - updatedContent = `# ${workspacePackage.name}\n\n${newEntry}\n`; - - await writeFile(changelogPath, updatedContent, "utf-8"); - return; - } - - const parsed = parseChangelog(existingContent.value); - const lines = existingContent.value.split("\n"); - - // Check if this version already exists - const existingVersionIndex = parsed.versions.findIndex((v) => v.version === version); - - if (existingVersionIndex !== -1) { - // Version exists - append new commits to it (PR update scenario) - const existingVersion = parsed.versions[existingVersionIndex]!; - - // For now, just replace the entire version entry - // TODO: In future, we could parse commits and only add new ones - const before = lines.slice(0, existingVersion.lineStart); - const after = lines.slice(existingVersion.lineEnd + 1); - - updatedContent = [...before, newEntry, ...after].join("\n"); - } else { - // Version doesn't exist - insert new entry at top (below package header) - const insertAt = parsed.headerLineEnd + 1; - - const before = lines.slice(0, insertAt); - const after = lines.slice(insertAt); - - // Add empty line after header if needed - if (before.length > 0 && before.at(-1) !== "") { - before.push(""); - } - - updatedContent = [...before, newEntry, "", ...after].join("\n"); - } - - // Write updated content back - await writeFile(changelogPath, updatedContent, "utf-8"); -} - -async function resolveCommitAuthors( - commits: GitCommit[], - githubClient: GitHubClient, -): Promise> { - const authorMap = new Map(); - const commitAuthors = new Map(); - - for (const commit of commits) { - const authorsForCommit: AuthorInfo[] = []; - - commit.authors.forEach((author, idx) => { - if (!author.email || !author.name) { - return; - } - - if (excludeAuthors.some((re) => re.test(author.name))) { - return; - } - - if (!authorMap.has(author.email)) { - authorMap.set(author.email, { - commits: [], - name: author.name, - email: author.email, - }); - } - - const info = authorMap.get(author.email)!; - - if (idx === 0) { - info.commits.push(commit.shortHash); - } - - authorsForCommit.push(info); - }); - - commitAuthors.set(commit.hash, authorsForCommit); - } - - const authors = [...authorMap.values()]; - await Promise.all(authors.map((info) => githubClient.resolveAuthorInfo(info))); - - return commitAuthors; -} - -// formatCommitLine moved to operations/changelog-format - -export function parseChangelog(content: string) { - const lines = content.split("\n"); - - let packageName: string | null = null; - - // We need to start at -1, since some changelogs might not have a package name header - // which will cause us to miss the first version entry otherwise. - let headerLineEnd = -1; - const versions: { - version: string; - lineStart: number; - lineEnd: number; - content: string; - }[] = []; - - // Extract package name from first heading (# @package/name) - for (let i = 0; i < lines.length; i++) { - const line = lines[i]!.trim(); - - if (line.startsWith("# ")) { - packageName = line.slice(2).trim(); - headerLineEnd = i; - break; - } - } - - // Find all version entries (## version or ## [version](link)) - for (let i = headerLineEnd + 1; i < lines.length; i++) { - const line = lines[i]!.trim(); - - if (line.startsWith("## ")) { - // Extract version from various formats: - // ## 0.1.0 - // ## [0.1.0](link) (date) - // ## 0.1.0 - const versionMatch = line.match(CHANGELOG_VERSION_RE); - - if (versionMatch) { - const version = versionMatch[1]!; - const lineStart = i; - - // Find where this version entry ends (next ## or end of file) - let lineEnd = lines.length - 1; - for (let j = i + 1; j < lines.length; j++) { - if (lines[j]!.trim().startsWith("## ")) { - lineEnd = j - 1; - break; - } - } - - const versionContent = lines.slice(lineStart, lineEnd + 1).join("\n"); - - versions.push({ - version, - lineStart, - lineEnd, - content: versionContent, - }); - } - } - } - - return { - packageName, - versions, - headerLineEnd, - }; -} diff --git a/src/core/git.ts b/src/core/git.ts deleted file mode 100644 index 16a1205..0000000 --- a/src/core/git.ts +++ /dev/null @@ -1,815 +0,0 @@ -import process from "node:process"; - -import { formatUnknownError } from "#shared/errors"; -import { logger, run, runIfNotDry } from "#shared/utils"; -import type { Result } from "#types"; -import { err, ok } from "#types"; -import farver from "farver"; -import semver from "semver"; - -const DEFAULT_BRANCH_RE = /^refs\/remotes\/origin\/(.+)$/; -const CHECKOUT_BRANCH_RE = /Switched to (?:a new )?branch '(.+)'/; -const COMMIT_HASH_RE = /^[0-9a-f]{7,40}$/i; - -/** - * Check if the working directory is clean (no uncommitted changes) - * @param {string} workspaceRoot - The root directory of the workspace - * @returns {Promise} A Promise resolving to true if clean, false otherwise - */ -export interface GitError { - type: "git"; - operation: string; - message: string; - stderr?: string; -} - -function toGitError(operation: string, error: unknown): GitError { - const formatted = formatUnknownError(error); - return { - type: "git", - operation, - message: formatted.message, - stderr: formatted.stderr, - }; -} - -function isMissingGitIdentityError(error: unknown): boolean { - const formatted = formatUnknownError(error); - const combined = `${formatted.message}\n${formatted.stderr ?? ""}`; - - return ( - combined.includes("Author identity unknown") || - combined.includes("empty ident name") || - combined.includes("Please tell me who you are") - ); -} - -async function ensureLocalGitIdentity(workspaceRoot: string): Promise> { - try { - const actor = process.env.GITHUB_ACTOR?.trim(); - - const name = - process.env.GIT_AUTHOR_NAME?.trim() || - process.env.GIT_COMMITTER_NAME?.trim() || - actor || - "github-actions[bot]"; - - const email = - process.env.GIT_AUTHOR_EMAIL?.trim() || - process.env.GIT_COMMITTER_EMAIL?.trim() || - (actor - ? `${actor}@users.noreply.github.com` - : "github-actions[bot]@users.noreply.github.com"); - - logger.warn( - "Git author identity missing. Configuring repository-local git identity for this run.", - ); - - await runIfNotDry("git", ["config", "user.name", name], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - await runIfNotDry("git", ["config", "user.email", email], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - logger.info(`Configured git identity: ${farver.dim(`${name} <${email}>`)}`); - return ok(undefined); - } catch (error) { - return err(toGitError("ensureLocalGitIdentity", error)); - } -} - -async function commitWithRetryOnMissingIdentity( - message: string, - workspaceRoot: string, - operation: "commitChanges" | "commitPaths", -): Promise> { - const runCommit = async () => - runIfNotDry("git", ["commit", "-m", message], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - try { - await runCommit(); - return ok(undefined); - } catch (error) { - if (!isMissingGitIdentityError(error)) { - return err(toGitError(operation, error)); - } - - const configured = await ensureLocalGitIdentity(workspaceRoot); - if (!configured.ok) { - return configured; - } - - try { - await runCommit(); - return ok(undefined); - } catch (retryError) { - return err(toGitError(operation, retryError)); - } - } -} - -export async function isWorkingDirectoryClean( - workspaceRoot: string, -): Promise> { - try { - const result = await run("git", ["status", "--porcelain"], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - return ok(result.stdout.trim() === ""); - } catch (error) { - return err(toGitError("isWorkingDirectoryClean", error)); - } -} - -/** - * Check if a git branch exists locally - * @param {string} branch - The branch name to check - * @param {string} workspaceRoot - The root directory of the workspace - * @returns {Promise} Promise resolving to true if branch exists, false otherwise - */ -/** - * Check if a remote branch exists on origin - * @param {string} branch - The branch name to check - * @param {string} workspaceRoot - The root directory of the workspace - * @returns {Promise>} Promise resolving to true if remote branch exists - */ -export async function doesRemoteBranchExist( - branch: string, - workspaceRoot: string, -): Promise> { - try { - await run("git", ["ls-remote", "--exit-code", "--heads", "origin", branch], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - return ok(true); - } catch (error) { - logger.verbose( - `Remote branch "origin/${branch}" does not exist: ${formatUnknownError(error).message}`, - ); - return ok(false); - } -} - -export async function doesBranchExist( - branch: string, - workspaceRoot: string, -): Promise> { - try { - await run("git", ["rev-parse", "--verify", branch], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - return ok(true); - } catch (error) { - logger.verbose(`Failed to verify branch "${branch}": ${formatUnknownError(error).message}`); - return ok(false); - } -} - -/** - * Retrieves the default branch name from the remote repository. - * Falls back to "main" if the default branch cannot be determined. - * @returns {Promise} A Promise resolving to the default branch name as a string. - */ -export async function getDefaultBranch(workspaceRoot: string): Promise> { - try { - const result = await run("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - const ref = result.stdout.trim(); - const match = ref.match(DEFAULT_BRANCH_RE); - if (match && match[1]) { - return ok(match[1]); - } - - return ok("main"); // Fallback - } catch (error) { - logger.verbose( - `Failed to detect default branch from origin/HEAD: ${formatUnknownError(error).message}`, - ); - return ok("main"); // Fallback - } -} - -/** - * Retrieves the name of the current branch in the repository. - * @param {string} workspaceRoot - The root directory of the workspace - * @returns {Promise} A Promise resolving to the current branch name as a string - */ -export async function getCurrentBranch(workspaceRoot: string): Promise> { - try { - const result = await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - return ok(result.stdout.trim()); - } catch (error) { - return err(toGitError("getCurrentBranch", error)); - } -} - -/** - * Retrieves the list of available branches in the repository. - * @param {string} workspaceRoot - The root directory of the workspace - * @returns {Promise} A Promise resolving to an array of branch names - */ -export async function getAvailableBranches( - workspaceRoot: string, -): Promise> { - try { - const result = await run("git", ["branch", "--list"], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - const branches = result.stdout - .split("\n") - .map((line) => line.replace("*", "").trim()) - .filter((line) => line.length > 0); - - return ok(branches); - } catch (error) { - return err(toGitError("getAvailableBranches", error)); - } -} - -/** - * Creates a new branch from the specified base branch. - * @param {string} branch - The name of the new branch to create - * @param {string} base - The base branch to create the new branch from - * @param {string} workspaceRoot - The root directory of the workspace - * @returns {Promise} A Promise that resolves when the branch is created - */ -export async function createBranch( - branch: string, - base: string, - workspaceRoot: string, -): Promise> { - try { - logger.info(`Creating branch: ${farver.green(branch)} from ${farver.cyan(base)}`); - await runIfNotDry("git", ["branch", branch, base], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - return ok(undefined); - } catch (error) { - return err(toGitError("createBranch", error)); - } -} - -export async function checkoutBranch( - branch: string, - workspaceRoot: string, -): Promise> { - try { - logger.info(`Switching to branch: ${farver.green(branch)}`); - const result = await run("git", ["checkout", branch], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - const output = result.stderr.trim(); - - // Switching Branches "Switched to branch '[name]'" - // New Branch "Switched to a new branch '[name]'" - const match = output.match(CHECKOUT_BRANCH_RE); - if (match && match[1] === branch) { - logger.info(`Successfully switched to branch: ${farver.green(branch)}`); - return ok(true); - } - - logger.warn(`Unexpected git checkout output: ${output}`); - return ok(false); - } catch (error) { - const gitError = toGitError("checkoutBranch", error); - logger.error(`Git checkout failed: ${gitError.message}`); - if (gitError.stderr) { - logger.error(`Git stderr: ${gitError.stderr}`); - } - - // Show available branches for debugging - try { - const branchResult = await run("git", ["branch", "-a"], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - logger.verbose(`Available branches:\n${branchResult.stdout}`); - } catch (error) { - logger.verbose(`Could not list available branches: ${formatUnknownError(error).message}`); - } - - return err(gitError); - } -} - -export async function pullLatestChanges( - branch: string, - workspaceRoot: string, -): Promise> { - try { - await run("git", ["pull", "origin", branch], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - return ok(true); - } catch (error) { - return err(toGitError("pullLatestChanges", error)); - } -} - -export async function rebaseBranch( - ontoBranch: string, - workspaceRoot: string, -): Promise> { - try { - logger.info(`Rebasing onto: ${farver.cyan(ontoBranch)}`); - await runIfNotDry("git", ["rebase", ontoBranch], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - return ok(undefined); - } catch (error) { - // Abort any in-progress rebase to leave the repo in a clean state - try { - await run("git", ["rebase", "--abort"], { - nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, - }); - logger.verbose("Aborted in-progress rebase after failure"); - } catch { - // Ignore abort errors — rebase may not have started - } - return err(toGitError("rebaseBranch", error)); - } -} - -export async function isBranchAheadOfRemote( - branch: string, - workspaceRoot: string, -): Promise> { - try { - const result = await run("git", ["rev-list", `origin/${branch}..${branch}`, "--count"], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - const commitCount = Number.parseInt(result.stdout.trim(), 10); - return ok(commitCount > 0); - } catch (error) { - logger.verbose( - `Failed to compare branch "${branch}" with remote: ${formatUnknownError(error).message}`, - ); - return ok(true); - } -} - -export async function commitChanges( - message: string, - workspaceRoot: string, -): Promise> { - try { - // Stage modifications and deletions to already-tracked files only. - // Using -u avoids accidentally staging untracked/unrelated files. - await run("git", ["add", "-u"], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - // Check if anything was actually staged (git add -u only touches tracked files; - // untracked files would cause isWorkingDirectoryClean to return false even when - // nothing is staged, leading to a "nothing to commit" error from git commit). - const staged = await run("git", ["diff", "--cached", "--name-only"], { - nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, - }); - if (staged.stdout.trim() === "") { - return ok(false); - } - - // Commit - logger.info(`Committing changes: ${farver.dim(message)}`); - const committed = await commitWithRetryOnMissingIdentity( - message, - workspaceRoot, - "commitChanges", - ); - if (!committed.ok) { - return committed; - } - - return ok(true); - } catch (error) { - const gitError = toGitError("commitChanges", error); - logger.error(`Git commit failed: ${gitError.message}`); - if (gitError.stderr) { - logger.error(`Git stderr: ${gitError.stderr}`); - } - return err(gitError); - } -} - -export async function commitPaths( - paths: string[], - message: string, - workspaceRoot: string, -): Promise> { - try { - if (paths.length === 0) { - return ok(false); - } - - await run("git", ["add", "--", ...paths], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - const staged = await run("git", ["diff", "--cached", "--name-only"], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - if (staged.stdout.trim() === "") { - return ok(false); - } - - logger.info(`Committing changes: ${farver.dim(message)}`); - const committed = await commitWithRetryOnMissingIdentity(message, workspaceRoot, "commitPaths"); - if (!committed.ok) { - return committed; - } - - return ok(true); - } catch (error) { - const gitError = toGitError("commitPaths", error); - logger.error(`Git commit failed: ${gitError.message}`); - if (gitError.stderr) { - logger.error(`Git stderr: ${gitError.stderr}`); - } - return err(gitError); - } -} - -export async function pushBranch( - branch: string, - workspaceRoot: string, - options?: { force?: boolean; forceWithLease?: boolean }, -): Promise> { - try { - const args = ["push", "origin", branch]; - - if (options?.forceWithLease) { - try { - await run("git", ["fetch", "origin", branch], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - args.push("--force-with-lease"); - logger.info(`Pushing branch: ${farver.green(branch)} ${farver.dim("(with lease)")}`); - } catch (error) { - const fetchError = toGitError("pushBranch.fetch", error); - const isMissingRemoteRef = - fetchError.stderr?.includes("couldn't find remote ref") || - fetchError.message.includes("couldn't find remote ref"); - - if (isMissingRemoteRef) { - logger.verbose( - `Remote branch origin/${branch} does not exist yet, falling back to regular push without --force-with-lease.`, - ); - } else { - return err(fetchError); - } - } - } else if (options?.force) { - args.push("--force"); - logger.info(`Force pushing branch: ${farver.green(branch)}`); - } else { - logger.info(`Pushing branch: ${farver.green(branch)}`); - } - - await runIfNotDry("git", args, { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - return ok(true); - } catch (error) { - return err(toGitError("pushBranch", error)); - } -} - -export async function readFileFromGit( - workspaceRoot: string, - ref: string, - filePath: string, -): Promise> { - try { - const result = await run("git", ["show", `${ref}:${filePath}`], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - return ok(result.stdout); - } catch (error) { - logger.verbose(`Failed to read ${filePath} from ${ref}: ${formatUnknownError(error).message}`); - return ok(null); - } -} - -export async function getMostRecentPackageTag( - workspaceRoot: string, - packageName: string, -): Promise> { - try { - // Tags for each package follow the format: packageName@version - const { stdout } = await run("git", ["tag", "--list", `${packageName}@*`], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - const tags = stdout - .split("\n") - .map((tag) => tag.trim()) - .filter(Boolean); - if (tags.length === 0) { - return ok(undefined); - } - - // Filter to valid semver only, then sort descending so the highest version comes first. - // Non-semver tags (e.g. "pkg@latest") would cause semver.rcompare to throw. - const sorted = tags - .filter((t) => semver.valid(t.slice(t.lastIndexOf("@") + 1))) - .toSorted((a, b) => { - const va = a.slice(a.lastIndexOf("@") + 1); - const vb = b.slice(b.lastIndexOf("@") + 1); - return semver.rcompare(va, vb); - }); - return ok(sorted[0]); - } catch (error) { - return err(toGitError("getMostRecentPackageTag", error)); - } -} - -export async function getMostRecentPackageStableTag( - workspaceRoot: string, - packageName: string, -): Promise> { - try { - const { stdout } = await run("git", ["tag", "--list", `${packageName}@*`], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - const tags = stdout - .split("\n") - .map((tag) => tag.trim()) - .filter((tag) => Boolean(tag) && semver.valid(tag.slice(tag.lastIndexOf("@") + 1))) - .toSorted((a, b) => { - const va = a.slice(a.lastIndexOf("@") + 1); - const vb = b.slice(b.lastIndexOf("@") + 1); - return semver.rcompare(va, vb); - }); - - for (const tag of tags) { - const atIndex = tag.lastIndexOf("@"); - if (atIndex === -1) { - continue; - } - - const version = tag.slice(atIndex + 1); - if (semver.valid(version) && semver.prerelease(version) == null) { - return ok(tag); - } - } - - return ok(undefined); - } catch (error) { - return err(toGitError("getMostRecentPackageStableTag", error)); - } -} - -/** - * Builds a mapping of commit SHAs to the list of files changed in each commit - * within a given inclusive range. - * - * Internally runs: - * git log --name-only --format=%h ^.. - * - * Notes - * - This includes the commit identified by `from` (via `from^..to`). - * - Order of commits in the resulting Map follows `git log` output - * (reverse chronological, newest first). - * - On failure (e.g., invalid refs), the function returns null. - * - Keys in the returned Map are short SHAs (7 chars, matching GitCommit.shortHash). - * - * @param {string} workspaceRoot Absolute path to the git repository root used as cwd. - * @param {string} from Starting commit/ref (inclusive). - * @param {string} to Ending commit/ref (inclusive). - * @returns {Promise | null>} Promise resolving to a Map where keys are short commit SHAs and values are - * arrays of file paths changed by that commit, or null on error. - */ -export async function getGroupedFilesByCommitSha( - workspaceRoot: string, - from: string, - to: string, -): Promise, GitError>> { - // short commit hash file paths - const commitsMap = new Map(); - - try { - const { stdout } = await run("git", ["log", "--name-only", "--format=%h", `${from}^..${to}`], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - const lines = stdout - .trim() - .split("\n") - .filter((line) => line.trim() !== ""); - - let currentSha: string | null = null; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Found a new commit hash - if (COMMIT_HASH_RE.test(trimmedLine)) { - currentSha = trimmedLine; - commitsMap.set(currentSha, []); - - continue; - } - - if (currentSha === null) { - // Malformed output: file path found before any commit hash - continue; - } - - // Found a file path, and we have a current hash to assign it to - // Note: In case of merge commits, an empty line might appear which is already filtered. - // If the line is NOT a hash, it must be a file path. - - // The file path is added to the array associated with the most recent hash. - // Note: In case of merge commits, an empty line might appear which is already filtered. - commitsMap.get(currentSha)!.push(trimmedLine); - } - - return ok(commitsMap); - } catch (error) { - return err(toGitError("getGroupedFilesByCommitSha", error)); - } -} - -/** - * Create a git tag for a package release - * @param packageName - The package name (e.g., "@scope/name") - * @param version - The version to tag (e.g., "1.2.3") - * @param workspaceRoot - The root directory of the workspace - * @returns Result indicating success or failure - */ -async function createPackageTag( - packageName: string, - version: string, - workspaceRoot: string, -): Promise> { - const tagName = `${packageName}@${version}`; - - try { - // Check if this tag already exists locally and points to the same commit as HEAD. - // If it exists but points elsewhere, we must not silently skip — fall through and - // let git tag fail or be overwritten as appropriate. - const existingTagResult = await run("git", ["tag", "--list", tagName], { - nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, - }); - if (existingTagResult.stdout.trim() === tagName) { - // Verify the tag resolves to HEAD so we don't silently ignore a mispointed tag - const [tagCommit, headCommit] = await Promise.all([ - run("git", ["rev-list", "-n1", tagName], { - nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, - }), - run("git", ["rev-parse", "HEAD"], { nodeOptions: { cwd: workspaceRoot, stdio: "pipe" } }), - ]); - if (tagCommit.stdout.trim() === headCommit.stdout.trim()) { - logger.verbose( - `Tag ${farver.green(tagName)} already exists and points to HEAD, skipping creation`, - ); - return ok(undefined); - } - logger.verbose( - `Tag ${farver.green(tagName)} exists but points to a different commit — proceeding`, - ); - } - - logger.info(`Creating tag: ${farver.green(tagName)}`); - await runIfNotDry("git", ["tag", tagName], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - return ok(undefined); - } catch (error) { - return err(toGitError("createPackageTag", error)); - } -} - -/** - * Push a specific tag to the remote repository - * @param tagName - The tag name to push - * @param workspaceRoot - The root directory of the workspace - * @returns Result indicating success or failure - */ -async function pushTag(tagName: string, workspaceRoot: string): Promise> { - try { - logger.info(`Pushing tag: ${farver.green(tagName)}`); - await runIfNotDry("git", ["push", "origin", tagName], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - return ok(undefined); - } catch (error) { - return err(toGitError("pushTag", error)); - } -} - -/** - * Create and push a package tag in one operation - * @param packageName - The package name - * @param version - The version to tag - * @param workspaceRoot - The root directory of the workspace - * @returns Result indicating success or failure - */ -export async function createAndPushPackageTag( - packageName: string, - version: string, - workspaceRoot: string, -): Promise> { - const createResult = await createPackageTag(packageName, version, workspaceRoot); - if (!createResult.ok) { - return createResult; - } - - const tagName = `${packageName}@${version}`; - return pushTag(tagName, workspaceRoot); -} diff --git a/src/core/github.ts b/src/core/github.ts deleted file mode 100644 index 6b57e2e..0000000 --- a/src/core/github.ts +++ /dev/null @@ -1,456 +0,0 @@ -import { formatUnknownError } from "#shared/errors"; -import type { AuthorInfo, PackageRelease } from "#shared/types"; -import { logger } from "#shared/utils"; -import { Eta } from "eta"; -import farver from "farver"; - -import { DEFAULT_PR_BODY_TEMPLATE } from "../options"; - -interface SharedGitHubOptions { - owner: string; - repo: string; - githubToken: string; -} - -export interface GitHubPullRequest { - number: number; - title: string; - body: string; - draft: boolean; - html_url?: string; - head?: { - sha: string; - }; -} - -type CommitStatusState = "error" | "failure" | "pending" | "success"; - -interface CommitStatusOptions { - state: CommitStatusState; - targetUrl?: string; - description?: string; - context: string; -} - -interface UpsertPullRequestOptions { - title: string; - body: string; - head?: string; - base?: string; - pullNumber?: number; -} - -interface UpsertReleaseOptions { - tagName: string; - name: string; - body?: string; - prerelease?: boolean; -} - -interface GitHubRelease { - id: number; - tagName: string; - name: string; - htmlUrl?: string; -} - -export interface GitHubError { - type: "github"; - operation: string; - message: string; - status?: number; -} - -function toGitHubError(operation: string, error: unknown): GitHubError { - const formatted = formatUnknownError(error); - - return { - type: "github", - operation, - message: formatted.message, - status: formatted.status, - }; -} - -export class GitHubClient { - private readonly owner: string; - private readonly repo: string; - private readonly githubToken: string; - private readonly apiBase = "https://api.github.com"; - - constructor({ owner, repo, githubToken }: SharedGitHubOptions) { - this.owner = owner; - this.repo = repo; - this.githubToken = githubToken; - } - - private async request(path: string, init: RequestInit = {}): Promise { - const url = path.startsWith("http") ? path : `${this.apiBase}${path}`; - const method = init.method ?? "GET"; - - let res: Response; - - try { - res = await fetch(url, { - ...init, - headers: { - ...init.headers, - Accept: "application/vnd.github.v3+json", - Authorization: `token ${this.githubToken}`, - "User-Agent": "ucdjs-release-scripts (+https://github.com/ucdjs/ucdjs-release-scripts)", - }, - }); - } catch (error) { - throw Object.assign( - new Error( - `[${method} ${path}] GitHub request failed: ${formatUnknownError(error).message}`, - ), - { - status: undefined, - }, - ); - } - - if (!res.ok) { - const errorText = await res.text(); - const parsedMessage = (() => { - try { - const parsed = JSON.parse(errorText) as { message?: string; errors?: unknown }; - if (typeof parsed.message === "string" && parsed.message.trim()) { - if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { - return `${parsed.message} (${JSON.stringify(parsed.errors)})`; - } - - return parsed.message; - } - - return errorText; - } catch { - return errorText; - } - })(); - - throw Object.assign( - new Error( - `[${method} ${path}] GitHub API request failed (${res.status} ${res.statusText}): ${parsedMessage || "No response body"}`, - ), - { - status: res.status, - }, - ); - } - - if (res.status === 204) { - return undefined as T; - } - - return res.json() as Promise; - } - - async getExistingPullRequest(branch: string): Promise { - const head = branch.includes(":") ? branch : `${this.owner}:${branch}`; - const endpoint = `/repos/${this.owner}/${this.repo}/pulls?state=open&head=${encodeURIComponent(head)}`; - - logger.verbose( - `Requesting pull request for branch: ${branch} (url: ${this.apiBase}${endpoint})`, - ); - const pulls = await this.request(endpoint); - - if (!Array.isArray(pulls) || pulls.length === 0) { - return null; - } - - const firstPullRequest: unknown = pulls[0]; - - if ( - typeof firstPullRequest !== "object" || - firstPullRequest === null || - !("number" in firstPullRequest) || - typeof firstPullRequest.number !== "number" || - !("title" in firstPullRequest) || - typeof firstPullRequest.title !== "string" || - !("body" in firstPullRequest) || - typeof firstPullRequest.body !== "string" || - !("draft" in firstPullRequest) || - typeof firstPullRequest.draft !== "boolean" || - !("html_url" in firstPullRequest) || - typeof firstPullRequest.html_url !== "string" - ) { - throw new TypeError("Pull request data validation failed"); - } - - const pullRequest: GitHubPullRequest = { - number: firstPullRequest.number, - title: firstPullRequest.title, - body: firstPullRequest.body, - draft: firstPullRequest.draft, - html_url: firstPullRequest.html_url, - head: - "head" in firstPullRequest && - typeof firstPullRequest.head === "object" && - firstPullRequest.head !== null && - "sha" in firstPullRequest.head && - typeof firstPullRequest.head.sha === "string" - ? { sha: firstPullRequest.head.sha } - : undefined, - }; - - logger.info(`Found existing pull request: ${farver.yellow(`#${pullRequest.number}`)}`); - return pullRequest; - } - - async upsertPullRequest({ - title, - body, - head, - base, - pullNumber, - }: UpsertPullRequestOptions): Promise { - const isUpdate = typeof pullNumber === "number"; - const endpoint = isUpdate - ? `/repos/${this.owner}/${this.repo}/pulls/${pullNumber}` - : `/repos/${this.owner}/${this.repo}/pulls`; - - const requestBody = isUpdate ? { title, body } : { title, body, head, base, draft: true }; - - logger.verbose( - `${isUpdate ? "Updating" : "Creating"} pull request (url: ${this.apiBase}${endpoint})`, - ); - - const pr = await this.request(endpoint, { - method: isUpdate ? "PATCH" : "POST", - body: JSON.stringify(requestBody), - }); - - if ( - typeof pr !== "object" || - pr === null || - !("number" in pr) || - typeof pr.number !== "number" || - !("title" in pr) || - typeof pr.title !== "string" || - !("body" in pr) || - typeof pr.body !== "string" || - !("draft" in pr) || - typeof pr.draft !== "boolean" || - !("html_url" in pr) || - typeof pr.html_url !== "string" - ) { - throw new TypeError("Pull request data validation failed"); - } - - const action = isUpdate ? "Updated" : "Created"; - logger.info(`${action} pull request: ${farver.yellow(`#${pr.number}`)}`); - - return { - number: pr.number, - title: pr.title, - body: pr.body, - draft: pr.draft, - html_url: pr.html_url, - }; - } - - async setCommitStatus({ - sha, - state, - targetUrl, - description, - context, - }: CommitStatusOptions & { sha: string }): Promise { - const endpoint = `/repos/${this.owner}/${this.repo}/statuses/${sha}`; - - logger.verbose(`Setting commit status on ${sha} to ${state} (url: ${this.apiBase}${endpoint})`); - - await this.request(endpoint, { - method: "POST", - body: JSON.stringify({ - state, - target_url: targetUrl, - description: description || "", - context, - }), - }); - - logger.info( - `Commit status set to ${farver.cyan(state)} for ${farver.gray(sha.substring(0, 7))}`, - ); - } - - async upsertReleaseByTag({ - tagName, - name, - body, - prerelease = false, - }: UpsertReleaseOptions): Promise<{ release: GitHubRelease; created: boolean }> { - const encodedTag = encodeURIComponent(tagName); - - let existingRelease: { - id: number; - tag_name: string; - name?: string; - html_url?: string; - } | null = null; - - try { - existingRelease = await this.request<{ - id: number; - tag_name: string; - name?: string; - html_url?: string; - }>(`/repos/${this.owner}/${this.repo}/releases/tags/${encodedTag}`); - } catch (error) { - const formatted = formatUnknownError(error); - if (formatted.status !== 404) { - throw error; - } - } - - if (existingRelease) { - logger.verbose(`Updating release for tag ${farver.cyan(tagName)}`); - - const updated = await this.request<{ - id: number; - tag_name: string; - name?: string; - html_url?: string; - }>(`/repos/${this.owner}/${this.repo}/releases/${existingRelease.id}`, { - method: "PATCH", - body: JSON.stringify({ - name, - body, - prerelease, - draft: false, - }), - }); - - logger.info(`Updated GitHub release for ${farver.cyan(tagName)}`); - return { - release: { - id: updated.id, - tagName: updated.tag_name, - name: updated.name ?? name, - htmlUrl: updated.html_url, - }, - created: false, - }; - } - - logger.verbose(`Creating release for tag ${farver.cyan(tagName)}`); - - const created = await this.request<{ - id: number; - tag_name: string; - name?: string; - html_url?: string; - }>(`/repos/${this.owner}/${this.repo}/releases`, { - method: "POST", - body: JSON.stringify({ - tag_name: tagName, - name, - body, - prerelease, - draft: false, - generate_release_notes: body == null, - }), - }); - - logger.info(`Created GitHub release for ${farver.cyan(tagName)}`); - return { - release: { - id: created.id, - tagName: created.tag_name, - name: created.name ?? name, - htmlUrl: created.html_url, - }, - created: true, - }; - } - - async resolveAuthorInfo(info: AuthorInfo): Promise { - if (info.login) { - return info; - } - - try { - // https://docs.github.com/en/search-github/searching-on-github/searching-users#search-only-users-or-organizations - const q = encodeURIComponent(`${info.email} type:user in:email`); - const data = await this.request<{ - items?: Array<{ login: string }>; - }>(`/search/users?q=${q}`); - - if (data.items && data.items.length > 0) { - info.login = data.items[0]!.login; - } - } catch (err) { - logger.warn( - `Failed to resolve author info for email ${info.email}: ${formatUnknownError(err).message}`, - ); - } - - if (info.login) { - return info; - } - - if (info.commits.length > 0) { - try { - const data = await this.request<{ - author: { - login: string; - }; - }>(`/repos/${this.owner}/${this.repo}/commits/${info.commits[0]}`); - - if (data.author && data.author.login) { - info.login = data.author.login; - } - } catch (err) { - logger.warn( - `Failed to resolve author info from commits for email ${info.email}: ${formatUnknownError(err).message}`, - ); - } - } - - return info; - } -} - -export function createGitHubClient(options: SharedGitHubOptions): GitHubClient { - return new GitHubClient(options); -} - -export { toGitHubError }; - -const NON_WHITESPACE_RE = /\S/; - -function dedentString(str: string): string { - const lines = str.split("\n"); - const minIndent = lines - .filter((line) => line.trim().length > 0) - .reduce((min, line) => Math.min(min, line.search(NON_WHITESPACE_RE)), Infinity); - - return lines - .map((line) => (minIndent === Infinity ? line : line.slice(minIndent))) - .join("\n") - .trim(); -} - -export function generatePullRequestBody(updates: PackageRelease[], body?: string): string { - const eta = new Eta(); - - const bodyTemplate = body ? dedentString(body) : DEFAULT_PR_BODY_TEMPLATE; - - const allPackages = updates.map((u) => ({ - name: u.package.name, - currentVersion: u.currentVersion, - newVersion: u.newVersion, - bumpType: u.bumpType, - hasDirectChanges: u.hasDirectChanges, - changeKind: u.changeKind, - })); - - return eta.renderString(bodyTemplate, { - packages: allPackages, - releases: allPackages.filter((p) => p.changeKind !== "as-is"), - asIs: allPackages.filter((p) => p.changeKind === "as-is"), - }); -} diff --git a/src/core/npm.ts b/src/core/npm.ts deleted file mode 100644 index f05ea3d..0000000 --- a/src/core/npm.ts +++ /dev/null @@ -1,256 +0,0 @@ -import process from "node:process"; - -import { formatUnknownError } from "#shared/errors"; -import { logger, runIfNotDry } from "#shared/utils"; -import type { Result } from "#types"; -import { err, ok } from "#types"; -import semver from "semver"; - -import type { NormalizedReleaseScriptsOptions } from "../options"; - -interface NPMError { - type: "npm"; - operation: string; - message: string; - code?: string; - stderr?: string; - status?: number; -} - -interface NPMPackageMetadata { - name: string; - "dist-tags": Record; - versions: Record; - time?: Record; -} - -function toNPMError(operation: string, error: unknown, code?: string): NPMError { - const formatted = formatUnknownError(error); - return { - type: "npm", - operation, - message: formatted.message, - code: code || formatted.code, - stderr: formatted.stderr, - status: formatted.status, - }; -} - -function classifyPublishErrorCode(error: unknown): string | undefined { - const formatted = formatUnknownError(error); - const combined = [formatted.message, formatted.stderr].filter(Boolean).join("\n"); - - if ( - combined.includes("E403") || - combined.toLowerCase().includes("access token expired or revoked") - ) { - return "E403"; - } - - if ( - combined.includes("EPUBLISHCONFLICT") || - combined.includes("E409") || - combined.includes("409 Conflict") || - combined.includes("Failed to save packument") - ) { - return "EPUBLISHCONFLICT"; - } - - if (combined.includes("EOTP")) { - return "EOTP"; - } - - return undefined; -} - -function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Get the NPM registry URL - * Respects NPM_CONFIG_REGISTRY environment variable, defaults to npmjs.org - */ -function getRegistryURL(): string { - return process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org"; -} - -/** - * Fetch package metadata from NPM registry - * @param packageName - The package name (e.g., "lodash" or "@scope/name") - * @returns Result with package metadata or error - */ -async function getPackageMetadata( - packageName: string, -): Promise> { - try { - const registry = getRegistryURL(); - const encodedName = packageName.startsWith("@") - ? `@${encodeURIComponent(packageName.slice(1))}` - : encodeURIComponent(packageName); - - const response = await fetch(`${registry}/${encodedName}`, { - headers: { - Accept: "application/json", - }, - signal: AbortSignal.timeout(30_000), - }); - - if (!response.ok) { - if (response.status === 404) { - return err(toNPMError("getPackageMetadata", `Package not found: ${packageName}`, "E404")); - } - return err( - toNPMError("getPackageMetadata", `HTTP ${response.status}: ${response.statusText}`), - ); - } - - const metadata = (await response.json()) as NPMPackageMetadata; - return ok(metadata); - } catch (error) { - return err(toNPMError("getPackageMetadata", error, "ENETWORK")); - } -} - -/** - * Check if a specific package version exists on NPM - * @param packageName - The package name - * @param version - The version to check (e.g., "1.2.3") - * @returns Result with boolean (true if version exists) or error - */ -export async function checkVersionExists( - packageName: string, - version: string, -): Promise> { - const metadataResult = await getPackageMetadata(packageName); - - if (!metadataResult.ok) { - // If package doesn't exist at all, version definitely doesn't exist - if (metadataResult.error.code === "E404") { - return ok(false); - } - return err(metadataResult.error); - } - - const metadata = metadataResult.value; - const exists = version in metadata.versions; - - return ok(exists); -} - -/** - * Publish a package to NPM - * Uses pnpm to handle workspace protocol and catalog: resolution automatically - * @param packageName - The package name to publish - * @param version - The package version to publish - * @param workspaceRoot - Path to the workspace root - * @param options - Normalized release scripts options - * @returns Result indicating success or failure - */ -export async function publishPackage( - packageName: string, - version: string, - workspaceRoot: string, - options: NormalizedReleaseScriptsOptions, -): Promise> { - const args: string[] = [ - "--filter", - packageName, - "publish", - "--access", - options.npm.access, - "--no-git-checks", - ]; - - // Add OTP if provided (for 2FA) - if (options.npm.otp) { - args.push("--otp", options.npm.otp); - } - - // Add tag if specified by env, otherwise infer for prereleases. - // Stable releases default to npm's default tag behavior (latest). - const explicitTag = process.env.NPM_CONFIG_TAG; - const prereleaseTag = (() => { - const prerelease = semver.prerelease(version); - if (!prerelease || prerelease.length === 0) { - return undefined; - } - - const identifier = prerelease[0]; - if (identifier === "alpha" || identifier === "beta") { - return identifier; - } - - return "next"; - })(); - - const publishTag = explicitTag || prereleaseTag; - if (publishTag) { - args.push("--tag", publishTag); - } - - // Set up environment for OIDC/provenance - const env: Record = { - ...process.env, - }; - - if (options.npm.provenance) { - env.NPM_CONFIG_PROVENANCE = "true"; - } - - const maxAttempts = 4; - const backoffMs = [3_000, 8_000, 15_000]; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const result = await runIfNotDry("pnpm", args, { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - env, - }, - }); - - if (result?.stdout && result.stdout.trim()) { - logger.verbose(result.stdout.trim()); - } - - if (result?.stderr && result.stderr.trim()) { - logger.verbose(result.stderr.trim()); - } - - return ok(undefined); - } catch (error) { - const code = classifyPublishErrorCode(error); - const isRetriableConflict = code === "EPUBLISHCONFLICT" && attempt < maxAttempts; - - if (isRetriableConflict) { - const delay = backoffMs[attempt - 1] ?? backoffMs.at(-1)!; - logger.warn( - `Publish conflict for ${packageName}@${version} (attempt ${attempt}/${maxAttempts}). Retrying in ${Math.ceil(delay / 1000)}s...`, - ); - await wait(delay); - continue; - } - - return err(toNPMError("publishPackage", error, code)); - } - } - - return err( - toNPMError( - "publishPackage", - new Error(`Failed to publish ${packageName}@${version} after ${maxAttempts} attempts`), - "EPUBLISHCONFLICT", - ), - ); -} - -/** - * Publish workflow status for tracking progress - */ -export interface PublishStatus { - published: string[]; - skipped: string[]; - failed: string[]; -} diff --git a/src/core/prompts.ts b/src/core/prompts.ts deleted file mode 100644 index 343021a..0000000 --- a/src/core/prompts.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { WorkspacePackage } from "#core/workspace"; -import { - getNextPrereleaseVersion, - getNextStableVersion, - getPrereleaseIdentifier, - isValidSemver, -} from "#operations/semver"; -import type { BumpKind } from "#shared/types"; -import farver from "farver"; -import prompts from "prompts"; -import semver from "semver"; - -export async function selectPackagePrompt(packages: WorkspacePackage[]): Promise { - const response = await prompts({ - type: "multiselect", - name: "selectedPackages", - message: "Select packages to release", - choices: packages.map((pkg) => ({ - title: `${pkg.name} (${farver.bold(pkg.version)})`, - value: pkg.name, - selected: true, - })), - min: 1, - hint: "Space to select/deselect. Return to submit.", - instructions: false, - }); - - if (!response.selectedPackages || response.selectedPackages.length === 0) { - return []; - } - - return response.selectedPackages; -} - -export async function selectVersionPrompt( - workspaceRoot: string, - pkg: WorkspacePackage, - currentVersion: string, - suggestedVersion: string, - options?: { - defaultChoice?: "auto" | "skip" | "suggested" | "as-is"; - suggestedHint?: string; - }, -): Promise { - const defaultChoice = options?.defaultChoice ?? "auto"; - const suggestedSuffix = options?.suggestedHint ? farver.dim(` (${options.suggestedHint})`) : ""; - const prereleaseIdentifier = getPrereleaseIdentifier(currentVersion); - const defaultPrereleaseId = - prereleaseIdentifier === "alpha" || prereleaseIdentifier === "beta" - ? prereleaseIdentifier - : "beta"; - - const nextDefaultPrerelease = getNextPrereleaseVersion( - currentVersion, - "next", - defaultPrereleaseId, - ); - const nextBeta = getNextPrereleaseVersion(currentVersion, "next", "beta"); - const nextAlpha = getNextPrereleaseVersion(currentVersion, "next", "alpha"); - const prePatchBeta = getNextPrereleaseVersion(currentVersion, "prepatch", "beta"); - const preMinorBeta = getNextPrereleaseVersion(currentVersion, "preminor", "beta"); - const preMajorBeta = getNextPrereleaseVersion(currentVersion, "premajor", "beta"); - const prePatchAlpha = getNextPrereleaseVersion(currentVersion, "prepatch", "alpha"); - const preMinorAlpha = getNextPrereleaseVersion(currentVersion, "preminor", "alpha"); - const preMajorAlpha = getNextPrereleaseVersion(currentVersion, "premajor", "alpha"); - const isCurrentPrerelease = prereleaseIdentifier != null; - - const choices = [ - { value: "skip", title: `skip ${farver.dim("(no change)")}` }, - { value: "suggested", title: `suggested ${farver.bold(suggestedVersion)}${suggestedSuffix}` }, - { value: "as-is", title: `as-is ${farver.dim("(keep current version)")}` }, - ...(isCurrentPrerelease - ? [ - { - value: "next-prerelease", - title: `next prerelease ${farver.bold(nextDefaultPrerelease)}`, - }, - ] - : []), - { value: "patch", title: `patch ${farver.bold(getNextStableVersion(pkg.version, "patch"))}` }, - { value: "minor", title: `minor ${farver.bold(getNextStableVersion(pkg.version, "minor"))}` }, - { value: "major", title: `major ${farver.bold(getNextStableVersion(pkg.version, "major"))}` }, - { value: "prerelease", title: `prerelease ${farver.dim("(choose strategy)")}` }, - { value: "custom", title: "custom" }, - ]; - - const initialValue = - defaultChoice === "auto" - ? suggestedVersion === currentVersion - ? "skip" - : "suggested" - : defaultChoice; - const initial = Math.max( - 0, - choices.findIndex((choice) => choice.value === initialValue), - ); - - const prereleaseVersionByChoice = { - "next-prerelease": nextDefaultPrerelease, - next: nextDefaultPrerelease, - "next-beta": nextBeta, - "next-alpha": nextAlpha, - "prepatch-beta": prePatchBeta, - "preminor-beta": preMinorBeta, - "premajor-beta": preMajorBeta, - "prepatch-alpha": prePatchAlpha, - "preminor-alpha": preMinorAlpha, - "premajor-alpha": preMajorAlpha, - } as const; - - const answers = await prompts({ - type: "autocomplete", - name: "version", - message: `${pkg.name}: ${farver.green(pkg.version)}`, - choices, - limit: choices.length, - initial, - }); - - // User cancelled (Ctrl+C) - if (!answers.version) { - return null; - } - - if (answers.version === "skip") { - return null; - } else if (answers.version === "suggested") { - return suggestedVersion; - } else if (answers.version === "custom") { - const customAnswer = await prompts({ - type: "text", - name: "custom", - message: "Enter the new version number:", - initial: suggestedVersion, - validate: (custom: string) => { - if (!isValidSemver(custom)) { - return "That's not a valid version number"; - } - if (!isValidSemver(currentVersion)) { - return `Current version "${currentVersion}" is not valid semver — cannot compare`; - } - if (!semver.gt(custom, currentVersion)) { - return `Version must be greater than the current version (${currentVersion})`; - } - return true; - }, - }); - - if (!customAnswer.custom) { - return null; - } - - return customAnswer.custom; - } else if (answers.version === "as-is") { - // TODO: verify that there isn't any tags already existing for this version? - return currentVersion; - } else if (answers.version === "prerelease") { - const prereleaseChoices = [ - { value: "next", title: `next ${farver.bold(nextDefaultPrerelease)}` }, - { value: "next-beta", title: `next beta ${farver.bold(nextBeta)}` }, - { value: "next-alpha", title: `next alpha ${farver.bold(nextAlpha)}` }, - { value: "prepatch-beta", title: `pre-patch (beta) ${farver.bold(prePatchBeta)}` }, - { value: "prepatch-alpha", title: `pre-patch (alpha) ${farver.bold(prePatchAlpha)}` }, - { value: "preminor-beta", title: `pre-minor (beta) ${farver.bold(preMinorBeta)}` }, - { value: "preminor-alpha", title: `pre-minor (alpha) ${farver.bold(preMinorAlpha)}` }, - { value: "premajor-beta", title: `pre-major (beta) ${farver.bold(preMajorBeta)}` }, - { value: "premajor-alpha", title: `pre-major (alpha) ${farver.bold(preMajorAlpha)}` }, - ]; - - const prereleaseAnswer = await prompts({ - type: "autocomplete", - name: "prerelease", - message: `${pkg.name}: select prerelease strategy`, - choices: prereleaseChoices, - limit: prereleaseChoices.length, - initial: 0, - }); - - if (!prereleaseAnswer.prerelease) { - return null; - } - - return prereleaseVersionByChoice[ - prereleaseAnswer.prerelease as keyof typeof prereleaseVersionByChoice - ]; - } - - const prereleaseVersion = - prereleaseVersionByChoice[answers.version as keyof typeof prereleaseVersionByChoice]; - - if (prereleaseVersion) { - return prereleaseVersion; - } - - const stableBump = answers.version as Exclude; - return getNextStableVersion(pkg.version, stableBump); -} - -export async function confirmOverridePrompt( - pkg: WorkspacePackage, - overrideVersion: string, -): Promise<"use" | "pick" | null> { - const response = await prompts({ - type: "select", - name: "choice", - message: `${pkg.name}: use override version ${farver.bold(overrideVersion)}?`, - choices: [ - { title: "use override", value: "use" }, - { title: "pick another version", value: "pick" }, - ], - initial: 0, - }); - - if (!response.choice) { - return null; - } - - return response.choice; -} diff --git a/src/core/workspace.ts b/src/core/workspace.ts deleted file mode 100644 index 54a2aa9..0000000 --- a/src/core/workspace.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { selectPackagePrompt } from "#core/prompts"; -import type { FindWorkspacePackagesOptions, PackageJson } from "#shared/types"; -import { getIsCI, logger, run } from "#shared/utils"; -import type { Result } from "#types"; -import { err, ok } from "#types"; -import farver from "farver"; - -import type { NormalizedReleaseScriptsOptions } from "../options"; - -interface RawProject { - name: string; - path: string; - version: string; - private: boolean; - dependencies?: Record; - devDependencies?: Record; -} - -export interface WorkspacePackage { - name: string; - version: string; - path: string; - packageJson: PackageJson; - workspaceDependencies: string[]; - workspaceDevDependencies: string[]; -} - -export interface WorkspaceError { - type: "workspace"; - operation: string; - message: string; -} - -function toWorkspaceError(operation: string, error: unknown): WorkspaceError { - const message = error instanceof Error ? error.message : String(error); - return { - type: "workspace", - operation, - message, - }; -} - -export async function discoverWorkspacePackages( - workspaceRoot: string, - options: NormalizedReleaseScriptsOptions, -): Promise> { - let workspaceOptions: FindWorkspacePackagesOptions; - let explicitPackages: string[] | undefined; - - // Normalize package options and determine if packages were explicitly specified - if (options.packages == null || options.packages === true) { - workspaceOptions = { excludePrivate: false }; - } else if (Array.isArray(options.packages)) { - workspaceOptions = { excludePrivate: false, include: options.packages }; - explicitPackages = options.packages; - } else { - workspaceOptions = options.packages; - if (options.packages.include) { - explicitPackages = options.packages.include; - } - } - - let workspacePackages: WorkspacePackage[]; - try { - workspacePackages = await findWorkspacePackages(workspaceRoot, workspaceOptions); - } catch (error) { - return err(toWorkspaceError("discoverWorkspacePackages", error)); - } - - // If specific packages were requested, validate they were all found - if (explicitPackages) { - const foundNames = new Set(workspacePackages.map((p) => p.name)); - const missing = explicitPackages.filter((p) => !foundNames.has(p)); - - if (missing.length > 0) { - return err( - toWorkspaceError( - "discoverWorkspacePackages", - `Package${missing.length > 1 ? "s" : ""} not found in workspace: ${missing.join(", ")}. ` + - `Check your package names or run 'pnpm ls' to see available packages`, - ), - ); - } - } - - // Show interactive prompt only if: - // 1. Not in CI - // 2. Prompt is enabled - // 3. No explicit packages were specified (user didn't pre-select specific packages) - const isPackagePromptEnabled = options.prompts?.packages !== false; - logger.verbose("Package prompt gating", { - isCI: getIsCI(), - isPackagePromptEnabled, - hasExplicitPackages: Boolean(explicitPackages), - include: workspaceOptions.include ?? [], - exclude: workspaceOptions.exclude ?? [], - excludePrivate: workspaceOptions.excludePrivate ?? false, - }); - - if (!getIsCI() && isPackagePromptEnabled && !explicitPackages) { - const selectedNames = await selectPackagePrompt(workspacePackages); - workspacePackages = workspacePackages.filter((pkg) => selectedNames.includes(pkg.name)); - } - - return ok(workspacePackages); -} - -async function findWorkspacePackages( - workspaceRoot: string, - options?: FindWorkspacePackagesOptions, -): Promise { - try { - const result = await run("pnpm", ["-r", "ls", "--json"], { - nodeOptions: { - cwd: workspaceRoot, - stdio: "pipe", - }, - }); - - const rawProjects: RawProject[] = JSON.parse(result.stdout); - - const allPackageNames = new Set(rawProjects.map((p) => p.name)); - const excludedPackages = new Set(); - - const promises = rawProjects.map(async (rawProject) => { - const packageJsonPath = join(rawProject.path, "package.json"); - const content = await readFile(packageJsonPath, "utf-8"); - const packageJson: PackageJson = JSON.parse(content); - - if (!shouldIncludePackage(packageJson, options)) { - excludedPackages.add(rawProject.name); - return null; - } - - return { - name: rawProject.name, - version: rawProject.version, - path: rawProject.path, - packageJson, - workspaceDependencies: Object.keys(rawProject.dependencies || []).filter((dep) => { - return allPackageNames.has(dep); - }), - workspaceDevDependencies: Object.keys(rawProject.devDependencies || []).filter((dep) => { - return allPackageNames.has(dep); - }), - }; - }); - - const packages = await Promise.all(promises); - - if (excludedPackages.size > 0) { - logger.info(`Excluded packages: ${farver.green([...excludedPackages].join(", "))}`); - } - - // Filter out excluded packages (nulls) - return packages.filter((pkg): pkg is WorkspacePackage => pkg !== null); - } catch (err) { - logger.error("Error discovering workspace packages:", err); - throw err; - } -} - -function shouldIncludePackage(pkg: PackageJson, options?: FindWorkspacePackagesOptions): boolean { - if (!options) { - return true; - } - - // Check if private packages should be excluded - if (options.excludePrivate && pkg.private) { - return false; - } - - // Check include list (if specified, only these packages are included) - if (options.include && options.include.length > 0) { - if (!options.include.includes(pkg.name)) { - return false; - } - } - - // Check exclude list - if (options.exclude?.includes(pkg.name)) { - return false; - } - - return true; -} diff --git a/src/index.ts b/src/index.ts index bc0daa5..c808ad6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,22 @@ -import process from "node:process"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, Layer } from "effect"; -import { printReleaseError, ReleaseError } from "#shared/errors"; -import { logger } from "#shared/utils"; -import type { ReleaseResult } from "#types"; -import { prepareWorkflow as release } from "#workflows/prepare"; -import { publishWorkflow as publish } from "#workflows/publish"; -import { verifyWorkflow as verify } from "#workflows/verify"; +import { logger } from "./shared/utils"; +import type { ReleaseResult } from "./types"; +import { ChangelogServiceLive } from "./services/changelog"; +import { prepareWorkflow as release } from "./release/prepare"; +import { publishWorkflow as publish } from "./release/publish"; +import { verifyWorkflow as verify } from "./release/verify"; -import type { WorkspacePackage } from "./core/workspace"; -import { discoverWorkspacePackages } from "./core/workspace"; +import type { WorkspacePackage } from "./services/workspace"; +import { GitHubServiceLive } from "./services/github"; +import { PromptServiceLive } from "./services/prompts"; +import { discoverWorkspacePackages } from "./services/workspace"; +import { WorkspaceServiceLive } from "./services/workspace"; import type { ReleaseScriptsOptionsInput } from "./options"; -import { normalizeReleaseScriptsOptions } from "./options"; +import { normalizeReleaseScriptsOptions, ReleaseOptions } from "./options"; +import { GitServiceLive } from "./services/git"; +import { NpmServiceLive } from "./services/npm"; export interface ReleaseScripts { verify: () => Promise; @@ -22,19 +28,9 @@ export interface ReleaseScripts { }; } -function withErrorBoundary(fn: () => Promise): Promise { - return fn().catch((e) => { - if (e instanceof ReleaseError) { - printReleaseError(e); - process.exit(1); - } - throw e; - }); -} - -export async function createReleaseScripts( +export function createReleaseScripts( options: ReleaseScriptsOptionsInput, -): Promise { +): ReleaseScripts { // Normalize options once for packages.list and packages.get const normalizedOptions = normalizeReleaseScriptsOptions(options); @@ -55,40 +51,41 @@ export async function createReleaseScripts( changelog: normalizedOptions.changelog, }); + const runtimeLayer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ReleaseOptions, normalizedOptions), + GitServiceLive, + GitHubServiceLive, + NpmServiceLive, + ChangelogServiceLive, + PromptServiceLive, + WorkspaceServiceLive, + ); + + const runEffect = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provide(runtimeLayer)) as Effect.Effect); + return { - async verify(): Promise { - return withErrorBoundary(() => verify(normalizedOptions)); + verify(): Promise { + return runEffect(verify()); }, - - async prepare(): Promise { - return withErrorBoundary(() => release(normalizedOptions)); + prepare(): Promise { + return runEffect(release()); }, - - async publish(): Promise { - return withErrorBoundary(() => publish(normalizedOptions)); + publish(): Promise { + return runEffect(publish()); }, - packages: { - async list(): Promise { - return withErrorBoundary(async () => { - const result = await discoverWorkspacePackages( - normalizedOptions.workspaceRoot, - normalizedOptions, - ); - if (!result.ok) throw new ReleaseError(result.error.message, undefined, result.error); - return result.value; - }); + list(): Promise { + return runEffect(discoverWorkspacePackages(normalizedOptions.workspaceRoot, normalizedOptions)); }, - - async get(packageName: string): Promise { - return withErrorBoundary(async () => { - const result = await discoverWorkspacePackages( - normalizedOptions.workspaceRoot, - normalizedOptions, - ); - if (!result.ok) throw new ReleaseError(result.error.message, undefined, result.error); - return result.value.find((p) => p.name === packageName); - }); + get(packageName: string): Promise { + return runEffect( + Effect.map( + discoverWorkspacePackages(normalizedOptions.workspaceRoot, normalizedOptions), + (packages) => packages.find((p) => p.name === packageName), + ), + ); }, }, }; diff --git a/src/operations/branch.ts b/src/operations/branch.ts deleted file mode 100644 index ae979c4..0000000 --- a/src/operations/branch.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { GitError } from "#core/git"; -import { - checkoutBranch, - commitChanges, - createBranch, - doesBranchExist, - doesRemoteBranchExist, - getCurrentBranch, - isBranchAheadOfRemote, - pullLatestChanges, - pushBranch, - rebaseBranch, -} from "#core/git"; -import { logger, run } from "#shared/utils"; -import type { Result } from "#types"; -import { err, ok } from "#types"; - -interface PrepareReleaseBranchOptions { - workspaceRoot: string; - releaseBranch: string; - defaultBranch: string; -} - -export async function prepareReleaseBranch( - options: PrepareReleaseBranchOptions, -): Promise> { - const { workspaceRoot, releaseBranch, defaultBranch } = options; - - const currentBranch = await getCurrentBranch(workspaceRoot); - if (!currentBranch.ok) return currentBranch; - - if (currentBranch.value !== defaultBranch) { - return err({ - type: "git", - operation: "validateBranch", - message: `Current branch is '${currentBranch.value}'. Please switch to '${defaultBranch}'.`, - }); - } - - const branchExists = await doesBranchExist(releaseBranch, workspaceRoot); - if (!branchExists.ok) return branchExists; - - if (!branchExists.value) { - const created = await createBranch(releaseBranch, defaultBranch, workspaceRoot); - if (!created.ok) return created; - } - - const checkedOut = await checkoutBranch(releaseBranch, workspaceRoot); - if (!checkedOut.ok) return checkedOut; - - if (branchExists.value) { - const remoteExists = await doesRemoteBranchExist(releaseBranch, workspaceRoot); - if (!remoteExists.ok) return remoteExists; - - if (remoteExists.value) { - const pulled = await pullLatestChanges(releaseBranch, workspaceRoot); - if (!pulled.ok) return pulled; - if (!pulled.value) { - logger.warn("Failed to pull latest changes, continuing anyway."); - } - } else { - logger.info(`Remote branch "origin/${releaseBranch}" does not exist yet, skipping pull.`); - } - } - - const rebased = await rebaseBranch(defaultBranch, workspaceRoot); - if (!rebased.ok) return rebased; - - return ok(undefined); -} - -interface SyncChangesOptions { - workspaceRoot: string; - releaseBranch: string; - commitMessage: string; - hasChanges: boolean; - /** Extra file paths to explicitly stage (e.g. new untracked files that git add -u would miss). */ - additionalPaths?: string[]; -} - -export async function syncReleaseChanges( - options: SyncChangesOptions, -): Promise> { - const { workspaceRoot, releaseBranch, commitMessage, hasChanges, additionalPaths } = options; - - // Stage any explicitly listed paths before commitChanges runs. - // commitChanges uses git add -u which only stages already-tracked files; - // new files (like the overrides JSON) would be silently skipped without this. - if (additionalPaths && additionalPaths.length > 0) { - try { - await run("git", ["add", "--", ...additionalPaths], { - nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, - }); - } catch (error) { - logger.verbose(`Failed to stage additional paths: ${String(error)}`); - } - } - - const committed = hasChanges ? await commitChanges(commitMessage, workspaceRoot) : ok(false); - - if (!committed.ok) return committed; - - const isAhead = await isBranchAheadOfRemote(releaseBranch, workspaceRoot); - if (!isAhead.ok) return isAhead; - - if (!committed.value && !isAhead.value) { - return ok(false); - } - - const pushed = await pushBranch(releaseBranch, workspaceRoot, { forceWithLease: true }); - if (!pushed.ok) return pushed; - - return ok(true); -} diff --git a/src/operations/calculate.ts b/src/operations/calculate.ts deleted file mode 100644 index ff8135e..0000000 --- a/src/operations/calculate.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { GitError } from "#core/git"; -import type { WorkspacePackage } from "#core/workspace"; -import { formatUnknownError } from "#shared/errors"; -import type { PackageRelease } from "#shared/types"; -import type { Result } from "#types"; -import { err, ok } from "#types"; -import { getGlobalCommitsPerPackage, getWorkspacePackageGroupedCommits } from "#versioning/commits"; -import { calculateAndPrepareVersionUpdates } from "#versioning/version"; - -interface CalculateUpdatesOptions { - workspacePackages: WorkspacePackage[]; - workspaceRoot: string; - showPrompt: boolean; - overrides: Record; - globalCommitMode: false | "dependencies" | "all"; -} - -export async function calculateUpdates(options: CalculateUpdatesOptions): Promise< - Result< - { - allUpdates: PackageRelease[]; - applyUpdates: () => Promise; - overrides: Record; - }, - GitError - > -> { - const { workspacePackages, workspaceRoot, showPrompt, overrides, globalCommitMode } = options; - - try { - const grouped = await getWorkspacePackageGroupedCommits(workspaceRoot, workspacePackages); - const global = await getGlobalCommitsPerPackage( - workspaceRoot, - grouped, - workspacePackages, - globalCommitMode, - ); - - const updates = await calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits: grouped, - workspaceRoot, - showPrompt, - globalCommitsPerPackage: global, - overrides, - }); - - return ok(updates); - } catch (error) { - const formatted = formatUnknownError(error); - return err({ - type: "git", - operation: "calculateUpdates", - message: formatted.message, - stderr: formatted.stderr, - }); - } -} - -export function ensureHasPackages( - packages: WorkspacePackage[], -): Result { - if (packages.length === 0) { - return err({ - type: "git", - operation: "discoverWorkspacePackages", - message: "No packages found to release", - }); - } - - return ok(packages); -} diff --git a/src/operations/pr.ts b/src/operations/pr.ts deleted file mode 100644 index e2e5711..0000000 --- a/src/operations/pr.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { GitHubClient, GitHubError, GitHubPullRequest } from "#core/github"; -import { generatePullRequestBody, toGitHubError } from "#core/github"; -import type { PackageRelease } from "#shared/types"; -import type { Result } from "#types"; -import { ok } from "#types"; - -interface SyncPullRequestOptions { - github: GitHubClient; - releaseBranch: string; - defaultBranch: string; - pullRequestTitle?: string; - pullRequestBody?: string; - updates: PackageRelease[]; -} - -export async function syncPullRequest(options: SyncPullRequestOptions): Promise< - Result< - { - pullRequest: GitHubPullRequest | null; - created: boolean; - }, - GitHubError - > -> { - const { github, releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody, updates } = - options; - - let existing: GitHubPullRequest | null = null; - try { - existing = await github.getExistingPullRequest(releaseBranch); - } catch (error) { - return { - ok: false, - error: toGitHubError("getExistingPullRequest", error), - }; - } - - const doesExist = !!existing; - const title = existing?.title || pullRequestTitle || "chore: update package versions"; - const body = generatePullRequestBody(updates, pullRequestBody); - - let pr: GitHubPullRequest | null = null; - try { - pr = await github.upsertPullRequest({ - pullNumber: existing?.number, - title, - body, - head: releaseBranch, - base: defaultBranch, - }); - } catch (error) { - return { - ok: false, - error: toGitHubError("upsertPullRequest", error), - }; - } - - return ok({ - pullRequest: pr, - created: !doesExist, - }); -} diff --git a/src/options.ts b/src/options.ts index f20f098..aa6cd2b 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,7 +1,6 @@ import process from "node:process"; -import type { GitHubClient } from "#core/github"; -import { createGitHubClient } from "#core/github"; +import { Context } from "effect"; import { ReleaseError } from "#shared/errors"; import type { CommitTypeRule } from "#shared/types"; import { dedent } from "@luxass/utils"; @@ -57,7 +56,6 @@ export type NormalizedReleaseScriptsOptions = DeepRequired< repo: string; safeguards: boolean; types: Record; - githubClient: GitHubClient; npm: { otp?: string; provenance: boolean; @@ -69,6 +67,10 @@ export type NormalizedReleaseScriptsOptions = DeepRequired< }; }; +export class ReleaseOptions extends Context.Service()( + "@ucdjs/release-scripts/ReleaseOptions", +) {} + export const DEFAULT_PR_BODY_TEMPLATE = dedent` This PR was automatically generated by the UCD release scripts. @@ -188,7 +190,6 @@ export function normalizeReleaseScriptsOptions( githubToken: token, owner, repo, - githubClient: createGitHubClient({ owner, repo, githubToken: token }), packages: normalizedPackages, branch: { release: branch.release ?? "release/next", diff --git a/src/release/branch.ts b/src/release/branch.ts new file mode 100644 index 0000000..f6f7c10 --- /dev/null +++ b/src/release/branch.ts @@ -0,0 +1,93 @@ +import { Effect } from "effect"; +import { GitError, GitService } from "../services/git"; +import { logger, runEffect } from "../shared/utils"; + +interface PrepareReleaseBranchOptions { + workspaceRoot: string; + releaseBranch: string; + defaultBranch: string; +} + +export const prepareReleaseBranch = Effect.fn("prepareReleaseBranch")(function* ( + options: PrepareReleaseBranchOptions, +) { + const git = yield* GitService; + const { workspaceRoot, releaseBranch, defaultBranch } = options; + + const currentBranch = yield* git.getCurrentBranch(workspaceRoot); + + if (currentBranch !== defaultBranch) { + return yield* Effect.fail(new GitError({ + operation: "validateBranch", + message: `Current branch is '${currentBranch}'. Please switch to '${defaultBranch}'.`, + })); + } + + const branchExists = yield* git.doesBranchExist(releaseBranch, workspaceRoot); + + if (!branchExists) { + yield* git.createBranch(releaseBranch, defaultBranch, workspaceRoot); + } + + const checkedOut = yield* git.checkoutBranch(releaseBranch, workspaceRoot); + + if (branchExists) { + const remoteExists = yield* git.doesRemoteBranchExist(releaseBranch, workspaceRoot); + + if (remoteExists) { + const pulled = yield* git.pullLatestChanges(releaseBranch, workspaceRoot); + if (!pulled) { + logger.warn("Failed to pull latest changes, continuing anyway."); + } + } else { + logger.info(`Remote branch "origin/${releaseBranch}" does not exist yet, skipping pull.`); + } + } + + const rebased = yield* git.rebaseBranch(defaultBranch, workspaceRoot); + void rebased; +}); + + +interface SyncChangesOptions { + workspaceRoot: string; + releaseBranch: string; + commitMessage: string; + hasChanges: boolean; + /** Extra file paths to explicitly stage (e.g. new untracked files that git add -u would miss). */ + additionalPaths?: string[]; +} + +export const syncReleaseChanges = Effect.fn("syncReleaseChanges")(function* ( + options: SyncChangesOptions, +) { + const git = yield* GitService; + const { workspaceRoot, releaseBranch, commitMessage, hasChanges, additionalPaths } = options; + + // Stage any explicitly listed paths before commitChanges runs. + // commitChanges uses git add -u which only stages already-tracked files; + // new files (like the overrides JSON) would be silently skipped without this. + if (additionalPaths && additionalPaths.length > 0) { + try { + yield* runEffect("git", ["add", "--", ...additionalPaths], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + } catch (error) { + logger.verbose(`Failed to stage additional paths: ${String(error)}`); + } + } + + const committed = hasChanges + ? yield* git.commitChanges(commitMessage, workspaceRoot) + : false; + + const isAhead = yield* git.isBranchAheadOfRemote(releaseBranch, workspaceRoot); + + if (!committed && !isAhead) { + return false; + } + + yield* git.pushBranch(releaseBranch, workspaceRoot, { forceWithLease: true }); + + return true; +}); diff --git a/src/release/calculate.ts b/src/release/calculate.ts new file mode 100644 index 0000000..0be5a63 --- /dev/null +++ b/src/release/calculate.ts @@ -0,0 +1,59 @@ +import { Effect } from "effect"; +import { GitError } from "../services/git"; +import type { WorkspacePackage } from "../services/workspace"; +import { formatUnknownError } from "../shared/errors"; +import { + getGlobalCommitsPerPackage, + getWorkspacePackageGroupedCommits, +} from "../versioning/commits"; +import { calculateAndPrepareVersionUpdates } from "../versioning/version"; + +interface CalculateUpdatesOptions { + workspacePackages: WorkspacePackage[]; + workspaceRoot: string; + showPrompt: boolean; + overrides: Record; + globalCommitMode: false | "dependencies" | "all"; +} + +export const calculateUpdates = Effect.fn("calculateUpdates")(function* ( + options: CalculateUpdatesOptions, +) { + const { workspacePackages, workspaceRoot, showPrompt, overrides, globalCommitMode } = options; + + try { + const grouped = yield* getWorkspacePackageGroupedCommits(workspaceRoot, workspacePackages); + const global = yield* getGlobalCommitsPerPackage( + workspaceRoot, + grouped, + workspacePackages, + globalCommitMode, + ); + + const updates = yield* calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits: grouped, + workspaceRoot, + showPrompt, + globalCommitsPerPackage: global, + overrides, + }); + + return updates; + } catch (error) { + const formatted = formatUnknownError(error); + return yield* Effect.fail(new GitError({ + operation: "calculateUpdates", + message: formatted.message, + stderr: formatted.stderr, + })); + } +}); + +export function ensureHasPackages(packages: WorkspacePackage[]): WorkspacePackage[] | null { + if (packages.length === 0) { + return null; + } + + return packages; +} diff --git a/src/release/pr.ts b/src/release/pr.ts new file mode 100644 index 0000000..60ec405 --- /dev/null +++ b/src/release/pr.ts @@ -0,0 +1,52 @@ +import { Effect } from "effect"; +import { + generatePullRequestBody, + GitHubError, + type GitHubPullRequest, + GitHubService, + toGitHubError, +} from "../services/github"; +import type { PackageRelease } from "../shared/types"; + +interface SyncPullRequestOptions { + releaseBranch: string; + defaultBranch: string; + pullRequestTitle?: string; + pullRequestBody?: string; + updates: PackageRelease[]; +} + +export const syncPullRequest = Effect.fn("syncPullRequest")(function* ( + options: SyncPullRequestOptions, +) { + const github = yield* GitHubService; + const { releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody, updates } = options; + + const existing = yield* Effect.catchTag( + github.getExistingPullRequest(releaseBranch), + "GitHubError", + (error) => + Effect.fail(new GitHubError({ ...error, operation: "getExistingPullRequest" })), + (error) => Effect.fail(toGitHubError("getExistingPullRequest", error)), + ); + + const doesExist = !!existing; + const title = existing?.title || pullRequestTitle || "chore: update package versions"; + const body = generatePullRequestBody(updates, pullRequestBody); + + const pr = yield* Effect.catchTag(github.upsertPullRequest({ + pullNumber: existing?.number, + title, + body, + head: releaseBranch, + base: defaultBranch, + }) as Effect.Effect, "GitHubError", (error) => + Effect.fail(new GitHubError({ ...error, operation: "upsertPullRequest" })), + (error) => Effect.fail(toGitHubError("upsertPullRequest", error)), + ); + + return { + pullRequest: pr, + created: !doesExist, + }; +}); diff --git a/src/release/prepare.ts b/src/release/prepare.ts new file mode 100644 index 0000000..a8f0f1a --- /dev/null +++ b/src/release/prepare.ts @@ -0,0 +1,376 @@ +import { join } from "node:path"; + +import { Effect, FileSystem } from "effect"; +import farver from "farver"; +import semver from "semver"; +import { ReleaseOptions } from "../options"; +import { ChangelogService } from "../services/changelog"; +import { type GitError, GitService } from "../services/git"; +import { type WorkspaceError, type WorkspacePackage, WorkspaceService } from "../services/workspace"; +import { type GitHubError } from "../services/github"; +import type { PackageRelease } from "../shared/types"; +import { exitWithError, formatUnknownError } from "../shared/errors"; +import { logger, ucdjsReleaseOverridesPath } from "../shared/utils"; +import { + getGlobalCommitsPerPackage, + getPackageCommitsSinceTag, + getWorkspacePackageGroupedCommits, +} from "../versioning/commits"; +import { prepareReleaseBranch, syncReleaseChanges } from "./branch"; +import { calculateUpdates, ensureHasPackages } from "./calculate"; +import { syncPullRequest } from "./pr"; + +export const prepareWorkflow = Effect.fn("prepareWorkflow")(function* () { + const options = yield* ReleaseOptions; + const changelog = yield* ChangelogService; + const fs = yield* FileSystem.FileSystem; + const git = yield* GitService; + const workspace = yield* WorkspaceService; + if (options.safeguards) { + const clean = yield* Effect.catchTag( + git.isWorkingDirectoryClean(options.workspaceRoot) as Effect.Effect, + "GitError", + (error) => + Effect.sync(() => + exitWithError( + "Failed to verify working directory state.", + "Ensure this is a valid git repository and try again.", + error, + ) + ), + ); + + if (!clean) { + exitWithError( + "Working directory is not clean. Please commit or stash your changes before proceeding.", + ); + } + } + + const discovered = yield* Effect.catchTag( + workspace.discoverWorkspacePackages(options.workspaceRoot, options) as Effect.Effect< + WorkspacePackage[], + WorkspaceError, + unknown + >, + "WorkspaceError", + (error) => + Effect.sync(() => exitWithError("Failed to discover packages.", undefined, error)), + ); + + const workspacePackages = ensureHasPackages(discovered); + if (workspacePackages === null) { + logger.warn("No packages found to release"); + return null; + } + + logger.section("📦 Workspace Packages"); + logger.item(`Found ${workspacePackages.length} packages`); + + for (const pkg of workspacePackages) { + logger.item(`${farver.cyan(pkg.name)} (${farver.bold(pkg.version)})`); + logger.item(` ${farver.gray("→")} ${farver.gray(pkg.path)}`); + } + + logger.emptyLine(); + + yield* Effect.catchTag(prepareReleaseBranch({ + workspaceRoot: options.workspaceRoot, + releaseBranch: options.branch.release, + defaultBranch: options.branch.default, + }) as Effect.Effect, "GitError", (error) => + Effect.sync(() => exitWithError("Failed to prepare release branch.", undefined, error)), + ); + + const overridesPath = join(options.workspaceRoot, ucdjsReleaseOverridesPath); + let existingOverrides: Record< + string, + { version: string; type: import("#shared/types").BumpKind } + > = {}; + try { + const overridesContent = yield* fs.readFileString(overridesPath); + existingOverrides = JSON.parse(overridesContent); + logger.info("Found existing version overrides file."); + } catch (error) { + logger.info("No existing version overrides file found. Continuing..."); + logger.verbose(`Reading overrides file failed: ${formatUnknownError(error).message}`); + } + + if (Object.keys(existingOverrides).length > 0) { + const packageNames = new Set(workspacePackages.map((p: typeof workspacePackages[number]) => p.name)); + const staleEntries: string[] = []; + + for (const [pkgName, override] of Object.entries(existingOverrides)) { + if (!packageNames.has(pkgName)) { + staleEntries.push(pkgName); + delete existingOverrides[pkgName]; + continue; + } + + const pkg = workspacePackages.find((p: typeof workspacePackages[number]) => p.name === pkgName); + if (pkg && semver.valid(override.version) && semver.gte(pkg.version, override.version)) { + staleEntries.push(pkgName); + delete existingOverrides[pkgName]; + } + } + + if (staleEntries.length > 0) { + logger.info(`Removed ${staleEntries.length} stale override(s): ${staleEntries.join(", ")}`); + } + } + + const updates = yield* Effect.catchTag(calculateUpdates({ + workspacePackages, + workspaceRoot: options.workspaceRoot, + showPrompt: options.prompts?.versions !== false, + globalCommitMode: options.globalCommitMode === "none" ? false : options.globalCommitMode, + overrides: existingOverrides, + }) as Effect.Effect, "GitError", (error) => + Effect.sync(() => exitWithError("Failed to calculate package updates.", undefined, error)), + ); + + const { allUpdates, applyUpdates, overrides: newOverrides } = updates; + const hasOverrideChanges = JSON.stringify(existingOverrides) !== JSON.stringify(newOverrides); + + if (Object.keys(newOverrides).length > 0 && hasOverrideChanges) { + logger.step("Writing version overrides file..."); + try { + yield* fs.makeDirectory(join(options.workspaceRoot, ".github"), { recursive: true }); + yield* fs.writeFileString(overridesPath, JSON.stringify(newOverrides, null, 2)); + logger.success("Successfully wrote version overrides file."); + } catch (e) { + logger.error("Failed to write version overrides file:", e); + } + } else if (Object.keys(newOverrides).length > 0) { + logger.step("Version overrides unchanged. Skipping write."); + } + + if (Object.keys(newOverrides).length === 0 && hasOverrideChanges) { + logger.info("Removing obsolete version overrides file..."); + try { + yield* fs.remove(overridesPath); + logger.success("Successfully removed obsolete version overrides file."); + } catch (e) { + const formatted = formatUnknownError(e); + if (formatted.code !== "ENOENT") { + logger.error("Failed to remove obsolete version overrides file:", e); + } + } + } + + if (allUpdates.filter((u: PackageRelease) => u.hasDirectChanges).length === 0) { + logger.warn("No packages have changes requiring a release"); + } + + logger.section("🔄 Version Updates"); + logger.item(`Updating ${allUpdates.length} packages (including dependents)`); + + for (const update of allUpdates) { + const isAsIs = update.changeKind === "as-is"; + const suffix = isAsIs ? farver.dim(" (as-is)") : ""; + logger.item(`${update.package.name}: ${update.currentVersion} → ${update.newVersion}${suffix}`); + } + + yield* applyUpdates(); + + if (options.changelog?.enabled) { + logger.step("Updating changelogs"); + + const groupedPackageCommits = yield* getWorkspacePackageGroupedCommits( + options.workspaceRoot, + workspacePackages, + ); + const globalCommitsPerPackage = yield* getGlobalCommitsPerPackage( + options.workspaceRoot, + groupedPackageCommits, + workspacePackages, + options.globalCommitMode === "none" ? false : options.globalCommitMode, + ); + + const changelogUpdates = allUpdates.filter( + (update: PackageRelease) => update.currentVersion !== update.newVersion, + ); + + const updatePackageChangelog = Effect.fn("updatePackageChangelog")(function* ( + update: PackageRelease, + ) { + let pkgCommits = groupedPackageCommits.get(update.package.name) || []; + let globalCommits = globalCommitsPerPackage.get(update.package.name) || []; + let previousVersionForChangelog: string | undefined = + update.currentVersion !== "0.0.0" ? update.currentVersion : undefined; + + const shouldCombinePrereleaseIntoStable = + options.changelog.combinePrereleaseIntoFirstStable && + semver.prerelease(update.currentVersion) != null && + semver.prerelease(update.newVersion) == null; + + if (shouldCombinePrereleaseIntoStable) { + const stableTag = yield* Effect.catchTag( + git.getMostRecentPackageStableTag( + options.workspaceRoot, + update.package.name, + ) as Effect.Effect, + "GitError", + (error) => + Effect.sync(() => { + logger.warn( + `Failed to resolve stable tag for ${update.package.name}: ${formatUnknownError(error).message}`, + ); + return undefined; + }) + ); + if (stableTag) { + logger.verbose( + `Combining prerelease changelog entries into stable release for ${update.package.name} using base tag ${stableTag}`, + ); + + const stableBaseCommits = yield* getPackageCommitsSinceTag( + options.workspaceRoot, + update.package, + stableTag, + ); + + pkgCommits = stableBaseCommits; + + const stableBaseGlobals = yield* getGlobalCommitsPerPackage( + options.workspaceRoot, + new Map([[update.package.name, stableBaseCommits]]), + workspacePackages, + options.globalCommitMode === "none" ? false : options.globalCommitMode, + ); + + globalCommits = stableBaseGlobals.get(update.package.name) || []; + + const atIndex = stableTag.lastIndexOf("@"); + if (atIndex !== -1) { + previousVersionForChangelog = stableTag.slice(atIndex + 1); + } + } + } + + const allCommits = [...pkgCommits, ...globalCommits]; + + if (allCommits.length === 0) { + logger.verbose( + `No commits for ${update.package.name}, writing changelog entry with no-significant-commits note`, + ); + } + + logger.verbose(`Updating changelog for ${farver.cyan(update.package.name)}`); + + yield* changelog.updateChangelog({ + normalizedOptions: { + ...options, + workspaceRoot: options.workspaceRoot, + }, + workspacePackage: update.package, + version: update.newVersion, + previousVersion: previousVersionForChangelog, + commits: allCommits, + date: new Date().toISOString().split("T")[0]!, + }); + }); + + const changelogEffects = changelogUpdates.map(updatePackageChangelog); + + const updates = yield* Effect.all(changelogEffects); + logger.success(`Updated ${updates.length} changelog(s)`); + } + + const hasChangesToPush = yield* Effect.catchTag(syncReleaseChanges({ + workspaceRoot: options.workspaceRoot, + releaseBranch: options.branch.release, + commitMessage: "chore: update release versions", + hasChanges: true, + // The overrides file may be a new untracked file that git add -u would miss. + // Explicitly include it so it gets committed alongside the version bumps. + additionalPaths: [overridesPath], + }) as Effect.Effect, "GitError", (error) => + Effect.sync(() => exitWithError("Failed to sync release changes.", undefined, error)), + ); + + if (!hasChangesToPush) { + // When there are no updates at all, the release branch is identical to the + // default branch. Attempting to create/update a PR would fail with a 422 + // ("No commits between main and "), so bail out early. + if (allUpdates.length === 0) { + logger.info("No changes to commit and no packages to release. Nothing to do."); + yield* Effect.catchTag( + git.checkoutBranch(options.branch.default, options.workspaceRoot) as Effect.Effect, + "GitError", + (error) => + Effect.sync(() => + exitWithError(`Failed to checkout branch: ${options.branch.default}`, undefined, error) + ), + ); + return null; + } + + const prResult = yield* Effect.catchTag(syncPullRequest({ + releaseBranch: options.branch.release, + defaultBranch: options.branch.default, + pullRequestTitle: options.pullRequest?.title, + pullRequestBody: options.pullRequest?.body, + updates: allUpdates, + }) as Effect.Effect, "GitHubError", (error) => + Effect.sync(() => exitWithError("Failed to sync release pull request.", undefined, error)), + ); + + if (prResult.pullRequest) { + logger.item("No updates needed, PR is already up to date"); + yield* Effect.catchTag( + git.checkoutBranch(options.branch.default, options.workspaceRoot) as Effect.Effect, + "GitError", + (error) => + Effect.sync(() => + exitWithError(`Failed to checkout branch: ${options.branch.default}`, undefined, error) + ), + ); + + return { + updates: allUpdates, + prUrl: prResult.pullRequest.html_url, + created: prResult.created, + }; + } + + logger.error("No changes to commit, and no existing PR. Nothing to do."); + return null; + } + + const prResult = yield* Effect.catchTag(syncPullRequest({ + releaseBranch: options.branch.release, + defaultBranch: options.branch.default, + pullRequestTitle: options.pullRequest?.title, + pullRequestBody: options.pullRequest?.body, + updates: allUpdates, + }) as Effect.Effect, "GitHubError", (error) => + Effect.sync(() => exitWithError("Failed to sync release pull request.", undefined, error)), + ); + + if (prResult.pullRequest?.html_url) { + logger.section("🚀 Pull Request"); + logger.success( + `Pull request ${prResult.created ? "created" : "updated"}: ${prResult.pullRequest.html_url}`, + ); + } + + const returnToDefault = yield* Effect.catchTag( + git.checkoutBranch(options.branch.default, options.workspaceRoot) as Effect.Effect, + "GitError", + (error) => + Effect.sync(() => + exitWithError(`Failed to checkout branch: ${options.branch.default}`, undefined, error) + ), + ); + + if (!returnToDefault) { + exitWithError(`Failed to checkout branch: ${options.branch.default}`); + } + + return { + updates: allUpdates, + prUrl: prResult.pullRequest?.html_url, + created: prResult.created, + }; +}); diff --git a/src/workflows/publish.ts b/src/release/publish.ts similarity index 52% rename from src/workflows/publish.ts rename to src/release/publish.ts index bad4bf2..db68b19 100644 --- a/src/workflows/publish.ts +++ b/src/release/publish.ts @@ -1,65 +1,69 @@ -import { readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { parseChangelog } from "#core/changelog"; -import { commitPaths, createAndPushPackageTag, getCurrentBranch, pushBranch } from "#core/git"; -import type { PublishStatus } from "#core/npm"; -import { checkVersionExists, publishPackage } from "#core/npm"; -import { discoverWorkspacePackages } from "#core/workspace"; -import { exitWithError } from "#shared/errors"; -import { logger, ucdjsReleaseOverridesPath } from "#shared/utils"; -import { buildPackageDependencyGraph, getPackagePublishOrder } from "#versioning/package"; +import { parseChangelog } from "../services/changelog"; +import { GitHubService } from "../services/github"; +import { type GitError, GitService } from "../services/git"; +import { type NPMError, NpmService } from "../services/npm"; +import type { PublishStatus } from "../services/npm"; +import { type WorkspaceError, type WorkspacePackage, WorkspaceService } from "../services/workspace"; +import { exitWithError } from "../shared/errors"; +import { formatUnknownError } from "../shared/errors"; +import { ReleaseOptions } from "../options"; +import { logger, ucdjsReleaseOverridesPath } from "../shared/utils"; +import { buildPackageDependencyGraph, getPackagePublishOrder } from "../versioning/package"; +import { Effect, FileSystem } from "effect"; import farver from "farver"; import semver from "semver"; - import type { NormalizedReleaseScriptsOptions } from "../options"; +import type { BumpKind } from "#shared/types"; -async function getReleaseBodyFromChangelog( - workspaceRoot: string, +const getReleaseBodyFromChangelog = Effect.fn("getReleaseBodyFromChangelogEffect")(function* ( + _workspaceRoot: string, packageName: string, packagePath: string, version: string, -): Promise { +) { + const fs = yield* FileSystem.FileSystem; const changelogPath = join(packagePath, "CHANGELOG.md"); + const changelogContent = yield* fs.readFileString(changelogPath).pipe(Effect.catch((err) => { + logger.verbose(`Could not read changelog entry for ${version} at ${changelogPath}: ${err.message}`); + return Effect.succeed([ + `## ${packageName}@${version}`, + "", + "⚠️ Could not read package changelog while creating this release.", + "", + `Expected changelog file: ${changelogPath}`, + ].join("\n")); + })); - try { - const changelogContent = await readFile(changelogPath, "utf-8"); - const parsed = parseChangelog(changelogContent); - const entry = parsed.versions.find((v) => v.version === version); - - if (!entry) { - return [ - `## ${packageName}@${version}`, - "", - "⚠️ Could not find a matching changelog entry for this version.", - "", - `Expected version ${version} in ${changelogPath}.`, - ].join("\n"); - } - - const lines = entry.content.trim().split("\n"); - if (lines[0]?.trim().startsWith("## ")) { - return lines.slice(1).join("\n").trim(); - } + const parsed = parseChangelog(changelogContent); + const entry = parsed.versions.find((v) => v.version === version); - return entry.content.trim(); - } catch { - logger.verbose(`Could not read changelog entry for ${version} at ${changelogPath}`); + if (!entry) { return [ `## ${packageName}@${version}`, "", - "⚠️ Could not read package changelog while creating this release.", + "⚠️ Could not find a matching changelog entry for this version.", "", - `Expected changelog file: ${changelogPath}`, + `Expected version ${version} in ${changelogPath}.`, ].join("\n"); } -} -async function cleanupPublishedOverrides( + const lines = entry.content.trim().split("\n"); + if (lines[0]?.trim().startsWith("## ")) { + return lines.slice(1).join("\n").trim(); + } + + return entry.content.trim(); +}); + +const cleanupPublishedOverrides = Effect.fn("cleanupPublishedOverridesEffect")(function* ( options: NormalizedReleaseScriptsOptions, workspacePackages: { name: string; version: string }[], publishedPackageNames: string[], -): Promise { +) { + const fs = yield* FileSystem.FileSystem; + if (publishedPackageNames.length === 0) { return false; } @@ -70,11 +74,18 @@ async function cleanupPublishedOverrides( } const overridesPath = join(options.workspaceRoot, ucdjsReleaseOverridesPath); - let overrides: Record; + const overrides = yield* fs.readFileString(overridesPath).pipe( + Effect.flatMap((content) => Effect.try({ + try: () => JSON.parse(content) as Record, + catch: () => null, + })), + Effect.catch(() => Effect.succeed(null)), + ); - try { - overrides = JSON.parse(await readFile(overridesPath, "utf-8")); - } catch { + if (!overrides) { return false; } @@ -104,35 +115,45 @@ async function cleanupPublishedOverrides( logger.step(`Cleaning up satisfied overrides (${removed.length})...`); if (Object.keys(overrides).length === 0) { - await rm(overridesPath, { force: true }); + yield* fs.remove(overridesPath, { force: true }); logger.success("Removed release override file (all entries satisfied)"); return true; } - await writeFile(overridesPath, JSON.stringify(overrides, null, 2), "utf-8"); + yield* fs.writeFileString(overridesPath, JSON.stringify(overrides, null, 2)); logger.success(`Removed satisfied overrides: ${removed.join(", ")}`); return true; -} - -export async function publishWorkflow(options: NormalizedReleaseScriptsOptions): Promise { +}); + +export const publishWorkflow = Effect.fn("publishWorkflow")(function* () { + const options = yield* ReleaseOptions; + const github = yield* GitHubService; + const git = yield* GitService; + const npm = yield* NpmService; + const workspace = yield* WorkspaceService; + const fs = yield* FileSystem.FileSystem; logger.section("📦 Publishing Packages"); // Warn if not on the expected default branch — publishing from a feature/release branch is unusual - const currentBranch = await getCurrentBranch(options.workspaceRoot); - if (currentBranch.ok && currentBranch.value !== options.branch.default) { + const currentBranch = yield* Effect.catchTag( + git.getCurrentBranch(options.workspaceRoot) as Effect.Effect, + "GitError", + () => Effect.succeed(undefined), + ); + if (currentBranch && currentBranch !== options.branch.default) { logger.warn( - `Publishing from branch "${currentBranch.value}" instead of the default branch "${options.branch.default}". ` + - `Pass --force if this is intentional.`, + `Publishing from branch "${currentBranch}" instead of the default branch "${options.branch.default}". ` + + `Pass --force if this is intentional.`, ); } // Discover workspace packages - const discovered = await discoverWorkspacePackages(options.workspaceRoot, options); - if (!discovered.ok) { - exitWithError("Failed to discover packages.", undefined, discovered.error); - } - - const workspacePackages = discovered.value; + const workspacePackages = yield* Effect.catchTag(workspace.discoverWorkspacePackages( + options.workspaceRoot, + options, + ) as Effect.Effect, "WorkspaceError", (error) => + Effect.sync(() => exitWithError("Failed to discover packages.", undefined, error)), + ); logger.item(`Found ${workspacePackages.length} packages in workspace`); // Build dependency graph for publish ordering @@ -168,26 +189,26 @@ export async function publishWorkflow(options: NormalizedReleaseScriptsOptions): // Check if version already exists on NPM logger.step(`Checking if ${farver.cyan(`${packageName}@${version}`)} exists on NPM...`); - const existsResult = await checkVersionExists(packageName, version); - - if (!existsResult.ok) { - logger.error(`Failed to check version: ${existsResult.error.message}`); - status.failed.push(packageName); - // Stop immediately on error - exitWithError( - `Publishing failed for ${packageName}.`, - "Check your network connection and NPM registry access", - existsResult.error, - ); - } - - const npmExists = existsResult.value; + const npmExists = yield* Effect.catchTag( + npm.checkVersionExists(packageName, version) as Effect.Effect, + "NPMError", + (error) => + Effect.sync(() => { + logger.error(`Failed to check version: ${formatUnknownError(error).message}`); + status.failed.push(packageName); + exitWithError( + `Publishing failed for ${packageName}.`, + "Check your network connection and NPM registry access", + error, + ); + }), + ); // Check if a changelog entry exists for this version let changelogEntryExists = false; const changelogPath = join(pkg.path, "CHANGELOG.md"); try { - const changelogContent = await readFile(changelogPath, "utf-8"); + const changelogContent = yield* fs.readFileString(changelogPath); const parsed = parseChangelog(changelogContent); changelogEntryExists = parsed.versions.some((v) => v.version === version); } catch { @@ -205,61 +226,64 @@ export async function publishWorkflow(options: NormalizedReleaseScriptsOptions): if (!npmExists) { // Publish to NPM logger.step(`Publishing ${farver.cyan(`${packageName}@${version}`)} to NPM...`); - const publishResult = await publishPackage( + yield* Effect.catchTag(npm.publishPackage( packageName, version, options.workspaceRoot, options, + ) as Effect.Effect, "NPMError", (error) => + Effect.sync(() => { + const formatted = formatUnknownError(error); + logger.error(`Failed to publish: ${formatted.message}`); + status.failed.push(packageName); + + let hint: string | undefined; + if (formatted.code === "E403") { + hint = "Authentication failed. Ensure your NPM token or OIDC configuration is correct"; + } else if (formatted.code === "EPUBLISHCONFLICT") { + hint = "Version conflict. The version may have been published recently"; + } else if (formatted.code === "EOTP") { + hint = "2FA/OTP required. Provide the otp option or use OIDC authentication"; + } + + exitWithError(`Publishing failed for ${packageName}`, hint, error); + }), ); - if (!publishResult.ok) { - logger.error(`Failed to publish: ${publishResult.error.message}`); - status.failed.push(packageName); - - // Provide helpful error messages for common issues - let hint: string | undefined; - if (publishResult.error.code === "E403") { - hint = "Authentication failed. Ensure your NPM token or OIDC configuration is correct"; - } else if (publishResult.error.code === "EPUBLISHCONFLICT") { - hint = "Version conflict. The version may have been published recently"; - } else if (publishResult.error.code === "EOTP") { - hint = "2FA/OTP required. Provide the otp option or use OIDC authentication"; - } - - exitWithError(`Publishing failed for ${packageName}`, hint, publishResult.error); - } - logger.success(`Published ${farver.cyan(`${packageName}@${version}`)}`); status.published.push(packageName); } // Create and push git tag logger.step(`Creating git tag ${farver.cyan(`${packageName}@${version}`)}...`); - const tagResult = await createAndPushPackageTag(packageName, version, options.workspaceRoot); + yield* Effect.catchTag( + git.createAndPushPackageTag(packageName, version, options.workspaceRoot) as Effect.Effect, + "GitError", + (error) => + Effect.sync(() => { + logger.error(`Failed to create/push tag: ${formatUnknownError(error).message}`); + status.failed.push(packageName); + exitWithError( + `Publishing failed for ${packageName}: could not create git tag`, + "Ensure the workflow token can push tags (contents: write) and git credentials are configured", + error, + ); + }), + ); const tagName = `${packageName}@${version}`; - if (!tagResult.ok) { - logger.error(`Failed to create/push tag: ${tagResult.error.message}`); - status.failed.push(packageName); - exitWithError( - `Publishing failed for ${packageName}: could not create git tag`, - "Ensure the workflow token can push tags (contents: write) and git credentials are configured", - tagResult.error, - ); - } - logger.success(`Created and pushed tag ${farver.cyan(tagName)}`); logger.step(`Creating GitHub release for ${farver.cyan(tagName)}...`); try { - const releaseBody = await getReleaseBodyFromChangelog( + const releaseBody = yield* getReleaseBodyFromChangelog( options.workspaceRoot, packageName, pkg.path, version, ); - const releaseResult = await options.githubClient.upsertReleaseByTag({ + const releaseResult = yield* github.upsertReleaseByTag({ tagName, name: tagName, body: releaseBody, @@ -314,42 +338,40 @@ export async function publishWorkflow(options: NormalizedReleaseScriptsOptions): exitWithError(`Publishing completed with ${status.failed.length} failure(s)`); } - const didCleanupOverrides = await cleanupPublishedOverrides( + const didCleanupOverrides = yield* cleanupPublishedOverrides( options, workspacePackages, status.published, ); - if (didCleanupOverrides && !options.dryRun) { + if (didCleanupOverrides && !options.dryRun) { logger.step("Committing override cleanup..."); - const commitResult = await commitPaths( + const commitResult = yield* git.commitPaths( [ucdjsReleaseOverridesPath], "chore: cleanup release overrides", options.workspaceRoot, ); - if (!commitResult.ok) { - exitWithError("Failed to commit override cleanup.", undefined, commitResult.error); - } - - if (commitResult.value) { - const currentBranch = await getCurrentBranch(options.workspaceRoot); - if (!currentBranch.ok) { - exitWithError( - "Failed to detect current branch for override cleanup push.", - undefined, - currentBranch.error, - ); - } + if (commitResult) { + const currentBranch = yield* Effect.catchTag( + git.getCurrentBranch(options.workspaceRoot) as Effect.Effect, + "GitError", + (error) => + Effect.sync(() => + exitWithError("Failed to detect current branch for override cleanup push.", undefined, error) + ), + ); - const pushResult = await pushBranch(currentBranch.value, options.workspaceRoot); - if (!pushResult.ok) { - exitWithError("Failed to push override cleanup commit.", undefined, pushResult.error); - } + yield* Effect.catchTag( + git.pushBranch(currentBranch, options.workspaceRoot) as Effect.Effect, + "GitError", + (error) => + Effect.sync(() => exitWithError("Failed to push override cleanup commit.", undefined, error)), + ); - logger.success(`Pushed override cleanup commit to ${farver.cyan(currentBranch.value)}`); + logger.success(`Pushed override cleanup commit to ${farver.cyan(currentBranch)}`); } } logger.success("All packages published successfully!"); -} +}); diff --git a/src/release/verify.ts b/src/release/verify.ts new file mode 100644 index 0000000..5d139d3 --- /dev/null +++ b/src/release/verify.ts @@ -0,0 +1,184 @@ +import { join, relative } from "node:path"; + +import { GitHubService } from "../services/github"; +import { type GitError, GitService } from "../services/git"; +import { type WorkspaceError, WorkspaceService, type WorkspacePackage } from "../services/workspace"; +import type { PackageRelease } from "../shared/types"; +import { calculateUpdates, ensureHasPackages } from "./calculate"; +import { exitWithError, formatUnknownError } from "../shared/errors"; +import { ReleaseOptions } from "../options"; +import { logger, ucdjsReleaseOverridesPath } from "../shared/utils"; +import { Effect } from "effect"; +import { gt } from "semver"; + +export const verifyWorkflow = Effect.fn("verifyWorkflow")(function* () { + const options = yield* ReleaseOptions; + const github = yield* GitHubService; + const git = yield* GitService; + const workspace = yield* WorkspaceService; + if (options.safeguards) { + const clean = yield* Effect.catchTag( + git.isWorkingDirectoryClean(options.workspaceRoot) as Effect.Effect, + "GitError", + (error) => + Effect.sync(() => + exitWithError( + "Failed to verify working directory state.", + "Ensure this is a valid git repository and try again.", + error, + ) + ), + ); + + if (!clean) { + exitWithError( + "Working directory is not clean. Please commit or stash your changes before proceeding.", + ); + } + } + + const releaseBranch = options.branch.release; + const defaultBranch = options.branch.default; + + const releasePr = yield* github.getExistingPullRequest(releaseBranch); + + if (!releasePr || !releasePr.head) { + logger.warn( + `No open release pull request found for branch "${releaseBranch}". Nothing to verify.`, + ); + return; + } + + const releaseHeadSha = releasePr.head.sha; + + logger.info( + `Found release PR #${releasePr.number}. Verifying against default branch "${defaultBranch}"...`, + ); + + const originalBranch = yield* Effect.catchTag( + git.getCurrentBranch(options.workspaceRoot) as Effect.Effect, + "GitError", + (error) => + Effect.sync(() => exitWithError("Failed to detect current branch.", undefined, error)), + ); + + if (originalBranch !== defaultBranch) { + const checkout = yield* git.checkoutBranch(defaultBranch, options.workspaceRoot); + if (!checkout) { + exitWithError(`Failed to checkout branch: ${defaultBranch}`); + } + } + + let existingOverrides: Record< + string, + { version: string; type: import("#shared/types").BumpKind } + > = {}; + try { + const overridesContent = yield* git.readFileFromGit( + options.workspaceRoot, + releaseHeadSha, + ucdjsReleaseOverridesPath, + ); + if (overridesContent) { + existingOverrides = JSON.parse(overridesContent); + logger.info("Found existing version overrides file on release branch."); + } + } catch (error) { + logger.info("No version overrides file found on release branch. Continuing..."); + logger.verbose(`Reading release overrides failed: ${formatUnknownError(error).message}`); + } + + const discovered = yield* Effect.catchTag( + workspace.discoverWorkspacePackages(options.workspaceRoot, options) as Effect.Effect< + WorkspacePackage[], + WorkspaceError, + unknown + >, + "WorkspaceError", + (error) => + Effect.sync(() => exitWithError("Failed to discover packages.", undefined, error)), + ); + + const mainPackages = ensureHasPackages(discovered); + if (mainPackages === null) { + logger.warn("No packages found to release"); + return; + } + + const updates = yield* Effect.catchTag(calculateUpdates({ + workspacePackages: mainPackages, + workspaceRoot: options.workspaceRoot, + showPrompt: false, + globalCommitMode: options.globalCommitMode === "none" ? false : options.globalCommitMode, + overrides: existingOverrides, + }) as Effect.Effect, "GitError", (error) => + Effect.sync(() => exitWithError("Failed to calculate expected package updates.", undefined, error)), + ); + + const expectedUpdates = updates.allUpdates; + const expectedVersionMap = new Map( + expectedUpdates.map((u: PackageRelease) => [u.package.name, u.newVersion]), + ); + + const prVersionMap = new Map(); + for (const pkg of mainPackages) { + const pkgJsonPath = relative(options.workspaceRoot, join(pkg.path, "package.json")); + const pkgJsonContent = yield* git.readFileFromGit( + options.workspaceRoot, + releaseHeadSha, + pkgJsonPath, + ); + if (pkgJsonContent) { + const pkgJson = JSON.parse(pkgJsonContent); + prVersionMap.set(pkg.name, pkgJson.version); + } + } + + if (originalBranch !== defaultBranch) { + yield* git.checkoutBranch(originalBranch, options.workspaceRoot); + } + + let isOutOfSync = false; + for (const [pkgName, expectedVersion] of expectedVersionMap.entries()) { + const prVersion = prVersionMap.get(pkgName); + if (!prVersion) { + logger.warn( + `Package "${pkgName}" found in default branch but not in release branch. Skipping.`, + ); + continue; + } + + if (gt(expectedVersion, prVersion)) { + logger.error( + `Package "${pkgName}" is out of sync. Expected version >= ${expectedVersion}, but PR has ${prVersion}.`, + ); + isOutOfSync = true; + } else { + logger.success( + `Package "${pkgName}" is up to date (PR version: ${prVersion}, Expected: ${expectedVersion})`, + ); + } + } + + const statusContext = "ucdjs/release-verify"; + + if (isOutOfSync) { + yield* github.setCommitStatus({ + sha: releaseHeadSha, + state: "failure", + context: statusContext, + description: + "Release PR is out of sync with the default branch. Please re-run the release process.", + }); + logger.error("Verification failed. Commit status set to 'failure'."); + } else { + yield* github.setCommitStatus({ + sha: releaseHeadSha, + state: "success", + context: statusContext, + description: "Release PR is up to date.", + targetUrl: `https://github.com/${options.owner}/${options.repo}/pull/${releasePr.number}`, + }); + logger.success("Verification successful. Commit status set to 'success'."); + } +}); diff --git a/src/services/changelog.ts b/src/services/changelog.ts new file mode 100644 index 0000000..23b8dd7 --- /dev/null +++ b/src/services/changelog.ts @@ -0,0 +1,304 @@ +import { join, relative } from "node:path"; + +import { buildTemplateGroups } from "../shared/changelog-format"; +import type { NormalizedReleaseScriptsOptions } from "../options"; +import { DEFAULT_CHANGELOG_TEMPLATE } from "../options"; +import type { AuthorInfo, CommitTypeRule } from "../shared/types"; +import { logger } from "../shared/utils"; +import { Context, Effect, FileSystem, Layer } from "effect"; +import type { GitCommit } from "commit-parser"; +import { Eta } from "eta"; + +import { readFileFromGit } from "./git"; +import { GitHubService } from "./github"; +import type { WorkspacePackage } from "./workspace"; + +const CHANGELOG_VERSION_RE = /##\s+(?:)?\[?([^\](\s<]+)/; +const excludeAuthors = [/\[bot\]/i, /dependabot/i, /\(bot\)/i]; + +export interface ChangelogServiceShape { + readonly generateChangelogEntry: (options: { + packageName: string; + version: string; + previousVersion?: string; + date: string; + commits: GitCommit[]; + owner: string; + repo: string; + types: Record; + template?: string; + }) => Effect.Effect; + readonly updateChangelog: (options: { + normalizedOptions: NormalizedReleaseScriptsOptions; + workspacePackage: WorkspacePackage; + version: string; + previousVersion?: string; + commits: GitCommit[]; + date: string; + }) => Effect.Effect; +} + +export class ChangelogService extends Context.Service()( + "@ucdjs/release-scripts/ChangelogService", +) {} + +// oxlint-disable-next-line require-yield +export const makeChangelogService = Effect.fn("makeChangelogService")(function* () { + const resolveCommitAuthors = Effect.fn("resolveCommitAuthors")(function* (commits: GitCommit[]) { + const github = yield* GitHubService; + const authorMap = new Map(); + const commitAuthors = new Map(); + + for (const commit of commits) { + const authorsForCommit: AuthorInfo[] = []; + + commit.authors.forEach((author, idx) => { + if (!author.email || !author.name) { + return; + } + + if (excludeAuthors.some((re) => re.test(author.name))) { + return; + } + + if (!authorMap.has(author.email)) { + authorMap.set(author.email, { + commits: [], + name: author.name, + email: author.email, + }); + } + + const info = authorMap.get(author.email)!; + + if (idx === 0) { + info.commits.push(commit.shortHash); + } + + authorsForCommit.push(info); + }); + + commitAuthors.set(commit.hash, authorsForCommit); + } + + const authors = [...authorMap.values()]; + yield* Effect.all(authors.map((info) => github.resolveAuthorInfo(info))); + + return commitAuthors; + }); + + const generateChangelogEntry: ChangelogServiceShape["generateChangelogEntry"] = Effect.fn("generateChangelogEntry")(function* (options) { + const { + packageName, + version, + previousVersion, + date, + commits, + owner, + repo, + types, + template, + } = options; + + const compareUrl = + previousVersion && previousVersion !== version + ? `https://github.com/${owner}/${repo}/compare/${packageName}@${previousVersion}...${packageName}@${version}` + : undefined; + + const commitAuthors = yield* resolveCommitAuthors(commits); + const templateGroups = buildTemplateGroups({ + commits, + owner, + repo, + types, + commitAuthors, + }); + + const templateData = { + packageName, + version, + previousVersion, + date, + compareUrl, + owner, + repo, + groups: templateGroups, + }; + + const eta = new Eta(); + const templateToUse = template || DEFAULT_CHANGELOG_TEMPLATE; + + return eta.renderString(templateToUse, templateData).trim(); + }); + + const updateChangelog: ChangelogServiceShape["updateChangelog"] = Effect.fn("updateChangelog")(function* (options) { + const fs = yield* FileSystem.FileSystem; + const { + version, + previousVersion, + commits, + date, + normalizedOptions, + workspacePackage, + } = options; + + if (previousVersion === version) { + logger.verbose( + `Skipping changelog update for ${workspacePackage.name}: version unchanged (${version})`, + ); + return; + } + + const changelogPath = join(workspacePackage.path, "CHANGELOG.md"); + const changelogRelativePath = relative( + normalizedOptions.workspaceRoot, + join(workspacePackage.path, "CHANGELOG.md"), + ); + + const existingContent = yield* readFileFromGit( + normalizedOptions.workspaceRoot, + normalizedOptions.branch.default, + changelogRelativePath, + ); + + logger.verbose("Existing content found: ", Boolean(existingContent)); + + const newEntry = yield* generateChangelogEntry({ + packageName: workspacePackage.name, + version, + previousVersion, + date, + commits, + owner: normalizedOptions.owner, + repo: normalizedOptions.repo, + types: normalizedOptions.types, + template: normalizedOptions.changelog?.template, + }); + + let updatedContent: string; + + if (!existingContent) { + updatedContent = `# ${workspacePackage.name}\n\n${newEntry}\n`; + yield* fs.writeFileString(changelogPath, updatedContent); + return; + } + + const parsed = parseChangelog(existingContent); + const lines = existingContent.split("\n"); + const existingVersionIndex = parsed.versions.findIndex((v) => v.version === version); + + if (existingVersionIndex !== -1) { + const existingVersion = parsed.versions[existingVersionIndex]!; + const before = lines.slice(0, existingVersion.lineStart); + const after = lines.slice(existingVersion.lineEnd + 1); + updatedContent = [...before, newEntry, ...after].join("\n"); + } else { + const insertAt = parsed.headerLineEnd + 1; + const before = lines.slice(0, insertAt); + const after = lines.slice(insertAt); + + if (before.length > 0 && before.at(-1) !== "") { + before.push(""); + } + + updatedContent = [...before, newEntry, "", ...after].join("\n"); + } + + yield* fs.writeFileString(changelogPath, updatedContent); + }); + + return ChangelogService.of({ + generateChangelogEntry, + updateChangelog, + }); +}); + +export const ChangelogServiceLive = Layer.effect(ChangelogService, makeChangelogService()); + +export const generateChangelogEntry = Effect.fn("generateChangelogEntry")(function* (options: { + packageName: string; + version: string; + previousVersion?: string; + date: string; + commits: GitCommit[]; + owner: string; + repo: string; + types: Record; + template?: string; +}) { + const changelog = yield* ChangelogService; + return yield* changelog.generateChangelogEntry(options); +}); + +export const updateChangelog = Effect.fn("updateChangelog")(function* (options: { + normalizedOptions: NormalizedReleaseScriptsOptions; + workspacePackage: WorkspacePackage; + version: string; + previousVersion?: string; + commits: GitCommit[]; + date: string; +}) { + const changelog = yield* ChangelogService; + return yield* changelog.updateChangelog(options); +}); + +// formatCommitLine moved to operations/changelog-format + +export function parseChangelog(content: string) { + const lines = content.split("\n"); + + let packageName: string | null = null; + let headerLineEnd = -1; + const versions: { + version: string; + lineStart: number; + lineEnd: number; + content: string; + }[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.trim(); + + if (line.startsWith("# ")) { + packageName = line.slice(2).trim(); + headerLineEnd = i; + break; + } + } + + for (let i = headerLineEnd + 1; i < lines.length; i++) { + const line = lines[i]!.trim(); + + if (line.startsWith("## ")) { + const versionMatch = line.match(CHANGELOG_VERSION_RE); + + if (versionMatch) { + const version = versionMatch[1]!; + const lineStart = i; + + let lineEnd = lines.length - 1; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j]!.trim().startsWith("## ")) { + lineEnd = j - 1; + break; + } + } + + const versionContent = lines.slice(lineStart, lineEnd + 1).join("\n"); + + versions.push({ + version, + lineStart, + lineEnd, + content: versionContent, + }); + } + } + } + + return { + packageName, + versions, + headerLineEnd, + }; +} diff --git a/src/services/git.ts b/src/services/git.ts new file mode 100644 index 0000000..8d8f835 --- /dev/null +++ b/src/services/git.ts @@ -0,0 +1,860 @@ +import process from "node:process"; + +import { formatUnknownError } from "../shared/errors"; +import { logger, runEffect, runIfNotDryEffect } from "../shared/utils"; +import { Cause, Context, Data, Effect, Exit, Layer } from "effect"; +import farver from "farver"; +import semver from "semver"; + +const DEFAULT_BRANCH_RE = /^refs\/remotes\/origin\/(.+)$/; +const CHECKOUT_BRANCH_RE = /Switched to (?:a new )?branch '(.+)'/; +const COMMIT_HASH_RE = /^[0-9a-f]{7,40}$/i; + +export class GitError extends Data.TaggedError("GitError")<{ + operation: string; + message: string; + stderr?: string; +}> {} + +export interface GitServiceShape { + readonly isWorkingDirectoryClean: ( + workspaceRoot: string, + ) => Effect.Effect; + readonly doesRemoteBranchExist: ( + branch: string, + workspaceRoot: string, + ) => Effect.Effect; + readonly doesBranchExist: ( + branch: string, + workspaceRoot: string, + ) => Effect.Effect; + readonly getDefaultBranch: ( + workspaceRoot: string, + ) => Effect.Effect; + readonly getCurrentBranch: ( + workspaceRoot: string, + ) => Effect.Effect; + readonly getAvailableBranches: ( + workspaceRoot: string, + ) => Effect.Effect; + readonly createBranch: ( + branch: string, + base: string, + workspaceRoot: string, + ) => Effect.Effect; + readonly checkoutBranch: ( + branch: string, + workspaceRoot: string, + ) => Effect.Effect; + readonly pullLatestChanges: ( + branch: string, + workspaceRoot: string, + ) => Effect.Effect; + readonly rebaseBranch: ( + ontoBranch: string, + workspaceRoot: string, + ) => Effect.Effect; + readonly isBranchAheadOfRemote: ( + branch: string, + workspaceRoot: string, + ) => Effect.Effect; + readonly commitChanges: ( + message: string, + workspaceRoot: string, + ) => Effect.Effect; + readonly commitPaths: ( + paths: string[], + message: string, + workspaceRoot: string, + ) => Effect.Effect; + readonly pushBranch: ( + branch: string, + workspaceRoot: string, + options?: { force?: boolean; forceWithLease?: boolean }, + ) => Effect.Effect; + readonly readFileFromGit: ( + workspaceRoot: string, + ref: string, + filePath: string, + ) => Effect.Effect; + readonly getMostRecentPackageTag: ( + workspaceRoot: string, + packageName: string, + ) => Effect.Effect; + readonly getMostRecentPackageStableTag: ( + workspaceRoot: string, + packageName: string, + ) => Effect.Effect; + readonly getGroupedFilesByCommitSha: ( + workspaceRoot: string, + from: string, + to: string, + ) => Effect.Effect, unknown, unknown>; + readonly createAndPushPackageTag: ( + packageName: string, + version: string, + workspaceRoot: string, + ) => Effect.Effect; +} + +function toGitError(operation: string, error: unknown): GitError { + const formatted = formatUnknownError(error); + return new GitError({ + operation, + message: formatted.message, + stderr: formatted.stderr, + }); +} + +function isMissingGitIdentityError(error: unknown): boolean { + const formatted = formatUnknownError(error); + const combined = `${formatted.message}\n${formatted.stderr ?? ""}`; + return ( + combined.includes("Author identity unknown") || + combined.includes("empty ident name") || + combined.includes("Please tell me who you are") + ); +} + +function isMissingGitPathError(error: unknown): boolean { + const formatted = formatUnknownError(error); + const combined = `${formatted.message}\n${formatted.stderr ?? ""}`; + return ( + combined.includes("exists on disk, but not in") || + combined.includes("does not exist in") || + combined.includes("Path '") || + (combined.includes("path '") && combined.includes("does not exist")) + ); +} + +export class GitService extends Context.Service()( + "@ucdjs/release-scripts/GitService", +) {} + +// oxlint-disable-next-line require-yield +export const makeGitService = Effect.fn("makeGitService")(function* () { + const ensureLocalGitIdentity = Effect.fn("ensureLocalGitIdentity")(function* ( + workspaceRoot: string, + ) { + try { + const actor = process.env.GITHUB_ACTOR?.trim(); + const name = + process.env.GIT_AUTHOR_NAME?.trim() || + process.env.GIT_COMMITTER_NAME?.trim() || + actor || + "github-actions[bot]"; + + const email = + process.env.GIT_AUTHOR_EMAIL?.trim() || + process.env.GIT_COMMITTER_EMAIL?.trim() || + (actor + ? `${actor}@users.noreply.github.com` + : "github-actions[bot]@users.noreply.github.com"); + + logger.warn( + "Git author identity missing. Configuring repository-local git identity for this run.", + ); + + yield* runIfNotDryEffect("git", ["config", "user.name", name], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + yield* runIfNotDryEffect("git", ["config", "user.email", email], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + + logger.info(`Configured git identity: ${farver.dim(`${name} <${email}>`)}`); + } catch (error) { + return yield* Effect.fail(toGitError("ensureLocalGitIdentity", error)); + } + }); + + const commitWithRetryOnMissingIdentity = Effect.fn( + "commitWithRetryOnMissingIdentity", + )(function* (message: string, workspaceRoot: string, operation: "commitChanges" | "commitPaths") { + const runCommit = () => + runIfNotDryEffect("git", ["commit", "-m", message], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + + const firstExit = yield* Effect.exit(runCommit()); + if (Exit.isSuccess(firstExit)) { + return; + } + + const firstError = Cause.squash(firstExit.cause); + if (!isMissingGitIdentityError(firstError)) { + return yield* Effect.fail(toGitError(operation, firstError)); + } + + yield* ensureLocalGitIdentity(workspaceRoot); + + const retryExit = yield* Effect.exit(runCommit()); + if (Exit.isSuccess(retryExit)) { + return; + } + + return yield* Effect.fail(toGitError(operation, Cause.squash(retryExit.cause))); + }); + + const isWorkingDirectoryClean: GitServiceShape["isWorkingDirectoryClean"] = Effect.fn( + "isWorkingDirectoryClean", + )(function* (workspaceRoot) { + const exit = yield* Effect.exit( + runEffect("git", ["status", "--porcelain"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isFailure(exit)) { + return yield* Effect.fail(toGitError("isWorkingDirectoryClean", Cause.squash(exit.cause))); + } + return exit.value.stdout.trim() === ""; + }); + + const getCurrentBranch: GitServiceShape["getCurrentBranch"] = Effect.fn( + "getCurrentBranch", + )(function* (workspaceRoot) { + const exit = yield* Effect.exit( + runEffect("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isFailure(exit)) { + return yield* Effect.fail(toGitError("getCurrentBranch", Cause.squash(exit.cause))); + } + return exit.value.stdout.trim(); + }); + + const checkoutBranch: GitServiceShape["checkoutBranch"] = Effect.fn( + "checkoutBranch", + )(function* (branch, workspaceRoot) { + logger.info(`Switching to branch: ${farver.green(branch)}`); + const exit = yield* Effect.exit( + runEffect("git", ["checkout", branch], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + + if (Exit.isFailure(exit)) { + const gitError = toGitError("checkoutBranch", Cause.squash(exit.cause)); + logger.error(`Git checkout failed: ${gitError.message}`); + if (gitError.stderr) { + logger.error(`Git stderr: ${gitError.stderr}`); + } + + const branchesExit = yield* Effect.exit( + runEffect("git", ["branch", "-a"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isSuccess(branchesExit)) { + logger.verbose(`Available branches:\n${branchesExit.value.stdout}`); + } + + return yield* Effect.fail(gitError); + } + + const output = exit.value.stderr.trim(); + const match = output.match(CHECKOUT_BRANCH_RE); + if (match && match[1] === branch) { + logger.info(`Successfully switched to branch: ${farver.green(branch)}`); + return true; + } + + logger.warn(`Unexpected git checkout output: ${output}`); + return false; + }); + + const commitPaths: GitServiceShape["commitPaths"] = Effect.fn("commitPaths")(function* ( + paths, + message, + workspaceRoot, + ) { + try { + if (paths.length === 0) { + return false; + } + + yield* runEffect("git", ["add", "--", ...paths], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + + const staged = yield* runEffect("git", ["diff", "--cached", "--name-only"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + + if (staged.stdout.trim() === "") { + return false; + } + + logger.info(`Committing changes: ${farver.dim(message)}`); + yield* commitWithRetryOnMissingIdentity(message, workspaceRoot, "commitPaths"); + + return true; + } catch (error) { + const gitError = toGitError("commitPaths", error); + logger.error(`Git commit failed: ${gitError.message}`); + if (gitError.stderr) { + logger.error(`Git stderr: ${gitError.stderr}`); + } + return yield* Effect.fail(gitError); + } + }); + + const pushBranch: GitServiceShape["pushBranch"] = Effect.fn("pushBranch")(function* ( + branch, + workspaceRoot, + options, + ) { + try { + const args = ["push", "origin", branch]; + + if (options?.forceWithLease) { + const fetchExit = yield* Effect.exit( + runEffect("git", ["fetch", "origin", branch], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isFailure(fetchExit)) { + const fetchError = toGitError("pushBranch.fetch", Cause.squash(fetchExit.cause)); + const isMissingRemoteRef = + fetchError.stderr?.includes("couldn't find remote ref") || + fetchError.message.includes("couldn't find remote ref"); + if (!isMissingRemoteRef) { + return yield* Effect.fail(fetchError); + } + logger.verbose( + `Remote branch origin/${branch} does not exist yet, falling back to regular push without --force-with-lease.`, + ); + } else { + args.push("--force-with-lease"); + logger.info(`Pushing branch: ${farver.green(branch)} ${farver.dim("(with lease)")}`); + } + } else if (options?.force) { + args.push("--force"); + logger.info(`Force pushing branch: ${farver.green(branch)}`); + } else { + logger.info(`Pushing branch: ${farver.green(branch)}`); + } + + yield* runIfNotDryEffect("git", args, { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + + return true; + } catch (error) { + return yield* Effect.fail(toGitError("pushBranch", error)); + } + }); + + const getMostRecentPackageStableTag: GitServiceShape["getMostRecentPackageStableTag"] = Effect.fn( + "getMostRecentPackageStableTag", + )(function* (workspaceRoot, packageName) { + try { + const { stdout } = yield* runEffect("git", ["tag", "--list", `${packageName}@*`], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + + const tags = stdout + .split("\n") + .map((tag) => tag.trim()) + .filter((tag) => Boolean(tag) && semver.valid(tag.slice(tag.lastIndexOf("@") + 1))) + .toSorted((a, b) => { + const va = a.slice(a.lastIndexOf("@") + 1); + const vb = b.slice(b.lastIndexOf("@") + 1); + return semver.rcompare(va, vb); + }); + + for (const tag of tags) { + const atIndex = tag.lastIndexOf("@"); + if (atIndex === -1) { + continue; + } + + const version = tag.slice(atIndex + 1); + if (semver.valid(version) && semver.prerelease(version) == null) { + return tag; + } + } + + return undefined; + } catch (error) { + return yield* Effect.fail(toGitError("getMostRecentPackageStableTag", error)); + } + }); + + const createPackageTag = Effect.fn("createPackageTag")(function* ( + packageName: string, + version: string, + workspaceRoot: string, + ) { + const tagName = `${packageName}@${version}`; + try { + const existingTagResult = yield* runEffect("git", ["tag", "--list", tagName], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + if (existingTagResult.stdout.trim() === tagName) { + const [tagCommit, headCommit] = yield* Effect.all([ + runEffect("git", ["rev-list", "-n1", tagName], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + runEffect("git", ["rev-parse", "HEAD"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ]); + if (tagCommit.stdout.trim() === headCommit.stdout.trim()) { + logger.verbose(`Tag ${farver.green(tagName)} already exists and points to HEAD, skipping creation`); + return; + } + logger.verbose(`Tag ${farver.green(tagName)} exists but points to a different commit — proceeding`); + } + + logger.info(`Creating tag: ${farver.green(tagName)}`); + yield* runIfNotDryEffect("git", ["tag", tagName], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + } catch (error) { + return yield* Effect.fail(toGitError("createPackageTag", error)); + } + }); + + const pushTag = Effect.fn("pushTag")(function* (tagName: string, workspaceRoot: string) { + try { + logger.info(`Pushing tag: ${farver.green(tagName)}`); + yield* runIfNotDryEffect("git", ["push", "origin", tagName], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + } catch (error) { + return yield* Effect.fail(toGitError("pushTag", error)); + } + }); + + const doesRemoteBranchExist: GitServiceShape["doesRemoteBranchExist"] = Effect.fn( + "doesRemoteBranchExist", + )(function* (branch, workspaceRoot) { + const exit = yield* Effect.exit( + runEffect("git", ["ls-remote", "--exit-code", "--heads", "origin", branch], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isFailure(exit)) { + logger.verbose( + `Remote branch "origin/${branch}" does not exist: ${formatUnknownError(Cause.squash(exit.cause)).message}`, + ); + return false; + } + return true; + }); + + const doesBranchExist: GitServiceShape["doesBranchExist"] = Effect.fn("doesBranchExist")(function* ( + branch, + workspaceRoot, + ) { + const exit = yield* Effect.exit( + runEffect("git", ["rev-parse", "--verify", branch], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isFailure(exit)) { + logger.verbose( + `Failed to verify branch "${branch}": ${formatUnknownError(Cause.squash(exit.cause)).message}`, + ); + return false; + } + return true; + }); + + const createBranch: GitServiceShape["createBranch"] = Effect.fn("createBranch")(function* ( + branch, + base, + workspaceRoot, + ) { + logger.info(`Creating branch: ${farver.green(branch)} from ${farver.cyan(base)}`); + const exit = yield* Effect.exit( + runIfNotDryEffect("git", ["branch", branch, base], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isFailure(exit)) { + return yield* Effect.fail(toGitError("createBranch", Cause.squash(exit.cause))); + } + }); + + const pullLatestChanges: GitServiceShape["pullLatestChanges"] = Effect.fn( + "pullLatestChanges", + )(function* (branch, workspaceRoot) { + try { + yield* runEffect("git", ["pull", "origin", branch], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + return true; + } catch (error) { + return yield* Effect.fail(toGitError("pullLatestChanges", error)); + } + }); + + const rebaseBranch: GitServiceShape["rebaseBranch"] = Effect.fn("rebaseBranch")(function* ( + ontoBranch, + workspaceRoot, + ) { + try { + logger.info(`Rebasing onto: ${farver.cyan(ontoBranch)}`); + yield* runIfNotDryEffect("git", ["rebase", ontoBranch], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + } catch (error) { + const abortExit = yield* Effect.exit( + runEffect("git", ["rebase", "--abort"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isSuccess(abortExit)) { + logger.verbose("Aborted in-progress rebase after failure"); + } + return yield* Effect.fail(toGitError("rebaseBranch", error)); + } + }); + + const isBranchAheadOfRemote: GitServiceShape["isBranchAheadOfRemote"] = Effect.fn( + "isBranchAheadOfRemote", + )(function* (branch, workspaceRoot) { + try { + const result = yield* runEffect("git", ["rev-list", `origin/${branch}..${branch}`, "--count"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + return Number.parseInt(result.stdout.trim(), 10) > 0; + } catch (error) { + logger.verbose( + `Failed to compare branch "${branch}" with remote: ${formatUnknownError(error).message}`, + ); + return true; + } + }); + + const commitChanges: GitServiceShape["commitChanges"] = Effect.fn("commitChanges")(function* ( + message, + workspaceRoot, + ) { + try { + yield* runEffect("git", ["add", "-u"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + const staged = yield* runEffect("git", ["diff", "--cached", "--name-only"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + if (staged.stdout.trim() === "") { + return false; + } + + logger.info(`Committing changes: ${farver.dim(message)}`); + yield* commitWithRetryOnMissingIdentity(message, workspaceRoot, "commitChanges"); + + return true; + } catch (error) { + const gitError = toGitError("commitChanges", error); + logger.error(`Git commit failed: ${gitError.message}`); + if (gitError.stderr) { + logger.error(`Git stderr: ${gitError.stderr}`); + } + return yield* Effect.fail(gitError); + } + }); + + const getDefaultBranch: GitServiceShape["getDefaultBranch"] = Effect.fn("getDefaultBranch")(function* ( + workspaceRoot, + ) { + const exit = yield* Effect.exit( + runEffect("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isFailure(exit)) { + logger.verbose( + `Failed to detect default branch from origin/HEAD: ${formatUnknownError(Cause.squash(exit.cause)).message}`, + ); + return "main"; + } + + const ref = exit.value.stdout.trim(); + const match = ref.match(DEFAULT_BRANCH_RE); + if (match && match[1]) { + return match[1]; + } + + return "main"; + }); + + const getAvailableBranches: GitServiceShape["getAvailableBranches"] = Effect.fn( + "getAvailableBranches", + )(function* (workspaceRoot) { + const exit = yield* Effect.exit( + runEffect("git", ["branch", "--list"], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isFailure(exit)) { + return yield* Effect.fail(toGitError("getAvailableBranches", Cause.squash(exit.cause))); + } + + const branches = exit.value.stdout + .split("\n") + .map((line) => line.replace("*", "").trim()) + .filter((line) => line.length > 0); + return branches; + }); + + const readFileFromGit: GitServiceShape["readFileFromGit"] = Effect.fn("readFileFromGit")(function* ( + workspaceRoot, + ref, + filePath, + ) { + const exit = yield* Effect.exit( + runEffect("git", ["show", `${ref}:${filePath}`], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }), + ); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + if (isMissingGitPathError(error)) { + logger.verbose( + `File ${filePath} is missing from ${ref}; treating as absent content rather than a hard failure.`, + ); + return null; + } + return yield* Effect.fail(toGitError("readFileFromGit", error)); + } + + return exit.value.stdout; + }); + + const getMostRecentPackageTag: GitServiceShape["getMostRecentPackageTag"] = Effect.fn( + "getMostRecentPackageTag", + )(function* (workspaceRoot, packageName) { + try { + const { stdout } = yield* runEffect("git", ["tag", "--list", `${packageName}@*`], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + + const tags = stdout.split("\n").map((tag) => tag.trim()).filter(Boolean); + if (tags.length === 0) { + return undefined; + } + + const sorted = tags + .filter((t) => semver.valid(t.slice(t.lastIndexOf("@") + 1))) + .toSorted((a, b) => { + const va = a.slice(a.lastIndexOf("@") + 1); + const vb = b.slice(b.lastIndexOf("@") + 1); + return semver.rcompare(va, vb); + }); + return sorted[0]; + } catch (error) { + return yield* Effect.fail(toGitError("getMostRecentPackageTag", error)); + } + }); + + const getGroupedFilesByCommitSha: GitServiceShape["getGroupedFilesByCommitSha"] = Effect.fn( + "getGroupedFilesByCommitSha", + )(function* (workspaceRoot, from, to) { + const commitsMap = new Map(); + try { + const { stdout } = yield* runEffect("git", ["log", "--name-only", "--format=%h", `${from}^..${to}`], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + + const lines = stdout.trim().split("\n").filter((line) => line.trim() !== ""); + let currentSha: string | null = null; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (COMMIT_HASH_RE.test(trimmedLine)) { + currentSha = trimmedLine; + commitsMap.set(currentSha, []); + continue; + } + if (currentSha === null) { + continue; + } + commitsMap.get(currentSha)!.push(trimmedLine); + } + + return commitsMap; + } catch (error) { + return yield* Effect.fail(toGitError("getGroupedFilesByCommitSha", error)); + } + }); + + const createAndPushPackageTag: GitServiceShape["createAndPushPackageTag"] = Effect.fn( + "createAndPushPackageTag", + )(function* (packageName, version, workspaceRoot) { + yield* createPackageTag(packageName, version, workspaceRoot); + const tagName = `${packageName}@${version}`; + return yield* pushTag(tagName, workspaceRoot); + }); + + return GitService.of({ + isWorkingDirectoryClean, + doesRemoteBranchExist, + doesBranchExist, + getDefaultBranch, + getAvailableBranches, + getCurrentBranch, + checkoutBranch, + pullLatestChanges, + rebaseBranch, + isBranchAheadOfRemote, + pushBranch, + readFileFromGit, + getMostRecentPackageStableTag, + createAndPushPackageTag, + createBranch, + commitPaths, + commitChanges, + getMostRecentPackageTag, + getGroupedFilesByCommitSha, + }); +}); + +export const GitServiceLive = Layer.effect(GitService, makeGitService()); + +export const isWorkingDirectoryClean = Effect.fn("isWorkingDirectoryClean")(function* (workspaceRoot: string) { + const git = yield* GitService; + return yield* git.isWorkingDirectoryClean(workspaceRoot); +}); + +export const getCurrentBranch = Effect.fn("getCurrentBranch")(function* (workspaceRoot: string) { + const git = yield* GitService; + return yield* git.getCurrentBranch(workspaceRoot); +}); + +export const checkoutBranch = Effect.fn("checkoutBranch")(function* (branch: string, workspaceRoot: string) { + const git = yield* GitService; + return yield* git.checkoutBranch(branch, workspaceRoot); +}); + +export const commitPaths = Effect.fn("commitPaths")(function* ( + paths: string[], + message: string, + workspaceRoot: string, +) { + const git = yield* GitService; + return yield* git.commitPaths(paths, message, workspaceRoot); +}); + +export const pushBranch = Effect.fn("pushBranch")(function* ( + branch: string, + workspaceRoot: string, + options?: { force?: boolean; forceWithLease?: boolean }, +) { + const git = yield* GitService; + return yield* git.pushBranch(branch, workspaceRoot, options); +}); + +export const getMostRecentPackageStableTag = Effect.fn("getMostRecentPackageStableTag")(function* ( + workspaceRoot: string, + packageName: string, +) { + const git = yield* GitService; + return yield* git.getMostRecentPackageStableTag(workspaceRoot, packageName); +}); + +export const doesRemoteBranchExist = Effect.fn("doesRemoteBranchExist")(function* ( + branch: string, + workspaceRoot: string, +) { + const git = yield* GitService; + return yield* git.doesRemoteBranchExist(branch, workspaceRoot); +}); + +export const doesBranchExist = Effect.fn("doesBranchExist")(function* ( + branch: string, + workspaceRoot: string, +) { + const git = yield* GitService; + return yield* git.doesBranchExist(branch, workspaceRoot); +}); + +export const getDefaultBranch = Effect.fn("getDefaultBranch")(function* (workspaceRoot: string) { + const git = yield* GitService; + return yield* git.getDefaultBranch(workspaceRoot); +}); + +export const getAvailableBranches = Effect.fn("getAvailableBranches")(function* (workspaceRoot: string) { + const git = yield* GitService; + return yield* git.getAvailableBranches(workspaceRoot); +}); + +export const createBranch = Effect.fn("createBranch")(function* ( + branch: string, + base: string, + workspaceRoot: string, +) { + const git = yield* GitService; + return yield* git.createBranch(branch, base, workspaceRoot); +}); + +export const pullLatestChanges = Effect.fn("pullLatestChanges")(function* ( + branch: string, + workspaceRoot: string, +) { + const git = yield* GitService; + return yield* git.pullLatestChanges(branch, workspaceRoot); +}); + +export const rebaseBranch = Effect.fn("rebaseBranch")(function* ( + ontoBranch: string, + workspaceRoot: string, +) { + const git = yield* GitService; + return yield* git.rebaseBranch(ontoBranch, workspaceRoot); +}); + +export const isBranchAheadOfRemote = Effect.fn("isBranchAheadOfRemote")(function* ( + branch: string, + workspaceRoot: string, +) { + const git = yield* GitService; + return yield* git.isBranchAheadOfRemote(branch, workspaceRoot); +}); + +export const commitChanges = Effect.fn("commitChanges")(function* ( + message: string, + workspaceRoot: string, +) { + const git = yield* GitService; + return yield* git.commitChanges(message, workspaceRoot); +}); + +export const readFileFromGit = Effect.fn("readFileFromGit")(function* ( + workspaceRoot: string, + ref: string, + filePath: string, +) { + const git = yield* GitService; + return yield* git.readFileFromGit(workspaceRoot, ref, filePath); +}); + +export const getMostRecentPackageTag = Effect.fn("getMostRecentPackageTag")(function* ( + workspaceRoot: string, + packageName: string, +) { + const git = yield* GitService; + return yield* git.getMostRecentPackageTag(workspaceRoot, packageName); +}); + +export const getGroupedFilesByCommitSha = Effect.fn("getGroupedFilesByCommitSha")(function* ( + workspaceRoot: string, + from: string, + to: string, +) { + const git = yield* GitService; + return yield* git.getGroupedFilesByCommitSha(workspaceRoot, from, to); +}); + +export const createAndPushPackageTag = Effect.fn("createAndPushPackageTag")(function* ( + packageName: string, + version: string, + workspaceRoot: string, +) { + const git = yield* GitService; + return yield* git.createAndPushPackageTag(packageName, version, workspaceRoot); +}); diff --git a/src/services/github.ts b/src/services/github.ts new file mode 100644 index 0000000..6e2bbd6 --- /dev/null +++ b/src/services/github.ts @@ -0,0 +1,514 @@ +import { formatUnknownError } from "../shared/errors"; +import { ReleaseOptions } from "../options"; +import type { AuthorInfo, PackageRelease } from "../shared/types"; +import { logger } from "../shared/utils"; +import { Context, Data, Effect, Layer } from "effect"; +import { Eta } from "eta"; +import farver from "farver"; + +import { DEFAULT_PR_BODY_TEMPLATE } from "../options"; + +interface SharedGitHubOptions { + owner: string; + repo: string; + githubToken: string; +} + +export interface GitHubPullRequest { + number: number; + title: string; + body: string; + draft: boolean; + html_url?: string; + head?: { + sha: string; + }; +} + +type CommitStatusState = "error" | "failure" | "pending" | "success"; + +interface CommitStatusOptions { + state: CommitStatusState; + targetUrl?: string; + description?: string; + context: string; +} + +interface UpsertPullRequestOptions { + title: string; + body: string; + head?: string; + base?: string; + pullNumber?: number; +} + +interface UpsertReleaseOptions { + tagName: string; + name: string; + body?: string; + prerelease?: boolean; +} + +interface GitHubRelease { + id: number; + tagName: string; + name: string; + htmlUrl?: string; +} + +export class GitHubError extends Data.TaggedError("GitHubError")<{ + operation: string; + message: string; + status?: number; +}> {} + +export interface GitHubServiceShape { + readonly getExistingPullRequest: (branch: string) => Effect.Effect; + readonly upsertPullRequest: ( + options: UpsertPullRequestOptions, + ) => Effect.Effect; + readonly setCommitStatus: ( + options: CommitStatusOptions & { sha: string }, + ) => Effect.Effect; + readonly upsertReleaseByTag: ( + options: UpsertReleaseOptions, + ) => Effect.Effect<{ release: GitHubRelease; created: boolean }, GitHubError>; + readonly resolveAuthorInfo: (info: AuthorInfo) => Effect.Effect; +} + +function toGitHubError(operation: string, error: unknown): GitHubError { + const formatted = formatUnknownError(error); + + return new GitHubError({ + operation, + message: formatted.message, + status: formatted.status, + }); +} + +export class GitHubService extends Context.Service()( + "@ucdjs/release-scripts/GitHubService", +) {} + +export const makeGitHubService = Effect.fn("makeGitHubService")(function* () { + const options = yield* ReleaseOptions; + const githubOptions: SharedGitHubOptions = { + owner: options.owner, + repo: options.repo, + githubToken: options.githubToken, + }; + const apiBase = "https://api.github.com"; + + const request = Effect.fn("githubRequest")(function* ( + operation: string, + path: string, + init: RequestInit = {}, + ) { + const url = path.startsWith("http") ? path : `${apiBase}${path}`; + const method = init.method ?? "GET"; + + const response = yield* Effect.tryPromise({ + try: () => + fetch(url, { + ...init, + headers: { + ...init.headers, + Accept: "application/vnd.github.v3+json", + Authorization: `token ${githubOptions.githubToken}`, + "User-Agent": "ucdjs-release-scripts (+https://github.com/ucdjs/ucdjs-release-scripts)", + }, + }), + catch: (error) => + toGitHubError( + operation, + Object.assign( + new Error(`[${method} ${path}] GitHub request failed: ${formatUnknownError(error).message}`), + { status: undefined }, + ), + ), + }); + + if (!response.ok) { + const errorText = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (error) => toGitHubError(operation, error), + }); + + const parsedMessage = (() => { + try { + const parsed = JSON.parse(errorText) as { message?: string; errors?: unknown }; + if (typeof parsed.message === "string" && parsed.message.trim()) { + if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { + return `${parsed.message} (${JSON.stringify(parsed.errors)})`; + } + + return parsed.message; + } + + return errorText; + } catch { + return errorText; + } + })(); + + return yield* Effect.fail( + toGitHubError( + operation, + Object.assign( + new Error( + `[${method} ${path}] GitHub API request failed (${response.status} ${response.statusText}): ${parsedMessage || "No response body"}`, + ), + { status: response.status }, + ), + ), + ); + } + + if (response.status === 204) { + return undefined as T; + } + + return yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: (error) => toGitHubError(operation, error), + }) + }); + + const getExistingPullRequest: GitHubServiceShape["getExistingPullRequest"] = Effect.fn( + "getExistingPullRequest", + )(function* (branch) { + const head = branch.includes(":") ? branch : `${githubOptions.owner}:${branch}`; + const endpoint = `/repos/${githubOptions.owner}/${githubOptions.repo}/pulls?state=open&head=${encodeURIComponent(head)}`; + + logger.verbose( + `Requesting pull request for branch: ${branch} (url: ${apiBase}${endpoint})`, + ); + const pulls = yield* request("getExistingPullRequest", endpoint); + + if (!Array.isArray(pulls) || pulls.length === 0) { + return null; + } + + const firstPullRequest: unknown = pulls[0]; + + if ( + typeof firstPullRequest !== "object" || + firstPullRequest === null || + !("number" in firstPullRequest) || + typeof firstPullRequest.number !== "number" || + !("title" in firstPullRequest) || + typeof firstPullRequest.title !== "string" || + !("body" in firstPullRequest) || + typeof firstPullRequest.body !== "string" || + !("draft" in firstPullRequest) || + typeof firstPullRequest.draft !== "boolean" || + !("html_url" in firstPullRequest) || + typeof firstPullRequest.html_url !== "string" + ) { + return yield* Effect.fail( + new GitHubError({ + operation: "getExistingPullRequest", + message: "Pull request data validation failed", + }), + ); + } + + const pullRequest: GitHubPullRequest = { + number: firstPullRequest.number, + title: firstPullRequest.title, + body: firstPullRequest.body, + draft: firstPullRequest.draft, + html_url: firstPullRequest.html_url, + head: + "head" in firstPullRequest && + typeof firstPullRequest.head === "object" && + firstPullRequest.head !== null && + "sha" in firstPullRequest.head && + typeof firstPullRequest.head.sha === "string" + ? { sha: firstPullRequest.head.sha } + : undefined, + }; + + logger.info(`Found existing pull request: ${farver.yellow(`#${pullRequest.number}`)}`); + return pullRequest; + }); + + const upsertPullRequest: GitHubServiceShape["upsertPullRequest"] = Effect.fn( + "upsertPullRequest", + )(function* ({ title, body, head, base, pullNumber }) { + const isUpdate = typeof pullNumber === "number"; + const endpoint = isUpdate + ? `/repos/${githubOptions.owner}/${githubOptions.repo}/pulls/${pullNumber}` + : `/repos/${githubOptions.owner}/${githubOptions.repo}/pulls`; + + const requestBody = isUpdate ? { title, body } : { title, body, head, base, draft: true }; + + logger.verbose( + `${isUpdate ? "Updating" : "Creating"} pull request (url: ${apiBase}${endpoint})`, + ); + + const pr = yield* request("upsertPullRequest", endpoint, { + method: isUpdate ? "PATCH" : "POST", + body: JSON.stringify(requestBody), + }); + + if ( + typeof pr !== "object" || + pr === null || + !("number" in pr) || + typeof pr.number !== "number" || + !("title" in pr) || + typeof pr.title !== "string" || + !("body" in pr) || + typeof pr.body !== "string" || + !("draft" in pr) || + typeof pr.draft !== "boolean" || + !("html_url" in pr) || + typeof pr.html_url !== "string" + ) { + return yield* Effect.fail( + new GitHubError({ + operation: "upsertPullRequest", + message: "Pull request data validation failed", + }), + ); + } + + const action = isUpdate ? "Updated" : "Created"; + logger.info(`${action} pull request: ${farver.yellow(`#${pr.number}`)}`); + + return { + number: pr.number, + title: pr.title, + body: pr.body, + draft: pr.draft, + html_url: pr.html_url, + }; + }); + + const setCommitStatus: GitHubServiceShape["setCommitStatus"] = Effect.fn( + "setCommitStatus", + )(function* ({ sha, state, targetUrl, description, context }) { + const endpoint = `/repos/${githubOptions.owner}/${githubOptions.repo}/statuses/${sha}`; + + logger.verbose(`Setting commit status on ${sha} to ${state} (url: ${apiBase}${endpoint})`); + + yield* request("setCommitStatus", endpoint, { + method: "POST", + body: JSON.stringify({ + state, + target_url: targetUrl, + description: description || "", + context, + }), + }); + + logger.info( + `Commit status set to ${farver.cyan(state)} for ${farver.gray(sha.substring(0, 7))}`, + ); + }); + + const upsertReleaseByTag: GitHubServiceShape["upsertReleaseByTag"] = Effect.fn( + "upsertReleaseByTag", + )(function* ({ tagName, name, body, prerelease = false }) { + const encodedTag = encodeURIComponent(tagName); + + const existingRelease = yield* request<{ + id: number; + tag_name: string; + name?: string; + html_url?: string; + }>("upsertReleaseByTag", `/repos/${githubOptions.owner}/${githubOptions.repo}/releases/tags/${encodedTag}`).pipe( + Effect.catchTag("GitHubError", (error) => + error.status === 404 ? Effect.succeed(null) : Effect.fail(error), + ), + ); + + if (existingRelease) { + logger.verbose(`Updating release for tag ${farver.cyan(tagName)}`); + + const updated = yield* request<{ + id: number; + tag_name: string; + name?: string; + html_url?: string; + }>("upsertReleaseByTag", `/repos/${githubOptions.owner}/${githubOptions.repo}/releases/${existingRelease.id}`, { + method: "PATCH", + body: JSON.stringify({ + name, + body, + prerelease, + draft: false, + }), + }); + + logger.info(`Updated GitHub release for ${farver.cyan(tagName)}`); + return { + release: { + id: updated.id, + tagName: updated.tag_name, + name: updated.name ?? name, + htmlUrl: updated.html_url, + }, + created: false, + }; + } + + logger.verbose(`Creating release for tag ${farver.cyan(tagName)}`); + + const created = yield* request<{ + id: number; + tag_name: string; + name?: string; + html_url?: string; + }>("upsertReleaseByTag", `/repos/${githubOptions.owner}/${githubOptions.repo}/releases`, { + method: "POST", + body: JSON.stringify({ + tag_name: tagName, + name, + body, + prerelease, + draft: false, + generate_release_notes: body == null, + }), + }); + + logger.info(`Created GitHub release for ${farver.cyan(tagName)}`); + return { + release: { + id: created.id, + tagName: created.tag_name, + name: created.name ?? name, + htmlUrl: created.html_url, + }, + created: true, + }; + }); + + const resolveAuthorInfo: GitHubServiceShape["resolveAuthorInfo"] = Effect.fn( + "resolveAuthorInfo", + )(function* (info) { + if (info.login) { + return info; + } + + const searchedInfo = yield* request<{ items?: Array<{ login: string }> }>( + "resolveAuthorInfo", + `/search/users?q=${encodeURIComponent(`${info.email} type:user in:email`)}`, + ).pipe( + Effect.map((data) => { + if (data.items && data.items.length > 0) { + return { ...info, login: data.items[0]!.login }; + } + return info; + }), + Effect.catchTag("GitHubError", (error) => { + logger.warn( + `Failed to resolve author info for email ${info.email}: ${formatUnknownError(error).message}`, + ); + return Effect.succeed(info); + }), + ); + + if (searchedInfo.login) { + return searchedInfo; + } + + if (searchedInfo.commits.length > 0) { + return yield* request<{ author: { login: string } }>( + "resolveAuthorInfo", + `/repos/${githubOptions.owner}/${githubOptions.repo}/commits/${searchedInfo.commits[0]}`, + ).pipe( + Effect.map((data) => + data.author?.login ? { ...searchedInfo, login: data.author.login } : searchedInfo, + ), + Effect.catchTag("GitHubError", (error) => { + logger.warn( + `Failed to resolve author info from commits for email ${searchedInfo.email}: ${formatUnknownError(error).message}`, + ); + return Effect.succeed(searchedInfo); + }), + ); + } + + return searchedInfo; + }); + + return GitHubService.of({ + getExistingPullRequest, + upsertPullRequest, + setCommitStatus, + upsertReleaseByTag, + resolveAuthorInfo, + }); +}); + +export const GitHubServiceLive = Layer.effect(GitHubService, makeGitHubService()); + +export const getExistingPullRequest = Effect.fn("getExistingPullRequest")(function* (branch: string) { + const github = yield* GitHubService; + return yield* github.getExistingPullRequest(branch); +}); + +export const upsertPullRequest = Effect.fn("upsertPullRequest")(function* ( + options: UpsertPullRequestOptions, +) { + const github = yield* GitHubService; + return yield* github.upsertPullRequest(options); +}); + +export const setCommitStatus = Effect.fn("setCommitStatus")(function* ( + options: CommitStatusOptions & { sha: string }, +) { + const github = yield* GitHubService; + return yield* github.setCommitStatus(options); +}); + +export const upsertReleaseByTag = Effect.fn("upsertReleaseByTag")(function* ( + options: UpsertReleaseOptions, +) { + const github = yield* GitHubService; + return yield* github.upsertReleaseByTag(options); +}); + +export const resolveAuthorInfo = Effect.fn("resolveAuthorInfo")(function* (info: AuthorInfo) { + const github = yield* GitHubService; + return yield* github.resolveAuthorInfo(info); +}); + +export { toGitHubError }; + +const NON_WHITESPACE_RE = /\S/; + +function dedentString(str: string): string { + const lines = str.split("\n"); + const minIndent = lines + .filter((line) => line.trim().length > 0) + .reduce((min, line) => Math.min(min, line.search(NON_WHITESPACE_RE)), Infinity); + + return lines + .map((line) => (minIndent === Infinity ? line : line.slice(minIndent))) + .join("\n") + .trim(); +} + +export function generatePullRequestBody(updates: PackageRelease[], body?: string): string { + const eta = new Eta(); + + const bodyTemplate = body ? dedentString(body) : DEFAULT_PR_BODY_TEMPLATE; + + const allPackages = updates.map((u) => ({ + name: u.package.name, + currentVersion: u.currentVersion, + newVersion: u.newVersion, + bumpType: u.bumpType, + hasDirectChanges: u.hasDirectChanges, + changeKind: u.changeKind, + })); + + return eta.renderString(bodyTemplate, { + packages: allPackages, + releases: allPackages.filter((p) => p.changeKind !== "as-is"), + asIs: allPackages.filter((p) => p.changeKind === "as-is"), + }); +} diff --git a/src/services/npm.ts b/src/services/npm.ts new file mode 100644 index 0000000..17415d4 --- /dev/null +++ b/src/services/npm.ts @@ -0,0 +1,267 @@ +import process from "node:process"; + +import type { NormalizedReleaseScriptsOptions } from "../options"; +import { formatUnknownError } from "../shared/errors"; +import { logger, runIfNotDryEffect } from "../shared/utils"; +import { Cause, Context, Data, Effect, Exit, Layer } from "effect"; +import semver from "semver"; + +export class NPMError extends Data.TaggedError("NPMError")<{ + operation: string; + message: string; + code?: string; + stderr?: string; + status?: number; +}> {} + +interface NPMPackageMetadata { + name: string; + "dist-tags": Record; + versions: Record; + time?: Record; +} + +export interface NpmServiceShape { + readonly checkVersionExists: ( + packageName: string, + version: string, + ) => Effect.Effect; + readonly publishPackage: ( + packageName: string, + version: string, + workspaceRoot: string, + options: NormalizedReleaseScriptsOptions, + ) => Effect.Effect; +} + +function toNPMError(operation: string, error: unknown, code?: string): NPMError { + const formatted = formatUnknownError(error); + return new NPMError({ + operation, + message: formatted.message, + code: code || formatted.code, + stderr: formatted.stderr, + status: formatted.status, + }); +} + +function classifyPublishErrorCode(error: unknown): string | undefined { + const formatted = formatUnknownError(error); + const combined = [formatted.message, formatted.stderr].filter(Boolean).join("\n"); + + if ( + combined.includes("E403") || + combined.toLowerCase().includes("access token expired or revoked") + ) { + return "E403"; + } + + if ( + combined.includes("EPUBLISHCONFLICT") || + combined.includes("E409") || + combined.includes("409 Conflict") || + combined.includes("Failed to save packument") + ) { + return "EPUBLISHCONFLICT"; + } + + if (combined.includes("EOTP")) { + return "EOTP"; + } + + return undefined; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getRegistryURL(): string { + return process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org"; +} + +export class NpmService extends Context.Service()( + "@ucdjs/release-scripts/NpmService", +) {} + +// oxlint-disable-next-line require-yield +export const makeNpmService = Effect.fn("makeNpmService")(function* () { + const getPackageMetadata = Effect.fn("getPackageMetadata")(function* (packageName: string) { + const registry = getRegistryURL(); + const encodedName = packageName.startsWith("@") + ? `@${encodeURIComponent(packageName.slice(1))}` + : encodeURIComponent(packageName); + + const responseExit = yield* Effect.exit( + Effect.tryPromise(() => + fetch(`${registry}/${encodedName}`, { + headers: { + Accept: "application/json", + }, + signal: AbortSignal.timeout(30_000), + }), + ), + ); + + if (Exit.isFailure(responseExit)) { + return yield* Effect.fail(toNPMError("getPackageMetadata", responseExit.cause, "ENETWORK")); + } + + const response = responseExit.value; + + if (!response.ok) { + if (response.status === 404) { + return yield* Effect.fail( + toNPMError("getPackageMetadata", `Package not found: ${packageName}`, "E404"), + ); + } + return yield* Effect.fail( + toNPMError("getPackageMetadata", `HTTP ${response.status}: ${response.statusText}`), + ); + } + + const metadata = (yield* Effect.tryPromise(() => response.json())) as NPMPackageMetadata; + return metadata; + }); + + const checkVersionExists: NpmServiceShape["checkVersionExists"] = Effect.fn( + "checkVersionExists", + )(function* (packageName, version) { + const metadataExit = yield* Effect.exit(getPackageMetadata(packageName)); + if (Exit.isFailure(metadataExit)) { + const error = Cause.squash(metadataExit.cause); + if (formatUnknownError(error).code === "E404") { + return false; + } + return yield* Effect.fail(error); + } + + return version in metadataExit.value.versions; + }); + + const publishPackage: NpmServiceShape["publishPackage"] = Effect.fn("publishPackage")(function* ( + packageName, + version, + workspaceRoot, + options, + ) { + const args: string[] = [ + "--filter", + packageName, + "publish", + "--access", + options.npm.access, + "--no-git-checks", + ]; + + if (options.npm.otp) { + args.push("--otp", options.npm.otp); + } + + const explicitTag = process.env.NPM_CONFIG_TAG; + const prereleaseTag = (() => { + const prerelease = semver.prerelease(version); + if (!prerelease || prerelease.length === 0) { + return undefined; + } + + const identifier = prerelease[0]; + if (identifier === "alpha" || identifier === "beta") { + return identifier; + } + + return "next"; + })(); + + const publishTag = explicitTag || prereleaseTag; + if (publishTag) { + args.push("--tag", publishTag); + } + + const env: Record = { + ...process.env, + }; + + if (options.npm.provenance) { + env.NPM_CONFIG_PROVENANCE = "true"; + } + + const maxAttempts = 4; + const backoffMs = [3_000, 8_000, 15_000]; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const result = yield* runIfNotDryEffect("pnpm", args, { + nodeOptions: { + cwd: workspaceRoot, + stdio: "pipe", + env, + }, + }); + + if (result?.stdout && result.stdout.trim()) { + logger.verbose(result.stdout.trim()); + } + + if (result?.stderr && result.stderr.trim()) { + logger.verbose(result.stderr.trim()); + } + + return; + } catch (error) { + const code = classifyPublishErrorCode(error); + const isRetriableConflict = code === "EPUBLISHCONFLICT" && attempt < maxAttempts; + + if (isRetriableConflict) { + const delay = backoffMs[attempt - 1] ?? backoffMs.at(-1)!; + logger.warn( + `Publish conflict for ${packageName}@${version} (attempt ${attempt}/${maxAttempts}). Retrying in ${Math.ceil(delay / 1000)}s...`, + ); + yield* Effect.tryPromise(() => wait(delay)); + continue; + } + + return yield* Effect.fail(toNPMError("publishPackage", error, code)); + } + } + + return yield* Effect.fail( + toNPMError( + "publishPackage", + new Error(`Failed to publish ${packageName}@${version} after ${maxAttempts} attempts`), + "EPUBLISHCONFLICT", + ), + ); + }); + + return NpmService.of({ + checkVersionExists, + publishPackage, + }); +}); + +export const NpmServiceLive = Layer.effect(NpmService, makeNpmService()); + +export const checkVersionExists = Effect.fn("checkVersionExists")(function* ( + packageName: string, + version: string, +) { + const npm = yield* NpmService; + return yield* npm.checkVersionExists(packageName, version); +}); + +export const publishPackage = Effect.fn("publishPackage")(function* ( + packageName: string, + version: string, + workspaceRoot: string, + options: NormalizedReleaseScriptsOptions, +) { + const npm = yield* NpmService; + return yield* npm.publishPackage(packageName, version, workspaceRoot, options); +}); + +export interface PublishStatus { + published: string[]; + skipped: string[]; + failed: string[]; +} diff --git a/src/services/prompts.ts b/src/services/prompts.ts new file mode 100644 index 0000000..0879be1 --- /dev/null +++ b/src/services/prompts.ts @@ -0,0 +1,289 @@ +import type { WorkspacePackage } from "./workspace"; +import { + getNextPrereleaseVersion, + getNextStableVersion, + getPrereleaseIdentifier, + isValidSemver, +} from "../shared/semver"; +import type { BumpKind } from "../shared/types"; +import { Context, Effect, Layer } from "effect"; +import farver from "farver"; +import prompts from "prompts"; +import semver from "semver"; + +export interface PromptServiceShape { + readonly selectPackagePrompt: (packages: WorkspacePackage[]) => Effect.Effect; + readonly selectVersionPrompt: ( + workspaceRoot: string, + pkg: WorkspacePackage, + currentVersion: string, + suggestedVersion: string, + options?: { + defaultChoice?: "auto" | "skip" | "suggested" | "as-is"; + suggestedHint?: string; + }, + ) => Effect.Effect; + readonly confirmOverridePrompt: ( + pkg: WorkspacePackage, + overrideVersion: string, + ) => Effect.Effect<"use" | "pick" | null, unknown>; +} + +export class PromptService extends Context.Service()( + "@ucdjs/release-scripts/PromptService", +) {} + +// oxlint-disable-next-line require-yield +export const makePromptService = Effect.fn("makePromptService")(function* () { + const selectPackagePrompt: PromptServiceShape["selectPackagePrompt"] = Effect.fn( + "selectPackagePrompt", + )(function* (packages) { + const response = yield* Effect.tryPromise(() => + prompts({ + type: "multiselect", + name: "selectedPackages", + message: "Select packages to release", + choices: packages.map((pkg) => ({ + title: `${pkg.name} (${farver.bold(pkg.version)})`, + value: pkg.name, + selected: true, + })), + min: 1, + hint: "Space to select/deselect. Return to submit.", + instructions: false, + }), + ); + + if (!response.selectedPackages || response.selectedPackages.length === 0) { + return []; + } + + return response.selectedPackages; + }); + + const selectVersionPrompt: PromptServiceShape["selectVersionPrompt"] = Effect.fn( + "selectVersionPrompt", + )(function* (workspaceRoot, pkg, currentVersion, suggestedVersion, options) { + const defaultChoice = options?.defaultChoice ?? "auto"; + const suggestedSuffix = options?.suggestedHint ? farver.dim(` (${options.suggestedHint})`) : ""; + const prereleaseIdentifier = getPrereleaseIdentifier(currentVersion); + const defaultPrereleaseId = + prereleaseIdentifier === "alpha" || prereleaseIdentifier === "beta" + ? prereleaseIdentifier + : "beta"; + + const nextDefaultPrerelease = getNextPrereleaseVersion( + currentVersion, + "next", + defaultPrereleaseId, + ); + const nextBeta = getNextPrereleaseVersion(currentVersion, "next", "beta"); + const nextAlpha = getNextPrereleaseVersion(currentVersion, "next", "alpha"); + const prePatchBeta = getNextPrereleaseVersion(currentVersion, "prepatch", "beta"); + const preMinorBeta = getNextPrereleaseVersion(currentVersion, "preminor", "beta"); + const preMajorBeta = getNextPrereleaseVersion(currentVersion, "premajor", "beta"); + const prePatchAlpha = getNextPrereleaseVersion(currentVersion, "prepatch", "alpha"); + const preMinorAlpha = getNextPrereleaseVersion(currentVersion, "preminor", "alpha"); + const preMajorAlpha = getNextPrereleaseVersion(currentVersion, "premajor", "alpha"); + const isCurrentPrerelease = prereleaseIdentifier != null; + + const choices = [ + { value: "skip", title: `skip ${farver.dim("(no change)")}` }, + { value: "suggested", title: `suggested ${farver.bold(suggestedVersion)}${suggestedSuffix}` }, + { value: "as-is", title: `as-is ${farver.dim("(keep current version)")}` }, + ...(isCurrentPrerelease + ? [ + { + value: "next-prerelease", + title: `next prerelease ${farver.bold(nextDefaultPrerelease)}`, + }, + ] + : []), + { value: "patch", title: `patch ${farver.bold(getNextStableVersion(pkg.version, "patch"))}` }, + { value: "minor", title: `minor ${farver.bold(getNextStableVersion(pkg.version, "minor"))}` }, + { value: "major", title: `major ${farver.bold(getNextStableVersion(pkg.version, "major"))}` }, + { value: "prerelease", title: `prerelease ${farver.dim("(choose strategy)")}` }, + { value: "custom", title: "custom" }, + ]; + + const initialValue = + defaultChoice === "auto" + ? suggestedVersion === currentVersion + ? "skip" + : "suggested" + : defaultChoice; + const initial = Math.max(0, choices.findIndex((choice) => choice.value === initialValue)); + + const prereleaseVersionByChoice = { + "next-prerelease": nextDefaultPrerelease, + next: nextDefaultPrerelease, + "next-beta": nextBeta, + "next-alpha": nextAlpha, + "prepatch-beta": prePatchBeta, + "preminor-beta": preMinorBeta, + "premajor-beta": preMajorBeta, + "prepatch-alpha": prePatchAlpha, + "preminor-alpha": preMinorAlpha, + "premajor-alpha": preMajorAlpha, + } as const; + + const answers = yield* Effect.tryPromise(() => + prompts({ + type: "autocomplete", + name: "version", + message: `${pkg.name}: ${farver.green(pkg.version)}`, + choices, + limit: choices.length, + initial, + }), + ); + + if (!answers.version) { + return null; + } + + if (answers.version === "skip") { + return null; + } + if (answers.version === "suggested") { + return suggestedVersion; + } + if (answers.version === "custom") { + const customAnswer = yield* Effect.tryPromise(() => + prompts({ + type: "text", + name: "custom", + message: "Enter the new version number:", + initial: suggestedVersion, + validate: (custom: string) => { + if (!isValidSemver(custom)) { + return "That's not a valid version number"; + } + if (!isValidSemver(currentVersion)) { + return `Current version "${currentVersion}" is not valid semver — cannot compare`; + } + if (!semver.gt(custom, currentVersion)) { + return `Version must be greater than the current version (${currentVersion})`; + } + return true; + }, + }), + ); + + if (!customAnswer.custom) { + return null; + } + + return customAnswer.custom; + } + if (answers.version === "as-is") { + return currentVersion; + } + if (answers.version === "prerelease") { + const prereleaseChoices = [ + { value: "next", title: `next ${farver.bold(nextDefaultPrerelease)}` }, + { value: "next-beta", title: `next beta ${farver.bold(nextBeta)}` }, + { value: "next-alpha", title: `next alpha ${farver.bold(nextAlpha)}` }, + { value: "prepatch-beta", title: `pre-patch (beta) ${farver.bold(prePatchBeta)}` }, + { value: "prepatch-alpha", title: `pre-patch (alpha) ${farver.bold(prePatchAlpha)}` }, + { value: "preminor-beta", title: `pre-minor (beta) ${farver.bold(preMinorBeta)}` }, + { value: "preminor-alpha", title: `pre-minor (alpha) ${farver.bold(preMinorAlpha)}` }, + { value: "premajor-beta", title: `pre-major (beta) ${farver.bold(preMajorBeta)}` }, + { value: "premajor-alpha", title: `pre-major (alpha) ${farver.bold(preMajorAlpha)}` }, + ]; + + const prereleaseAnswer = yield* Effect.tryPromise(() => + prompts({ + type: "autocomplete", + name: "prerelease", + message: `${pkg.name}: select prerelease strategy`, + choices: prereleaseChoices, + limit: prereleaseChoices.length, + initial: 0, + }), + ); + + if (!prereleaseAnswer.prerelease) { + return null; + } + + return prereleaseVersionByChoice[ + prereleaseAnswer.prerelease as keyof typeof prereleaseVersionByChoice + ]; + } + + const prereleaseVersion = + prereleaseVersionByChoice[answers.version as keyof typeof prereleaseVersionByChoice]; + + if (prereleaseVersion) { + return prereleaseVersion; + } + + const stableBump = answers.version as Exclude; + return getNextStableVersion(pkg.version, stableBump); + }); + + const confirmOverridePrompt: PromptServiceShape["confirmOverridePrompt"] = Effect.fn( + "confirmOverridePrompt", + )(function* (pkg, overrideVersion) { + const response = yield* Effect.tryPromise(() => + prompts({ + type: "select", + name: "choice", + message: `${pkg.name}: use override version ${farver.bold(overrideVersion)}?`, + choices: [ + { title: "use override", value: "use" }, + { title: "pick another version", value: "pick" }, + ], + initial: 0, + }), + ); + + if (!response.choice) { + return null; + } + + return response.choice; + }); + + return PromptService.of({ + selectPackagePrompt, + selectVersionPrompt, + confirmOverridePrompt, + }); +}); + +export const selectPackagePrompt = Effect.fn("selectPackagePrompt")(function* (packages: WorkspacePackage[]) { + const prompts = yield* PromptService; + return yield* prompts.selectPackagePrompt(packages); +}); + +export const selectVersionPrompt = Effect.fn("selectVersionPrompt")(function* ( + workspaceRoot: string, + pkg: WorkspacePackage, + currentVersion: string, + suggestedVersion: string, + options?: { + defaultChoice?: "auto" | "skip" | "suggested" | "as-is"; + suggestedHint?: string; + }, +) { + const prompts = yield* PromptService; + return yield* prompts.selectVersionPrompt( + workspaceRoot, + pkg, + currentVersion, + suggestedVersion, + options, + ); +}); + +export const confirmOverridePrompt = Effect.fn("confirmOverridePrompt")(function* ( + pkg: WorkspacePackage, + overrideVersion: string, +) { + const prompts = yield* PromptService; + return yield* prompts.confirmOverridePrompt(pkg, overrideVersion); +}); + +export const PromptServiceLive = Layer.effect(PromptService, makePromptService()); diff --git a/src/services/workspace.ts b/src/services/workspace.ts new file mode 100644 index 0000000..51a0334 --- /dev/null +++ b/src/services/workspace.ts @@ -0,0 +1,215 @@ +import { join } from "node:path"; + +import { PromptService } from "./prompts"; +import type { FindWorkspacePackagesOptions, PackageJson } from "../shared/types"; +import { getIsCI, logger, runEffect } from "../shared/utils"; +import { Context, Data, Effect, FileSystem, Layer } from "effect"; +import farver from "farver"; + +import type { NormalizedReleaseScriptsOptions } from "../options"; + +interface RawProject { + name: string; + path: string; + version: string; + private: boolean; + dependencies?: Record; + devDependencies?: Record; +} + +export interface WorkspacePackage { + name: string; + version: string; + path: string; + packageJson: PackageJson; + workspaceDependencies: string[]; + workspaceDevDependencies: string[]; +} + +export class WorkspaceError extends Data.TaggedError("WorkspaceError")<{ + operation: string; + message: string; +}> {} + +export interface WorkspaceServiceShape { + readonly discoverWorkspacePackages: ( + workspaceRoot: string, + options: NormalizedReleaseScriptsOptions, + ) => Effect.Effect; +} + +function toWorkspaceError(operation: string, error: unknown): WorkspaceError { + const message = error instanceof Error ? error.message : String(error); + return new WorkspaceError({ + operation, + message, + }); +} + +export class WorkspaceService extends Context.Service()( + "@ucdjs/release-scripts/WorkspaceService", +) {} + +function shouldIncludePackage(pkg: PackageJson, options?: FindWorkspacePackagesOptions): boolean { + if (!options) { + return true; + } + + if (options.excludePrivate && pkg.private) { + return false; + } + + if (options.include && options.include.length > 0) { + if (!options.include.includes(pkg.name)) { + return false; + } + } + + if (options.exclude?.includes(pkg.name)) { + return false; + } + + return true; +} + +// oxlint-disable-next-line require-yield +export const makeWorkspaceService = Effect.fn("makeWorkspaceService")(function* () { + const fs = yield* FileSystem.FileSystem; + const readWorkspacePackage = Effect.fn("readWorkspacePackage")(function* ( + rawProject: RawProject, + allPackageNames: Set, + options?: FindWorkspacePackagesOptions, + ) { + const packageJsonPath = join(rawProject.path, "package.json"); + const content = yield* fs.readFileString(packageJsonPath); + const packageJson: PackageJson = JSON.parse(content); + + if (!shouldIncludePackage(packageJson, options)) { + return null; + } + + return { + name: rawProject.name, + version: rawProject.version, + path: rawProject.path, + packageJson, + workspaceDependencies: Object.keys(rawProject.dependencies || []).filter((dep) => { + return allPackageNames.has(dep); + }), + workspaceDevDependencies: Object.keys(rawProject.devDependencies || []).filter((dep) => { + return allPackageNames.has(dep); + }), + } satisfies WorkspacePackage; + }); + + const findWorkspacePackages = Effect.fn("findWorkspacePackages")(function* ( + workspaceRoot: string, + options?: FindWorkspacePackagesOptions, + ) { + try { + const result = yield* runEffect("pnpm", ["-r", "ls", "--json"], { + nodeOptions: { + cwd: workspaceRoot, + stdio: "pipe", + }, + }); + + const rawProjects: RawProject[] = JSON.parse(result.stdout); + + const allPackageNames = new Set(rawProjects.map((p) => p.name)); + const excludedPackages = new Set(); + + const packages = yield* Effect.all( + rawProjects.map((rawProject) => + readWorkspacePackage(rawProject, allPackageNames, options).pipe( + Effect.tap((pkg) => + pkg === null ? Effect.sync(() => excludedPackages.add(rawProject.name)) : Effect.void, + ), + ), + ), + ); + + if (excludedPackages.size > 0) { + logger.info(`Excluded packages: ${farver.green([...excludedPackages].join(", "))}`); + } + + return packages.filter((pkg): pkg is WorkspacePackage => pkg !== null); + } catch (error) { + logger.error("Error discovering workspace packages:", error); + throw error; + } + }); + + const discoverWorkspacePackages: WorkspaceServiceShape["discoverWorkspacePackages"] = Effect.fn( + "discoverWorkspacePackages", + )(function* (workspaceRoot, options) { + const prompts = yield* PromptService; + let workspaceOptions: FindWorkspacePackagesOptions; + let explicitPackages: string[] | undefined; + + if (options.packages == null || options.packages === true) { + workspaceOptions = { excludePrivate: false }; + } else if (Array.isArray(options.packages)) { + workspaceOptions = { excludePrivate: false, include: options.packages }; + explicitPackages = options.packages; + } else { + workspaceOptions = options.packages; + if (options.packages.include) { + explicitPackages = options.packages.include; + } + } + + let workspacePackages: WorkspacePackage[]; + try { + workspacePackages = yield* findWorkspacePackages(workspaceRoot, workspaceOptions); + } catch (error) { + return yield* Effect.fail(toWorkspaceError("discoverWorkspacePackages", error)); + } + + if (explicitPackages) { + const foundNames = new Set(workspacePackages.map((p) => p.name)); + const missing = explicitPackages.filter((p) => !foundNames.has(p)); + + if (missing.length > 0) { + return yield* Effect.fail( + toWorkspaceError( + "discoverWorkspacePackages", + `Package${missing.length > 1 ? "s" : ""} not found in workspace: ${missing.join(", ")}. ` + + `Check your package names or run 'pnpm ls' to see available packages`, + ), + ); + } + } + + const isPackagePromptEnabled = options.prompts?.packages !== false; + logger.verbose("Package prompt gating", { + isCI: getIsCI(), + isPackagePromptEnabled, + hasExplicitPackages: Boolean(explicitPackages), + include: workspaceOptions.include ?? [], + exclude: workspaceOptions.exclude ?? [], + excludePrivate: workspaceOptions.excludePrivate ?? false, + }); + + if (!getIsCI() && isPackagePromptEnabled && !explicitPackages) { + const selectedNames = yield* prompts.selectPackagePrompt(workspacePackages); + workspacePackages = workspacePackages.filter((pkg) => selectedNames.includes(pkg.name)); + } + + return workspacePackages; + }); + + return WorkspaceService.of({ + discoverWorkspacePackages, + }); +}); + +export const discoverWorkspacePackages = Effect.fn("discoverWorkspacePackages")(function* ( + workspaceRoot: string, + options: NormalizedReleaseScriptsOptions, +) { + const workspace = yield* WorkspaceService; + return yield* workspace.discoverWorkspacePackages(workspaceRoot, options); +}); + +export const WorkspaceServiceLive = Layer.effect(WorkspaceService, makeWorkspaceService()); diff --git a/src/operations/changelog-format.ts b/src/shared/changelog-format.ts similarity index 100% rename from src/operations/changelog-format.ts rename to src/shared/changelog-format.ts diff --git a/src/operations/semver.ts b/src/shared/semver.ts similarity index 100% rename from src/operations/semver.ts rename to src/shared/semver.ts diff --git a/src/shared/types.ts b/src/shared/types.ts index 5321f94..8f46570 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,4 +1,4 @@ -import type { WorkspacePackage } from "#core/workspace"; +import type { WorkspacePackage } from "../services/workspace"; export type BumpKind = "none" | "patch" | "minor" | "major"; diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 462bbfc..c039f6c 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -2,12 +2,110 @@ import process from "node:process"; import readline from "node:readline"; import { parseArgs } from "node:util"; +import { Effect, Stream } from "effect"; +import { ChildProcess } from "effect/unstable/process"; import farver from "farver"; -import type { Options as TinyExecOptions, Result as TinyExecResult } from "tinyexec"; -import { exec } from "tinyexec"; export const ucdjsReleaseOverridesPath = ".github/ucdjs-release.overrides.json"; +export interface CommandRunOptions { + throwOnError?: boolean; + nodeOptions?: { + cwd?: string; + env?: NodeJS.ProcessEnv; + stdio?: "inherit" | "pipe"; + }; +} + +export interface CommandRunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export class CommandError extends Error { + readonly stdout: string; + readonly stderr: string; + readonly exitCode?: number; + readonly shortMessage: string; + + constructor(params: { + message: string; + stdout?: string; + stderr?: string; + exitCode?: number; + cause?: unknown; + }) { + super(params.message); + this.name = "CommandError"; + this.stdout = params.stdout ?? ""; + this.stderr = params.stderr ?? ""; + this.exitCode = params.exitCode; + this.shortMessage = [this.stderr, this.stdout, params.message].find((value) => value.trim()) ?? params.message; + this.cause = params.cause; + } +} + +export function runCommandEffect( + bin: string, + args: string[], + opts: CommandRunOptions = {}, +) { + const stdio = opts.nodeOptions?.stdio ?? "inherit"; + const shouldPipeOutput = stdio === "pipe"; + const command = ChildProcess.make(bin, args, { + cwd: opts.nodeOptions?.cwd, + env: opts.nodeOptions?.env, + stdin: "inherit", + stdout: shouldPipeOutput ? "pipe" : "inherit", + stderr: shouldPipeOutput ? "pipe" : "inherit", + }); + + const makeFailure = (message: string, cause?: unknown, result?: Partial) => + new CommandError({ + message, + stdout: result?.stdout, + stderr: result?.stderr, + exitCode: result?.exitCode, + cause, + }); + + const executeCommand = Effect.fn("executeCommand")(function* () { + const handle = yield* command; + const [stdout, stderr, exitCode] = yield* Effect.all([ + shouldPipeOutput + ? Stream.mkString(Stream.decodeText(handle.stdout)) + : Effect.succeed(""), + shouldPipeOutput + ? Stream.mkString(Stream.decodeText(handle.stderr)) + : Effect.succeed(""), + handle.exitCode, + ]); + + const result: CommandRunResult = { + stdout, + stderr, + exitCode: Number(exitCode), + }; + + if (result.exitCode !== 0 && opts.throwOnError !== false) { + return yield* Effect.fail( + makeFailure(`Process exited with non-zero status ${result.exitCode}`, undefined, result), + ); + } + + return result; + }); + + return Effect.scoped(executeCommand()).pipe( + Effect.mapError((error) => + error instanceof CommandError + ? error + : makeFailure(`Failed to run command: ${bin} ${args.join(" ")}`, error), + ), + ); +} + function parseCLIFlags(): { dry: boolean; verbose: boolean; force: boolean } { const { values } = parseArgs({ args: process.argv.slice(2), @@ -112,29 +210,34 @@ export const logger = { }, }; -export async function run( +export const runEffect = Effect.fn("runEffect")( + (bin: string, args: string[], opts: CommandRunOptions = {}) => + runCommandEffect(bin, args, { + throwOnError: true, + ...opts, + nodeOptions: { + stdio: "inherit", + ...opts.nodeOptions, + }, + }), +); + +const dryRunEffect = Effect.fn("dryRunEffect")( + (bin: string, args: string[], opts?: CommandRunOptions) => + Effect.sync(() => { + logger.verbose(farver.blue(`[dryrun] ${bin} ${args.join(" ")}`), opts || ""); + }), +); + +export const runIfNotDryEffect = Effect.fn("runIfNotDryEffect")(function* ( bin: string, args: string[], - opts: Partial = {}, -): Promise { - return exec(bin, args, { - throwOnError: true, - ...opts, - nodeOptions: { - stdio: "inherit", - ...opts.nodeOptions, - }, - }); -} - -async function dryRun(bin: string, args: string[], opts?: Partial): Promise { - return logger.verbose(farver.blue(`[dryrun] ${bin} ${args.join(" ")}`), opts || ""); -} - -export async function runIfNotDry( - bin: string, - args: string[], - opts?: Partial, -): Promise { - return getIsDryRun() ? dryRun(bin, args, opts) : run(bin, args, opts); -} + opts?: CommandRunOptions, +) { + if (getIsDryRun()) { + yield* dryRunEffect(bin, args, opts); + return; + } + + return yield* runEffect(bin, args, opts); +}); diff --git a/src/operations/version.ts b/src/shared/version.ts similarity index 91% rename from src/operations/version.ts rename to src/shared/version.ts index 3655990..136d850 100644 --- a/src/operations/version.ts +++ b/src/shared/version.ts @@ -1,5 +1,5 @@ -import type { WorkspacePackage } from "#core/workspace"; -import type { BumpKind, PackageRelease } from "#shared/types"; +import type { WorkspacePackage } from "../services/workspace"; +import type { BumpKind, PackageRelease } from "./types"; import type { GitCommit } from "commit-parser"; import { getNextVersion } from "./semver"; diff --git a/src/types.ts b/src/types.ts index 70b0d62..ef42e62 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,33 +1,5 @@ import type { PackageRelease } from "#shared/types"; -export type Result = Ok | Err; - -export interface Ok { - ok: true; - value: T; -} - -export interface Err { - ok: false; - error: E; -} - -export function ok(value: T): Ok { - return { ok: true, value }; -} - -export function err(error: E): Err { - return { ok: false, error }; -} - -export function isOk(result: Result): result is Ok { - return result.ok; -} - -export function isErr(result: Result): result is Err { - return !result.ok; -} - export interface ReleaseResult { /** * Packages that will be updated diff --git a/src/versioning/commits.ts b/src/versioning/commits.ts index 2900f3d..c4c1b6f 100644 --- a/src/versioning/commits.ts +++ b/src/versioning/commits.ts @@ -1,8 +1,9 @@ -import { getGroupedFilesByCommitSha, getMostRecentPackageTag } from "#core/git"; -import type { WorkspacePackage } from "#core/workspace"; -import { logger } from "#shared/utils"; +import { getGroupedFilesByCommitSha, getMostRecentPackageTag } from "../services/git"; +import type { WorkspacePackage } from "../services/workspace"; +import { logger } from "../shared/utils"; import type { GitCommit } from "commit-parser"; import { getCommits } from "commit-parser"; +import { Effect } from "effect"; import farver from "farver"; /** @@ -13,65 +14,75 @@ import farver from "farver"; * @param {WorkspacePackage[]} packages - Array of workspace packages to analyze * @returns {Promise>} A map of package names to their commits since their last release */ -export async function getWorkspacePackageGroupedCommits( - workspaceRoot: string, - packages: WorkspacePackage[], -): Promise> { - const changedPackages = new Map(); - - const promises = packages.map(async (pkg) => { - // Get the latest tag that corresponds to the workspace package - // This will ensure that we only get commits, since the last release of this package. - const lastTagResult = await getMostRecentPackageTag(workspaceRoot, pkg.name); - const lastTag = lastTagResult.ok ? lastTagResult.value : undefined; - - // Get all commits since the last tag, that affect this package - const allCommits = await getCommits({ - from: lastTag, - to: "HEAD", - cwd: workspaceRoot, - folder: pkg.path, +export const getWorkspacePackageGroupedCommits = Effect.fn( + "getWorkspacePackageGroupedCommits", +)(function* (workspaceRoot: string, packages: WorkspacePackage[]) { + const changedPackages = new Map(); + + const loadPackageCommits = Effect.fn("loadPackageCommits")(function* ( + pkg: WorkspacePackage, + fromTag?: string, + ) { + const allCommits = yield* Effect.tryPromise(() => + getCommits({ + from: fromTag, + to: "HEAD", + cwd: workspaceRoot, + folder: pkg.path, + }), + ); + + logger.verbose( + `Found ${farver.cyan(allCommits.length)} commits for package ${farver.bold(pkg.name)} since ${farver.cyan(fromTag ?? "start")}`, + ); + + return allCommits; }); - logger.verbose( - `Found ${farver.cyan(allCommits.length)} commits for package ${farver.bold( - pkg.name, - )} since tag ${farver.cyan(lastTag ?? "N/A")}`, + const results = yield* Effect.all( + packages.map((pkg) => + Effect.fn("getPackageCommitGroup")(function* () { + const lastTagExit = yield* Effect.exit(getMostRecentPackageTag(workspaceRoot, pkg.name)); + const lastTag = lastTagExit._tag === "Success" ? lastTagExit.value : undefined; + const allCommits = yield* loadPackageCommits(pkg, lastTag); + + return { + pkgName: pkg.name, + commits: allCommits, + }; + })(), + ), ); - return { - pkgName: pkg.name, - commits: allCommits, - }; - }); - - const results = await Promise.all(promises); + for (const { pkgName, commits } of results) { + changedPackages.set(pkgName, commits); + } - for (const { pkgName, commits } of results) { - changedPackages.set(pkgName, commits); - } + return changedPackages; +}); - return changedPackages; -} -export async function getPackageCommitsSinceTag( +export const getPackageCommitsSinceTag = Effect.fn("getPackageCommitsSinceTag")(function* ( workspaceRoot: string, pkg: WorkspacePackage, fromTag?: string, -): Promise { - const allCommits = await getCommits({ - from: fromTag, - to: "HEAD", - cwd: workspaceRoot, - folder: pkg.path, - }); +) { + const allCommits = yield* Effect.tryPromise(() => + getCommits({ + from: fromTag, + to: "HEAD", + cwd: workspaceRoot, + folder: pkg.path, + }), + ); - logger.verbose( - `Found ${farver.cyan(allCommits.length)} commits for package ${farver.bold(pkg.name)} since ${farver.cyan(fromTag ?? "start")}`, - ); + logger.verbose( + `Found ${farver.cyan(allCommits.length)} commits for package ${farver.bold(pkg.name)} since ${farver.cyan(fromTag ?? "start")}`, + ); + + return allCommits; +}); - return allCommits; -} /** * Check if a file path touches any package folder. @@ -208,12 +219,12 @@ export function filterGlobalCommits( * @param mode - Filter mode: false (disabled), "all" (all global commits), or "dependencies" (only dependency-related) * @returns Map of package name to their global commits */ -export async function getGlobalCommitsPerPackage( +export const getGlobalCommitsPerPackage = Effect.fn("getGlobalCommitsPerPackage")(function* ( workspaceRoot: string, packageCommits: Map, allPackages: WorkspacePackage[], mode?: false | "dependencies" | "all", -): Promise> { +) { const result = new Map(); if (!mode) { @@ -234,19 +245,21 @@ export async function getGlobalCommitsPerPackage( `${farver.cyan(commitRange.oldest)}..${farver.cyan(commitRange.newest)}`, ); - const commitFilesMap = await getGroupedFilesByCommitSha( + const commitFilesMap = yield* getGroupedFilesByCommitSha( workspaceRoot, commitRange.oldest, commitRange.newest, - ); - if (!commitFilesMap.ok) { - logger.warn("Failed to get commit file list, returning empty global commits"); + ).pipe(Effect.catch(() => { + logger.warn("Failed to get commit file list, returning empty global commits"); + return Effect.succeed(null); + })); + if (commitFilesMap === null) { return result; } logger.verbose( "Got file lists for commits", - `${farver.cyan(commitFilesMap.value.size)} commits in ONE git call`, + `${farver.cyan(commitFilesMap.size)} commits in ONE git call`, ); const packagePaths = new Set(allPackages.map((p) => p.path)); @@ -259,7 +272,7 @@ export async function getGlobalCommitsPerPackage( const filtered = filterGlobalCommits( commits, - commitFilesMap.value, + commitFilesMap, packagePaths, workspaceRoot, mode, @@ -273,4 +286,4 @@ export async function getGlobalCommitsPerPackage( } return result; -} +}); diff --git a/src/versioning/package.ts b/src/versioning/package.ts index 11f451e..5f884e4 100644 --- a/src/versioning/package.ts +++ b/src/versioning/package.ts @@ -1,7 +1,7 @@ -import type { WorkspacePackage } from "#core/workspace"; -import { createVersionUpdate } from "#operations/version"; -import type { PackageRelease, PackageUpdateOrder } from "#shared/types"; -import { logger } from "#shared/utils"; +import type { WorkspacePackage } from "../services/workspace"; +import { createVersionUpdate } from "../shared/version"; +import type { PackageRelease, PackageUpdateOrder } from "../shared/types"; +import { logger } from "../shared/utils"; interface PackageDependencyGraph { packages: Map; diff --git a/src/versioning/version.ts b/src/versioning/version.ts index 06164c2..a3eb3bd 100644 --- a/src/versioning/version.ts +++ b/src/versioning/version.ts @@ -1,13 +1,13 @@ -import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { confirmOverridePrompt, selectVersionPrompt } from "#core/prompts"; -import type { WorkspacePackage } from "#core/workspace"; -import { calculateBumpType, getNextVersion } from "#operations/semver"; -import { determineHighestBump } from "#operations/version"; -import type { BumpKind, PackageJson, PackageRelease } from "#shared/types"; -import { getIsCI, logger } from "#shared/utils"; -import { buildPackageDependencyGraph, createDependentUpdates } from "#versioning/package"; +import { PromptService } from "../services/prompts"; +import { Effect, FileSystem } from "effect"; +import type { WorkspacePackage } from "../services/workspace"; +import { calculateBumpType, getNextVersion } from "../shared/semver"; +import { determineHighestBump } from "../shared/version"; +import type { BumpKind, PackageJson, PackageRelease } from "../shared/types"; +import { getIsCI, logger } from "../shared/utils"; +import { buildPackageDependencyGraph, createDependentUpdates } from "./package"; import type { GitCommit } from "commit-parser"; import farver from "farver"; @@ -86,12 +86,12 @@ function formatCommitsForDisplay(commits: GitCommit[]): string { return formattedCommits; } -interface VersionOverride { +export interface VersionOverride { type: BumpKind; version: string; } -type VersionOverrides = Record; +export type VersionOverrides = Record; /** * Pure function that resolves version bump from commits and overrides. @@ -147,18 +147,15 @@ interface CalculateVersionUpdatesOptions { overrides?: VersionOverrides; } -async function calculateVersionUpdates({ +const calculateVersionUpdatesEffect = Effect.fn("calculateVersionUpdatesEffect")(function* ({ workspacePackages, packageCommits, workspaceRoot, showPrompt, globalCommitsPerPackage, overrides: initialOverrides = {}, -}: CalculateVersionUpdatesOptions): Promise<{ - updates: PackageRelease[]; - overrides: VersionOverrides; - excludedPackages: Set; -}> { +}: CalculateVersionUpdatesOptions) { + const prompts = yield* PromptService; const versionUpdates: PackageRelease[] = []; const processedPackages = new Set(); const newOverrides: VersionOverrides = { ...initialOverrides }; @@ -205,7 +202,7 @@ async function calculateVersionUpdates({ logger.emptyLine(); if (override) { - const overrideChoice = await confirmOverridePrompt(pkg, override.version); + const overrideChoice = yield* prompts.confirmOverridePrompt(pkg, override.version); if (overrideChoice === null) continue; if (overrideChoice === "use") { newOverrides[pkgName] = { type: override.type, version: override.version }; @@ -230,18 +227,12 @@ async function calculateVersionUpdates({ // rather than falling back to the auto-detected version. } - const selectedVersion = await selectVersionPrompt( - workspaceRoot, - pkg, - pkg.version, - newVersion, - { + const selectedVersion = yield* prompts.selectVersionPrompt(workspaceRoot, pkg, pkg.version, newVersion, { defaultChoice: "suggested", suggestedHint: override ? `override: ${override.version}, auto: ${determinedBump} → ${autoVersion}` : `auto: ${determinedBump} → ${autoVersion}`, - }, - ); + }); if (selectedVersion === null) continue; @@ -295,10 +286,10 @@ async function calculateVersionUpdates({ logger.item("No direct commits found"); logger.item(farver.dim(`Auto bump: none → ${pkg.version}`)); - const newVersion = await selectVersionPrompt(workspaceRoot, pkg, pkg.version, pkg.version, { - defaultChoice: "auto", - suggestedHint: `auto: none → ${pkg.version}`, - }); + const newVersion = yield* prompts.selectVersionPrompt(workspaceRoot, pkg, pkg.version, pkg.version, { + defaultChoice: "auto", + suggestedHint: `auto: none → ${pkg.version}`, + }); if (newVersion === null) break; if (newVersion === pkg.version) { @@ -324,30 +315,27 @@ async function calculateVersionUpdates({ } return { updates: versionUpdates, overrides: newOverrides, excludedPackages }; -} +}); /** * Calculate version updates and prepare dependent updates * Returns both the updates and a function to apply them */ -export async function calculateAndPrepareVersionUpdates({ +export const calculateAndPrepareVersionUpdates = Effect.fn( + "calculateAndPrepareVersionUpdates", +)(function* ({ workspacePackages, packageCommits, workspaceRoot, showPrompt, globalCommitsPerPackage, overrides, -}: CalculateVersionUpdatesOptions): Promise<{ - allUpdates: PackageRelease[]; - applyUpdates: () => Promise; - overrides: VersionOverrides; -}> { - // Calculate direct version updates +}: CalculateVersionUpdatesOptions) { const { updates: directUpdates, overrides: newOverrides, excludedPackages: promptExcludedPackages, - } = await calculateVersionUpdates({ + } = yield* calculateVersionUpdatesEffect({ workspacePackages, packageCommits, workspaceRoot, @@ -356,7 +344,6 @@ export async function calculateAndPrepareVersionUpdates({ overrides, }); - // Build dependency graph and calculate dependent updates const graph = buildPackageDependencyGraph(workspacePackages); const overrideExcludedPackages = new Set( Object.entries(newOverrides) @@ -375,32 +362,34 @@ export async function calculateAndPrepareVersionUpdates({ excludedPackages, ); - // Create apply function that updates all package.json files - const applyUpdates = async () => { - await Promise.all( - allUpdates.map(async (update: PackageRelease) => { + const applyUpdates = Effect.fn("applyUpdates")(function* () { + const fs = yield* FileSystem.FileSystem; + yield* Effect.all( + allUpdates.map((update: PackageRelease) => { const depUpdates = getDependencyUpdates(update.package, allUpdates); - await updatePackageJson(update.package, update.newVersion, depUpdates); + return updatePackageJson(fs, update.package, update.newVersion, depUpdates); }), ); - }; + }); return { allUpdates, applyUpdates, overrides: newOverrides, }; -} +}); -async function updatePackageJson( + +const updatePackageJson = Effect.fn("updatePackageJson")( + function* ( + fs: FileSystem.FileSystem, pkg: WorkspacePackage, newVersion: string, dependencyUpdates: Map, -): Promise { +) { const packageJsonPath = join(pkg.path, "package.json"); - // Read current package.json - const content = await readFile(packageJsonPath, "utf-8"); + const content = yield* fs.readFileString(packageJsonPath); const packageJson: PackageJson = JSON.parse(content); // Update version @@ -434,11 +423,10 @@ async function updatePackageJson( updateDependency(packageJson.peerDependencies, depName, depVersion, true); } - // Write back with formatting const updated = `${JSON.stringify(packageJson, null, 2)}\n`; - await writeFile(packageJsonPath, updated, "utf-8"); + yield* fs.writeFileString(packageJsonPath, updated); logger.verbose(` - Successfully wrote updated package.json`); -} +}); /** * Get all dependency updates needed for a package diff --git a/src/workflows/prepare.ts b/src/workflows/prepare.ts deleted file mode 100644 index c96e141..0000000 --- a/src/workflows/prepare.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { updateChangelog } from "#core/changelog"; -import { checkoutBranch, getMostRecentPackageStableTag, isWorkingDirectoryClean } from "#core/git"; -import { discoverWorkspacePackages } from "#core/workspace"; -import { prepareReleaseBranch, syncReleaseChanges } from "#operations/branch"; -import { calculateUpdates, ensureHasPackages } from "#operations/calculate"; -import { syncPullRequest } from "#operations/pr"; -import { exitWithError, formatUnknownError } from "#shared/errors"; -import { logger, ucdjsReleaseOverridesPath } from "#shared/utils"; -import type { ReleaseResult } from "#types"; -import { - getGlobalCommitsPerPackage, - getPackageCommitsSinceTag, - getWorkspacePackageGroupedCommits, -} from "#versioning/commits"; -import farver from "farver"; -import semver from "semver"; - -import type { NormalizedReleaseScriptsOptions } from "../options"; - -export async function prepareWorkflow( - options: NormalizedReleaseScriptsOptions, -): Promise { - if (options.safeguards) { - const clean = await isWorkingDirectoryClean(options.workspaceRoot); - if (!clean.ok) { - exitWithError( - "Failed to verify working directory state.", - "Ensure this is a valid git repository and try again.", - clean.error, - ); - } - - if (!clean.value) { - exitWithError( - "Working directory is not clean. Please commit or stash your changes before proceeding.", - ); - } - } - - const discovered = await discoverWorkspacePackages(options.workspaceRoot, options); - if (!discovered.ok) { - exitWithError("Failed to discover packages.", undefined, discovered.error); - } - - const ensured = ensureHasPackages(discovered.value); - if (!ensured.ok) { - logger.warn(ensured.error.message); - return null; - } - - const workspacePackages = ensured.value; - - logger.section("📦 Workspace Packages"); - logger.item(`Found ${workspacePackages.length} packages`); - - for (const pkg of workspacePackages) { - logger.item(`${farver.cyan(pkg.name)} (${farver.bold(pkg.version)})`); - logger.item(` ${farver.gray("→")} ${farver.gray(pkg.path)}`); - } - - logger.emptyLine(); - - const prepareBranchResult = await prepareReleaseBranch({ - workspaceRoot: options.workspaceRoot, - releaseBranch: options.branch.release, - defaultBranch: options.branch.default, - }); - - if (!prepareBranchResult.ok) { - exitWithError("Failed to prepare release branch.", undefined, prepareBranchResult.error); - } - - const overridesPath = join(options.workspaceRoot, ucdjsReleaseOverridesPath); - let existingOverrides: Record< - string, - { version: string; type: import("#shared/types").BumpKind } - > = {}; - try { - const overridesContent = await readFile(overridesPath, "utf-8"); - existingOverrides = JSON.parse(overridesContent); - logger.info("Found existing version overrides file."); - } catch (error) { - logger.info("No existing version overrides file found. Continuing..."); - logger.verbose(`Reading overrides file failed: ${formatUnknownError(error).message}`); - } - - if (Object.keys(existingOverrides).length > 0) { - const packageNames = new Set(workspacePackages.map((p) => p.name)); - const staleEntries: string[] = []; - - for (const [pkgName, override] of Object.entries(existingOverrides)) { - if (!packageNames.has(pkgName)) { - staleEntries.push(pkgName); - delete existingOverrides[pkgName]; - continue; - } - - const pkg = workspacePackages.find((p) => p.name === pkgName); - if (pkg && semver.valid(override.version) && semver.gte(pkg.version, override.version)) { - staleEntries.push(pkgName); - delete existingOverrides[pkgName]; - } - } - - if (staleEntries.length > 0) { - logger.info(`Removed ${staleEntries.length} stale override(s): ${staleEntries.join(", ")}`); - } - } - - const updatesResult = await calculateUpdates({ - workspacePackages, - workspaceRoot: options.workspaceRoot, - showPrompt: options.prompts?.versions !== false, - globalCommitMode: options.globalCommitMode === "none" ? false : options.globalCommitMode, - overrides: existingOverrides, - }); - - if (!updatesResult.ok) { - exitWithError("Failed to calculate package updates.", undefined, updatesResult.error); - } - - const { allUpdates, applyUpdates, overrides: newOverrides } = updatesResult.value; - const hasOverrideChanges = JSON.stringify(existingOverrides) !== JSON.stringify(newOverrides); - - if (Object.keys(newOverrides).length > 0 && hasOverrideChanges) { - logger.step("Writing version overrides file..."); - try { - await mkdir(join(options.workspaceRoot, ".github"), { recursive: true }); - await writeFile(overridesPath, JSON.stringify(newOverrides, null, 2), "utf-8"); - logger.success("Successfully wrote version overrides file."); - } catch (e) { - logger.error("Failed to write version overrides file:", e); - } - } else if (Object.keys(newOverrides).length > 0) { - logger.step("Version overrides unchanged. Skipping write."); - } - - if (Object.keys(newOverrides).length === 0 && hasOverrideChanges) { - logger.info("Removing obsolete version overrides file..."); - try { - await rm(overridesPath); - logger.success("Successfully removed obsolete version overrides file."); - } catch (e) { - const formatted = formatUnknownError(e); - if (formatted.code !== "ENOENT") { - logger.error("Failed to remove obsolete version overrides file:", e); - } - } - } - - if (allUpdates.filter((u) => u.hasDirectChanges).length === 0) { - logger.warn("No packages have changes requiring a release"); - } - - logger.section("🔄 Version Updates"); - logger.item(`Updating ${allUpdates.length} packages (including dependents)`); - - for (const update of allUpdates) { - const isAsIs = update.changeKind === "as-is"; - const suffix = isAsIs ? farver.dim(" (as-is)") : ""; - logger.item(`${update.package.name}: ${update.currentVersion} → ${update.newVersion}${suffix}`); - } - - await applyUpdates(); - - if (options.changelog?.enabled) { - logger.step("Updating changelogs"); - - const groupedPackageCommits = await getWorkspacePackageGroupedCommits( - options.workspaceRoot, - workspacePackages, - ); - const globalCommitsPerPackage = await getGlobalCommitsPerPackage( - options.workspaceRoot, - groupedPackageCommits, - workspacePackages, - options.globalCommitMode === "none" ? false : options.globalCommitMode, - ); - - const changelogUpdates = allUpdates.filter( - (update) => update.currentVersion !== update.newVersion, - ); - - const changelogPromises = changelogUpdates - .map((update) => { - return (async () => { - let pkgCommits = groupedPackageCommits.get(update.package.name) || []; - let globalCommits = globalCommitsPerPackage.get(update.package.name) || []; - let previousVersionForChangelog: string | undefined = - update.currentVersion !== "0.0.0" ? update.currentVersion : undefined; - - const shouldCombinePrereleaseIntoStable = - options.changelog.combinePrereleaseIntoFirstStable && - semver.prerelease(update.currentVersion) != null && - semver.prerelease(update.newVersion) == null; - - if (shouldCombinePrereleaseIntoStable) { - const stableTagResult = await getMostRecentPackageStableTag( - options.workspaceRoot, - update.package.name, - ); - if (!stableTagResult.ok) { - logger.warn( - `Failed to resolve stable tag for ${update.package.name}: ${stableTagResult.error.message}`, - ); - } else { - const stableTag = stableTagResult.value; - if (stableTag) { - logger.verbose( - `Combining prerelease changelog entries into stable release for ${update.package.name} using base tag ${stableTag}`, - ); - - const stableBaseCommits = await getPackageCommitsSinceTag( - options.workspaceRoot, - update.package, - stableTag, - ); - - pkgCommits = stableBaseCommits; - - const stableBaseGlobals = await getGlobalCommitsPerPackage( - options.workspaceRoot, - new Map([[update.package.name, stableBaseCommits]]), - workspacePackages, - options.globalCommitMode === "none" ? false : options.globalCommitMode, - ); - - globalCommits = stableBaseGlobals.get(update.package.name) || []; - - const atIndex = stableTag.lastIndexOf("@"); - if (atIndex !== -1) { - previousVersionForChangelog = stableTag.slice(atIndex + 1); - } - } - } - } - - const allCommits = [...pkgCommits, ...globalCommits]; - - if (allCommits.length === 0) { - logger.verbose( - `No commits for ${update.package.name}, writing changelog entry with no-significant-commits note`, - ); - } - - logger.verbose(`Updating changelog for ${farver.cyan(update.package.name)}`); - - await updateChangelog({ - normalizedOptions: { - ...options, - workspaceRoot: options.workspaceRoot, - }, - githubClient: options.githubClient, - workspacePackage: update.package, - version: update.newVersion, - previousVersion: previousVersionForChangelog, - commits: allCommits, - date: new Date().toISOString().split("T")[0]!, - }); - })(); - }) - .filter((p): p is Promise => p != null); - - const updates = await Promise.all(changelogPromises); - logger.success(`Updated ${updates.length} changelog(s)`); - } - - const hasChangesToPush = await syncReleaseChanges({ - workspaceRoot: options.workspaceRoot, - releaseBranch: options.branch.release, - commitMessage: "chore: update release versions", - hasChanges: true, - // The overrides file may be a new untracked file that git add -u would miss. - // Explicitly include it so it gets committed alongside the version bumps. - additionalPaths: [overridesPath], - }); - - if (!hasChangesToPush.ok) { - exitWithError("Failed to sync release changes.", undefined, hasChangesToPush.error); - } - - if (!hasChangesToPush.value) { - // When there are no updates at all, the release branch is identical to the - // default branch. Attempting to create/update a PR would fail with a 422 - // ("No commits between main and "), so bail out early. - if (allUpdates.length === 0) { - logger.info("No changes to commit and no packages to release. Nothing to do."); - const checkoutResult = await checkoutBranch(options.branch.default, options.workspaceRoot); - if (!checkoutResult.ok) { - exitWithError( - `Failed to checkout branch: ${options.branch.default}`, - undefined, - checkoutResult.error, - ); - } - return null; - } - - const prResult = await syncPullRequest({ - github: options.githubClient, - releaseBranch: options.branch.release, - defaultBranch: options.branch.default, - pullRequestTitle: options.pullRequest?.title, - pullRequestBody: options.pullRequest?.body, - updates: allUpdates, - }); - - if (!prResult.ok) { - exitWithError("Failed to sync release pull request.", undefined, prResult.error); - } - - if (prResult.value.pullRequest) { - logger.item("No updates needed, PR is already up to date"); - const checkoutResult = await checkoutBranch(options.branch.default, options.workspaceRoot); - if (!checkoutResult.ok) { - exitWithError( - `Failed to checkout branch: ${options.branch.default}`, - undefined, - checkoutResult.error, - ); - } - - return { - updates: allUpdates, - prUrl: prResult.value.pullRequest.html_url, - created: prResult.value.created, - }; - } - - logger.error("No changes to commit, and no existing PR. Nothing to do."); - return null; - } - - const prResult = await syncPullRequest({ - github: options.githubClient, - releaseBranch: options.branch.release, - defaultBranch: options.branch.default, - pullRequestTitle: options.pullRequest?.title, - pullRequestBody: options.pullRequest?.body, - updates: allUpdates, - }); - - if (!prResult.ok) { - exitWithError("Failed to sync release pull request.", undefined, prResult.error); - } - - if (prResult.value.pullRequest?.html_url) { - logger.section("🚀 Pull Request"); - logger.success( - `Pull request ${prResult.value.created ? "created" : "updated"}: ${prResult.value.pullRequest.html_url}`, - ); - } - - const returnToDefault = await checkoutBranch(options.branch.default, options.workspaceRoot); - if (!returnToDefault.ok) { - exitWithError( - `Failed to checkout branch: ${options.branch.default}`, - undefined, - returnToDefault.error, - ); - } - - if (!returnToDefault.value) { - exitWithError(`Failed to checkout branch: ${options.branch.default}`); - } - - return { - updates: allUpdates, - prUrl: prResult.value.pullRequest?.html_url, - created: prResult.value.created, - }; -} diff --git a/src/workflows/verify.ts b/src/workflows/verify.ts deleted file mode 100644 index cfb1b4f..0000000 --- a/src/workflows/verify.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { join, relative } from "node:path"; - -import { - checkoutBranch, - getCurrentBranch, - isWorkingDirectoryClean, - readFileFromGit, -} from "#core/git"; -import { discoverWorkspacePackages } from "#core/workspace"; -import { calculateUpdates, ensureHasPackages } from "#operations/calculate"; -import { exitWithError, formatUnknownError } from "#shared/errors"; -import { logger, ucdjsReleaseOverridesPath } from "#shared/utils"; -import { gt } from "semver"; - -import type { NormalizedReleaseScriptsOptions } from "../options"; - -export async function verifyWorkflow(options: NormalizedReleaseScriptsOptions): Promise { - if (options.safeguards) { - const clean = await isWorkingDirectoryClean(options.workspaceRoot); - if (!clean.ok) { - exitWithError( - "Failed to verify working directory state.", - "Ensure this is a valid git repository and try again.", - clean.error, - ); - } - - if (!clean.value) { - exitWithError( - "Working directory is not clean. Please commit or stash your changes before proceeding.", - ); - } - } - - const releaseBranch = options.branch.release; - const defaultBranch = options.branch.default; - - const releasePr = await options.githubClient.getExistingPullRequest(releaseBranch); - - if (!releasePr || !releasePr.head) { - logger.warn( - `No open release pull request found for branch "${releaseBranch}". Nothing to verify.`, - ); - return; - } - - logger.info( - `Found release PR #${releasePr.number}. Verifying against default branch "${defaultBranch}"...`, - ); - - const originalBranch = await getCurrentBranch(options.workspaceRoot); - if (!originalBranch.ok) { - exitWithError("Failed to detect current branch.", undefined, originalBranch.error); - } - - if (originalBranch.value !== defaultBranch) { - const checkout = await checkoutBranch(defaultBranch, options.workspaceRoot); - if (!checkout.ok) { - exitWithError(`Failed to checkout branch: ${defaultBranch}`, undefined, checkout.error); - } - - if (!checkout.value) { - exitWithError(`Failed to checkout branch: ${defaultBranch}`); - } - } - - let existingOverrides: Record< - string, - { version: string; type: import("#shared/types").BumpKind } - > = {}; - try { - const overridesContent = await readFileFromGit( - options.workspaceRoot, - releasePr.head.sha, - ucdjsReleaseOverridesPath, - ); - if (overridesContent.ok && overridesContent.value) { - existingOverrides = JSON.parse(overridesContent.value); - logger.info("Found existing version overrides file on release branch."); - } - } catch (error) { - logger.info("No version overrides file found on release branch. Continuing..."); - logger.verbose(`Reading release overrides failed: ${formatUnknownError(error).message}`); - } - - const discovered = await discoverWorkspacePackages(options.workspaceRoot, options); - if (!discovered.ok) { - exitWithError("Failed to discover packages.", undefined, discovered.error); - } - - const ensured = ensureHasPackages(discovered.value); - if (!ensured.ok) { - logger.warn(ensured.error.message); - return; - } - - const mainPackages = ensured.value; - - const updatesResult = await calculateUpdates({ - workspacePackages: mainPackages, - workspaceRoot: options.workspaceRoot, - showPrompt: false, - globalCommitMode: options.globalCommitMode === "none" ? false : options.globalCommitMode, - overrides: existingOverrides, - }); - - if (!updatesResult.ok) { - exitWithError("Failed to calculate expected package updates.", undefined, updatesResult.error); - } - - const expectedUpdates = updatesResult.value.allUpdates; - const expectedVersionMap = new Map( - expectedUpdates.map((u) => [u.package.name, u.newVersion]), - ); - - const prVersionMap = new Map(); - for (const pkg of mainPackages) { - const pkgJsonPath = relative(options.workspaceRoot, join(pkg.path, "package.json")); - const pkgJsonContent = await readFileFromGit( - options.workspaceRoot, - releasePr.head.sha, - pkgJsonPath, - ); - if (pkgJsonContent.ok && pkgJsonContent.value) { - const pkgJson = JSON.parse(pkgJsonContent.value); - prVersionMap.set(pkg.name, pkgJson.version); - } - } - - if (originalBranch.value !== defaultBranch) { - await checkoutBranch(originalBranch.value, options.workspaceRoot); - } - - let isOutOfSync = false; - for (const [pkgName, expectedVersion] of expectedVersionMap.entries()) { - const prVersion = prVersionMap.get(pkgName); - if (!prVersion) { - logger.warn( - `Package "${pkgName}" found in default branch but not in release branch. Skipping.`, - ); - continue; - } - - if (gt(expectedVersion, prVersion)) { - logger.error( - `Package "${pkgName}" is out of sync. Expected version >= ${expectedVersion}, but PR has ${prVersion}.`, - ); - isOutOfSync = true; - } else { - logger.success( - `Package "${pkgName}" is up to date (PR version: ${prVersion}, Expected: ${expectedVersion})`, - ); - } - } - - const statusContext = "ucdjs/release-verify"; - - if (isOutOfSync) { - await options.githubClient.setCommitStatus({ - sha: releasePr.head.sha, - state: "failure", - context: statusContext, - description: - "Release PR is out of sync with the default branch. Please re-run the release process.", - }); - logger.error("Verification failed. Commit status set to 'failure'."); - } else { - await options.githubClient.setCommitStatus({ - sha: releasePr.head.sha, - state: "success", - context: statusContext, - description: "Release PR is up to date.", - targetUrl: `https://github.com/${options.owner}/${options.repo}/pull/${releasePr.number}`, - }); - logger.success("Verification successful. Commit status set to 'success'."); - } -} diff --git a/test/_shared.ts b/test/_shared.ts index 117bcbe..762dac6 100644 --- a/test/_shared.ts +++ b/test/_shared.ts @@ -1,5 +1,6 @@ -import type { GitHubClient } from "#core/github"; -import type { WorkspacePackage } from "#core/workspace"; +import type { GitHubServiceShape } from "../src/services/github"; +import { Effect } from "effect"; +import type { WorkspacePackage } from "../src/services/workspace"; import type { GitCommit } from "commit-parser"; import type { NormalizedReleaseScriptsOptions } from "../src/options"; @@ -25,13 +26,20 @@ export function createCommit(overrides: Partial = {}): GitCommit { } as GitCommit; } -export function createGitHubClientStub(overrides: Partial = {}): GitHubClient { - const stub: Partial = { - resolveAuthorInfo: async (info) => info, +export function createGitHubServiceStub( + overrides: Partial = {}, +): GitHubServiceShape { + const stub: GitHubServiceShape = { + getExistingPullRequest: () => Effect.succeed(null), + upsertPullRequest: () => Effect.succeed(null), + setCommitStatus: () => Effect.void, + upsertReleaseByTag: () => + Effect.succeed({ release: { id: 1, tagName: "tag", name: "tag" }, created: true }), + resolveAuthorInfo: (info: any) => Effect.succeed(info), ...overrides, }; - return stub as GitHubClient; + return stub; } export function createNormalizedReleaseOptions( @@ -43,7 +51,6 @@ export function createNormalizedReleaseOptions( packages: true, versions: true, }, - githubClient: overrides.githubClient ?? createGitHubClientStub(), npm: { provenance: true, otp: undefined, @@ -118,7 +125,7 @@ export function createChangelogTestContext( overrides: { normalizedOptions?: Partial; workspacePackage?: Partial; - githubClient?: Partial; + githubService?: Partial; } = {}, ) { const normalizedOptions = createNormalizedReleaseOptions({ @@ -127,11 +134,11 @@ export function createChangelogTestContext( }); const workspacePackage = createWorkspacePackage(workspaceRoot, overrides.workspacePackage); - const githubClient = createGitHubClientStub(overrides.githubClient); + const githubService = createGitHubServiceStub(overrides.githubService); return { normalizedOptions, workspacePackage, - githubClient, + githubService, }; } diff --git a/test/core/changelog.authors.test.ts b/test/core/changelog.authors.test.ts index 0594fad..0c22d35 100644 --- a/test/core/changelog.authors.test.ts +++ b/test/core/changelog.authors.test.ts @@ -1,5 +1,9 @@ -import { generateChangelogEntry } from "#core/changelog"; -import type { GitHubClient } from "#core/github"; +import { generateChangelogEntry } from "../../src/services/changelog"; +import { ChangelogServiceLive } from "../../src/services/changelog"; +import { GitHubService } from "../../src/services/github"; +import { GitServiceLive } from "../../src/services/git"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, Layer } from "effect"; import { describe, expect, it, vi } from "vitest"; import { DEFAULT_TYPES } from "../../src/options"; @@ -13,16 +17,15 @@ describe("generateChangelogEntry author rendering", () => { }), ]; - const githubClient = { - resolveAuthorInfo: vi.fn(async (info) => { - if (!info.login) { + const resolveAuthorInfo = vi.fn((info) => { + if (!info.login) { info.login = info.email.split("@")[0]!; } return info; - }), - } as unknown as GitHubClient; + }); - const entry = await generateChangelogEntry({ + const entry = await Effect.runPromise( + generateChangelogEntry({ packageName: "@ucdjs/test", version: "1.0.1", previousVersion: "1.0.0", @@ -31,10 +34,25 @@ describe("generateChangelogEntry author rendering", () => { owner: "ucdjs", repo: "release-scripts", types: DEFAULT_TYPES, - githubClient, - }); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + GitServiceLive, + ChangelogServiceLive, + Layer.succeed(GitHubService)(Object.assign({}, { + getExistingPullRequest: vi.fn(), + upsertPullRequest: vi.fn(), + setCommitStatus: vi.fn(), + upsertReleaseByTag: vi.fn(), + resolveAuthorInfo: (info: any) => Effect.succeed(resolveAuthorInfo(info) as any), + }) as any), + ), + ), + ) as Effect.Effect, + ); expect(entry).toContain("(by [@author](https://github.com/author))"); - expect(githubClient.resolveAuthorInfo).toHaveBeenCalledTimes(1); + expect(resolveAuthorInfo).toHaveBeenCalledTimes(1); }); }); diff --git a/test/core/changelog.test.ts b/test/core/changelog.test.ts index e586552..f1c1632 100644 --- a/test/core/changelog.test.ts +++ b/test/core/changelog.test.ts @@ -1,17 +1,46 @@ +import { NodeServices } from "@effect/platform-node"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { generateChangelogEntry, parseChangelog, updateChangelog } from "#core/changelog"; +import { GitServiceLive } from "../../src/services/git"; +import { ChangelogServiceLive } from "../../src/services/changelog"; +import { generateChangelogEntry, parseChangelog, updateChangelog } from "../../src/services/changelog"; +import { GitHubService } from "../../src/services/github"; +import { runEffect } from "#shared/utils"; import { dedent } from "@luxass/utils"; -import * as tinyexec from "tinyexec"; +import { Effect, Layer } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { testdir } from "vitest-testdirs"; import { DEFAULT_TYPES } from "../../src/options"; -import { createChangelogTestContext, createCommit, createGitHubClientStub } from "../_shared"; - -vi.mock("tinyexec"); -const mockExec = vi.mocked(tinyexec.x); +import { createChangelogTestContext, createCommit, createGitHubServiceStub } from "../_shared"; + +vi.mock("#shared/utils", async () => { + const actual = await vi.importActual("#shared/utils"); + return { + ...actual, + runEffect: vi.fn(), + }; +}); +const mockRun = vi.mocked(runEffect); +const runNode = (effect: Effect.Effect, githubService = createGitHubServiceStub()) => + Effect.runPromise( + effect.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + GitServiceLive, + ChangelogServiceLive, + Layer.succeed(GitHubService)(Object.assign({ + getExistingPullRequest: vi.fn(), + upsertPullRequest: vi.fn(), + setCommitStatus: vi.fn(), + upsertReleaseByTag: vi.fn(), + }, githubService) as any), + ), + ), + ) as Effect.Effect, + ); beforeEach(() => { vi.clearAllMocks(); @@ -39,15 +68,14 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await generateChangelogEntry({ + const entry = await runNode(generateChangelogEntry({ ...baseEntryOptions, version: "0.2.0", previousVersion: "0.1.0", date: "2025-01-16", commits, types: DEFAULT_TYPES, - githubClient: createGitHubClientStub(), - }); + }), createGitHubServiceStub()); expect(entry).toMatchInlineSnapshot(` "## [0.2.0](https://github.com/ucdjs/test-repo/compare/@ucdjs/test@0.1.0...@ucdjs/test@0.2.0) (2025-01-16) @@ -68,15 +96,14 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await generateChangelogEntry({ + const entry = await runNode(generateChangelogEntry({ ...baseEntryOptions, version: "0.1.1", previousVersion: "0.1.0", date: "2025-01-16", commits, types: DEFAULT_TYPES, - githubClient: createGitHubClientStub(), - }); + }), createGitHubServiceStub()); expect(entry).toMatchInlineSnapshot(` "## [0.1.1](https://github.com/ucdjs/test-repo/compare/@ucdjs/test@0.1.0...@ucdjs/test@0.1.1) (2025-01-16) @@ -110,15 +137,14 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await generateChangelogEntry({ + const entry = await runNode(generateChangelogEntry({ ...baseEntryOptions, version: "0.3.0", previousVersion: "0.2.0", date: "2025-01-16", commits, types: DEFAULT_TYPES, - githubClient: createGitHubClientStub(), - }); + }), createGitHubServiceStub()); expect(entry).toMatchInlineSnapshot(` "## [0.3.0](https://github.com/ucdjs/test-repo/compare/@ucdjs/test@0.2.0...@ucdjs/test@0.3.0) (2025-01-16) @@ -142,14 +168,13 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await generateChangelogEntry({ + const entry = await runNode(generateChangelogEntry({ ...baseEntryOptions, version: "0.1.0", date: "2025-01-16", commits, types: DEFAULT_TYPES, - githubClient: createGitHubClientStub(), - }); + }), createGitHubServiceStub()); expect(entry).toMatchInlineSnapshot(` "## 0.1.0 (2025-01-16) @@ -170,15 +195,14 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await generateChangelogEntry({ + const entry = await runNode(generateChangelogEntry({ ...baseEntryOptions, version: "0.1.1", previousVersion: "0.1.0", date: "2025-01-16", commits, types: DEFAULT_TYPES, - githubClient: createGitHubClientStub(), - }); + }), createGitHubServiceStub()); expect(entry).toMatchInlineSnapshot(` "## [0.1.1](https://github.com/ucdjs/test-repo/compare/@ucdjs/test@0.1.0...@ucdjs/test@0.1.1) (2025-01-16) @@ -199,15 +223,14 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await generateChangelogEntry({ + const entry = await runNode(generateChangelogEntry({ ...baseEntryOptions, version: "0.1.1", previousVersion: "0.1.0", date: "2025-01-16", commits, types: DEFAULT_TYPES, - githubClient: createGitHubClientStub(), - }); + }), createGitHubServiceStub()); expect(entry).toMatchInlineSnapshot(` "## [0.1.1](https://github.com/ucdjs/test-repo/compare/@ucdjs/test@0.1.0...@ucdjs/test@0.1.1) (2025-01-16) @@ -351,10 +374,10 @@ describe("parseChangelog", () => { describe("updateChangelog", () => { it("should create a new changelog file", async () => { const testdirPath = await testdir({}); - const { normalizedOptions, workspacePackage, githubClient } = + const { normalizedOptions, workspacePackage, githubService } = createChangelogTestContext(testdirPath); - mockExec.mockRejectedValue(new Error("fatal: path 'CHANGELOG.md' does not exist")); + mockRun.mockReturnValue(Effect.fail(new Error("fatal: path 'CHANGELOG.md' does not exist")) as any); const commits = [ createCommit({ @@ -365,14 +388,13 @@ describe("updateChangelog", () => { }), ]; - await updateChangelog({ + await runNode(updateChangelog({ normalizedOptions, workspacePackage, version: "0.1.0", commits, date: "2025-01-16", - githubClient, - }); + }), githubService); const content = await readFile(join(testdirPath, "CHANGELOG.md"), "utf-8"); @@ -401,9 +423,9 @@ describe("updateChangelog", () => { }), ]; - mockExec.mockRejectedValueOnce(new Error("fatal: path 'CHANGELOG.md' does not exist")); + mockRun.mockReturnValueOnce(Effect.fail(new Error("fatal: path 'CHANGELOG.md' does not exist")) as any); - await updateChangelog({ + await runNode(updateChangelog({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.1.0", @@ -416,22 +438,20 @@ describe("updateChangelog", () => { }), ], date: "2025-01-15", - githubClient: context.githubClient, - }); + }), context.githubService); const existingChangelog = await readFile(join(testdirPath, "CHANGELOG.md"), "utf-8"); - mockExec.mockResolvedValueOnce({ stdout: existingChangelog, stderr: "", exitCode: 0 }); + mockRun.mockReturnValueOnce(Effect.succeed({ stdout: existingChangelog, stderr: "", exitCode: 0 }) as any); - await updateChangelog({ + await runNode(updateChangelog({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.2.0", previousVersion: "0.1.0", commits, date: "2025-01-16", - githubClient: context.githubClient, - }); + }), context.githubService); const content = await readFile(join(testdirPath, "CHANGELOG.md"), "utf-8"); @@ -458,9 +478,9 @@ describe("updateChangelog", () => { const testdirPath = await testdir({}); const context = createChangelogTestContext(testdirPath); - mockExec.mockRejectedValueOnce(new Error("fatal: path 'CHANGELOG.md' does not exist")); + mockRun.mockReturnValueOnce(Effect.fail(new Error("fatal: path 'CHANGELOG.md' does not exist")) as any); - await updateChangelog({ + await runNode(updateChangelog({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.2.0", @@ -473,12 +493,11 @@ describe("updateChangelog", () => { }), ], date: "2025-01-16", - githubClient: context.githubClient, - }); + }), context.githubService); - mockExec.mockRejectedValueOnce(new Error("fatal: path 'CHANGELOG.md' does not exist")); + mockRun.mockReturnValueOnce(Effect.fail(new Error("fatal: path 'CHANGELOG.md' does not exist")) as any); - await updateChangelog({ + await runNode(updateChangelog({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.2.0", @@ -497,8 +516,7 @@ describe("updateChangelog", () => { }), ], date: "2025-01-16", - githubClient: context.githubClient, - }); + }), context.githubService); const content = await readFile(join(testdirPath, "CHANGELOG.md"), "utf-8"); const parsed = parseChangelog(content); @@ -525,9 +543,9 @@ describe("updateChangelog", () => { await writeFile(join(testdirPath, "CHANGELOG.md"), `${existingChangelog}\n`, "utf-8"); - mockExec.mockResolvedValueOnce({ stdout: existingChangelog, stderr: "", exitCode: 0 }); + mockRun.mockReturnValueOnce(Effect.succeed({ stdout: existingChangelog, stderr: "", exitCode: 0 }) as any); - await updateChangelog({ + await runNode(updateChangelog({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.1.0", @@ -541,8 +559,7 @@ describe("updateChangelog", () => { }), ], date: "2025-01-16", - githubClient: context.githubClient, - }); + }), context.githubService); const content = await readFile(join(testdirPath, "CHANGELOG.md"), "utf-8"); diff --git a/test/core/git.test.ts b/test/core/git.test.ts index e592ae2..82ef623 100644 --- a/test/core/git.test.ts +++ b/test/core/git.test.ts @@ -1,4 +1,6 @@ +import { NodeServices } from "@effect/platform-node"; import { + GitServiceLive, createBranch, doesBranchExist, doesRemoteBranchExist, @@ -7,12 +9,24 @@ import { getDefaultBranch, getMostRecentPackageTag, isWorkingDirectoryClean, -} from "#core/git"; -import * as tinyexec from "tinyexec"; -import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest"; +} from "../../src/services/git"; +import { runEffect, runIfNotDryEffect } from "#shared/utils"; +import { expect, it, layer } from "@effect/vitest"; +import { Cause, Effect, Layer } from "effect"; +import { afterEach, assert, beforeEach, describe, vi } from "vitest"; + +vi.mock("#shared/utils", async () => { + const actual = await vi.importActual("#shared/utils"); + return { + ...actual, + runEffect: vi.fn(), + runIfNotDryEffect: vi.fn(), + }; +}); -vi.mock("tinyexec"); -const mockExec = vi.mocked(tinyexec.exec); +const mockRunEffect = vi.mocked(runEffect); +const mockRunIfNotDryEffect = vi.mocked(runIfNotDryEffect); +const asTest = (effect: Effect.Effect): any => effect; beforeEach(() => { vi.clearAllMocks(); @@ -22,17 +36,18 @@ afterEach(() => { vi.resetAllMocks(); }); -describe("git utilities", () => { +layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) => { describe("isWorkingDirectoryClean", () => { - it("should return true if working directory is clean", async () => { - mockExec.mockResolvedValue({ + it.effect("should return true if working directory is clean", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0, - }); + }) as any); - const result = await isWorkingDirectoryClean("/workspace"); - expect(mockExec).toHaveBeenCalledWith( + const result = yield* isWorkingDirectoryClean("/workspace"); + expect(mockRunEffect).toHaveBeenCalledWith( "git", ["status", "--porcelain"], expect.objectContaining({ @@ -43,45 +58,47 @@ describe("git utilities", () => { }), ); - assert(result.ok); - expect(result.value).toBe(true); - }); + expect(result).toBe(true); + }))); - it("should return false if working directory has uncommitted changes", async () => { - mockExec.mockResolvedValue({ + it.effect("should return false if working directory has uncommitted changes", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: " M src/index.ts\n", stderr: "", exitCode: 0, - }); + }) as any); - const result = await isWorkingDirectoryClean("/workspace"); - assert(result.ok); - expect(result.value).toBe(false); - }); + const result = yield* isWorkingDirectoryClean("/workspace"); + expect(result).toBe(false); + }))); - it("should return error when git command fails", async () => { + it.effect("should return error when git command fails", () => + asTest(Effect.gen(function* () { const gitError = new Error("fatal: not a git repository"); - mockExec.mockRejectedValue(gitError); + mockRunEffect.mockReturnValue(Effect.fail(gitError) as any); - const result = await isWorkingDirectoryClean("/workspace"); + const exit = yield* Effect.exit(isWorkingDirectoryClean("/workspace")); - assert(!result.ok); - expect(result.error.type).toBe("git"); - expect(result.error.operation).toBe("isWorkingDirectoryClean"); - }); + assert(exit._tag === "Failure"); + const error = Cause.squash(exit.cause) as any; + expect(error._tag).toBe("GitError"); + expect(error.operation).toBe("isWorkingDirectoryClean"); + }))); }); describe("branch utilities", () => { describe("doesRemoteBranchExist", () => { - it("should return true when remote branch exists", async () => { - mockExec.mockResolvedValue({ + it.effect("should return true when remote branch exists", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "abc123\trefs/heads/main\n", stderr: "", exitCode: 0, - }); + }) as any); - const result = await doesRemoteBranchExist("main", "/workspace"); - expect(mockExec).toHaveBeenCalledWith( + const result = yield* doesRemoteBranchExist("main", "/workspace"); + expect(mockRunEffect).toHaveBeenCalledWith( "git", ["ls-remote", "--exit-code", "--heads", "origin", "main"], expect.objectContaining({ @@ -92,29 +109,29 @@ describe("git utilities", () => { }), ); - assert(result.ok); - expect(result.value).toBe(true); - }); + expect(result).toBe(true); + }))); - it("should return false when remote branch does not exist", async () => { - mockExec.mockRejectedValue(new Error("exit code 2")); + it.effect("should return false when remote branch does not exist", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.fail(new Error("exit code 2")) as any); - const result = await doesRemoteBranchExist("release/next", "/workspace"); - assert(result.ok); - expect(result.value).toBe(false); - }); + const result = yield* doesRemoteBranchExist("release/next", "/workspace"); + expect(result).toBe(false); + }))); }); describe("doesBranchExist", () => { - it("should return true if branch exists", async () => { - mockExec.mockResolvedValue({ + it.effect("should return true if branch exists", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "branch-sha-123456", stderr: "", exitCode: 0, - }); + }) as any); - const result = await doesBranchExist("feature-branch", "/workspace"); - expect(mockExec).toHaveBeenCalledWith( + const result = yield* doesBranchExist("feature-branch", "/workspace"); + expect(mockRunEffect).toHaveBeenCalledWith( "git", ["rev-parse", "--verify", "feature-branch"], expect.objectContaining({ @@ -125,30 +142,30 @@ describe("git utilities", () => { }), ); - assert(result.ok); - expect(result.value).toBe(true); - }); + expect(result).toBe(true); + }))); - it("should return false if branch does not exist", async () => { - mockExec.mockRejectedValue(new Error("fatal: Needed a single revision")); + it.effect("should return false if branch does not exist", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.fail(new Error("fatal: Needed a single revision")) as any); - const result = await doesBranchExist("nonexistent-branch", "/workspace"); - assert(result.ok); - expect(result.value).toBe(false); - }); + const result = yield* doesBranchExist("nonexistent-branch", "/workspace"); + expect(result).toBe(false); + }))); }); describe("getDefaultBranch", () => { - it("should return the default branch name", async () => { - mockExec.mockResolvedValue({ + it.effect("should return the default branch name", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "refs/remotes/origin/main\n", stderr: "", exitCode: 0, - }); + }) as any); - const result = await getDefaultBranch("/workspace"); + const result = yield* getDefaultBranch("/workspace"); - expect(mockExec).toHaveBeenCalledWith( + expect(mockRunEffect).toHaveBeenCalledWith( "git", ["symbolic-ref", "refs/remotes/origin/HEAD"], expect.objectContaining({ @@ -158,57 +175,57 @@ describe("git utilities", () => { }), ); - assert(result.ok); - expect(result.value).toBe("main"); - }); + expect(result).toBe("main"); + }))); - it("should return different branch name", async () => { - mockExec.mockResolvedValue({ + it.effect("should return different branch name", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "refs/remotes/origin/develop\n", stderr: "", exitCode: 0, - }); + }) as any); - const result = await getDefaultBranch("/workspace"); + const result = yield* getDefaultBranch("/workspace"); - assert(result.ok); - expect(result.value).toBe("develop"); - }); + expect(result).toBe("develop"); + }))); - it("should return 'main' if default branch cannot be determined", async () => { - mockExec.mockRejectedValue(new Error("Some git error")); + it.effect("should return 'main' if default branch cannot be determined", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.fail(new Error("Some git error")) as any); - const result = await getDefaultBranch("/workspace"); + const result = yield* getDefaultBranch("/workspace"); - assert(result.ok); - expect(result.value).toBe("main"); - }); + expect(result).toBe("main"); + }))); - it("should return 'main' if remote show output is unexpected", async () => { - mockExec.mockResolvedValue({ + it.effect("should return 'main' if remote show output is unexpected", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "Some unexpected output\n", stderr: "", exitCode: 0, - }); + }) as any); - const result = await getDefaultBranch("/workspace"); + const result = yield* getDefaultBranch("/workspace"); - assert(result.ok); - expect(result.value).toBe("main"); - }); + expect(result).toBe("main"); + }))); }); describe("getCurrentBranch", () => { - it("should return the current branch name", async () => { - mockExec.mockResolvedValue({ + it.effect("should return the current branch name", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "feature-branch\n", stderr: "", exitCode: 0, - }); + }) as any); - const result = await getCurrentBranch("/workspace"); + const result = yield* getCurrentBranch("/workspace"); - expect(mockExec).toHaveBeenCalledWith( + expect(mockRunEffect).toHaveBeenCalledWith( "git", ["rev-parse", "--abbrev-ref", "HEAD"], expect.objectContaining({ @@ -219,30 +236,31 @@ describe("git utilities", () => { }), ); - assert(result.ok); - expect(result.value).toBe("feature-branch"); - }); + expect(result).toBe("feature-branch"); + }))); - it("should handle errors", async () => { - mockExec.mockRejectedValue(new Error("Some git error")); + it.effect("should handle errors", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.fail(new Error("Some git error")) as any); - const result = await getCurrentBranch("/workspace"); - assert(!result.ok); - expect(result.error.operation).toBe("getCurrentBranch"); - }); + const exit = yield* Effect.exit(getCurrentBranch("/workspace")); + assert(exit._tag === "Failure"); + expect((Cause.squash(exit.cause) as any).operation).toBe("getCurrentBranch"); + }))); }); describe("getAvailableBranches", () => { - it("should return a list of available branches", async () => { - mockExec.mockResolvedValue({ + it.effect("should return a list of available branches", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: " main\n* feature-branch\ndevelop\n", stderr: "", exitCode: 0, - }); + }) as any); - const result = await getAvailableBranches("/workspace"); + const result = yield* getAvailableBranches("/workspace"); - expect(mockExec).toHaveBeenCalledWith( + expect(mockRunEffect).toHaveBeenCalledWith( "git", ["branch", "--list"], expect.objectContaining({ @@ -253,30 +271,31 @@ describe("git utilities", () => { }), ); - assert(result.ok); - expect(result.value).toEqual(["main", "feature-branch", "develop"]); - }); + expect(result).toEqual(["main", "feature-branch", "develop"]); + }))); - it("should handle errors", async () => { - mockExec.mockRejectedValue(new Error("Some git error")); + it.effect("should handle errors", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.fail(new Error("Some git error")) as any); - const result = await getAvailableBranches("/workspace"); - assert(!result.ok); - expect(result.error.operation).toBe("getAvailableBranches"); - }); + const exit = yield* Effect.exit(getAvailableBranches("/workspace")); + assert(exit._tag === "Failure"); + expect((Cause.squash(exit.cause) as any).operation).toBe("getAvailableBranches"); + }))); }); describe("createBranch", () => { - it("should create a new branch from the specified base branch", async () => { - mockExec.mockResolvedValue({ + it.effect("should create a new branch from the specified base branch", () => + asTest(Effect.gen(function* () { + mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0, - }); + }) as any); - const result = await createBranch("new-feature", "main", "/workspace"); + const result = yield* createBranch("new-feature", "main", "/workspace"); - expect(mockExec).toHaveBeenCalledWith( + expect(mockRunIfNotDryEffect).toHaveBeenCalledWith( "git", ["branch", "new-feature", "main"], expect.objectContaining({ @@ -286,30 +305,32 @@ describe("git utilities", () => { }), }), ); - expect(result.ok).toBe(true); - }); + expect(result).toBeUndefined(); + }))); - it("should handle errors", async () => { - mockExec.mockRejectedValue(new Error("Some git error")); + it.effect("should handle errors", () => + asTest(Effect.gen(function* () { + mockRunIfNotDryEffect.mockReturnValue(Effect.fail(new Error("Some git error")) as any); - const result = await createBranch("new-feature", "main", "/workspace"); - assert(!result.ok); - expect(result.error.operation).toBe("createBranch"); - }); + const exit = yield* Effect.exit(createBranch("new-feature", "main", "/workspace")); + assert(exit._tag === "Failure"); + expect((Cause.squash(exit.cause) as any).operation).toBe("createBranch"); + }))); }); }); describe("package tags", () => { - it("should return the highest semver tag for a package", async () => { - mockExec.mockResolvedValue({ + it.effect("should return the highest semver tag for a package", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "other-package@1.0.0\nmy-package@1.2.0\nmy-package@1.10.0\nmy-package@1.1.0\n", stderr: "", exitCode: 0, - } as any); + } as any) as any); - const result = await getMostRecentPackageTag("/workspace", "my-package"); + const result = yield* getMostRecentPackageTag("/workspace", "my-package"); - expect(mockExec).toHaveBeenCalledWith( + expect(mockRunEffect).toHaveBeenCalledWith( "git", ["tag", "--list", "my-package@*"], expect.objectContaining({ @@ -319,48 +340,46 @@ describe("git utilities", () => { }), }), ); - assert(result.ok); - // Should be 1.10.0, not 1.2.0 (semver order, not alphabetical) - expect(result.value).toBe("my-package@1.10.0"); - }); + expect(result).toBe("my-package@1.10.0"); + }))); - it("should ignore non-semver tags like @latest", async () => { - mockExec.mockResolvedValue({ + it.effect("should ignore non-semver tags like @latest", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "my-package@latest\nmy-package@1.0.0\nmy-package@2.0.0\n", stderr: "", exitCode: 0, - } as any); + } as any) as any); - const result = await getMostRecentPackageTag("/workspace", "my-package"); + const result = yield* getMostRecentPackageTag("/workspace", "my-package"); - assert(result.ok); - expect(result.value).toBe("my-package@2.0.0"); - }); + expect(result).toBe("my-package@2.0.0"); + }))); - it("should return undefined if no tag exists for package", async () => { - mockExec.mockResolvedValue({ + it.effect("should return undefined if no tag exists for package", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0, - } as any); + } as any) as any); - const result = await getMostRecentPackageTag("/workspace", "my-package"); + const result = yield* getMostRecentPackageTag("/workspace", "my-package"); - assert(result.ok); - expect(result.value).toBeUndefined(); - }); + expect(result).toBeUndefined(); + }))); - it("should return undefined if no tags exist", async () => { - mockExec.mockResolvedValue({ + it.effect("should return undefined if no tags exist", () => + asTest(Effect.gen(function* () { + mockRunEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0, - } as any); + } as any) as any); - const result = await getMostRecentPackageTag("/workspace", "my-package"); + const result = yield* getMostRecentPackageTag("/workspace", "my-package"); - assert(result.ok); - expect(result.value).toBeUndefined(); - }); + expect(result).toBeUndefined(); + }))); }); }); diff --git a/test/core/github.test.ts b/test/core/github.test.ts index 6ec134f..1b6e303 100644 --- a/test/core/github.test.ts +++ b/test/core/github.test.ts @@ -1,26 +1,48 @@ -import { createGitHubClient, generatePullRequestBody } from "#core/github"; +import { + getExistingPullRequest, + GitHubServiceLive, + resolveAuthorInfo, + setCommitStatus, + upsertPullRequest, + upsertReleaseByTag, +} from "../../src/services/github"; +import { NodeServices } from "@effect/platform-node"; +import { ReleaseOptions } from "../../src/options"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; import { HttpResponse } from "msw"; -import { describe, expect, it } from "vitest"; +import { describe } from "vitest"; import { GITHUB_API_BASE, mockFetch } from "../_msw"; - -describe("gitHubClient.getExistingPullRequest", () => { +import { createNormalizedReleaseOptions } from "../_shared"; + +const runGitHub = (effect: Effect.Effect) => + Effect.runPromise( + effect.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.provide( + GitHubServiceLive, + Layer.succeed( + ReleaseOptions, + createNormalizedReleaseOptions({ owner: "ucdjs", repo: "test-repo" }), + ), + ), + ), + ), + ) as Effect.Effect, + ); + +describe("GitHubService", () => { it("returns null when no open PRs exist", async () => { - mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => { - return HttpResponse.json([]); - }); - - const result = await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).getExistingPullRequest("release/next"); - expect(result).toBeNull(); + mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => HttpResponse.json([])); + await expect(runGitHub(getExistingPullRequest("release/next"))).resolves.toBeNull(); }); it("returns the first open PR for the branch", async () => { - mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => { - return HttpResponse.json([ + mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => + HttpResponse.json([ { number: 42, title: "chore: release v1.0.0", @@ -29,53 +51,29 @@ describe("gitHubClient.getExistingPullRequest", () => { html_url: "https://github.com/ucdjs/test-repo/pull/42", head: { sha: "abc1234" }, }, - ]); - }); + ]), + ); - const result = await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).getExistingPullRequest("release/next"); + const result = await runGitHub(getExistingPullRequest("release/next")); expect(result?.number).toBe(42); - expect(result?.title).toBe("chore: release v1.0.0"); - expect(result?.draft).toBe(true); expect(result?.head?.sha).toBe("abc1234"); }); - it("throws when PR shape from API is invalid", async () => { - mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => { - return HttpResponse.json([{ number: "not-a-number" }]); - }); - - await expect( - createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).getExistingPullRequest("release/next"), - ).rejects.toThrow("Pull request data validation failed"); - }); + it("fails when PR shape from API is invalid", async () => { + mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => + HttpResponse.json([{ number: "not-a-number" }]), + ); - it("throws on non-2xx response", async () => { - mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => { - return HttpResponse.json({ message: "Bad credentials" }, { status: 401 }); + await expect(runGitHub(getExistingPullRequest("release/next"))).rejects.toMatchObject({ + _tag: "GitHubError", + operation: "getExistingPullRequest", + message: "Pull request data validation failed", }); - - await expect( - createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).getExistingPullRequest("release/next"), - ).rejects.toThrow("401"); }); -}); -describe("gitHubClient.upsertPullRequest", () => { it("creates a new draft PR when no pullNumber is provided", async () => { - mockFetch("POST", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => { - return HttpResponse.json( + mockFetch("POST", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => + HttpResponse.json( { number: 10, title: "chore: new release", @@ -84,68 +82,21 @@ describe("gitHubClient.upsertPullRequest", () => { html_url: "https://github.com/ucdjs/test-repo/pull/10", }, { status: 201 }, - ); - }); + ), + ); - const result = await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).upsertPullRequest({ - title: "chore: new release", - body: "Release body", - head: "release/next", - base: "main", - }); + const result = await runGitHub( + upsertPullRequest({ + title: "chore: new release", + body: "Release body", + head: "release/next", + base: "main", + }), + ); expect(result?.number).toBe(10); expect(result?.draft).toBe(true); }); - it("updates an existing PR when pullNumber is provided", async () => { - mockFetch("PATCH", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls/5`, () => { - return HttpResponse.json({ - number: 5, - title: "chore: updated release", - body: "Updated body", - draft: false, - html_url: "https://github.com/ucdjs/test-repo/pull/5", - }); - }); - - const result = await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).upsertPullRequest({ - title: "chore: updated release", - body: "Updated body", - pullNumber: 5, - }); - expect(result?.number).toBe(5); - expect(result?.title).toBe("chore: updated release"); - }); - - it("throws when PR response shape is invalid", async () => { - mockFetch("POST", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => { - return HttpResponse.json({ id: 1 }, { status: 201 }); - }); - - await expect( - createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).upsertPullRequest({ - title: "x", - body: "y", - head: "h", - base: "b", - }), - ).rejects.toThrow("Pull request data validation failed"); - }); -}); - -describe("gitHubClient.setCommitStatus", () => { it("sends the correct payload to the statuses endpoint", async () => { let captured: unknown; mockFetch( @@ -157,16 +108,14 @@ describe("gitHubClient.setCommitStatus", () => { }, ); - await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).setCommitStatus({ - sha: "abc1234", - state: "success", - context: "release/verify", - description: "All checks passed", - }); + await runGitHub( + setCommitStatus({ + sha: "abc1234", + state: "success", + context: "release/verify", + description: "All checks passed", + }), + ); expect(captured).toMatchObject({ state: "success", @@ -174,23 +123,19 @@ describe("gitHubClient.setCommitStatus", () => { description: "All checks passed", }); }); -}); -describe("gitHubClient.upsertReleaseByTag", () => { - it("creates a new release when none exists for the tag", async () => { + it("creates a release when none exists for the tag", async () => { mockFetch([ [ "GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/releases/tags/:tag`, - () => { - return HttpResponse.json({ message: "Not Found" }, { status: 404 }); - }, + () => HttpResponse.json({ message: "Not Found" }, { status: 404 }), ], [ "POST", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/releases`, - () => { - return HttpResponse.json( + () => + HttpResponse.json( { id: 99, tag_name: "pkg@1.0.0", @@ -198,286 +143,25 @@ describe("gitHubClient.upsertReleaseByTag", () => { html_url: "https://github.com/ucdjs/test-repo/releases/tag/pkg%401.0.0", }, { status: 201 }, - ); - }, + ), ], ]); - const { release, created } = await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).upsertReleaseByTag({ - tagName: "pkg@1.0.0", - name: "pkg@1.0.0", - body: "Release notes", - }); + const { release, created } = await runGitHub( + upsertReleaseByTag({ tagName: "pkg@1.0.0", name: "pkg@1.0.0", body: "Release notes" }), + ); expect(created).toBe(true); expect(release.id).toBe(99); }); - it("updates an existing release when one already exists for the tag", async () => { - mockFetch([ - [ - "GET", - `${GITHUB_API_BASE}/repos/ucdjs/test-repo/releases/tags/:tag`, - () => { - return HttpResponse.json({ id: 7, tag_name: "pkg@1.0.0", name: "pkg@1.0.0" }); - }, - ], - [ - "PATCH", - `${GITHUB_API_BASE}/repos/ucdjs/test-repo/releases/7`, - () => { - return HttpResponse.json({ id: 7, tag_name: "pkg@1.0.0", name: "Updated" }); - }, - ], - ]); - - const { release, created } = await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).upsertReleaseByTag({ - tagName: "pkg@1.0.0", - name: "Updated", - body: "Updated notes", - }); - expect(created).toBe(false); - expect(release.id).toBe(7); - }); - - it("rethrows non-404 errors when fetching the existing release", async () => { - mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/releases/tags/:tag`, () => { - return HttpResponse.json({ message: "Server Error" }, { status: 500 }); - }); - - await expect( - createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).upsertReleaseByTag({ - tagName: "pkg@1.0.0", - name: "pkg@1.0.0", - body: "notes", - }), - ).rejects.toThrow("500"); - }); -}); - -describe("gitHubClient.resolveAuthorInfo", () => { - it("returns info unchanged when login is already set", async () => { - const result = await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).resolveAuthorInfo({ name: "Test", email: "t@test.com", login: "testuser", commits: [] }); - expect(result.login).toBe("testuser"); - }); - it("resolves login via user search by email", async () => { - mockFetch("GET", `${GITHUB_API_BASE}/search/users`, () => { - return HttpResponse.json({ items: [{ login: "resolved-user" }] }); - }); - - const result = await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).resolveAuthorInfo({ name: "Test", email: "t@test.com", login: undefined, commits: [] }); - expect(result.login).toBe("resolved-user"); - }); - - it("falls back to commit author when user search returns no results", async () => { - mockFetch([ - [ - "GET", - `${GITHUB_API_BASE}/search/users`, - () => { - return HttpResponse.json({ items: [] }); - }, - ], - [ - "GET", - `${GITHUB_API_BASE}/repos/ucdjs/test-repo/commits/:sha`, - () => { - return HttpResponse.json({ author: { login: "commit-author" } }); - }, - ], - ]); - - const result = await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).resolveAuthorInfo({ - name: "Test", - email: "t@test.com", - login: undefined, - commits: ["abc123"], - }); - expect(result.login).toBe("commit-author"); - }); - - it("returns info without login when both lookups fail", async () => { - mockFetch("GET", `${GITHUB_API_BASE}/search/users`, () => { - return HttpResponse.json({ message: "Forbidden" }, { status: 403 }); - }); - - const result = await createGitHubClient({ - owner: "ucdjs", - repo: "test-repo", - githubToken: "test-token", - }).resolveAuthorInfo({ name: "Test", email: "t@test.com", login: undefined, commits: [] }); - expect(result.login).toBeUndefined(); - }); -}); - -describe("generatePullRequestBody", () => { - it("renders a body containing the package name and new version", () => { - const body = generatePullRequestBody([ - { - package: { - name: "@scope/pkg", - version: "1.0.0", - path: "/workspace", - packageJson: { name: "@scope/pkg", version: "1.0.0" }, - workspaceDependencies: [], - workspaceDevDependencies: [], - }, - currentVersion: "1.0.0", - newVersion: "1.1.0", - bumpType: "minor", - hasDirectChanges: true, - changeKind: "auto", - }, - ]); - expect(body).toContain("@scope/pkg"); - expect(body).toContain("1.1.0"); - }); - - it("renders all packages when multiple updates are provided", () => { - const body = generatePullRequestBody([ - { - package: { - name: "@scope/a", - version: "1.0.0", - path: "/workspace/a", - packageJson: { name: "@scope/a", version: "1.0.0" }, - workspaceDependencies: [], - workspaceDevDependencies: [], - }, - currentVersion: "1.0.0", - newVersion: "2.0.0", - bumpType: "major", - hasDirectChanges: true, - changeKind: "auto", - }, - { - package: { - name: "@scope/b", - version: "3.0.0", - path: "/workspace/b", - packageJson: { name: "@scope/b", version: "3.0.0" }, - workspaceDependencies: [], - workspaceDevDependencies: [], - }, - currentVersion: "3.0.0", - newVersion: "3.1.0", - bumpType: "minor", - hasDirectChanges: false, - changeKind: "dependent", - }, - ]); - expect(body).toContain("@scope/a"); - expect(body).toContain("@scope/b"); - }); - - it("uses a custom template when provided", () => { - const body = generatePullRequestBody( - [ - { - package: { - name: "@scope/pkg", - version: "1.0.0", - path: "/workspace", - packageJson: { name: "@scope/pkg", version: "1.0.0" }, - workspaceDependencies: [], - workspaceDevDependencies: [], - }, - currentVersion: "1.0.0", - newVersion: "1.1.0", - bumpType: "minor", - hasDirectChanges: true, - changeKind: "auto", - }, - ], - "<% it.packages.forEach(p => { %><%= p.name %> → <%= p.newVersion %><% }); %>", + mockFetch("GET", `${GITHUB_API_BASE}/search/users`, () => + HttpResponse.json({ items: [{ login: "resolved-user" }] }), ); - expect(body).toContain("@scope/pkg → 1.1.0"); - }); - - it("separates as-is packages from real releases in the default template", () => { - const body = generatePullRequestBody([ - { - package: { - name: "@scope/released", - version: "1.0.0", - path: "/workspace/a", - packageJson: { name: "@scope/released", version: "1.0.0" }, - workspaceDependencies: [], - workspaceDevDependencies: [], - }, - currentVersion: "1.0.0", - newVersion: "2.0.0", - bumpType: "major", - hasDirectChanges: true, - changeKind: "auto", - }, - { - package: { - name: "@scope/kept", - version: "3.0.0", - path: "/workspace/b", - packageJson: { name: "@scope/kept", version: "3.0.0" }, - workspaceDependencies: [], - workspaceDevDependencies: [], - }, - currentVersion: "3.0.0", - newVersion: "3.0.0", - bumpType: "none", - hasDirectChanges: true, - changeKind: "as-is", - }, - ]); - expect(body).toContain("@scope/released"); - expect(body).toContain("1.0.0 → 2.0.0 (major)"); - expect(body).toContain("@scope/kept"); - expect(body).toContain("3.0.0 (as-is)"); - expect(body).toContain("keeping their current version"); - }); - it("shows 'no packages to release' when all updates are as-is", () => { - const body = generatePullRequestBody([ - { - package: { - name: "@scope/kept", - version: "1.0.0", - path: "/workspace", - packageJson: { name: "@scope/kept", version: "1.0.0" }, - workspaceDependencies: [], - workspaceDevDependencies: [], - }, - currentVersion: "1.0.0", - newVersion: "1.0.0", - bumpType: "none", - hasDirectChanges: true, - changeKind: "as-is", - }, - ]); - expect(body).toContain("no packages to release"); - expect(body).toContain("@scope/kept"); - expect(body).toContain("keeping their current version"); + const result = await runGitHub( + resolveAuthorInfo({ name: "Test", email: "t@test.com", login: undefined, commits: [] }), + ); + expect(result.login).toBe("resolved-user"); }); }); diff --git a/test/core/npm.test.ts b/test/core/npm.test.ts index 2bc6bc0..86f3d58 100644 --- a/test/core/npm.test.ts +++ b/test/core/npm.test.ts @@ -1,14 +1,25 @@ -import { checkVersionExists, publishPackage } from "#core/npm"; +import { NodeServices } from "@effect/platform-node"; +import { NpmServiceLive } from "../../src/services/npm"; +import { checkVersionExists, publishPackage } from "../../src/services/npm"; +import { runIfNotDryEffect } from "#shared/utils"; +import { expect, it, layer } from "@effect/vitest"; +import { Cause, Effect, Layer } from "effect"; import { HttpResponse } from "msw"; -import * as tinyexec from "tinyexec"; -import { afterEach, assert, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, assert, beforeEach, vi } from "vitest"; import { mockFetch, NPM_REGISTRY } from "../_msw"; import { createNormalizedReleaseOptions } from "../_shared"; -vi.mock("tinyexec"); +vi.mock("#shared/utils", async () => { + const actual = await vi.importActual("#shared/utils"); + return { + ...actual, + runIfNotDryEffect: vi.fn(), + }; +}); -const mockExec = vi.mocked(tinyexec.exec); +const mockRunIfNotDryEffect = vi.mocked(runIfNotDryEffect); +const asTest = (effect: Effect.Effect): any => effect; let previousNpmRegistry: string | undefined; @@ -26,18 +37,19 @@ afterEach(() => { } }); -describe("checkVersionExists", () => { - it("returns false when the package does not exist on the registry (404)", async () => { +layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("checkVersionExists", (it) => { + it.effect("returns false when the package does not exist on the registry (404)", () => + asTest(Effect.gen(function* () { mockFetch("GET", `${NPM_REGISTRY}/:pkg`, () => { return HttpResponse.json({ error: "Not found" }, { status: 404 }); }); - const result = await checkVersionExists("my-package", "1.0.0"); - assert(result.ok); - expect(result.value).toBe(false); - }); + const result = yield* checkVersionExists("my-package", "1.0.0"); + expect(result).toBe(false); + }))); - it("returns true when the requested version exists", async () => { + it.effect("returns true when the requested version exists", () => + asTest(Effect.gen(function* () { mockFetch("GET", `${NPM_REGISTRY}/:pkg`, () => { return HttpResponse.json({ name: "my-package", @@ -46,12 +58,12 @@ describe("checkVersionExists", () => { }); }); - const result = await checkVersionExists("my-package", "1.0.0"); - assert(result.ok); - expect(result.value).toBe(true); - }); + const result = yield* checkVersionExists("my-package", "1.0.0"); + expect(result).toBe(true); + }))); - it("returns false when the package exists but the requested version does not", async () => { + it.effect("returns false when the package exists but the requested version does not", () => + asTest(Effect.gen(function* () { mockFetch("GET", `${NPM_REGISTRY}/:pkg`, () => { return HttpResponse.json({ name: "my-package", @@ -60,22 +72,23 @@ describe("checkVersionExists", () => { }); }); - const result = await checkVersionExists("my-package", "2.0.0"); - assert(result.ok); - expect(result.value).toBe(false); - }); + const result = yield* checkVersionExists("my-package", "2.0.0"); + expect(result).toBe(false); + }))); - it("returns err on a non-404 registry error", async () => { + it.effect("returns err on a non-404 registry error", () => + asTest(Effect.gen(function* () { mockFetch("GET", `${NPM_REGISTRY}/:pkg`, () => { return HttpResponse.json({ error: "Service Unavailable" }, { status: 503 }); }); - const result = await checkVersionExists("my-package", "1.0.0"); - assert(!result.ok); - expect(result.error.type).toBe("npm"); - }); + const exit = yield* Effect.exit(checkVersionExists("my-package", "1.0.0")); + assert(exit._tag === "Failure"); + expect((Cause.squash(exit.cause) as any)._tag).toBe("NPMError"); + }))); - it("url-encodes scoped package names correctly", async () => { + it.effect("url-encodes scoped package names correctly", () => + asTest(Effect.gen(function* () { let capturedUrl = ""; mockFetch("GET", `${NPM_REGISTRY}/:pkg`, ({ request }) => { capturedUrl = request.url; @@ -86,12 +99,13 @@ describe("checkVersionExists", () => { }); }); - await checkVersionExists("@scope/pkg", "0.1.0"); + yield* checkVersionExists("@scope/pkg", "0.1.0"); // @scope/pkg is encoded as @scope%2Fpkg (single path segment) expect(capturedUrl).toContain("@scope%2Fpkg"); - }); + }))); - it("respects NPM_CONFIG_REGISTRY env var", async () => { + it.effect("respects NPM_CONFIG_REGISTRY env var", () => + asTest(Effect.gen(function* () { process.env.NPM_CONFIG_REGISTRY = "https://my-registry.example.com"; mockFetch("GET", "https://my-registry.example.com/:pkg", () => { @@ -102,96 +116,102 @@ describe("checkVersionExists", () => { }); }); - const result = await checkVersionExists("my-package", "3.0.0"); - assert(result.ok); - expect(result.value).toBe(true); - }); + const result = yield* checkVersionExists("my-package", "3.0.0"); + expect(result).toBe(true); + }))); - it("returns err with ENETWORK code on network failure", async () => { + it.effect("returns err with ENETWORK code on network failure", () => + asTest(Effect.gen(function* () { mockFetch("GET", `${NPM_REGISTRY}/:pkg`, () => { return HttpResponse.error(); }); - const result = await checkVersionExists("my-package", "1.0.0"); - assert(!result.ok); - expect(result.error.type).toBe("npm"); - expect(result.error.code).toBe("ENETWORK"); - }); + const exit = yield* Effect.exit(checkVersionExists("my-package", "1.0.0")); + assert(exit._tag === "Failure"); + const error = Cause.squash(exit.cause) as any; + expect(error._tag).toBe("NPMError"); + expect(error.code).toBe("ENETWORK"); + }))); }); -describe("publishPackage", () => { - it("passes --tag beta for a beta prerelease version", async () => { - mockExec.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 } as any); +layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("publishPackage", (it) => { + it.effect("passes --tag beta for a beta prerelease version", () => + asTest(Effect.gen(function* () { + mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0 } as any) as any); - await publishPackage( + yield* publishPackage( "@scope/pkg", "1.0.0-beta.1", "/workspace", createNormalizedReleaseOptions({ dryRun: false }), ); - expect(mockExec).toHaveBeenCalledWith( + expect(mockRunIfNotDryEffect).toHaveBeenCalledWith( "pnpm", expect.arrayContaining(["--tag", "beta"]), expect.anything(), ); - }); + }))); - it("passes --tag alpha for an alpha prerelease version", async () => { - mockExec.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 } as any); + it.effect("passes --tag alpha for an alpha prerelease version", () => + asTest(Effect.gen(function* () { + mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0 } as any) as any); - await publishPackage( + yield* publishPackage( "@scope/pkg", "1.0.0-alpha.1", "/workspace", createNormalizedReleaseOptions({ dryRun: false }), ); - expect(mockExec).toHaveBeenCalledWith( + expect(mockRunIfNotDryEffect).toHaveBeenCalledWith( "pnpm", expect.arrayContaining(["--tag", "alpha"]), expect.anything(), ); - }); + }))); - it("passes --tag next for an unrecognised prerelease identifier", async () => { - mockExec.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 } as any); + it.effect("passes --tag next for an unrecognised prerelease identifier", () => + asTest(Effect.gen(function* () { + mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0 } as any) as any); - await publishPackage( + yield* publishPackage( "@scope/pkg", "1.0.0-rc.1", "/workspace", createNormalizedReleaseOptions({ dryRun: false }), ); - expect(mockExec).toHaveBeenCalledWith( + expect(mockRunIfNotDryEffect).toHaveBeenCalledWith( "pnpm", expect.arrayContaining(["--tag", "next"]), expect.anything(), ); - }); + }))); - it("does not pass --tag for a stable release", async () => { - mockExec.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 } as any); + it.effect("does not pass --tag for a stable release", () => + asTest(Effect.gen(function* () { + mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0 } as any) as any); - await publishPackage( + yield* publishPackage( "@scope/pkg", "1.0.0", "/workspace", createNormalizedReleaseOptions({ dryRun: false }), ); - expect(mockExec).toHaveBeenCalledWith( + expect(mockRunIfNotDryEffect).toHaveBeenCalledWith( "pnpm", expect.not.arrayContaining(["--tag"]), expect.anything(), ); - }); + }))); - it("passes --otp when npm.otp is set in options", async () => { - mockExec.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0 } as any); + it.effect("passes --otp when npm.otp is set in options", () => + asTest(Effect.gen(function* () { + mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0 } as any) as any); - await publishPackage( + yield* publishPackage( "@scope/pkg", "1.0.0", "/workspace", @@ -201,10 +221,10 @@ describe("publishPackage", () => { }), ); - expect(mockExec).toHaveBeenCalledWith( + expect(mockRunIfNotDryEffect).toHaveBeenCalledWith( "pnpm", expect.arrayContaining(["--otp", "123456"]), expect.anything(), ); - }); + }))); }); diff --git a/test/core/types.test.ts b/test/core/types.test.ts index fab9b62..2beb253 100644 --- a/test/core/types.test.ts +++ b/test/core/types.test.ts @@ -1,40 +1,37 @@ import { describe, expect, it } from "vitest"; -import type { GitError } from "../../src/core/git"; -import type { GitHubError } from "../../src/core/github"; -import type { WorkspaceError } from "../../src/core/workspace"; +import { GitError } from "../../src/services/git"; +import { GitHubError } from "../../src/services/github"; +import { WorkspaceError } from "../../src/services/workspace"; describe("core types", () => { it("matches git error shape", () => { - const err: GitError = { - type: "git", + const err = new GitError({ operation: "push", message: "failed", - }; + }); - expect(err.type).toBe("git"); + expect(err._tag).toBe("GitError"); expect(err.operation).toBe("push"); }); it("matches github error shape", () => { - const err: GitHubError = { - type: "github", + const err = new GitHubError({ operation: "request", message: "failed", - }; + }); - expect(err.type).toBe("github"); + expect(err._tag).toBe("GitHubError"); expect(err.operation).toBe("request"); }); it("matches workspace error shape", () => { - const err: WorkspaceError = { - type: "workspace", + const err = new WorkspaceError({ operation: "discover", message: "failed", - }; + }); - expect(err.type).toBe("workspace"); + expect(err._tag).toBe("WorkspaceError"); expect(err.operation).toBe("discover"); }); }); diff --git a/test/operations/branch.test.ts b/test/operations/branch.test.ts index ae6b3bc..2ea1a7b 100644 --- a/test/operations/branch.test.ts +++ b/test/operations/branch.test.ts @@ -1,11 +1,52 @@ -import * as git from "#core/git"; -import { prepareReleaseBranch } from "#operations/branch"; -import { ok } from "#types"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NodeServices } from "@effect/platform-node"; +import * as git from "../../src/services/git"; +import { prepareReleaseBranch } from "../../src/release/branch"; +import { expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, vi } from "vitest"; -vi.mock("#core/git"); +vi.mock("../../src/services/git", async () => { + const actual = await vi.importActual("../../src/services/git"); + return { + ...actual, + getCurrentBranch: vi.fn(), + doesBranchExist: vi.fn(), + doesRemoteBranchExist: vi.fn(), + checkoutBranch: vi.fn(), + rebaseBranch: vi.fn(), + pullLatestChanges: vi.fn(), + createBranch: vi.fn(), + commitChanges: vi.fn(), + isBranchAheadOfRemote: vi.fn(), + pushBranch: vi.fn(), + }; +}); const mockedGit = vi.mocked(git); +const withNode = (effect: Effect.Effect): any => + effect.pipe( + Effect.provide(NodeServices.layer as any), + Effect.provideService(git.GitService, { + isWorkingDirectoryClean: mockedGit.isWorkingDirectoryClean, + doesRemoteBranchExist: mockedGit.doesRemoteBranchExist, + doesBranchExist: mockedGit.doesBranchExist, + getDefaultBranch: mockedGit.getDefaultBranch, + getCurrentBranch: mockedGit.getCurrentBranch, + checkoutBranch: mockedGit.checkoutBranch, + pullLatestChanges: mockedGit.pullLatestChanges, + rebaseBranch: mockedGit.rebaseBranch, + isBranchAheadOfRemote: mockedGit.isBranchAheadOfRemote, + pushBranch: mockedGit.pushBranch, + readFileFromGit: mockedGit.readFileFromGit, + getMostRecentPackageStableTag: mockedGit.getMostRecentPackageStableTag, + createAndPushPackageTag: mockedGit.createAndPushPackageTag, + createBranch: mockedGit.createBranch, + commitPaths: mockedGit.commitPaths, + commitChanges: mockedGit.commitChanges, + getMostRecentPackageTag: mockedGit.getMostRecentPackageTag, + getGroupedFilesByCommitSha: mockedGit.getGroupedFilesByCommitSha, + } as any), + ); beforeEach(() => { vi.clearAllMocks(); @@ -22,46 +63,46 @@ describe("prepareReleaseBranch", () => { defaultBranch: "main", }; - it("skips pull when remote branch does not exist", async () => { - mockedGit.getCurrentBranch.mockResolvedValue(ok("main")); - mockedGit.doesBranchExist.mockResolvedValue(ok(true)); - mockedGit.doesRemoteBranchExist.mockResolvedValue(ok(false)); - mockedGit.checkoutBranch.mockResolvedValue(ok(true)); - mockedGit.rebaseBranch.mockResolvedValue(ok(undefined)); + it.effect("skips pull when remote branch does not exist", () => + withNode(Effect.gen(function* () { + mockedGit.getCurrentBranch.mockReturnValue(Effect.succeed("main") as any); + mockedGit.doesBranchExist.mockReturnValue(Effect.succeed(true) as any); + mockedGit.doesRemoteBranchExist.mockReturnValue(Effect.succeed(false) as any); + mockedGit.checkoutBranch.mockReturnValue(Effect.succeed(true) as any); + mockedGit.rebaseBranch.mockReturnValue(Effect.succeed(undefined) as any); - const result = await prepareReleaseBranch(baseOptions); + yield* prepareReleaseBranch(baseOptions); - expect(result.ok).toBe(true); expect(mockedGit.doesRemoteBranchExist).toHaveBeenCalledWith("release/next", "/workspace"); expect(mockedGit.pullLatestChanges).not.toHaveBeenCalled(); - }); + }))); - it("pulls when remote branch exists", async () => { - mockedGit.getCurrentBranch.mockResolvedValue(ok("main")); - mockedGit.doesBranchExist.mockResolvedValue(ok(true)); - mockedGit.doesRemoteBranchExist.mockResolvedValue(ok(true)); - mockedGit.checkoutBranch.mockResolvedValue(ok(true)); - mockedGit.pullLatestChanges.mockResolvedValue(ok(true)); - mockedGit.rebaseBranch.mockResolvedValue(ok(undefined)); + it.effect("pulls when remote branch exists", () => + withNode(Effect.gen(function* () { + mockedGit.getCurrentBranch.mockReturnValue(Effect.succeed("main") as any); + mockedGit.doesBranchExist.mockReturnValue(Effect.succeed(true) as any); + mockedGit.doesRemoteBranchExist.mockReturnValue(Effect.succeed(true) as any); + mockedGit.checkoutBranch.mockReturnValue(Effect.succeed(true) as any); + mockedGit.pullLatestChanges.mockReturnValue(Effect.succeed(true) as any); + mockedGit.rebaseBranch.mockReturnValue(Effect.succeed(undefined) as any); - const result = await prepareReleaseBranch(baseOptions); + yield* prepareReleaseBranch(baseOptions); - expect(result.ok).toBe(true); expect(mockedGit.pullLatestChanges).toHaveBeenCalledWith("release/next", "/workspace"); - }); + }))); - it("creates branch when it does not exist locally", async () => { - mockedGit.getCurrentBranch.mockResolvedValue(ok("main")); - mockedGit.doesBranchExist.mockResolvedValue(ok(false)); - mockedGit.createBranch.mockResolvedValue(ok(undefined)); - mockedGit.checkoutBranch.mockResolvedValue(ok(true)); - mockedGit.rebaseBranch.mockResolvedValue(ok(undefined)); + it.effect("creates branch when it does not exist locally", () => + withNode(Effect.gen(function* () { + mockedGit.getCurrentBranch.mockReturnValue(Effect.succeed("main") as any); + mockedGit.doesBranchExist.mockReturnValue(Effect.succeed(false) as any); + mockedGit.createBranch.mockReturnValue(Effect.succeed(undefined) as any); + mockedGit.checkoutBranch.mockReturnValue(Effect.succeed(true) as any); + mockedGit.rebaseBranch.mockReturnValue(Effect.succeed(undefined) as any); - const result = await prepareReleaseBranch(baseOptions); + yield* prepareReleaseBranch(baseOptions); - expect(result.ok).toBe(true); expect(mockedGit.createBranch).toHaveBeenCalledWith("release/next", "main", "/workspace"); expect(mockedGit.doesRemoteBranchExist).not.toHaveBeenCalled(); expect(mockedGit.pullLatestChanges).not.toHaveBeenCalled(); - }); + }))); }); diff --git a/test/operations/changelog-format.test.ts b/test/operations/changelog-format.test.ts index a535c2a..a188ce1 100644 --- a/test/operations/changelog-format.test.ts +++ b/test/operations/changelog-format.test.ts @@ -1,4 +1,4 @@ -import { buildTemplateGroups } from "#operations/changelog-format"; +import { buildTemplateGroups } from "#shared/changelog-format"; import type { AuthorInfo, CommitTypeRule } from "#shared/types"; import type { GitCommit } from "commit-parser"; import { describe, expect, it } from "vitest"; diff --git a/test/operations/pr.test.ts b/test/operations/pr.test.ts index a315703..0bbaf79 100644 --- a/test/operations/pr.test.ts +++ b/test/operations/pr.test.ts @@ -1,17 +1,32 @@ -import { createGitHubClient } from "#core/github"; -import { syncPullRequest } from "#operations/pr"; +import { GitHubServiceLive } from "../../src/services/github"; +import { NodeServices } from "@effect/platform-node"; +import { ReleaseOptions } from "../../src/options"; +import { syncPullRequest } from "../../src/release/pr"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; import { HttpResponse } from "msw"; -import { assert, describe, expect, it } from "vitest"; +import { describe } from "vitest"; import { GITHUB_API_BASE, mockFetch } from "../_msw"; -import { createWorkspacePackage } from "../_shared"; +import { createNormalizedReleaseOptions, createWorkspacePackage } from "../_shared"; const OWNER = "ucdjs"; const REPO = "test-repo"; -function makeClient() { - return createGitHubClient({ owner: OWNER, repo: REPO, githubToken: "test-token" }); -} +const runWithGitHub = (effect: Effect.Effect) => + Effect.runPromise( + effect.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.provide( + GitHubServiceLive, + Layer.succeed(ReleaseOptions, createNormalizedReleaseOptions({ owner: OWNER, repo: REPO })), + ), + ), + ), + ) as Effect.Effect, + ); const NO_UPDATES = [ { @@ -43,17 +58,15 @@ describe("syncPullRequest", () => { ), ); - const result = await syncPullRequest({ - github: makeClient(), + const result = await runWithGitHub(syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", pullRequestTitle: "chore: release", updates: NO_UPDATES, - }); + })); - assert(result.ok); - expect(result.value.created).toBe(true); - expect(result.value.pullRequest?.number).toBe(10); + expect(result.created).toBe(true); + expect(result.pullRequest?.number).toBe(10); }); it("updates an existing PR and returns created: false", async () => { @@ -80,16 +93,14 @@ describe("syncPullRequest", () => { }), ); - const result = await syncPullRequest({ - github: makeClient(), + const result = await runWithGitHub(syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", updates: NO_UPDATES, - }); + })); - assert(result.ok); - expect(result.value.created).toBe(false); - expect(result.value.pullRequest?.number).toBe(5); + expect(result.created).toBe(false); + expect(result.pullRequest?.number).toBe(5); }); it("preserves the existing PR title instead of overriding it", async () => { @@ -120,13 +131,12 @@ describe("syncPullRequest", () => { }); }); - await syncPullRequest({ - github: makeClient(), + await runWithGitHub(syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", pullRequestTitle: "chore: caller title", updates: NO_UPDATES, - }); + })); expect(capturedTitle).toBe("chore: preserved title"); }); @@ -153,13 +163,12 @@ describe("syncPullRequest", () => { ); }); - await syncPullRequest({ - github: makeClient(), + await runWithGitHub(syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", pullRequestTitle: "chore: caller title", updates: NO_UPDATES, - }); + })); expect(capturedTitle).toBe("chore: caller title"); }); @@ -186,12 +195,11 @@ describe("syncPullRequest", () => { ); }); - await syncPullRequest({ - github: makeClient(), + await runWithGitHub(syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", updates: NO_UPDATES, - }); + })); expect(capturedTitle).toBe("chore: update package versions"); }); @@ -201,16 +209,11 @@ describe("syncPullRequest", () => { HttpResponse.json({ message: "Bad credentials" }, { status: 401 }), ); - const result = await syncPullRequest({ - github: makeClient(), + await expect(runWithGitHub(syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", updates: NO_UPDATES, - }); - - assert(!result.ok); - expect(result.error.type).toBe("github"); - expect(result.error.operation).toBe("getExistingPullRequest"); + }))).rejects.toMatchObject({ _tag: "GitHubError", operation: "getExistingPullRequest" }); }); it("returns err when upsertPullRequest fails", async () => { @@ -221,15 +224,10 @@ describe("syncPullRequest", () => { HttpResponse.json({ message: "Validation failed" }, { status: 422 }), ); - const result = await syncPullRequest({ - github: makeClient(), + await expect(runWithGitHub(syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", updates: NO_UPDATES, - }); - - assert(!result.ok); - expect(result.error.type).toBe("github"); - expect(result.error.operation).toBe("upsertPullRequest"); + }))).rejects.toMatchObject({ _tag: "GitHubError", operation: "upsertPullRequest" }); }); }); diff --git a/test/operations/semver.test.ts b/test/operations/semver.test.ts index 659d85a..38aefb8 100644 --- a/test/operations/semver.test.ts +++ b/test/operations/semver.test.ts @@ -4,7 +4,7 @@ import { getNextVersion, getPrereleaseIdentifier, isValidSemver, -} from "#operations/semver"; +} from "#shared/semver"; import { describe, expect, it } from "vitest"; describe("semver operations", () => { diff --git a/test/operations/version.test.ts b/test/operations/version.test.ts index d050c16..9b7fff2 100644 --- a/test/operations/version.test.ts +++ b/test/operations/version.test.ts @@ -1,4 +1,4 @@ -import { determineHighestBump } from "#operations/version"; +import { determineHighestBump } from "#shared/version"; import { describe, expect, it } from "vitest"; import { createCommit } from "../_shared"; diff --git a/test/shared/runtime.test.ts b/test/shared/runtime.test.ts new file mode 100644 index 0000000..7d1b76c --- /dev/null +++ b/test/shared/runtime.test.ts @@ -0,0 +1,48 @@ +import process from "node:process"; + +import { NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Option } from "effect"; + +import { CommandError, runCommandEffect } from "../../src/shared/utils"; + +it.effect("runCommandEffect captures stdout with pipe stdio", () => + Effect.scoped( + Effect.gen(function* () { + const result = yield* runCommandEffect( + process.execPath, + ["-e", "process.stdout.write('ok')"], + { + nodeOptions: { + stdio: "pipe", + }, + }, + ).pipe(Effect.provide(NodeServices.layer)); + + expect(result.stdout).toBe("ok"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }), + )); + +it.effect("runCommandEffect fails with CommandError on non-zero exit", () => + Effect.scoped( + Effect.gen(function* () { + const exit = yield* Effect.exit( + runCommandEffect(process.execPath, ["-e", "process.stderr.write('boom'); process.exit(2)"], { + nodeOptions: { + stdio: "pipe", + }, + }).pipe(Effect.provide(NodeServices.layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + + if (Exit.isFailure(exit)) { + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + expect(error).toBeInstanceOf(CommandError); + expect(error?.stderr).toContain("boom"); + expect(error?.exitCode).toBe(2); + } + }), + )); diff --git a/test/shared/utils.test.ts b/test/shared/utils.test.ts index e9fa540..b771730 100644 --- a/test/shared/utils.test.ts +++ b/test/shared/utils.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe } from "vitest"; +import { expect, it } from "@effect/vitest"; import { getIsCI } from "../../src/shared/utils"; @@ -17,33 +19,39 @@ describe("getIsCI", () => { } }); - it("returns true when CI=true", () => { - process.env.CI = "true"; - expect(getIsCI()).toBe(true); - }); - - it("returns true when CI is non-empty string", () => { - process.env.CI = "1"; - expect(getIsCI()).toBe(true); - }); - - it("returns false when CI is unset", () => { - delete process.env.CI; - expect(getIsCI()).toBe(false); - }); + it.effect("returns true when CI=true", () => + Effect.sync(() => { + process.env.CI = "true"; + expect(getIsCI()).toBe(true); + })); - it("returns false when CI=false", () => { - process.env.CI = "false"; - expect(getIsCI()).toBe(false); - }); - - it("returns false when CI is empty string", () => { - process.env.CI = ""; - expect(getIsCI()).toBe(false); - }); + it.effect("returns true when CI is non-empty string", () => + Effect.sync(() => { + process.env.CI = "1"; + expect(getIsCI()).toBe(true); + })); - it("returns false when CI=FALSE (case insensitive)", () => { - process.env.CI = "FALSE"; - expect(getIsCI()).toBe(false); - }); + it.effect("returns false when CI is unset", () => + Effect.sync(() => { + delete process.env.CI; + expect(getIsCI()).toBe(false); + })); + + it.effect("returns false when CI=false", () => + Effect.sync(() => { + process.env.CI = "false"; + expect(getIsCI()).toBe(false); + })); + + it.effect("returns false when CI is empty string", () => + Effect.sync(() => { + process.env.CI = ""; + expect(getIsCI()).toBe(false); + })); + + it.effect("returns false when CI=FALSE (case insensitive)", () => + Effect.sync(() => { + process.env.CI = "FALSE"; + expect(getIsCI()).toBe(false); + })); }); diff --git a/test/types/result.test.ts b/test/types/result.test.ts deleted file mode 100644 index 1c064f7..0000000 --- a/test/types/result.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { err, isErr, isOk, ok } from "../../src/types"; - -describe("result", () => { - it("creates ok values", () => { - const result = ok(123); - expect(result.ok).toBe(true); - expect(result.value).toBe(123); - expect(isOk(result)).toBe(true); - expect(isErr(result)).toBe(false); - }); - - it("creates err values", () => { - const result = err("boom"); - expect(result.ok).toBe(false); - expect(result.error).toBe("boom"); - expect(isOk(result)).toBe(false); - expect(isErr(result)).toBe(true); - }); -}); diff --git a/test/versioning/commits.test.ts b/test/versioning/commits.test.ts index cd06236..3fe3d94 100644 --- a/test/versioning/commits.test.ts +++ b/test/versioning/commits.test.ts @@ -1,4 +1,4 @@ -import { determineHighestBump } from "#operations/version"; +import { determineHighestBump } from "#shared/version"; import { describe, expect, it } from "vitest"; import { createCommit } from "../_shared"; diff --git a/test/versioning/package-graph.test.ts b/test/versioning/package-graph.test.ts index bf12f25..7ff9576 100644 --- a/test/versioning/package-graph.test.ts +++ b/test/versioning/package-graph.test.ts @@ -1,4 +1,4 @@ -import { getNextVersion } from "#operations/semver"; +import { getNextVersion } from "#shared/semver"; import type { PackageRelease } from "#shared/types"; import { buildPackageDependencyGraph, diff --git a/test/versioning/version-dependent-updates.test.ts b/test/versioning/version-dependent-updates.test.ts index 763218b..29a1381 100644 --- a/test/versioning/version-dependent-updates.test.ts +++ b/test/versioning/version-dependent-updates.test.ts @@ -1,13 +1,19 @@ +import { PromptServiceLive } from "../../src/services/prompts"; import type { PackageRelease } from "#shared/types"; import { calculateAndPrepareVersionUpdates } from "#versioning/version"; +import { Effect } from "effect"; import { describe, expect, it, vi } from "vitest"; import { createWorkspacePackage } from "../_shared"; -vi.mock("#core/prompts", () => ({ - confirmOverridePrompt: vi.fn(), - selectVersionPrompt: vi.fn(), -})); +vi.mock("../../src/services/prompts", async () => { + const actual = await vi.importActual("../../src/services/prompts"); + return { + ...actual, + confirmOverridePrompt: vi.fn(), + selectVersionPrompt: vi.fn(), + }; +}); describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { it("adds dependent patch bumps and preserves direct updates", async () => { @@ -38,14 +44,16 @@ describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { ]); const globalCommitsPerPackage = new Map(); - const result = await calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits, - workspaceRoot: "/repo", - showPrompt: false, - globalCommitsPerPackage, - overrides: {}, - }); + const result = await Effect.runPromise( + calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits, + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage, + overrides: {}, + }).pipe(Effect.provide(PromptServiceLive)), + ); const byName = new Map(result.allUpdates.map((update) => [update.package.name, update])); @@ -83,16 +91,18 @@ describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { ]); const globalCommitsPerPackage = new Map(); - const result = await calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits, - workspaceRoot: "/repo", - showPrompt: false, - globalCommitsPerPackage, - overrides: { - "pkg-a": { type: "none", version: "1.0.0" }, - }, - }); + const result = await Effect.runPromise( + calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits, + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage, + overrides: { + "pkg-a": { type: "none", version: "1.0.0" }, + }, + }).pipe(Effect.provide(PromptServiceLive)), + ); const updatedNames = result.allUpdates.map((update) => update.package.name).toSorted(); expect(updatedNames).toEqual(["pkg-b"]); @@ -105,14 +115,16 @@ describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { }); const workspacePackages = [pkgA]; - const result = await calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits: new Map(), - workspaceRoot: "/repo", - showPrompt: false, - globalCommitsPerPackage: new Map(), - overrides: {}, - }); + const result = await Effect.runPromise( + calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits: new Map(), + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage: new Map(), + overrides: {}, + }).pipe(Effect.provide(PromptServiceLive)), + ); expect(result.allUpdates).toEqual([] as PackageRelease[]); }); From 390a79e3974d4c811a9ddfccb2027bd01e8a0793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20N=C3=B8rg=C3=A5rd?= Date: Mon, 11 May 2026 17:58:43 +0200 Subject: [PATCH 3/6] refactor: remove redundant service facade exports --- src/index.ts | 25 +++-- src/services/changelog.ts | 32 +------ src/services/git.ts | 143 ---------------------------- src/services/github.ts | 31 ------ src/services/npm.ts | 18 ---- src/services/prompts.ts | 33 ------- src/services/workspace.ts | 8 -- src/versioning/commits.ts | 8 +- test/core/changelog.authors.test.ts | 24 ++--- test/core/changelog.test.ts | 58 ++++++++--- test/core/git.test.ts | 72 ++++++++------ test/core/github.test.ts | 53 ++++++----- test/core/npm.test.ts | 39 +++++--- test/operations/branch.test.ts | 42 ++++---- 14 files changed, 204 insertions(+), 382 deletions(-) diff --git a/src/index.ts b/src/index.ts index c808ad6..170af36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,8 +11,7 @@ import { verifyWorkflow as verify } from "./release/verify"; import type { WorkspacePackage } from "./services/workspace"; import { GitHubServiceLive } from "./services/github"; import { PromptServiceLive } from "./services/prompts"; -import { discoverWorkspacePackages } from "./services/workspace"; -import { WorkspaceServiceLive } from "./services/workspace"; +import { WorkspaceService, WorkspaceServiceLive } from "./services/workspace"; import type { ReleaseScriptsOptionsInput } from "./options"; import { normalizeReleaseScriptsOptions, ReleaseOptions } from "./options"; import { GitServiceLive } from "./services/git"; @@ -77,14 +76,26 @@ export function createReleaseScripts( }, packages: { list(): Promise { - return runEffect(discoverWorkspacePackages(normalizedOptions.workspaceRoot, normalizedOptions)); + return runEffect( + Effect.gen(function* () { + const workspace = yield* WorkspaceService; + return yield* workspace.discoverWorkspacePackages( + normalizedOptions.workspaceRoot, + normalizedOptions, + ); + }), + ); }, get(packageName: string): Promise { return runEffect( - Effect.map( - discoverWorkspacePackages(normalizedOptions.workspaceRoot, normalizedOptions), - (packages) => packages.find((p) => p.name === packageName), - ), + Effect.gen(function* () { + const workspace = yield* WorkspaceService; + const packages = yield* workspace.discoverWorkspacePackages( + normalizedOptions.workspaceRoot, + normalizedOptions, + ); + return packages.find((p) => p.name === packageName); + }), ); }, }, diff --git a/src/services/changelog.ts b/src/services/changelog.ts index 23b8dd7..28c42dc 100644 --- a/src/services/changelog.ts +++ b/src/services/changelog.ts @@ -9,7 +9,7 @@ import { Context, Effect, FileSystem, Layer } from "effect"; import type { GitCommit } from "commit-parser"; import { Eta } from "eta"; -import { readFileFromGit } from "./git"; +import { GitService } from "./git"; import { GitHubService } from "./github"; import type { WorkspacePackage } from "./workspace"; @@ -133,6 +133,7 @@ export const makeChangelogService = Effect.fn("makeChangelogService")(function* const updateChangelog: ChangelogServiceShape["updateChangelog"] = Effect.fn("updateChangelog")(function* (options) { const fs = yield* FileSystem.FileSystem; + const git = yield* GitService; const { version, previousVersion, @@ -155,7 +156,7 @@ export const makeChangelogService = Effect.fn("makeChangelogService")(function* join(workspacePackage.path, "CHANGELOG.md"), ); - const existingContent = yield* readFileFromGit( + const existingContent = yield* git.readFileFromGit( normalizedOptions.workspaceRoot, normalizedOptions.branch.default, changelogRelativePath, @@ -215,33 +216,6 @@ export const makeChangelogService = Effect.fn("makeChangelogService")(function* export const ChangelogServiceLive = Layer.effect(ChangelogService, makeChangelogService()); -export const generateChangelogEntry = Effect.fn("generateChangelogEntry")(function* (options: { - packageName: string; - version: string; - previousVersion?: string; - date: string; - commits: GitCommit[]; - owner: string; - repo: string; - types: Record; - template?: string; -}) { - const changelog = yield* ChangelogService; - return yield* changelog.generateChangelogEntry(options); -}); - -export const updateChangelog = Effect.fn("updateChangelog")(function* (options: { - normalizedOptions: NormalizedReleaseScriptsOptions; - workspacePackage: WorkspacePackage; - version: string; - previousVersion?: string; - commits: GitCommit[]; - date: string; -}) { - const changelog = yield* ChangelogService; - return yield* changelog.updateChangelog(options); -}); - // formatCommitLine moved to operations/changelog-format export function parseChangelog(content: string) { diff --git a/src/services/git.ts b/src/services/git.ts index 8d8f835..d0030c0 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -715,146 +715,3 @@ export const makeGitService = Effect.fn("makeGitService")(function* () { }); export const GitServiceLive = Layer.effect(GitService, makeGitService()); - -export const isWorkingDirectoryClean = Effect.fn("isWorkingDirectoryClean")(function* (workspaceRoot: string) { - const git = yield* GitService; - return yield* git.isWorkingDirectoryClean(workspaceRoot); -}); - -export const getCurrentBranch = Effect.fn("getCurrentBranch")(function* (workspaceRoot: string) { - const git = yield* GitService; - return yield* git.getCurrentBranch(workspaceRoot); -}); - -export const checkoutBranch = Effect.fn("checkoutBranch")(function* (branch: string, workspaceRoot: string) { - const git = yield* GitService; - return yield* git.checkoutBranch(branch, workspaceRoot); -}); - -export const commitPaths = Effect.fn("commitPaths")(function* ( - paths: string[], - message: string, - workspaceRoot: string, -) { - const git = yield* GitService; - return yield* git.commitPaths(paths, message, workspaceRoot); -}); - -export const pushBranch = Effect.fn("pushBranch")(function* ( - branch: string, - workspaceRoot: string, - options?: { force?: boolean; forceWithLease?: boolean }, -) { - const git = yield* GitService; - return yield* git.pushBranch(branch, workspaceRoot, options); -}); - -export const getMostRecentPackageStableTag = Effect.fn("getMostRecentPackageStableTag")(function* ( - workspaceRoot: string, - packageName: string, -) { - const git = yield* GitService; - return yield* git.getMostRecentPackageStableTag(workspaceRoot, packageName); -}); - -export const doesRemoteBranchExist = Effect.fn("doesRemoteBranchExist")(function* ( - branch: string, - workspaceRoot: string, -) { - const git = yield* GitService; - return yield* git.doesRemoteBranchExist(branch, workspaceRoot); -}); - -export const doesBranchExist = Effect.fn("doesBranchExist")(function* ( - branch: string, - workspaceRoot: string, -) { - const git = yield* GitService; - return yield* git.doesBranchExist(branch, workspaceRoot); -}); - -export const getDefaultBranch = Effect.fn("getDefaultBranch")(function* (workspaceRoot: string) { - const git = yield* GitService; - return yield* git.getDefaultBranch(workspaceRoot); -}); - -export const getAvailableBranches = Effect.fn("getAvailableBranches")(function* (workspaceRoot: string) { - const git = yield* GitService; - return yield* git.getAvailableBranches(workspaceRoot); -}); - -export const createBranch = Effect.fn("createBranch")(function* ( - branch: string, - base: string, - workspaceRoot: string, -) { - const git = yield* GitService; - return yield* git.createBranch(branch, base, workspaceRoot); -}); - -export const pullLatestChanges = Effect.fn("pullLatestChanges")(function* ( - branch: string, - workspaceRoot: string, -) { - const git = yield* GitService; - return yield* git.pullLatestChanges(branch, workspaceRoot); -}); - -export const rebaseBranch = Effect.fn("rebaseBranch")(function* ( - ontoBranch: string, - workspaceRoot: string, -) { - const git = yield* GitService; - return yield* git.rebaseBranch(ontoBranch, workspaceRoot); -}); - -export const isBranchAheadOfRemote = Effect.fn("isBranchAheadOfRemote")(function* ( - branch: string, - workspaceRoot: string, -) { - const git = yield* GitService; - return yield* git.isBranchAheadOfRemote(branch, workspaceRoot); -}); - -export const commitChanges = Effect.fn("commitChanges")(function* ( - message: string, - workspaceRoot: string, -) { - const git = yield* GitService; - return yield* git.commitChanges(message, workspaceRoot); -}); - -export const readFileFromGit = Effect.fn("readFileFromGit")(function* ( - workspaceRoot: string, - ref: string, - filePath: string, -) { - const git = yield* GitService; - return yield* git.readFileFromGit(workspaceRoot, ref, filePath); -}); - -export const getMostRecentPackageTag = Effect.fn("getMostRecentPackageTag")(function* ( - workspaceRoot: string, - packageName: string, -) { - const git = yield* GitService; - return yield* git.getMostRecentPackageTag(workspaceRoot, packageName); -}); - -export const getGroupedFilesByCommitSha = Effect.fn("getGroupedFilesByCommitSha")(function* ( - workspaceRoot: string, - from: string, - to: string, -) { - const git = yield* GitService; - return yield* git.getGroupedFilesByCommitSha(workspaceRoot, from, to); -}); - -export const createAndPushPackageTag = Effect.fn("createAndPushPackageTag")(function* ( - packageName: string, - version: string, - workspaceRoot: string, -) { - const git = yield* GitService; - return yield* git.createAndPushPackageTag(packageName, version, workspaceRoot); -}); diff --git a/src/services/github.ts b/src/services/github.ts index 6e2bbd6..26a8b39 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -445,37 +445,6 @@ export const makeGitHubService = Effect.fn("makeGitHubService")(function* () { export const GitHubServiceLive = Layer.effect(GitHubService, makeGitHubService()); -export const getExistingPullRequest = Effect.fn("getExistingPullRequest")(function* (branch: string) { - const github = yield* GitHubService; - return yield* github.getExistingPullRequest(branch); -}); - -export const upsertPullRequest = Effect.fn("upsertPullRequest")(function* ( - options: UpsertPullRequestOptions, -) { - const github = yield* GitHubService; - return yield* github.upsertPullRequest(options); -}); - -export const setCommitStatus = Effect.fn("setCommitStatus")(function* ( - options: CommitStatusOptions & { sha: string }, -) { - const github = yield* GitHubService; - return yield* github.setCommitStatus(options); -}); - -export const upsertReleaseByTag = Effect.fn("upsertReleaseByTag")(function* ( - options: UpsertReleaseOptions, -) { - const github = yield* GitHubService; - return yield* github.upsertReleaseByTag(options); -}); - -export const resolveAuthorInfo = Effect.fn("resolveAuthorInfo")(function* (info: AuthorInfo) { - const github = yield* GitHubService; - return yield* github.resolveAuthorInfo(info); -}); - export { toGitHubError }; const NON_WHITESPACE_RE = /\S/; diff --git a/src/services/npm.ts b/src/services/npm.ts index 17415d4..c018e58 100644 --- a/src/services/npm.ts +++ b/src/services/npm.ts @@ -242,24 +242,6 @@ export const makeNpmService = Effect.fn("makeNpmService")(function* () { export const NpmServiceLive = Layer.effect(NpmService, makeNpmService()); -export const checkVersionExists = Effect.fn("checkVersionExists")(function* ( - packageName: string, - version: string, -) { - const npm = yield* NpmService; - return yield* npm.checkVersionExists(packageName, version); -}); - -export const publishPackage = Effect.fn("publishPackage")(function* ( - packageName: string, - version: string, - workspaceRoot: string, - options: NormalizedReleaseScriptsOptions, -) { - const npm = yield* NpmService; - return yield* npm.publishPackage(packageName, version, workspaceRoot, options); -}); - export interface PublishStatus { published: string[]; skipped: string[]; diff --git a/src/services/prompts.ts b/src/services/prompts.ts index 0879be1..4a62084 100644 --- a/src/services/prompts.ts +++ b/src/services/prompts.ts @@ -253,37 +253,4 @@ export const makePromptService = Effect.fn("makePromptService")(function* () { }); }); -export const selectPackagePrompt = Effect.fn("selectPackagePrompt")(function* (packages: WorkspacePackage[]) { - const prompts = yield* PromptService; - return yield* prompts.selectPackagePrompt(packages); -}); - -export const selectVersionPrompt = Effect.fn("selectVersionPrompt")(function* ( - workspaceRoot: string, - pkg: WorkspacePackage, - currentVersion: string, - suggestedVersion: string, - options?: { - defaultChoice?: "auto" | "skip" | "suggested" | "as-is"; - suggestedHint?: string; - }, -) { - const prompts = yield* PromptService; - return yield* prompts.selectVersionPrompt( - workspaceRoot, - pkg, - currentVersion, - suggestedVersion, - options, - ); -}); - -export const confirmOverridePrompt = Effect.fn("confirmOverridePrompt")(function* ( - pkg: WorkspacePackage, - overrideVersion: string, -) { - const prompts = yield* PromptService; - return yield* prompts.confirmOverridePrompt(pkg, overrideVersion); -}); - export const PromptServiceLive = Layer.effect(PromptService, makePromptService()); diff --git a/src/services/workspace.ts b/src/services/workspace.ts index 51a0334..c0609af 100644 --- a/src/services/workspace.ts +++ b/src/services/workspace.ts @@ -204,12 +204,4 @@ export const makeWorkspaceService = Effect.fn("makeWorkspaceService")(function* }); }); -export const discoverWorkspacePackages = Effect.fn("discoverWorkspacePackages")(function* ( - workspaceRoot: string, - options: NormalizedReleaseScriptsOptions, -) { - const workspace = yield* WorkspaceService; - return yield* workspace.discoverWorkspacePackages(workspaceRoot, options); -}); - export const WorkspaceServiceLive = Layer.effect(WorkspaceService, makeWorkspaceService()); diff --git a/src/versioning/commits.ts b/src/versioning/commits.ts index c4c1b6f..a3bc10a 100644 --- a/src/versioning/commits.ts +++ b/src/versioning/commits.ts @@ -1,4 +1,4 @@ -import { getGroupedFilesByCommitSha, getMostRecentPackageTag } from "../services/git"; +import { GitService } from "../services/git"; import type { WorkspacePackage } from "../services/workspace"; import { logger } from "../shared/utils"; import type { GitCommit } from "commit-parser"; @@ -17,6 +17,7 @@ import farver from "farver"; export const getWorkspacePackageGroupedCommits = Effect.fn( "getWorkspacePackageGroupedCommits", )(function* (workspaceRoot: string, packages: WorkspacePackage[]) { + const git = yield* GitService; const changedPackages = new Map(); const loadPackageCommits = Effect.fn("loadPackageCommits")(function* ( @@ -42,7 +43,7 @@ export const getWorkspacePackageGroupedCommits = Effect.fn( const results = yield* Effect.all( packages.map((pkg) => Effect.fn("getPackageCommitGroup")(function* () { - const lastTagExit = yield* Effect.exit(getMostRecentPackageTag(workspaceRoot, pkg.name)); + const lastTagExit = yield* Effect.exit(git.getMostRecentPackageTag(workspaceRoot, pkg.name)); const lastTag = lastTagExit._tag === "Success" ? lastTagExit.value : undefined; const allCommits = yield* loadPackageCommits(pkg, lastTag); @@ -245,7 +246,8 @@ export const getGlobalCommitsPerPackage = Effect.fn("getGlobalCommitsPerPackage" `${farver.cyan(commitRange.oldest)}..${farver.cyan(commitRange.newest)}`, ); - const commitFilesMap = yield* getGroupedFilesByCommitSha( + const git = yield* GitService; + const commitFilesMap = yield* git.getGroupedFilesByCommitSha( workspaceRoot, commitRange.oldest, commitRange.newest, diff --git a/test/core/changelog.authors.test.ts b/test/core/changelog.authors.test.ts index 0c22d35..2db4b2c 100644 --- a/test/core/changelog.authors.test.ts +++ b/test/core/changelog.authors.test.ts @@ -1,5 +1,4 @@ -import { generateChangelogEntry } from "../../src/services/changelog"; -import { ChangelogServiceLive } from "../../src/services/changelog"; +import { ChangelogService, ChangelogServiceLive } from "../../src/services/changelog"; import { GitHubService } from "../../src/services/github"; import { GitServiceLive } from "../../src/services/git"; import { NodeServices } from "@effect/platform-node"; @@ -25,15 +24,18 @@ describe("generateChangelogEntry author rendering", () => { }); const entry = await Effect.runPromise( - generateChangelogEntry({ - packageName: "@ucdjs/test", - version: "1.0.1", - previousVersion: "1.0.0", - date: "2025-11-18", - commits, - owner: "ucdjs", - repo: "release-scripts", - types: DEFAULT_TYPES, + Effect.gen(function* () { + const changelog = yield* ChangelogService; + return yield* changelog.generateChangelogEntry({ + packageName: "@ucdjs/test", + version: "1.0.1", + previousVersion: "1.0.0", + date: "2025-11-18", + commits, + owner: "ucdjs", + repo: "release-scripts", + types: DEFAULT_TYPES, + }); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/test/core/changelog.test.ts b/test/core/changelog.test.ts index f1c1632..7290f81 100644 --- a/test/core/changelog.test.ts +++ b/test/core/changelog.test.ts @@ -3,17 +3,20 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { GitServiceLive } from "../../src/services/git"; -import { ChangelogServiceLive } from "../../src/services/changelog"; -import { generateChangelogEntry, parseChangelog, updateChangelog } from "../../src/services/changelog"; +import { ChangelogService, ChangelogServiceLive, parseChangelog } from "../../src/services/changelog"; import { GitHubService } from "../../src/services/github"; +import type { NormalizedReleaseScriptsOptions } from "../../src/options"; import { runEffect } from "#shared/utils"; import { dedent } from "@luxass/utils"; import { Effect, Layer } from "effect"; +import type { GitCommit } from "commit-parser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { testdir } from "vitest-testdirs"; import { DEFAULT_TYPES } from "../../src/options"; import { createChangelogTestContext, createCommit, createGitHubServiceStub } from "../_shared"; +import type { CommitTypeRule } from "../../src/shared/types"; +import type { WorkspacePackage } from "../../src/services/workspace"; vi.mock("#shared/utils", async () => { const actual = await vi.importActual("#shared/utils"); @@ -41,6 +44,33 @@ const runNode = (effect: Effect.Effect, githubService = create ), ) as Effect.Effect, ); +const generateEntry = (options: { + packageName: string; + version: string; + previousVersion?: string; + date: string; + commits: GitCommit[]; + owner: string; + repo: string; + types: Record; + template?: string; +}) => + Effect.gen(function* () { + const changelog = yield* ChangelogService; + return yield* changelog.generateChangelogEntry(options); + }); +const applyChangelogUpdate = (options: { + normalizedOptions: NormalizedReleaseScriptsOptions; + workspacePackage: WorkspacePackage; + version: string; + previousVersion?: string; + commits: GitCommit[]; + date: string; +}) => + Effect.gen(function* () { + const changelog = yield* ChangelogService; + return yield* changelog.updateChangelog(options); + }); beforeEach(() => { vi.clearAllMocks(); @@ -68,7 +98,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateChangelogEntry({ + const entry = await runNode(generateEntry({ ...baseEntryOptions, version: "0.2.0", previousVersion: "0.1.0", @@ -96,7 +126,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateChangelogEntry({ + const entry = await runNode(generateEntry({ ...baseEntryOptions, version: "0.1.1", previousVersion: "0.1.0", @@ -137,7 +167,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateChangelogEntry({ + const entry = await runNode(generateEntry({ ...baseEntryOptions, version: "0.3.0", previousVersion: "0.2.0", @@ -168,7 +198,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateChangelogEntry({ + const entry = await runNode(generateEntry({ ...baseEntryOptions, version: "0.1.0", date: "2025-01-16", @@ -195,7 +225,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateChangelogEntry({ + const entry = await runNode(generateEntry({ ...baseEntryOptions, version: "0.1.1", previousVersion: "0.1.0", @@ -223,7 +253,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateChangelogEntry({ + const entry = await runNode(generateEntry({ ...baseEntryOptions, version: "0.1.1", previousVersion: "0.1.0", @@ -388,7 +418,7 @@ describe("updateChangelog", () => { }), ]; - await runNode(updateChangelog({ + await runNode(applyChangelogUpdate({ normalizedOptions, workspacePackage, version: "0.1.0", @@ -425,7 +455,7 @@ describe("updateChangelog", () => { mockRun.mockReturnValueOnce(Effect.fail(new Error("fatal: path 'CHANGELOG.md' does not exist")) as any); - await runNode(updateChangelog({ + await runNode(applyChangelogUpdate({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.1.0", @@ -444,7 +474,7 @@ describe("updateChangelog", () => { mockRun.mockReturnValueOnce(Effect.succeed({ stdout: existingChangelog, stderr: "", exitCode: 0 }) as any); - await runNode(updateChangelog({ + await runNode(applyChangelogUpdate({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.2.0", @@ -480,7 +510,7 @@ describe("updateChangelog", () => { mockRun.mockReturnValueOnce(Effect.fail(new Error("fatal: path 'CHANGELOG.md' does not exist")) as any); - await runNode(updateChangelog({ + await runNode(applyChangelogUpdate({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.2.0", @@ -497,7 +527,7 @@ describe("updateChangelog", () => { mockRun.mockReturnValueOnce(Effect.fail(new Error("fatal: path 'CHANGELOG.md' does not exist")) as any); - await runNode(updateChangelog({ + await runNode(applyChangelogUpdate({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.2.0", @@ -545,7 +575,7 @@ describe("updateChangelog", () => { mockRun.mockReturnValueOnce(Effect.succeed({ stdout: existingChangelog, stderr: "", exitCode: 0 }) as any); - await runNode(updateChangelog({ + await runNode(applyChangelogUpdate({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.1.0", diff --git a/test/core/git.test.ts b/test/core/git.test.ts index 82ef623..21e8a15 100644 --- a/test/core/git.test.ts +++ b/test/core/git.test.ts @@ -1,14 +1,7 @@ import { NodeServices } from "@effect/platform-node"; import { + GitService, GitServiceLive, - createBranch, - doesBranchExist, - doesRemoteBranchExist, - getAvailableBranches, - getCurrentBranch, - getDefaultBranch, - getMostRecentPackageTag, - isWorkingDirectoryClean, } from "../../src/services/git"; import { runEffect, runIfNotDryEffect } from "#shared/utils"; import { expect, it, layer } from "@effect/vitest"; @@ -46,7 +39,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, }) as any); - const result = yield* isWorkingDirectoryClean("/workspace"); + const git = yield* GitService; + const result = yield* git.isWorkingDirectoryClean("/workspace"); expect(mockRunEffect).toHaveBeenCalledWith( "git", ["status", "--porcelain"], @@ -69,7 +63,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, }) as any); - const result = yield* isWorkingDirectoryClean("/workspace"); + const git = yield* GitService; + const result = yield* git.isWorkingDirectoryClean("/workspace"); expect(result).toBe(false); }))); @@ -78,7 +73,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) const gitError = new Error("fatal: not a git repository"); mockRunEffect.mockReturnValue(Effect.fail(gitError) as any); - const exit = yield* Effect.exit(isWorkingDirectoryClean("/workspace")); + const git = yield* GitService; + const exit = yield* Effect.exit(git.isWorkingDirectoryClean("/workspace")); assert(exit._tag === "Failure"); const error = Cause.squash(exit.cause) as any; @@ -97,7 +93,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, }) as any); - const result = yield* doesRemoteBranchExist("main", "/workspace"); + const git = yield* GitService; + const result = yield* git.doesRemoteBranchExist("main", "/workspace"); expect(mockRunEffect).toHaveBeenCalledWith( "git", ["ls-remote", "--exit-code", "--heads", "origin", "main"], @@ -116,7 +113,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) asTest(Effect.gen(function* () { mockRunEffect.mockReturnValue(Effect.fail(new Error("exit code 2")) as any); - const result = yield* doesRemoteBranchExist("release/next", "/workspace"); + const git = yield* GitService; + const result = yield* git.doesRemoteBranchExist("release/next", "/workspace"); expect(result).toBe(false); }))); }); @@ -130,7 +128,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, }) as any); - const result = yield* doesBranchExist("feature-branch", "/workspace"); + const git = yield* GitService; + const result = yield* git.doesBranchExist("feature-branch", "/workspace"); expect(mockRunEffect).toHaveBeenCalledWith( "git", ["rev-parse", "--verify", "feature-branch"], @@ -149,7 +148,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) asTest(Effect.gen(function* () { mockRunEffect.mockReturnValue(Effect.fail(new Error("fatal: Needed a single revision")) as any); - const result = yield* doesBranchExist("nonexistent-branch", "/workspace"); + const git = yield* GitService; + const result = yield* git.doesBranchExist("nonexistent-branch", "/workspace"); expect(result).toBe(false); }))); }); @@ -163,7 +163,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, }) as any); - const result = yield* getDefaultBranch("/workspace"); + const git = yield* GitService; + const result = yield* git.getDefaultBranch("/workspace"); expect(mockRunEffect).toHaveBeenCalledWith( "git", @@ -186,7 +187,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, }) as any); - const result = yield* getDefaultBranch("/workspace"); + const git = yield* GitService; + const result = yield* git.getDefaultBranch("/workspace"); expect(result).toBe("develop"); }))); @@ -195,7 +197,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) asTest(Effect.gen(function* () { mockRunEffect.mockReturnValue(Effect.fail(new Error("Some git error")) as any); - const result = yield* getDefaultBranch("/workspace"); + const git = yield* GitService; + const result = yield* git.getDefaultBranch("/workspace"); expect(result).toBe("main"); }))); @@ -208,7 +211,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, }) as any); - const result = yield* getDefaultBranch("/workspace"); + const git = yield* GitService; + const result = yield* git.getDefaultBranch("/workspace"); expect(result).toBe("main"); }))); @@ -223,7 +227,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, }) as any); - const result = yield* getCurrentBranch("/workspace"); + const git = yield* GitService; + const result = yield* git.getCurrentBranch("/workspace"); expect(mockRunEffect).toHaveBeenCalledWith( "git", @@ -243,7 +248,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) asTest(Effect.gen(function* () { mockRunEffect.mockReturnValue(Effect.fail(new Error("Some git error")) as any); - const exit = yield* Effect.exit(getCurrentBranch("/workspace")); + const git = yield* GitService; + const exit = yield* Effect.exit(git.getCurrentBranch("/workspace")); assert(exit._tag === "Failure"); expect((Cause.squash(exit.cause) as any).operation).toBe("getCurrentBranch"); }))); @@ -258,7 +264,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, }) as any); - const result = yield* getAvailableBranches("/workspace"); + const git = yield* GitService; + const result = yield* git.getAvailableBranches("/workspace"); expect(mockRunEffect).toHaveBeenCalledWith( "git", @@ -278,7 +285,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) asTest(Effect.gen(function* () { mockRunEffect.mockReturnValue(Effect.fail(new Error("Some git error")) as any); - const exit = yield* Effect.exit(getAvailableBranches("/workspace")); + const git = yield* GitService; + const exit = yield* Effect.exit(git.getAvailableBranches("/workspace")); assert(exit._tag === "Failure"); expect((Cause.squash(exit.cause) as any).operation).toBe("getAvailableBranches"); }))); @@ -293,7 +301,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, }) as any); - const result = yield* createBranch("new-feature", "main", "/workspace"); + const git = yield* GitService; + const result = yield* git.createBranch("new-feature", "main", "/workspace"); expect(mockRunIfNotDryEffect).toHaveBeenCalledWith( "git", @@ -312,7 +321,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) asTest(Effect.gen(function* () { mockRunIfNotDryEffect.mockReturnValue(Effect.fail(new Error("Some git error")) as any); - const exit = yield* Effect.exit(createBranch("new-feature", "main", "/workspace")); + const git = yield* GitService; + const exit = yield* Effect.exit(git.createBranch("new-feature", "main", "/workspace")); assert(exit._tag === "Failure"); expect((Cause.squash(exit.cause) as any).operation).toBe("createBranch"); }))); @@ -328,7 +338,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, } as any) as any); - const result = yield* getMostRecentPackageTag("/workspace", "my-package"); + const git = yield* GitService; + const result = yield* git.getMostRecentPackageTag("/workspace", "my-package"); expect(mockRunEffect).toHaveBeenCalledWith( "git", @@ -351,7 +362,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, } as any) as any); - const result = yield* getMostRecentPackageTag("/workspace", "my-package"); + const git = yield* GitService; + const result = yield* git.getMostRecentPackageTag("/workspace", "my-package"); expect(result).toBe("my-package@2.0.0"); }))); @@ -364,7 +376,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, } as any) as any); - const result = yield* getMostRecentPackageTag("/workspace", "my-package"); + const git = yield* GitService; + const result = yield* git.getMostRecentPackageTag("/workspace", "my-package"); expect(result).toBeUndefined(); }))); @@ -377,7 +390,8 @@ layer(Layer.mergeAll(NodeServices.layer, GitServiceLive))("git utilities", (it) exitCode: 0, } as any) as any); - const result = yield* getMostRecentPackageTag("/workspace", "my-package"); + const git = yield* GitService; + const result = yield* git.getMostRecentPackageTag("/workspace", "my-package"); expect(result).toBeUndefined(); }))); diff --git a/test/core/github.test.ts b/test/core/github.test.ts index 1b6e303..69e21b3 100644 --- a/test/core/github.test.ts +++ b/test/core/github.test.ts @@ -1,10 +1,6 @@ import { - getExistingPullRequest, + GitHubService, GitHubServiceLive, - resolveAuthorInfo, - setCommitStatus, - upsertPullRequest, - upsertReleaseByTag, } from "../../src/services/github"; import { NodeServices } from "@effect/platform-node"; import { ReleaseOptions } from "../../src/options"; @@ -37,7 +33,10 @@ const runGitHub = (effect: Effect.Effect) => describe("GitHubService", () => { it("returns null when no open PRs exist", async () => { mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => HttpResponse.json([])); - await expect(runGitHub(getExistingPullRequest("release/next"))).resolves.toBeNull(); + await expect(runGitHub(Effect.gen(function* () { + const github = yield* GitHubService; + return yield* github.getExistingPullRequest("release/next"); + }))).resolves.toBeNull(); }); it("returns the first open PR for the branch", async () => { @@ -54,7 +53,10 @@ describe("GitHubService", () => { ]), ); - const result = await runGitHub(getExistingPullRequest("release/next")); + const result = await runGitHub(Effect.gen(function* () { + const github = yield* GitHubService; + return yield* github.getExistingPullRequest("release/next"); + })); expect(result?.number).toBe(42); expect(result?.head?.sha).toBe("abc1234"); }); @@ -64,7 +66,10 @@ describe("GitHubService", () => { HttpResponse.json([{ number: "not-a-number" }]), ); - await expect(runGitHub(getExistingPullRequest("release/next"))).rejects.toMatchObject({ + await expect(runGitHub(Effect.gen(function* () { + const github = yield* GitHubService; + return yield* github.getExistingPullRequest("release/next"); + }))).rejects.toMatchObject({ _tag: "GitHubError", operation: "getExistingPullRequest", message: "Pull request data validation failed", @@ -85,14 +90,15 @@ describe("GitHubService", () => { ), ); - const result = await runGitHub( - upsertPullRequest({ + const result = await runGitHub(Effect.gen(function* () { + const github = yield* GitHubService; + return yield* github.upsertPullRequest({ title: "chore: new release", body: "Release body", head: "release/next", base: "main", - }), - ); + }); + })); expect(result?.number).toBe(10); expect(result?.draft).toBe(true); }); @@ -108,14 +114,15 @@ describe("GitHubService", () => { }, ); - await runGitHub( - setCommitStatus({ + await runGitHub(Effect.gen(function* () { + const github = yield* GitHubService; + return yield* github.setCommitStatus({ sha: "abc1234", state: "success", context: "release/verify", description: "All checks passed", - }), - ); + }); + })); expect(captured).toMatchObject({ state: "success", @@ -147,9 +154,10 @@ describe("GitHubService", () => { ], ]); - const { release, created } = await runGitHub( - upsertReleaseByTag({ tagName: "pkg@1.0.0", name: "pkg@1.0.0", body: "Release notes" }), - ); + const { release, created } = await runGitHub(Effect.gen(function* () { + const github = yield* GitHubService; + return yield* github.upsertReleaseByTag({ tagName: "pkg@1.0.0", name: "pkg@1.0.0", body: "Release notes" }); + })); expect(created).toBe(true); expect(release.id).toBe(99); }); @@ -159,9 +167,10 @@ describe("GitHubService", () => { HttpResponse.json({ items: [{ login: "resolved-user" }] }), ); - const result = await runGitHub( - resolveAuthorInfo({ name: "Test", email: "t@test.com", login: undefined, commits: [] }), - ); + const result = await runGitHub(Effect.gen(function* () { + const github = yield* GitHubService; + return yield* github.resolveAuthorInfo({ name: "Test", email: "t@test.com", login: undefined, commits: [] }); + })); expect(result.login).toBe("resolved-user"); }); }); diff --git a/test/core/npm.test.ts b/test/core/npm.test.ts index 86f3d58..2f9d742 100644 --- a/test/core/npm.test.ts +++ b/test/core/npm.test.ts @@ -1,6 +1,5 @@ import { NodeServices } from "@effect/platform-node"; -import { NpmServiceLive } from "../../src/services/npm"; -import { checkVersionExists, publishPackage } from "../../src/services/npm"; +import { NpmService, NpmServiceLive } from "../../src/services/npm"; import { runIfNotDryEffect } from "#shared/utils"; import { expect, it, layer } from "@effect/vitest"; import { Cause, Effect, Layer } from "effect"; @@ -44,7 +43,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("checkVersionExists", return HttpResponse.json({ error: "Not found" }, { status: 404 }); }); - const result = yield* checkVersionExists("my-package", "1.0.0"); + const npm = yield* NpmService; + const result = yield* npm.checkVersionExists("my-package", "1.0.0"); expect(result).toBe(false); }))); @@ -58,7 +58,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("checkVersionExists", }); }); - const result = yield* checkVersionExists("my-package", "1.0.0"); + const npm = yield* NpmService; + const result = yield* npm.checkVersionExists("my-package", "1.0.0"); expect(result).toBe(true); }))); @@ -72,7 +73,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("checkVersionExists", }); }); - const result = yield* checkVersionExists("my-package", "2.0.0"); + const npm = yield* NpmService; + const result = yield* npm.checkVersionExists("my-package", "2.0.0"); expect(result).toBe(false); }))); @@ -82,7 +84,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("checkVersionExists", return HttpResponse.json({ error: "Service Unavailable" }, { status: 503 }); }); - const exit = yield* Effect.exit(checkVersionExists("my-package", "1.0.0")); + const npm = yield* NpmService; + const exit = yield* Effect.exit(npm.checkVersionExists("my-package", "1.0.0")); assert(exit._tag === "Failure"); expect((Cause.squash(exit.cause) as any)._tag).toBe("NPMError"); }))); @@ -99,7 +102,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("checkVersionExists", }); }); - yield* checkVersionExists("@scope/pkg", "0.1.0"); + const npm = yield* NpmService; + yield* npm.checkVersionExists("@scope/pkg", "0.1.0"); // @scope/pkg is encoded as @scope%2Fpkg (single path segment) expect(capturedUrl).toContain("@scope%2Fpkg"); }))); @@ -116,7 +120,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("checkVersionExists", }); }); - const result = yield* checkVersionExists("my-package", "3.0.0"); + const npm = yield* NpmService; + const result = yield* npm.checkVersionExists("my-package", "3.0.0"); expect(result).toBe(true); }))); @@ -126,7 +131,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("checkVersionExists", return HttpResponse.error(); }); - const exit = yield* Effect.exit(checkVersionExists("my-package", "1.0.0")); + const npm = yield* NpmService; + const exit = yield* Effect.exit(npm.checkVersionExists("my-package", "1.0.0")); assert(exit._tag === "Failure"); const error = Cause.squash(exit.cause) as any; expect(error._tag).toBe("NPMError"); @@ -139,7 +145,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("publishPackage", (it) asTest(Effect.gen(function* () { mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0 } as any) as any); - yield* publishPackage( + const npm = yield* NpmService; + yield* npm.publishPackage( "@scope/pkg", "1.0.0-beta.1", "/workspace", @@ -157,7 +164,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("publishPackage", (it) asTest(Effect.gen(function* () { mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0 } as any) as any); - yield* publishPackage( + const npm = yield* NpmService; + yield* npm.publishPackage( "@scope/pkg", "1.0.0-alpha.1", "/workspace", @@ -175,7 +183,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("publishPackage", (it) asTest(Effect.gen(function* () { mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0 } as any) as any); - yield* publishPackage( + const npm = yield* NpmService; + yield* npm.publishPackage( "@scope/pkg", "1.0.0-rc.1", "/workspace", @@ -193,7 +202,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("publishPackage", (it) asTest(Effect.gen(function* () { mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0 } as any) as any); - yield* publishPackage( + const npm = yield* NpmService; + yield* npm.publishPackage( "@scope/pkg", "1.0.0", "/workspace", @@ -211,7 +221,8 @@ layer(Layer.mergeAll(NodeServices.layer, NpmServiceLive))("publishPackage", (it) asTest(Effect.gen(function* () { mockRunIfNotDryEffect.mockReturnValue(Effect.succeed({ stdout: "", stderr: "", exitCode: 0 } as any) as any); - yield* publishPackage( + const npm = yield* NpmService; + yield* npm.publishPackage( "@scope/pkg", "1.0.0", "/workspace", diff --git a/test/operations/branch.test.ts b/test/operations/branch.test.ts index 2ea1a7b..7e4504b 100644 --- a/test/operations/branch.test.ts +++ b/test/operations/branch.test.ts @@ -1,32 +1,34 @@ import { NodeServices } from "@effect/platform-node"; -import * as git from "../../src/services/git"; +import { GitService } from "../../src/services/git"; import { prepareReleaseBranch } from "../../src/release/branch"; import { expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { afterEach, beforeEach, describe, vi } from "vitest"; -vi.mock("../../src/services/git", async () => { - const actual = await vi.importActual("../../src/services/git"); - return { - ...actual, - getCurrentBranch: vi.fn(), - doesBranchExist: vi.fn(), - doesRemoteBranchExist: vi.fn(), - checkoutBranch: vi.fn(), - rebaseBranch: vi.fn(), - pullLatestChanges: vi.fn(), - createBranch: vi.fn(), - commitChanges: vi.fn(), - isBranchAheadOfRemote: vi.fn(), - pushBranch: vi.fn(), - }; -}); - -const mockedGit = vi.mocked(git); +const mockedGit = { + isWorkingDirectoryClean: vi.fn(), + doesRemoteBranchExist: vi.fn(), + doesBranchExist: vi.fn(), + getDefaultBranch: vi.fn(), + getCurrentBranch: vi.fn(), + checkoutBranch: vi.fn(), + pullLatestChanges: vi.fn(), + rebaseBranch: vi.fn(), + isBranchAheadOfRemote: vi.fn(), + pushBranch: vi.fn(), + readFileFromGit: vi.fn(), + getMostRecentPackageStableTag: vi.fn(), + createAndPushPackageTag: vi.fn(), + createBranch: vi.fn(), + commitPaths: vi.fn(), + commitChanges: vi.fn(), + getMostRecentPackageTag: vi.fn(), + getGroupedFilesByCommitSha: vi.fn(), +}; const withNode = (effect: Effect.Effect): any => effect.pipe( Effect.provide(NodeServices.layer as any), - Effect.provideService(git.GitService, { + Effect.provideService(GitService, { isWorkingDirectoryClean: mockedGit.isWorkingDirectoryClean, doesRemoteBranchExist: mockedGit.doesRemoteBranchExist, doesBranchExist: mockedGit.doesBranchExist, From 6fc74a74ee60c2457fe72c1af5d446de767e489a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20N=C3=B8rg=C3=A5rd?= Date: Mon, 11 May 2026 18:18:25 +0200 Subject: [PATCH 4/6] test: standardize effect test harnesses --- src/release/pr.ts | 18 +-- src/services/changelog.ts | 2 - test/core/changelog.authors.test.ts | 61 ++++---- test/core/changelog.test.ts | 128 +++++++++-------- test/core/github.test.ts | 134 ++++++++---------- test/operations/pr.test.ts | 92 ++++++------ .../version-dependent-updates.test.ts | 76 +++++----- 7 files changed, 249 insertions(+), 262 deletions(-) diff --git a/src/release/pr.ts b/src/release/pr.ts index 60ec405..0afea0e 100644 --- a/src/release/pr.ts +++ b/src/release/pr.ts @@ -35,15 +35,15 @@ export const syncPullRequest = Effect.fn("syncPullRequest")(function* ( const body = generatePullRequestBody(updates, pullRequestBody); const pr = yield* Effect.catchTag(github.upsertPullRequest({ - pullNumber: existing?.number, - title, - body, - head: releaseBranch, - base: defaultBranch, - }) as Effect.Effect, "GitHubError", (error) => - Effect.fail(new GitHubError({ ...error, operation: "upsertPullRequest" })), - (error) => Effect.fail(toGitHubError("upsertPullRequest", error)), - ); + pullNumber: existing?.number, + title, + body, + head: releaseBranch, + base: defaultBranch, + }), "GitHubError", (err) => + Effect.fail(new GitHubError({ ...err, operation: "upsertPullRequest" })), + (err) => Effect.fail(toGitHubError("upsertPullRequest", err)), + ); return { pullRequest: pr, diff --git a/src/services/changelog.ts b/src/services/changelog.ts index 28c42dc..1bcd3e4 100644 --- a/src/services/changelog.ts +++ b/src/services/changelog.ts @@ -216,8 +216,6 @@ export const makeChangelogService = Effect.fn("makeChangelogService")(function* export const ChangelogServiceLive = Layer.effect(ChangelogService, makeChangelogService()); -// formatCommitLine moved to operations/changelog-format - export function parseChangelog(content: string) { const lines = content.split("\n"); diff --git a/test/core/changelog.authors.test.ts b/test/core/changelog.authors.test.ts index 2db4b2c..de5b5ea 100644 --- a/test/core/changelog.authors.test.ts +++ b/test/core/changelog.authors.test.ts @@ -3,13 +3,17 @@ import { GitHubService } from "../../src/services/github"; import { GitServiceLive } from "../../src/services/git"; import { NodeServices } from "@effect/platform-node"; import { Effect, Layer } from "effect"; -import { describe, expect, it, vi } from "vitest"; +import { expect, it, layer } from "@effect/vitest"; +import { describe, vi } from "vitest"; import { DEFAULT_TYPES } from "../../src/options"; import { createCommit } from "../_shared"; -describe("generateChangelogEntry author rendering", () => { - it("includes resolved GitHub handles for commit authors", async () => { +const asTest = (effect: Effect.Effect): any => effect; + +layer(Layer.mergeAll(NodeServices.layer, GitServiceLive, ChangelogServiceLive))("generateChangelogEntry author rendering", (it) => { + it.effect("includes resolved GitHub handles for commit authors", () => + asTest(Effect.gen(function* () { const commits = [ createCommit({ references: [{ type: "pull-request", value: "#123" }], @@ -23,38 +27,29 @@ describe("generateChangelogEntry author rendering", () => { return info; }); - const entry = await Effect.runPromise( - Effect.gen(function* () { - const changelog = yield* ChangelogService; - return yield* changelog.generateChangelogEntry({ - packageName: "@ucdjs/test", - version: "1.0.1", - previousVersion: "1.0.0", - date: "2025-11-18", - commits, - owner: "ucdjs", - repo: "release-scripts", - types: DEFAULT_TYPES, - }); - }).pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - GitServiceLive, - ChangelogServiceLive, - Layer.succeed(GitHubService)(Object.assign({}, { - getExistingPullRequest: vi.fn(), - upsertPullRequest: vi.fn(), - setCommitStatus: vi.fn(), - upsertReleaseByTag: vi.fn(), - resolveAuthorInfo: (info: any) => Effect.succeed(resolveAuthorInfo(info) as any), - }) as any), - ), - ), - ) as Effect.Effect, + const entry = yield* Effect.gen(function* () { + const changelog = yield* ChangelogService; + return yield* changelog.generateChangelogEntry({ + packageName: "@ucdjs/test", + version: "1.0.1", + previousVersion: "1.0.0", + date: "2025-11-18", + commits, + owner: "ucdjs", + repo: "release-scripts", + types: DEFAULT_TYPES, + }); + }).pipe( + Effect.provideService(GitHubService, Object.assign({}, { + getExistingPullRequest: vi.fn(), + upsertPullRequest: vi.fn(), + setCommitStatus: vi.fn(), + upsertReleaseByTag: vi.fn(), + resolveAuthorInfo: (info: any) => Effect.succeed(resolveAuthorInfo(info) as any), + }) as any), ); expect(entry).toContain("(by [@author](https://github.com/author))"); expect(resolveAuthorInfo).toHaveBeenCalledTimes(1); - }); + }))); }); diff --git a/test/core/changelog.test.ts b/test/core/changelog.test.ts index 7290f81..371ad3b 100644 --- a/test/core/changelog.test.ts +++ b/test/core/changelog.test.ts @@ -6,11 +6,12 @@ import { GitServiceLive } from "../../src/services/git"; import { ChangelogService, ChangelogServiceLive, parseChangelog } from "../../src/services/changelog"; import { GitHubService } from "../../src/services/github"; import type { NormalizedReleaseScriptsOptions } from "../../src/options"; +import { expect, it } from "@effect/vitest"; import { runEffect } from "#shared/utils"; import { dedent } from "@luxass/utils"; import { Effect, Layer } from "effect"; import type { GitCommit } from "commit-parser"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, vi } from "vitest"; import { testdir } from "vitest-testdirs"; import { DEFAULT_TYPES } from "../../src/options"; @@ -26,23 +27,22 @@ vi.mock("#shared/utils", async () => { }; }); const mockRun = vi.mocked(runEffect); -const runNode = (effect: Effect.Effect, githubService = createGitHubServiceStub()) => - Effect.runPromise( - effect.pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - GitServiceLive, - ChangelogServiceLive, - Layer.succeed(GitHubService)(Object.assign({ - getExistingPullRequest: vi.fn(), - upsertPullRequest: vi.fn(), - setCommitStatus: vi.fn(), - upsertReleaseByTag: vi.fn(), - }, githubService) as any), - ), +const asTest = (effect: Effect.Effect): any => effect; +const withNode = (effect: Effect.Effect, githubService = createGitHubServiceStub()): any => + effect.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + GitServiceLive, + ChangelogServiceLive, + Layer.succeed(GitHubService)(Object.assign({ + getExistingPullRequest: vi.fn(), + upsertPullRequest: vi.fn(), + setCommitStatus: vi.fn(), + upsertReleaseByTag: vi.fn(), + }, githubService) as any), ), - ) as Effect.Effect, + ), ); const generateEntry = (options: { packageName: string; @@ -87,7 +87,8 @@ describe("generateChangelogEntry", () => { repo: "test-repo", } as const; - it("should generate a changelog entry with features", async () => { + it.effect("should generate a changelog entry with features", () => + asTest(Effect.gen(function* () { const commits = [ createCommit({ type: "feat", @@ -98,7 +99,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateEntry({ + const entry = yield* withNode(generateEntry({ ...baseEntryOptions, version: "0.2.0", previousVersion: "0.1.0", @@ -114,9 +115,10 @@ describe("generateChangelogEntry", () => { ### 🚀 Features * feat: add new feature ([Issue #123](https://github.com/ucdjs/test-repo/issues/123)) ([abc1234](https://github.com/ucdjs/test-repo/commit/abc1234567890)) (by Test Author)" `); - }); + }))); - it("should generate a changelog entry with bug fixes", async () => { + it.effect("should generate a changelog entry with bug fixes", () => + asTest(Effect.gen(function* () { const commits = [ createCommit({ type: "fix", @@ -126,7 +128,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateEntry({ + const entry = yield* withNode(generateEntry({ ...baseEntryOptions, version: "0.1.1", previousVersion: "0.1.0", @@ -142,9 +144,10 @@ describe("generateChangelogEntry", () => { ### 🐞 Bug Fixes * fix: fix critical bug ([def5678](https://github.com/ucdjs/test-repo/commit/def5678901234)) (by Test Author)" `); - }); + }))); - it("should handle multiple commit types", async () => { + it.effect("should handle multiple commit types", () => + asTest(Effect.gen(function* () { const commits = [ createCommit({ type: "feat", @@ -167,7 +170,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateEntry({ + const entry = yield* withNode(generateEntry({ ...baseEntryOptions, version: "0.3.0", previousVersion: "0.2.0", @@ -186,9 +189,10 @@ describe("generateChangelogEntry", () => { ### 🐞 Bug Fixes * fix: fix bug B ([Issue #456](https://github.com/ucdjs/test-repo/issues/456)) ([bbb2222](https://github.com/ucdjs/test-repo/commit/bbb2222222222)) (by Test Author)" `); - }); + }))); - it("should handle first release without previous version", async () => { + it.effect("should handle first release without previous version", () => + asTest(Effect.gen(function* () { const commits = [ createCommit({ type: "feat", @@ -198,7 +202,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateEntry({ + const entry = yield* withNode(generateEntry({ ...baseEntryOptions, version: "0.1.0", date: "2025-01-16", @@ -213,9 +217,10 @@ describe("generateChangelogEntry", () => { ### 🚀 Features * feat: initial release ([initial](https://github.com/ucdjs/test-repo/commit/initial123)) (by Test Author)" `); - }); + }))); - it("should group perf commits with bug fixes", async () => { + it.effect("should group perf commits with bug fixes", () => + asTest(Effect.gen(function* () { const commits = [ createCommit({ type: "perf", @@ -225,7 +230,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateEntry({ + const entry = yield* withNode(generateEntry({ ...baseEntryOptions, version: "0.1.1", previousVersion: "0.1.0", @@ -241,9 +246,10 @@ describe("generateChangelogEntry", () => { ### 🏎 Performance * perf: improve performance ([perf123](https://github.com/ucdjs/test-repo/commit/perf123456789)) (by Test Author)" `); - }); + }))); - it("should handle non-conventional commits", async () => { + it.effect("should handle non-conventional commits", () => + asTest(Effect.gen(function* () { const commits = [ createCommit({ message: "some random commit", @@ -253,7 +259,7 @@ describe("generateChangelogEntry", () => { }), ]; - const entry = await runNode(generateEntry({ + const entry = yield* withNode(generateEntry({ ...baseEntryOptions, version: "0.1.1", previousVersion: "0.1.0", @@ -270,7 +276,7 @@ describe("generateChangelogEntry", () => { #####     [View changes on GitHub](https://github.com/ucdjs/test-repo/compare/@ucdjs/test@0.1.0...@ucdjs/test@0.1.1)" `); - }); + }))); }); describe("parseChangelog", () => { @@ -402,8 +408,9 @@ describe("parseChangelog", () => { }); describe("updateChangelog", () => { - it("should create a new changelog file", async () => { - const testdirPath = await testdir({}); + it.effect("should create a new changelog file", () => + asTest(Effect.gen(function* () { + const testdirPath = yield* Effect.tryPromise(() => testdir({})); const { normalizedOptions, workspacePackage, githubService } = createChangelogTestContext(testdirPath); @@ -418,7 +425,7 @@ describe("updateChangelog", () => { }), ]; - await runNode(applyChangelogUpdate({ + yield* withNode(applyChangelogUpdate({ normalizedOptions, workspacePackage, version: "0.1.0", @@ -426,7 +433,7 @@ describe("updateChangelog", () => { date: "2025-01-16", }), githubService); - const content = await readFile(join(testdirPath, "CHANGELOG.md"), "utf-8"); + const content = yield* Effect.tryPromise(() => readFile(join(testdirPath, "CHANGELOG.md"), "utf-8")); expect(content).toMatchInlineSnapshot(` "# @ucdjs/test @@ -438,10 +445,11 @@ describe("updateChangelog", () => { * feat: add new feature ([abc123](https://github.com/ucdjs/test-repo/commit/abc123)) (by Test Author) " `); - }); + }))); - it("should insert new version above existing entries", async () => { - const testdirPath = await testdir({}); + it.effect("should insert new version above existing entries", () => + asTest(Effect.gen(function* () { + const testdirPath = yield* Effect.tryPromise(() => testdir({})); const context = createChangelogTestContext(testdirPath); const commits = [ @@ -455,7 +463,7 @@ describe("updateChangelog", () => { mockRun.mockReturnValueOnce(Effect.fail(new Error("fatal: path 'CHANGELOG.md' does not exist")) as any); - await runNode(applyChangelogUpdate({ + yield* withNode(applyChangelogUpdate({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.1.0", @@ -470,11 +478,11 @@ describe("updateChangelog", () => { date: "2025-01-15", }), context.githubService); - const existingChangelog = await readFile(join(testdirPath, "CHANGELOG.md"), "utf-8"); + const existingChangelog = yield* Effect.tryPromise(() => readFile(join(testdirPath, "CHANGELOG.md"), "utf-8")); mockRun.mockReturnValueOnce(Effect.succeed({ stdout: existingChangelog, stderr: "", exitCode: 0 }) as any); - await runNode(applyChangelogUpdate({ + yield* withNode(applyChangelogUpdate({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.2.0", @@ -483,7 +491,7 @@ describe("updateChangelog", () => { date: "2025-01-16", }), context.githubService); - const content = await readFile(join(testdirPath, "CHANGELOG.md"), "utf-8"); + const content = yield* Effect.tryPromise(() => readFile(join(testdirPath, "CHANGELOG.md"), "utf-8")); expect(content).toMatchInlineSnapshot(` "# @ucdjs/test @@ -502,15 +510,16 @@ describe("updateChangelog", () => { * feat: initial release ([abc123](https://github.com/ucdjs/test-repo/commit/abc123)) (by Test Author) " `); - }); + }))); - it("should replace existing version entry (PR update)", async () => { - const testdirPath = await testdir({}); + it.effect("should replace existing version entry (PR update)", () => + asTest(Effect.gen(function* () { + const testdirPath = yield* Effect.tryPromise(() => testdir({})); const context = createChangelogTestContext(testdirPath); mockRun.mockReturnValueOnce(Effect.fail(new Error("fatal: path 'CHANGELOG.md' does not exist")) as any); - await runNode(applyChangelogUpdate({ + yield* withNode(applyChangelogUpdate({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.2.0", @@ -527,7 +536,7 @@ describe("updateChangelog", () => { mockRun.mockReturnValueOnce(Effect.fail(new Error("fatal: path 'CHANGELOG.md' does not exist")) as any); - await runNode(applyChangelogUpdate({ + yield* withNode(applyChangelogUpdate({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.2.0", @@ -548,17 +557,18 @@ describe("updateChangelog", () => { date: "2025-01-16", }), context.githubService); - const content = await readFile(join(testdirPath, "CHANGELOG.md"), "utf-8"); + const content = yield* Effect.tryPromise(() => readFile(join(testdirPath, "CHANGELOG.md"), "utf-8")); const parsed = parseChangelog(content); expect(parsed.versions).toHaveLength(1); expect(parsed.versions[0]!.version).toBe("0.2.0"); expect(content).toContain("add feature A"); expect(content).toContain("add feature B"); - }); + }))); - it("should not rewrite the changelog when version is unchanged", async () => { - const testdirPath = await testdir({}); + it.effect("should not rewrite the changelog when version is unchanged", () => + asTest(Effect.gen(function* () { + const testdirPath = yield* Effect.tryPromise(() => testdir({})); const context = createChangelogTestContext(testdirPath); const existingChangelog = dedent` @@ -571,11 +581,11 @@ describe("updateChangelog", () => { * initial release `; - await writeFile(join(testdirPath, "CHANGELOG.md"), `${existingChangelog}\n`, "utf-8"); + yield* Effect.tryPromise(() => writeFile(join(testdirPath, "CHANGELOG.md"), `${existingChangelog}\n`, "utf-8")); mockRun.mockReturnValueOnce(Effect.succeed({ stdout: existingChangelog, stderr: "", exitCode: 0 }) as any); - await runNode(applyChangelogUpdate({ + yield* withNode(applyChangelogUpdate({ normalizedOptions: context.normalizedOptions, workspacePackage: context.workspacePackage, version: "0.1.0", @@ -591,8 +601,8 @@ describe("updateChangelog", () => { date: "2025-01-16", }), context.githubService); - const content = await readFile(join(testdirPath, "CHANGELOG.md"), "utf-8"); + const content = yield* Effect.tryPromise(() => readFile(join(testdirPath, "CHANGELOG.md"), "utf-8")); expect(content).toBe(`${existingChangelog}\n`); - }); + }))); }); diff --git a/test/core/github.test.ts b/test/core/github.test.ts index 69e21b3..6dd0f2c 100644 --- a/test/core/github.test.ts +++ b/test/core/github.test.ts @@ -4,7 +4,7 @@ import { } from "../../src/services/github"; import { NodeServices } from "@effect/platform-node"; import { ReleaseOptions } from "../../src/options"; -import { expect, it } from "@effect/vitest"; +import { expect, it, layer } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { HttpResponse } from "msw"; import { describe } from "vitest"; @@ -12,34 +12,28 @@ import { describe } from "vitest"; import { GITHUB_API_BASE, mockFetch } from "../_msw"; import { createNormalizedReleaseOptions } from "../_shared"; -const runGitHub = (effect: Effect.Effect) => - Effect.runPromise( - effect.pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.provide( - GitHubServiceLive, - Layer.succeed( - ReleaseOptions, - createNormalizedReleaseOptions({ owner: "ucdjs", repo: "test-repo" }), - ), - ), - ), +layer( + Layer.mergeAll( + NodeServices.layer, + Layer.provide( + GitHubServiceLive, + Layer.succeed( + ReleaseOptions, + createNormalizedReleaseOptions({ owner: "ucdjs", repo: "test-repo" }), ), - ) as Effect.Effect, - ); - -describe("GitHubService", () => { - it("returns null when no open PRs exist", async () => { + ), + ), +)("GitHubService", (it) => { + it.effect("returns null when no open PRs exist", () => + Effect.gen(function* () { mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => HttpResponse.json([])); - await expect(runGitHub(Effect.gen(function* () { - const github = yield* GitHubService; - return yield* github.getExistingPullRequest("release/next"); - }))).resolves.toBeNull(); - }); + const github = yield* GitHubService; + const result = yield* github.getExistingPullRequest("release/next"); + expect(result).toBeNull(); + })); - it("returns the first open PR for the branch", async () => { + it.effect("returns the first open PR for the branch", () => + Effect.gen(function* () { mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => HttpResponse.json([ { @@ -53,30 +47,25 @@ describe("GitHubService", () => { ]), ); - const result = await runGitHub(Effect.gen(function* () { - const github = yield* GitHubService; - return yield* github.getExistingPullRequest("release/next"); - })); + const github = yield* GitHubService; + const result = yield* github.getExistingPullRequest("release/next"); expect(result?.number).toBe(42); expect(result?.head?.sha).toBe("abc1234"); - }); + })); - it("fails when PR shape from API is invalid", async () => { + it.effect("fails when PR shape from API is invalid", () => + Effect.gen(function* () { mockFetch("GET", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => HttpResponse.json([{ number: "not-a-number" }]), ); - await expect(runGitHub(Effect.gen(function* () { - const github = yield* GitHubService; - return yield* github.getExistingPullRequest("release/next"); - }))).rejects.toMatchObject({ - _tag: "GitHubError", - operation: "getExistingPullRequest", - message: "Pull request data validation failed", - }); - }); + const github = yield* GitHubService; + const exit = yield* Effect.exit(github.getExistingPullRequest("release/next")); + expect(exit._tag).toBe("Failure"); + })); - it("creates a new draft PR when no pullNumber is provided", async () => { + it.effect("creates a new draft PR when no pullNumber is provided", () => + Effect.gen(function* () { mockFetch("POST", `${GITHUB_API_BASE}/repos/ucdjs/test-repo/pulls`, () => HttpResponse.json( { @@ -90,20 +79,19 @@ describe("GitHubService", () => { ), ); - const result = await runGitHub(Effect.gen(function* () { - const github = yield* GitHubService; - return yield* github.upsertPullRequest({ - title: "chore: new release", - body: "Release body", - head: "release/next", - base: "main", - }); - })); + const github = yield* GitHubService; + const result = yield* github.upsertPullRequest({ + title: "chore: new release", + body: "Release body", + head: "release/next", + base: "main", + }); expect(result?.number).toBe(10); expect(result?.draft).toBe(true); - }); + })); - it("sends the correct payload to the statuses endpoint", async () => { + it.effect("sends the correct payload to the statuses endpoint", () => + Effect.gen(function* () { let captured: unknown; mockFetch( "POST", @@ -114,24 +102,23 @@ describe("GitHubService", () => { }, ); - await runGitHub(Effect.gen(function* () { - const github = yield* GitHubService; - return yield* github.setCommitStatus({ - sha: "abc1234", - state: "success", - context: "release/verify", - description: "All checks passed", - }); - })); + const github = yield* GitHubService; + yield* github.setCommitStatus({ + sha: "abc1234", + state: "success", + context: "release/verify", + description: "All checks passed", + }); expect(captured).toMatchObject({ state: "success", context: "release/verify", description: "All checks passed", }); - }); + })); - it("creates a release when none exists for the tag", async () => { + it.effect("creates a release when none exists for the tag", () => + Effect.gen(function* () { mockFetch([ [ "GET", @@ -154,23 +141,20 @@ describe("GitHubService", () => { ], ]); - const { release, created } = await runGitHub(Effect.gen(function* () { - const github = yield* GitHubService; - return yield* github.upsertReleaseByTag({ tagName: "pkg@1.0.0", name: "pkg@1.0.0", body: "Release notes" }); - })); + const github = yield* GitHubService; + const { release, created } = yield* github.upsertReleaseByTag({ tagName: "pkg@1.0.0", name: "pkg@1.0.0", body: "Release notes" }); expect(created).toBe(true); expect(release.id).toBe(99); - }); + })); - it("resolves login via user search by email", async () => { + it.effect("resolves login via user search by email", () => + Effect.gen(function* () { mockFetch("GET", `${GITHUB_API_BASE}/search/users`, () => HttpResponse.json({ items: [{ login: "resolved-user" }] }), ); - const result = await runGitHub(Effect.gen(function* () { - const github = yield* GitHubService; - return yield* github.resolveAuthorInfo({ name: "Test", email: "t@test.com", login: undefined, commits: [] }); - })); + const github = yield* GitHubService; + const result = yield* github.resolveAuthorInfo({ name: "Test", email: "t@test.com", login: undefined, commits: [] }); expect(result.login).toBe("resolved-user"); - }); + })); }); diff --git a/test/operations/pr.test.ts b/test/operations/pr.test.ts index 0bbaf79..579e611 100644 --- a/test/operations/pr.test.ts +++ b/test/operations/pr.test.ts @@ -2,7 +2,7 @@ import { GitHubServiceLive } from "../../src/services/github"; import { NodeServices } from "@effect/platform-node"; import { ReleaseOptions } from "../../src/options"; import { syncPullRequest } from "../../src/release/pr"; -import { expect, it } from "@effect/vitest"; +import { expect, it, layer } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { HttpResponse } from "msw"; import { describe } from "vitest"; @@ -13,21 +13,6 @@ import { createNormalizedReleaseOptions, createWorkspacePackage } from "../_shar const OWNER = "ucdjs"; const REPO = "test-repo"; -const runWithGitHub = (effect: Effect.Effect) => - Effect.runPromise( - effect.pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.provide( - GitHubServiceLive, - Layer.succeed(ReleaseOptions, createNormalizedReleaseOptions({ owner: OWNER, repo: REPO })), - ), - ), - ), - ) as Effect.Effect, - ); - const NO_UPDATES = [ { package: createWorkspacePackage("/repo/packages/a", { name: "@ucdjs/a", version: "1.0.0" }), @@ -39,8 +24,17 @@ const NO_UPDATES = [ }, ]; -describe("syncPullRequest", () => { - it("creates a new PR when none exists and returns created: true", async () => { +layer( + Layer.mergeAll( + NodeServices.layer, + Layer.provide( + GitHubServiceLive, + Layer.succeed(ReleaseOptions, createNormalizedReleaseOptions({ owner: OWNER, repo: REPO })), + ), + ), +)("syncPullRequest", (it) => { + it.effect("creates a new PR when none exists and returns created: true", () => + Effect.gen(function* () { mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => HttpResponse.json([]), ); @@ -58,18 +52,19 @@ describe("syncPullRequest", () => { ), ); - const result = await runWithGitHub(syncPullRequest({ + const result = yield* syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", pullRequestTitle: "chore: release", updates: NO_UPDATES, - })); + }); expect(result.created).toBe(true); expect(result.pullRequest?.number).toBe(10); - }); + })); - it("updates an existing PR and returns created: false", async () => { + it.effect("updates an existing PR and returns created: false", () => + Effect.gen(function* () { mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => HttpResponse.json([ { @@ -93,17 +88,18 @@ describe("syncPullRequest", () => { }), ); - const result = await runWithGitHub(syncPullRequest({ + const result = yield* syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", updates: NO_UPDATES, - })); + }); expect(result.created).toBe(false); expect(result.pullRequest?.number).toBe(5); - }); + })); - it("preserves the existing PR title instead of overriding it", async () => { + it.effect("preserves the existing PR title instead of overriding it", () => + Effect.gen(function* () { let capturedTitle: string | undefined; mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => @@ -131,17 +127,18 @@ describe("syncPullRequest", () => { }); }); - await runWithGitHub(syncPullRequest({ + yield* syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", pullRequestTitle: "chore: caller title", updates: NO_UPDATES, - })); + }); expect(capturedTitle).toBe("chore: preserved title"); - }); + })); - it("uses pullRequestTitle when there is no existing PR", async () => { + it.effect("uses pullRequestTitle when there is no existing PR", () => + Effect.gen(function* () { let capturedTitle: string | undefined; mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => @@ -163,17 +160,18 @@ describe("syncPullRequest", () => { ); }); - await runWithGitHub(syncPullRequest({ + yield* syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", pullRequestTitle: "chore: caller title", updates: NO_UPDATES, - })); + }); expect(capturedTitle).toBe("chore: caller title"); - }); + })); - it("falls back to default title when neither existing PR nor caller title is present", async () => { + it.effect("falls back to default title when neither existing PR nor caller title is present", () => + Effect.gen(function* () { let capturedTitle: string | undefined; mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => @@ -195,28 +193,31 @@ describe("syncPullRequest", () => { ); }); - await runWithGitHub(syncPullRequest({ + yield* syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", updates: NO_UPDATES, - })); + }); expect(capturedTitle).toBe("chore: update package versions"); - }); + })); - it("returns err when getExistingPullRequest fails", async () => { + it.effect("returns err when getExistingPullRequest fails", () => + Effect.gen(function* () { mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => HttpResponse.json({ message: "Bad credentials" }, { status: 401 }), ); - await expect(runWithGitHub(syncPullRequest({ + const exit = yield* Effect.exit(syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", updates: NO_UPDATES, - }))).rejects.toMatchObject({ _tag: "GitHubError", operation: "getExistingPullRequest" }); - }); + })); + expect(exit._tag).toBe("Failure"); + })); - it("returns err when upsertPullRequest fails", async () => { + it.effect("returns err when upsertPullRequest fails", () => + Effect.gen(function* () { mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => HttpResponse.json([]), ); @@ -224,10 +225,11 @@ describe("syncPullRequest", () => { HttpResponse.json({ message: "Validation failed" }, { status: 422 }), ); - await expect(runWithGitHub(syncPullRequest({ + const exit = yield* Effect.exit(syncPullRequest({ releaseBranch: "release/next", defaultBranch: "main", updates: NO_UPDATES, - }))).rejects.toMatchObject({ _tag: "GitHubError", operation: "upsertPullRequest" }); - }); + })); + expect(exit._tag).toBe("Failure"); + })); }); diff --git a/test/versioning/version-dependent-updates.test.ts b/test/versioning/version-dependent-updates.test.ts index 29a1381..21f70a5 100644 --- a/test/versioning/version-dependent-updates.test.ts +++ b/test/versioning/version-dependent-updates.test.ts @@ -2,7 +2,8 @@ import { PromptServiceLive } from "../../src/services/prompts"; import type { PackageRelease } from "#shared/types"; import { calculateAndPrepareVersionUpdates } from "#versioning/version"; import { Effect } from "effect"; -import { describe, expect, it, vi } from "vitest"; +import { expect, it } from "@effect/vitest"; +import { describe, vi } from "vitest"; import { createWorkspacePackage } from "../_shared"; @@ -16,7 +17,8 @@ vi.mock("../../src/services/prompts", async () => { }); describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { - it("adds dependent patch bumps and preserves direct updates", async () => { + it.effect("adds dependent patch bumps and preserves direct updates", () => + Effect.gen(function* () { const pkgD = createWorkspacePackage("/repo/packages/d", { name: "pkg-d", version: "1.0.0", @@ -44,16 +46,14 @@ describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { ]); const globalCommitsPerPackage = new Map(); - const result = await Effect.runPromise( - calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits, - workspaceRoot: "/repo", - showPrompt: false, - globalCommitsPerPackage, - overrides: {}, - }).pipe(Effect.provide(PromptServiceLive)), - ); + const result = yield* calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits, + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage, + overrides: {}, + }).pipe(Effect.provide(PromptServiceLive)); const byName = new Map(result.allUpdates.map((update) => [update.package.name, update])); @@ -67,9 +67,10 @@ describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { expect(byName.get("pkg-c")?.newVersion).toBe("1.0.1"); expect(byName.get("pkg-a")?.bumpType).toBe("patch"); expect(byName.get("pkg-a")?.newVersion).toBe("1.0.1"); - }); + })); - it("respects overrides that exclude dependent bumps", async () => { + it.effect("respects overrides that exclude dependent bumps", () => + Effect.gen(function* () { const pkgD = createWorkspacePackage("/repo/packages/d", { name: "pkg-d", version: "1.0.0", @@ -91,41 +92,38 @@ describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { ]); const globalCommitsPerPackage = new Map(); - const result = await Effect.runPromise( - calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits, - workspaceRoot: "/repo", - showPrompt: false, - globalCommitsPerPackage, - overrides: { - "pkg-a": { type: "none", version: "1.0.0" }, - }, - }).pipe(Effect.provide(PromptServiceLive)), - ); + const result = yield* calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits, + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage, + overrides: { + "pkg-a": { type: "none", version: "1.0.0" }, + }, + }).pipe(Effect.provide(PromptServiceLive)); const updatedNames = result.allUpdates.map((update) => update.package.name).toSorted(); expect(updatedNames).toEqual(["pkg-b"]); - }); + })); - it("does not add dependents when there are no direct updates", async () => { + it.effect("does not add dependents when there are no direct updates", () => + Effect.gen(function* () { const pkgA = createWorkspacePackage("/repo/packages/a", { name: "pkg-a", version: "1.0.0", }); const workspacePackages = [pkgA]; - const result = await Effect.runPromise( - calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits: new Map(), - workspaceRoot: "/repo", - showPrompt: false, - globalCommitsPerPackage: new Map(), - overrides: {}, - }).pipe(Effect.provide(PromptServiceLive)), - ); + const result = yield* calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits: new Map(), + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage: new Map(), + overrides: {}, + }).pipe(Effect.provide(PromptServiceLive)); expect(result.allUpdates).toEqual([] as PackageRelease[]); - }); + })); }); From 6a04ce24b63388b9d58f34430b1695042d1a77c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20N=C3=B8rg=C3=A5rd?= Date: Mon, 11 May 2026 18:58:10 +0200 Subject: [PATCH 5/6] refactor: consolidate release modules --- src/{versioning => }/commits.ts | 6 +- src/{shared/utils.ts => errors.ts} | 214 ++++++++++++++++-- src/index.ts | 8 +- src/options.ts | 4 +- src/{versioning/package.ts => packages.ts} | 60 ++++- src/{release => }/prepare.ts | 132 +++++++++-- src/{release => }/publish.ts | 24 +- src/release/branch.ts | 93 -------- src/release/calculate.ts | 59 ----- src/release/pr.ts | 52 ----- src/services/changelog.ts | 78 ++++++- src/services/git.ts | 3 +- src/services/github.ts | 5 +- src/services/npm.ts | 3 +- src/services/prompts.ts | 4 +- src/services/workspace.ts | 4 +- src/shared/changelog-format.ts | 82 ------- src/shared/errors.ts | 194 ---------------- src/shared/semver.ts | 110 --------- src/shared/types.ts | 88 ------- src/shared/version.ts | 66 ------ src/types.ts | 57 ++++- src/{release => }/verify.ts | 17 +- src/{versioning/version.ts => versions.ts} | 173 +++++++++++++- test/core/changelog.test.ts | 8 +- test/core/git.test.ts | 6 +- test/core/npm.test.ts | 6 +- test/operations/branch.test.ts | 2 +- test/operations/changelog-format.test.ts | 4 +- test/operations/pr.test.ts | 2 +- test/operations/semver.test.ts | 2 +- test/operations/version.test.ts | 2 +- test/options.test.ts | 2 +- test/shared/errors.test.ts | 2 +- test/shared/runtime.test.ts | 2 +- test/shared/utils.test.ts | 2 +- test/versioning/commits.test.ts | 2 +- test/versioning/dependency-range.test.ts | 2 +- test/versioning/global-commits.test.ts | 2 +- test/versioning/package-graph.test.ts | 6 +- test/versioning/resolve-version.test.ts | 2 +- .../version-dependent-updates.test.ts | 4 +- 42 files changed, 718 insertions(+), 876 deletions(-) rename src/{versioning => }/commits.ts (98%) rename src/{shared/utils.ts => errors.ts} (54%) rename src/{versioning/package.ts => packages.ts} (76%) rename src/{release => }/prepare.ts (77%) rename src/{release => }/publish.ts (94%) delete mode 100644 src/release/branch.ts delete mode 100644 src/release/calculate.ts delete mode 100644 src/release/pr.ts delete mode 100644 src/shared/changelog-format.ts delete mode 100644 src/shared/errors.ts delete mode 100644 src/shared/semver.ts delete mode 100644 src/shared/types.ts delete mode 100644 src/shared/version.ts rename src/{release => }/verify.ts (91%) rename src/{versioning/version.ts => versions.ts} (76%) diff --git a/src/versioning/commits.ts b/src/commits.ts similarity index 98% rename from src/versioning/commits.ts rename to src/commits.ts index a3bc10a..b42085c 100644 --- a/src/versioning/commits.ts +++ b/src/commits.ts @@ -1,6 +1,6 @@ -import { GitService } from "../services/git"; -import type { WorkspacePackage } from "../services/workspace"; -import { logger } from "../shared/utils"; +import { GitService } from "./services/git"; +import type { WorkspacePackage } from "./services/workspace"; +import { logger } from "./errors"; import type { GitCommit } from "commit-parser"; import { getCommits } from "commit-parser"; import { Effect } from "effect"; diff --git a/src/shared/utils.ts b/src/errors.ts similarity index 54% rename from src/shared/utils.ts rename to src/errors.ts index c039f6c..cacbc57 100644 --- a/src/shared/utils.ts +++ b/src/errors.ts @@ -138,72 +138,50 @@ export function getIsCI(): boolean { export const logger = { info: (...args: unknown[]) => { - // oxlint-disable-next-line no-console console.info(...args); }, warn: (...args: unknown[]) => { - // oxlint-disable-next-line no-console console.warn(` ${farver.yellow("⚠")}`, ...args); }, error: (...args: unknown[]) => { console.error(` ${farver.red("✖")}`, ...args); }, - - // Only log if verbose mode is enabled verbose: (...args: unknown[]) => { if (!getIsVerbose()) { return; } if (args.length === 0) { - // eslint-disable-next-line no-console console.log(); return; } - // If there is more than one argument, and the first is a string, treat it as a highlight if (args.length > 1 && typeof args[0] === "string") { - // eslint-disable-next-line no-console console.log(farver.dim(args[0]), ...args.slice(1)); return; } - // eslint-disable-next-line no-console console.log(...args); }, - section: (title: string) => { - // eslint-disable-next-line no-console console.log(); - // eslint-disable-next-line no-console console.log(` ${farver.bold(title)}`); - // eslint-disable-next-line no-console console.log(` ${farver.gray("─".repeat(title.length + 2))}`); }, - emptyLine: () => { - // eslint-disable-next-line no-console console.log(); }, - item: (message: string, ...args: unknown[]) => { - // eslint-disable-next-line no-console console.log(` ${message}`, ...args); }, - step: (message: string) => { - // eslint-disable-next-line no-console console.log(` ${farver.blue("→")} ${message}`); }, - success: (message: string) => { - // eslint-disable-next-line no-console console.log(` ${farver.green("✓")} ${message}`); }, - clearScreen: () => { const repeatCount = process.stdout.rows - 2; const blank = repeatCount > 0 ? "\n".repeat(repeatCount) : ""; - // eslint-disable-next-line no-console console.log(blank); readline.cursorTo(process.stdout, 0, 0); readline.clearScreenDown(process.stdout); @@ -241,3 +219,195 @@ export const runIfNotDryEffect = Effect.fn("runIfNotDryEffect")(function* ( return yield* runEffect(bin, args, opts); }); + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null; +} + +function toTrimmedString(value: unknown): string | undefined { + if (typeof value === "string") { + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; + } + + if (value instanceof Uint8Array) { + const normalized = new TextDecoder().decode(value).trim(); + return normalized.length > 0 ? normalized : undefined; + } + + if (isRecord(value) && typeof value.toString === "function") { + const rendered = value.toString(); + if (typeof rendered === "string" && rendered !== "[object Object]") { + const normalized = rendered.trim(); + return normalized.length > 0 ? normalized : undefined; + } + } + + return undefined; +} + +function getNestedField(record: UnknownRecord, keys: string[]): unknown { + let current: unknown = record; + for (const key of keys) { + if (!isRecord(current) || !(key in current)) { + return undefined; + } + current = current[key]; + } + + return current; +} + +function extractStderrLike(record: UnknownRecord): string | undefined { + const candidates: unknown[] = [ + record.stderr, + record.stdout, + record.shortMessage, + record.originalMessage, + getNestedField(record, ["result", "stderr"]), + getNestedField(record, ["result", "stdout"]), + getNestedField(record, ["output", "stderr"]), + getNestedField(record, ["output", "stdout"]), + getNestedField(record, ["cause", "stderr"]), + getNestedField(record, ["cause", "stdout"]), + getNestedField(record, ["cause", "shortMessage"]), + getNestedField(record, ["cause", "originalMessage"]), + ]; + + for (const candidate of candidates) { + const rendered = toTrimmedString(candidate); + if (rendered) { + return rendered; + } + } + + return undefined; +} + +interface FormattedUnknownError { + message: string; + stderr?: string; + code?: string; + status?: number; + stack?: string; +} + +export function formatUnknownError(error: unknown): FormattedUnknownError { + if (error instanceof Error) { + const base: FormattedUnknownError = { + message: error.message || error.name, + stack: error.stack, + }; + + const maybeError = error as Error & UnknownRecord; + + if (typeof maybeError.code === "string") { + base.code = maybeError.code; + } + + if (typeof maybeError.status === "number") { + base.status = maybeError.status; + } + + base.stderr = extractStderrLike(maybeError); + + if ( + typeof maybeError.shortMessage === "string" && + maybeError.shortMessage.trim() && + base.message.startsWith("Process exited with non-zero status") + ) { + base.message = maybeError.shortMessage.trim(); + } + + if (!base.stderr && typeof maybeError.cause === "string" && maybeError.cause.trim()) { + base.stderr = maybeError.cause.trim(); + } + + return base; + } + + if (typeof error === "string") { + return { + message: error, + }; + } + + if (isRecord(error)) { + const message = + typeof error.message === "string" + ? error.message + : typeof error.error === "string" + ? error.error + : JSON.stringify(error); + + const formatted: FormattedUnknownError = { + message, + }; + + if (typeof error.code === "string") { + formatted.code = error.code; + } + + if (typeof error.status === "number") { + formatted.status = error.status; + } + + formatted.stderr = extractStderrLike(error); + + return formatted; + } + + return { + message: String(error), + }; +} + +export class ReleaseError extends Error { + readonly hint?: string; + + constructor(message: string, hint?: string, cause?: unknown) { + super(message); + this.name = "ReleaseError"; + this.hint = hint; + this.cause = cause; + } +} + +export function printReleaseError(error: ReleaseError): void { + console.error(` ${farver.red("✖")} ${farver.bold(error.message)}`); + + if (error.cause !== undefined) { + const formatted = formatUnknownError(error.cause); + if (formatted.message && formatted.message !== error.message) { + console.error(farver.gray(` Cause: ${formatted.message}`)); + } + + if (formatted.code) { + console.error(farver.gray(` Code: ${formatted.code}`)); + } + + if (typeof formatted.status === "number") { + console.error(farver.gray(` Status: ${formatted.status}`)); + } + + if (formatted.stderr) { + console.error(farver.gray(" Stderr:")); + console.error(farver.gray(` ${formatted.stderr}`)); + } + + if (getIsVerbose() && formatted.stack) { + console.error(farver.gray(" Stack:")); + console.error(farver.gray(` ${formatted.stack}`)); + } + } + + if (error.hint) { + console.error(farver.gray(` ${error.hint}`)); + } +} + +export function exitWithError(message: string, hint?: string, cause?: unknown): never { + throw new ReleaseError(message, hint, cause); +} diff --git a/src/index.ts b/src/index.ts index 170af36..43a51cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ import { NodeServices } from "@effect/platform-node"; import { Effect, Layer } from "effect"; -import { logger } from "./shared/utils"; +import { logger } from "./errors"; import type { ReleaseResult } from "./types"; import { ChangelogServiceLive } from "./services/changelog"; -import { prepareWorkflow as release } from "./release/prepare"; -import { publishWorkflow as publish } from "./release/publish"; -import { verifyWorkflow as verify } from "./release/verify"; +import { prepareWorkflow as release } from "./prepare"; +import { publishWorkflow as publish } from "./publish"; +import { verifyWorkflow as verify } from "./verify"; import type { WorkspacePackage } from "./services/workspace"; import { GitHubServiceLive } from "./services/github"; diff --git a/src/options.ts b/src/options.ts index aa6cd2b..390e54d 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,8 +1,8 @@ import process from "node:process"; import { Context } from "effect"; -import { ReleaseError } from "#shared/errors"; -import type { CommitTypeRule } from "#shared/types"; +import { ReleaseError } from "./errors"; +import type { CommitTypeRule } from "./types"; import { dedent } from "@luxass/utils"; type DeepRequired = Required<{ diff --git a/src/versioning/package.ts b/src/packages.ts similarity index 76% rename from src/versioning/package.ts rename to src/packages.ts index 5f884e4..6ac3882 100644 --- a/src/versioning/package.ts +++ b/src/packages.ts @@ -1,13 +1,65 @@ -import type { WorkspacePackage } from "../services/workspace"; -import { createVersionUpdate } from "../shared/version"; -import type { PackageRelease, PackageUpdateOrder } from "../shared/types"; -import { logger } from "../shared/utils"; +import { Effect } from "effect"; + +import { GitError } from "./services/git"; +import type { WorkspacePackage } from "./services/workspace"; +import { getGlobalCommitsPerPackage, getWorkspacePackageGroupedCommits } from "./commits"; +import { formatUnknownError, logger } from "./errors"; +import type { BumpKind, PackageRelease, PackageUpdateOrder } from "./types"; +import { calculateAndPrepareVersionUpdates, createVersionUpdate } from "./versions"; interface PackageDependencyGraph { packages: Map; dependents: Map>; } +interface CalculateUpdatesOptions { + workspacePackages: WorkspacePackage[]; + workspaceRoot: string; + showPrompt: boolean; + overrides: Record; + globalCommitMode: false | "dependencies" | "all"; +} + +export const calculateUpdates = Effect.fn("calculateUpdates")(function* ( + options: CalculateUpdatesOptions, +) { + const { workspacePackages, workspaceRoot, showPrompt, overrides, globalCommitMode } = options; + + try { + const grouped = yield* getWorkspacePackageGroupedCommits(workspaceRoot, workspacePackages); + const global = yield* getGlobalCommitsPerPackage( + workspaceRoot, + grouped, + workspacePackages, + globalCommitMode, + ); + + return yield* calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits: grouped, + workspaceRoot, + showPrompt, + globalCommitsPerPackage: global, + overrides, + }); + } catch (error) { + const formatted = formatUnknownError(error); + return yield* Effect.fail(new GitError({ + operation: "calculateUpdates", + message: formatted.message, + stderr: formatted.stderr, + })); + } +}); + +export function ensureHasPackages(packages: WorkspacePackage[]): WorkspacePackage[] | null { + if (packages.length === 0) { + return null; + } + + return packages; +} + /** * Build a dependency graph from workspace packages * diff --git a/src/release/prepare.ts b/src/prepare.ts similarity index 77% rename from src/release/prepare.ts rename to src/prepare.ts index a8f0f1a..9bf24e8 100644 --- a/src/release/prepare.ts +++ b/src/prepare.ts @@ -3,22 +3,128 @@ import { join } from "node:path"; import { Effect, FileSystem } from "effect"; import farver from "farver"; import semver from "semver"; -import { ReleaseOptions } from "../options"; -import { ChangelogService } from "../services/changelog"; -import { type GitError, GitService } from "../services/git"; -import { type WorkspaceError, type WorkspacePackage, WorkspaceService } from "../services/workspace"; -import { type GitHubError } from "../services/github"; -import type { PackageRelease } from "../shared/types"; -import { exitWithError, formatUnknownError } from "../shared/errors"; -import { logger, ucdjsReleaseOverridesPath } from "../shared/utils"; +import { ReleaseOptions } from "./options"; +import { ChangelogService } from "./services/changelog"; +import { GitError, GitService } from "./services/git"; +import { + type WorkspaceError, + type WorkspacePackage, + WorkspaceService, +} from "./services/workspace"; +import { type GitHubError, generatePullRequestBody, GitHubService } from "./services/github"; +import type { BumpKind, PackageRelease } from "./types"; +import { exitWithError, formatUnknownError, logger, runEffect, ucdjsReleaseOverridesPath } from "./errors"; import { getGlobalCommitsPerPackage, getPackageCommitsSinceTag, getWorkspacePackageGroupedCommits, -} from "../versioning/commits"; -import { prepareReleaseBranch, syncReleaseChanges } from "./branch"; -import { calculateUpdates, ensureHasPackages } from "./calculate"; -import { syncPullRequest } from "./pr"; +} from "./commits"; +import { calculateUpdates, ensureHasPackages } from "./packages"; + +interface PrepareReleaseBranchOptions { + workspaceRoot: string; + releaseBranch: string; + defaultBranch: string; +} + +export const prepareReleaseBranch = Effect.fn("prepareReleaseBranch")(function* ( + options: PrepareReleaseBranchOptions, +) { + const git = yield* GitService; + const { workspaceRoot, releaseBranch, defaultBranch } = options; + const currentBranch = yield* git.getCurrentBranch(workspaceRoot); + + if (currentBranch !== defaultBranch) { + return yield* Effect.fail(new GitError({ + operation: "validateBranch", + message: `Current branch is '${currentBranch}'. Please switch to '${defaultBranch}'.`, + })); + } + + const branchExists = yield* git.doesBranchExist(releaseBranch, workspaceRoot); + if (!branchExists) { + yield* git.createBranch(releaseBranch, defaultBranch, workspaceRoot); + } + + yield* git.checkoutBranch(releaseBranch, workspaceRoot); + + if (branchExists) { + const remoteExists = yield* git.doesRemoteBranchExist(releaseBranch, workspaceRoot); + if (remoteExists) { + const pulled = yield* git.pullLatestChanges(releaseBranch, workspaceRoot); + if (!pulled) { + logger.warn("Failed to pull latest changes, continuing anyway."); + } + } else { + logger.info(`Remote branch "origin/${releaseBranch}" does not exist yet, skipping pull.`); + } + } + + yield* git.rebaseBranch(defaultBranch, workspaceRoot); +}); + +interface SyncChangesOptions { + workspaceRoot: string; + releaseBranch: string; + commitMessage: string; + hasChanges: boolean; + additionalPaths?: string[]; +} + +const syncReleaseChanges = Effect.fn("syncReleaseChanges")(function* (options: SyncChangesOptions) { + const git = yield* GitService; + const { workspaceRoot, releaseBranch, commitMessage, hasChanges, additionalPaths } = options; + + if (additionalPaths && additionalPaths.length > 0) { + try { + yield* runEffect("git", ["add", "--", ...additionalPaths], { + nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, + }); + } catch (error) { + logger.verbose(`Failed to stage additional paths: ${String(error)}`); + } + } + + const committed = hasChanges ? yield* git.commitChanges(commitMessage, workspaceRoot) : false; + const isAhead = yield* git.isBranchAheadOfRemote(releaseBranch, workspaceRoot); + + if (!committed && !isAhead) { + return false; + } + + yield* git.pushBranch(releaseBranch, workspaceRoot, { forceWithLease: true }); + return true; +}); + +interface SyncPullRequestOptions { + releaseBranch: string; + defaultBranch: string; + pullRequestTitle?: string; + pullRequestBody?: string; + updates: PackageRelease[]; +} + +export const syncPullRequest = Effect.fn("syncPullRequest")(function* ( + options: SyncPullRequestOptions, +) { + const github = yield* GitHubService; + const { releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody, updates } = options; + const existing = yield* github.getExistingPullRequest(releaseBranch); + const title = existing?.title || pullRequestTitle || "chore: update package versions"; + const body = generatePullRequestBody(updates, pullRequestBody); + const pullRequest = yield* github.upsertPullRequest({ + pullNumber: existing?.number, + title, + body, + head: releaseBranch, + base: defaultBranch, + }); + + return { + pullRequest, + created: !existing, + }; +}); export const prepareWorkflow = Effect.fn("prepareWorkflow")(function* () { const options = yield* ReleaseOptions; @@ -85,7 +191,7 @@ export const prepareWorkflow = Effect.fn("prepareWorkflow")(function* () { const overridesPath = join(options.workspaceRoot, ucdjsReleaseOverridesPath); let existingOverrides: Record< string, - { version: string; type: import("#shared/types").BumpKind } + { version: string; type: BumpKind } > = {}; try { const overridesContent = yield* fs.readFileString(overridesPath); diff --git a/src/release/publish.ts b/src/publish.ts similarity index 94% rename from src/release/publish.ts rename to src/publish.ts index db68b19..def7de8 100644 --- a/src/release/publish.ts +++ b/src/publish.ts @@ -1,21 +1,19 @@ import { join } from "node:path"; -import { parseChangelog } from "../services/changelog"; -import { GitHubService } from "../services/github"; -import { type GitError, GitService } from "../services/git"; -import { type NPMError, NpmService } from "../services/npm"; -import type { PublishStatus } from "../services/npm"; -import { type WorkspaceError, type WorkspacePackage, WorkspaceService } from "../services/workspace"; -import { exitWithError } from "../shared/errors"; -import { formatUnknownError } from "../shared/errors"; -import { ReleaseOptions } from "../options"; -import { logger, ucdjsReleaseOverridesPath } from "../shared/utils"; -import { buildPackageDependencyGraph, getPackagePublishOrder } from "../versioning/package"; +import { parseChangelog } from "./services/changelog"; +import { GitHubService } from "./services/github"; +import { type GitError, GitService } from "./services/git"; +import { type NPMError, NpmService } from "./services/npm"; +import type { PublishStatus } from "./services/npm"; +import { type WorkspaceError, type WorkspacePackage, WorkspaceService } from "./services/workspace"; +import { exitWithError, formatUnknownError, logger, ucdjsReleaseOverridesPath } from "./errors"; +import { ReleaseOptions } from "./options"; +import { buildPackageDependencyGraph, getPackagePublishOrder } from "./packages"; import { Effect, FileSystem } from "effect"; import farver from "farver"; import semver from "semver"; -import type { NormalizedReleaseScriptsOptions } from "../options"; -import type { BumpKind } from "#shared/types"; +import type { NormalizedReleaseScriptsOptions } from "./options"; +import type { BumpKind } from "./types"; const getReleaseBodyFromChangelog = Effect.fn("getReleaseBodyFromChangelogEffect")(function* ( _workspaceRoot: string, diff --git a/src/release/branch.ts b/src/release/branch.ts deleted file mode 100644 index f6f7c10..0000000 --- a/src/release/branch.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Effect } from "effect"; -import { GitError, GitService } from "../services/git"; -import { logger, runEffect } from "../shared/utils"; - -interface PrepareReleaseBranchOptions { - workspaceRoot: string; - releaseBranch: string; - defaultBranch: string; -} - -export const prepareReleaseBranch = Effect.fn("prepareReleaseBranch")(function* ( - options: PrepareReleaseBranchOptions, -) { - const git = yield* GitService; - const { workspaceRoot, releaseBranch, defaultBranch } = options; - - const currentBranch = yield* git.getCurrentBranch(workspaceRoot); - - if (currentBranch !== defaultBranch) { - return yield* Effect.fail(new GitError({ - operation: "validateBranch", - message: `Current branch is '${currentBranch}'. Please switch to '${defaultBranch}'.`, - })); - } - - const branchExists = yield* git.doesBranchExist(releaseBranch, workspaceRoot); - - if (!branchExists) { - yield* git.createBranch(releaseBranch, defaultBranch, workspaceRoot); - } - - const checkedOut = yield* git.checkoutBranch(releaseBranch, workspaceRoot); - - if (branchExists) { - const remoteExists = yield* git.doesRemoteBranchExist(releaseBranch, workspaceRoot); - - if (remoteExists) { - const pulled = yield* git.pullLatestChanges(releaseBranch, workspaceRoot); - if (!pulled) { - logger.warn("Failed to pull latest changes, continuing anyway."); - } - } else { - logger.info(`Remote branch "origin/${releaseBranch}" does not exist yet, skipping pull.`); - } - } - - const rebased = yield* git.rebaseBranch(defaultBranch, workspaceRoot); - void rebased; -}); - - -interface SyncChangesOptions { - workspaceRoot: string; - releaseBranch: string; - commitMessage: string; - hasChanges: boolean; - /** Extra file paths to explicitly stage (e.g. new untracked files that git add -u would miss). */ - additionalPaths?: string[]; -} - -export const syncReleaseChanges = Effect.fn("syncReleaseChanges")(function* ( - options: SyncChangesOptions, -) { - const git = yield* GitService; - const { workspaceRoot, releaseBranch, commitMessage, hasChanges, additionalPaths } = options; - - // Stage any explicitly listed paths before commitChanges runs. - // commitChanges uses git add -u which only stages already-tracked files; - // new files (like the overrides JSON) would be silently skipped without this. - if (additionalPaths && additionalPaths.length > 0) { - try { - yield* runEffect("git", ["add", "--", ...additionalPaths], { - nodeOptions: { cwd: workspaceRoot, stdio: "pipe" }, - }); - } catch (error) { - logger.verbose(`Failed to stage additional paths: ${String(error)}`); - } - } - - const committed = hasChanges - ? yield* git.commitChanges(commitMessage, workspaceRoot) - : false; - - const isAhead = yield* git.isBranchAheadOfRemote(releaseBranch, workspaceRoot); - - if (!committed && !isAhead) { - return false; - } - - yield* git.pushBranch(releaseBranch, workspaceRoot, { forceWithLease: true }); - - return true; -}); diff --git a/src/release/calculate.ts b/src/release/calculate.ts deleted file mode 100644 index 0be5a63..0000000 --- a/src/release/calculate.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Effect } from "effect"; -import { GitError } from "../services/git"; -import type { WorkspacePackage } from "../services/workspace"; -import { formatUnknownError } from "../shared/errors"; -import { - getGlobalCommitsPerPackage, - getWorkspacePackageGroupedCommits, -} from "../versioning/commits"; -import { calculateAndPrepareVersionUpdates } from "../versioning/version"; - -interface CalculateUpdatesOptions { - workspacePackages: WorkspacePackage[]; - workspaceRoot: string; - showPrompt: boolean; - overrides: Record; - globalCommitMode: false | "dependencies" | "all"; -} - -export const calculateUpdates = Effect.fn("calculateUpdates")(function* ( - options: CalculateUpdatesOptions, -) { - const { workspacePackages, workspaceRoot, showPrompt, overrides, globalCommitMode } = options; - - try { - const grouped = yield* getWorkspacePackageGroupedCommits(workspaceRoot, workspacePackages); - const global = yield* getGlobalCommitsPerPackage( - workspaceRoot, - grouped, - workspacePackages, - globalCommitMode, - ); - - const updates = yield* calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits: grouped, - workspaceRoot, - showPrompt, - globalCommitsPerPackage: global, - overrides, - }); - - return updates; - } catch (error) { - const formatted = formatUnknownError(error); - return yield* Effect.fail(new GitError({ - operation: "calculateUpdates", - message: formatted.message, - stderr: formatted.stderr, - })); - } -}); - -export function ensureHasPackages(packages: WorkspacePackage[]): WorkspacePackage[] | null { - if (packages.length === 0) { - return null; - } - - return packages; -} diff --git a/src/release/pr.ts b/src/release/pr.ts deleted file mode 100644 index 0afea0e..0000000 --- a/src/release/pr.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Effect } from "effect"; -import { - generatePullRequestBody, - GitHubError, - type GitHubPullRequest, - GitHubService, - toGitHubError, -} from "../services/github"; -import type { PackageRelease } from "../shared/types"; - -interface SyncPullRequestOptions { - releaseBranch: string; - defaultBranch: string; - pullRequestTitle?: string; - pullRequestBody?: string; - updates: PackageRelease[]; -} - -export const syncPullRequest = Effect.fn("syncPullRequest")(function* ( - options: SyncPullRequestOptions, -) { - const github = yield* GitHubService; - const { releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody, updates } = options; - - const existing = yield* Effect.catchTag( - github.getExistingPullRequest(releaseBranch), - "GitHubError", - (error) => - Effect.fail(new GitHubError({ ...error, operation: "getExistingPullRequest" })), - (error) => Effect.fail(toGitHubError("getExistingPullRequest", error)), - ); - - const doesExist = !!existing; - const title = existing?.title || pullRequestTitle || "chore: update package versions"; - const body = generatePullRequestBody(updates, pullRequestBody); - - const pr = yield* Effect.catchTag(github.upsertPullRequest({ - pullNumber: existing?.number, - title, - body, - head: releaseBranch, - base: defaultBranch, - }), "GitHubError", (err) => - Effect.fail(new GitHubError({ ...err, operation: "upsertPullRequest" })), - (err) => Effect.fail(toGitHubError("upsertPullRequest", err)), - ); - - return { - pullRequest: pr, - created: !doesExist, - }; -}); diff --git a/src/services/changelog.ts b/src/services/changelog.ts index 1bcd3e4..27d46ba 100644 --- a/src/services/changelog.ts +++ b/src/services/changelog.ts @@ -1,12 +1,12 @@ import { join, relative } from "node:path"; -import { buildTemplateGroups } from "../shared/changelog-format"; import type { NormalizedReleaseScriptsOptions } from "../options"; import { DEFAULT_CHANGELOG_TEMPLATE } from "../options"; -import type { AuthorInfo, CommitTypeRule } from "../shared/types"; -import { logger } from "../shared/utils"; +import type { AuthorInfo, CommitTypeRule } from "../types"; +import { logger } from "../errors"; import { Context, Effect, FileSystem, Layer } from "effect"; import type { GitCommit } from "commit-parser"; +import { groupByType } from "commit-parser"; import { Eta } from "eta"; import { GitService } from "./git"; @@ -14,8 +14,80 @@ import { GitHubService } from "./github"; import type { WorkspacePackage } from "./workspace"; const CHANGELOG_VERSION_RE = /##\s+(?:)?\[?([^\](\s<]+)/; +const HASH_PREFIX_RE = /^#/; const excludeAuthors = [/\[bot\]/i, /dependabot/i, /\(bot\)/i]; +function formatCommitLine(options: { + commit: GitCommit; + owner: string; + repo: string; + authors: AuthorInfo[]; +}): string { + const { commit, owner, repo, authors } = options; + const commitUrl = `https://github.com/${owner}/${repo}/commit/${commit.hash}`; + let line = commit.description; + const references = commit.references ?? []; + + for (const ref of references) { + if (!ref.value) continue; + + const number = Number.parseInt(ref.value.replace(HASH_PREFIX_RE, ""), 10); + if (Number.isNaN(number)) continue; + + if (ref.type === "issue") { + line += ` ([Issue ${ref.value}](https://github.com/${owner}/${repo}/issues/${number}))`; + continue; + } + + line += ` ([PR ${ref.value}](https://github.com/${owner}/${repo}/pull/${number}))`; + } + + line += ` ([${commit.shortHash}](${commitUrl}))`; + + if (authors.length > 0) { + const authorList = authors + .map((author) => + author.login ? `[@${author.login}](https://github.com/${author.login})` : author.name, + ) + .join(", "); + + line += ` (by ${authorList})`; + } + + return line; +} + +export function buildTemplateGroups(options: { + commits: GitCommit[]; + owner: string; + repo: string; + types: Record; + commitAuthors: Map; +}): Array<{ name: string; title: string; commits: Array<{ line: string }> }> { + const { commits, owner, repo, types, commitAuthors } = options; + const mergeKeys = Object.fromEntries( + Object.entries(types).map(([key, value]) => [key, value.types ?? [key]]), + ); + + const grouped = groupByType(commits, { + includeNonConventional: false, + mergeKeys, + }); + + return Object.entries(types).map(([key, value]) => ({ + name: key, + title: value.title, + commits: (grouped.get(key) ?? []).map((commit) => ({ + line: formatCommitLine({ + commit, + owner, + repo, + authors: commitAuthors.get(commit.hash) ?? [], + }), + })), + })); +} + export interface ChangelogServiceShape { readonly generateChangelogEntry: (options: { packageName: string; diff --git a/src/services/git.ts b/src/services/git.ts index d0030c0..d4b7864 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -1,7 +1,6 @@ import process from "node:process"; -import { formatUnknownError } from "../shared/errors"; -import { logger, runEffect, runIfNotDryEffect } from "../shared/utils"; +import { formatUnknownError, logger, runEffect, runIfNotDryEffect } from "../errors"; import { Cause, Context, Data, Effect, Exit, Layer } from "effect"; import farver from "farver"; import semver from "semver"; diff --git a/src/services/github.ts b/src/services/github.ts index 26a8b39..378843e 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -1,7 +1,6 @@ -import { formatUnknownError } from "../shared/errors"; +import { formatUnknownError, logger } from "../errors"; import { ReleaseOptions } from "../options"; -import type { AuthorInfo, PackageRelease } from "../shared/types"; -import { logger } from "../shared/utils"; +import type { AuthorInfo, PackageRelease } from "../types"; import { Context, Data, Effect, Layer } from "effect"; import { Eta } from "eta"; import farver from "farver"; diff --git a/src/services/npm.ts b/src/services/npm.ts index c018e58..335b18c 100644 --- a/src/services/npm.ts +++ b/src/services/npm.ts @@ -1,8 +1,7 @@ import process from "node:process"; import type { NormalizedReleaseScriptsOptions } from "../options"; -import { formatUnknownError } from "../shared/errors"; -import { logger, runIfNotDryEffect } from "../shared/utils"; +import { formatUnknownError, logger, runIfNotDryEffect } from "../errors"; import { Cause, Context, Data, Effect, Exit, Layer } from "effect"; import semver from "semver"; diff --git a/src/services/prompts.ts b/src/services/prompts.ts index 4a62084..2103012 100644 --- a/src/services/prompts.ts +++ b/src/services/prompts.ts @@ -4,8 +4,8 @@ import { getNextStableVersion, getPrereleaseIdentifier, isValidSemver, -} from "../shared/semver"; -import type { BumpKind } from "../shared/types"; +} from "../versions"; +import type { BumpKind } from "../types"; import { Context, Effect, Layer } from "effect"; import farver from "farver"; import prompts from "prompts"; diff --git a/src/services/workspace.ts b/src/services/workspace.ts index c0609af..2b22513 100644 --- a/src/services/workspace.ts +++ b/src/services/workspace.ts @@ -1,8 +1,8 @@ import { join } from "node:path"; import { PromptService } from "./prompts"; -import type { FindWorkspacePackagesOptions, PackageJson } from "../shared/types"; -import { getIsCI, logger, runEffect } from "../shared/utils"; +import type { FindWorkspacePackagesOptions, PackageJson } from "../types"; +import { getIsCI, logger, runEffect } from "../errors"; import { Context, Data, Effect, FileSystem, Layer } from "effect"; import farver from "farver"; diff --git a/src/shared/changelog-format.ts b/src/shared/changelog-format.ts deleted file mode 100644 index 91f357c..0000000 --- a/src/shared/changelog-format.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { AuthorInfo, CommitTypeRule } from "#shared/types"; -import type { GitCommit } from "commit-parser"; -import { groupByType } from "commit-parser"; - -const HASH_PREFIX_RE = /^#/; - -interface FormatCommitLineOptions { - commit: GitCommit; - owner: string; - repo: string; - authors: AuthorInfo[]; -} - -function formatCommitLine({ commit, owner, repo, authors }: FormatCommitLineOptions): string { - const commitUrl = `https://github.com/${owner}/${repo}/commit/${commit.hash}`; - let line = commit.description; - const references = commit.references ?? []; - - for (const ref of references) { - if (!ref.value) continue; - - const number = Number.parseInt(ref.value.replace(HASH_PREFIX_RE, ""), 10); - if (Number.isNaN(number)) continue; - - if (ref.type === "issue") { - line += ` ([Issue ${ref.value}](https://github.com/${owner}/${repo}/issues/${number}))`; - continue; - } - - line += ` ([PR ${ref.value}](https://github.com/${owner}/${repo}/pull/${number}))`; - } - - line += ` ([${commit.shortHash}](${commitUrl}))`; - - if (authors.length > 0) { - const authorList = authors - .map((author) => - author.login ? `[@${author.login}](https://github.com/${author.login})` : author.name, - ) - .join(", "); - - line += ` (by ${authorList})`; - } - - return line; -} - -export function buildTemplateGroups(options: { - commits: GitCommit[]; - owner: string; - repo: string; - types: Record; - commitAuthors: Map; -}): Array<{ name: string; title: string; commits: Array<{ line: string }> }> { - const { commits, owner, repo, types, commitAuthors } = options; - const mergeKeys = Object.fromEntries( - Object.entries(types).map(([key, value]) => [key, value.types ?? [key]]), - ); - - const grouped = groupByType(commits, { - includeNonConventional: false, - mergeKeys, - }); - - return Object.entries(types).map(([key, value]) => { - const commitsInGroup = grouped.get(key) ?? []; - const formattedCommits = commitsInGroup.map((commit) => ({ - line: formatCommitLine({ - commit, - owner, - repo, - authors: commitAuthors.get(commit.hash) ?? [], - }), - })); - - return { - name: key, - title: value.title, - commits: formattedCommits, - }; - }); -} diff --git a/src/shared/errors.ts b/src/shared/errors.ts deleted file mode 100644 index a0a4716..0000000 --- a/src/shared/errors.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { getIsVerbose } from "#shared/utils"; -import farver from "farver"; - -type UnknownRecord = Record; - -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null; -} - -function toTrimmedString(value: unknown): string | undefined { - if (typeof value === "string") { - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; - } - - if (value instanceof Uint8Array) { - const normalized = new TextDecoder().decode(value).trim(); - return normalized.length > 0 ? normalized : undefined; - } - - if (isRecord(value) && typeof value.toString === "function") { - const rendered = value.toString(); - if (typeof rendered === "string" && rendered !== "[object Object]") { - const normalized = rendered.trim(); - return normalized.length > 0 ? normalized : undefined; - } - } - - return undefined; -} - -function getNestedField(record: UnknownRecord, keys: string[]): unknown { - let current: unknown = record; - for (const key of keys) { - if (!isRecord(current) || !(key in current)) { - return undefined; - } - current = current[key]; - } - - return current; -} - -function extractStderrLike(record: UnknownRecord): string | undefined { - const candidates: unknown[] = [ - record.stderr, - record.stdout, - record.shortMessage, - record.originalMessage, - getNestedField(record, ["result", "stderr"]), - getNestedField(record, ["result", "stdout"]), - getNestedField(record, ["output", "stderr"]), - getNestedField(record, ["output", "stdout"]), - getNestedField(record, ["cause", "stderr"]), - getNestedField(record, ["cause", "stdout"]), - getNestedField(record, ["cause", "shortMessage"]), - getNestedField(record, ["cause", "originalMessage"]), - ]; - - for (const candidate of candidates) { - const rendered = toTrimmedString(candidate); - if (rendered) { - return rendered; - } - } - - return undefined; -} - -interface FormattedUnknownError { - message: string; - stderr?: string; - code?: string; - status?: number; - stack?: string; -} - -export function formatUnknownError(error: unknown): FormattedUnknownError { - if (error instanceof Error) { - const base: FormattedUnknownError = { - message: error.message || error.name, - stack: error.stack, - }; - - const maybeError = error as Error & UnknownRecord; - - if (typeof maybeError.code === "string") { - base.code = maybeError.code; - } - - if (typeof maybeError.status === "number") { - base.status = maybeError.status; - } - - base.stderr = extractStderrLike(maybeError); - - if ( - typeof maybeError.shortMessage === "string" && - maybeError.shortMessage.trim() && - base.message.startsWith("Process exited with non-zero status") - ) { - base.message = maybeError.shortMessage.trim(); - } - - if (!base.stderr && typeof maybeError.cause === "string" && maybeError.cause.trim()) { - base.stderr = maybeError.cause.trim(); - } - - return base; - } - - if (typeof error === "string") { - return { - message: error, - }; - } - - if (isRecord(error)) { - const message = - typeof error.message === "string" - ? error.message - : typeof error.error === "string" - ? error.error - : JSON.stringify(error); - - const formatted: FormattedUnknownError = { - message, - }; - - if (typeof error.code === "string") { - formatted.code = error.code; - } - - if (typeof error.status === "number") { - formatted.status = error.status; - } - - formatted.stderr = extractStderrLike(error); - - return formatted; - } - - return { - message: String(error), - }; -} - -export class ReleaseError extends Error { - readonly hint?: string; - - constructor(message: string, hint?: string, cause?: unknown) { - super(message); - this.name = "ReleaseError"; - this.hint = hint; - this.cause = cause; - } -} - -export function printReleaseError(error: ReleaseError): void { - console.error(` ${farver.red("✖")} ${farver.bold(error.message)}`); - - if (error.cause !== undefined) { - const formatted = formatUnknownError(error.cause); - if (formatted.message && formatted.message !== error.message) { - console.error(farver.gray(` Cause: ${formatted.message}`)); - } - - if (formatted.code) { - console.error(farver.gray(` Code: ${formatted.code}`)); - } - - if (typeof formatted.status === "number") { - console.error(farver.gray(` Status: ${formatted.status}`)); - } - - if (formatted.stderr) { - console.error(farver.gray(" Stderr:")); - console.error(farver.gray(` ${formatted.stderr}`)); - } - - if (getIsVerbose() && formatted.stack) { - console.error(farver.gray(" Stack:")); - console.error(farver.gray(` ${formatted.stack}`)); - } - } - - if (error.hint) { - console.error(farver.gray(` ${error.hint}`)); - } -} - -export function exitWithError(message: string, hint?: string, cause?: unknown): never { - throw new ReleaseError(message, hint, cause); -} diff --git a/src/shared/semver.ts b/src/shared/semver.ts deleted file mode 100644 index 2ab29f7..0000000 --- a/src/shared/semver.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { BumpKind } from "#shared/types"; -import semver from "semver"; - -export function isValidSemver(version: string): boolean { - return semver.valid(version) != null; -} - -export function getNextVersion(currentVersion: string, bump: BumpKind): string { - if (bump === "none") { - return currentVersion; - } - - if (!isValidSemver(currentVersion)) { - throw new Error(`Cannot bump version for invalid semver: ${currentVersion}`); - } - - // If currently a prerelease, stay in the same prerelease track rather than - // promoting to stable. Use the existing identifier (e.g. "beta") if present. - if (semver.prerelease(currentVersion)) { - const identifier = getPrereleaseIdentifier(currentVersion); - const next = identifier - ? semver.inc(currentVersion, "prerelease", identifier) - : semver.inc(currentVersion, "prerelease"); - if (!next) { - throw new Error(`Failed to bump prerelease version ${currentVersion}`); - } - return next; - } - - const next = semver.inc(currentVersion, bump); - if (!next) { - throw new Error(`Failed to bump version ${currentVersion} with bump ${bump}`); - } - - return next; -} - -/** - * Like getNextVersion but always produces a stable version, even when - * currentVersion is a prerelease. Use this when the caller explicitly wants - * to promote to a stable release (e.g. patch/minor/major prompt choices). - */ -export function getNextStableVersion( - currentVersion: string, - bump: Exclude, -): string { - if (!isValidSemver(currentVersion)) { - throw new Error(`Cannot bump version for invalid semver: ${currentVersion}`); - } - - const next = semver.inc(currentVersion, bump); - if (!next) { - throw new Error(`Failed to bump version ${currentVersion} with bump ${bump}`); - } - - return next; -} - -export function calculateBumpType(oldVersion: string, newVersion: string): BumpKind { - if (!isValidSemver(oldVersion) || !isValidSemver(newVersion)) { - throw new Error( - `Cannot calculate bump type for invalid semver: ${oldVersion} or ${newVersion}`, - ); - } - - const diff = semver.diff(oldVersion, newVersion); - if (!diff) { - return "none"; - } - - if (diff === "major" || diff === "premajor") return "major"; - if (diff === "minor" || diff === "preminor") return "minor"; - if (diff === "patch" || diff === "prepatch" || diff === "prerelease") return "patch"; - - if (semver.gt(newVersion, oldVersion)) { - return "patch"; - } - - return "none"; -} - -export function getPrereleaseIdentifier(version: string): string | undefined { - const parsed = semver.parse(version); - if (!parsed || parsed.prerelease.length === 0) { - return undefined; - } - - const identifier = parsed.prerelease[0]; - return typeof identifier === "string" ? identifier : undefined; -} - -export function getNextPrereleaseVersion( - currentVersion: string, - mode: "next" | "prepatch" | "preminor" | "premajor", - identifier?: string, -): string { - if (!isValidSemver(currentVersion)) { - throw new Error(`Cannot bump prerelease for invalid semver: ${currentVersion}`); - } - - const releaseType = mode === "next" ? "prerelease" : mode; - const next = identifier - ? semver.inc(currentVersion, releaseType, identifier) - : semver.inc(currentVersion, releaseType); - if (!next) { - throw new Error(`Failed to compute prerelease version for ${currentVersion}`); - } - - return next; -} diff --git a/src/shared/types.ts b/src/shared/types.ts deleted file mode 100644 index 8f46570..0000000 --- a/src/shared/types.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { WorkspacePackage } from "../services/workspace"; - -export type BumpKind = "none" | "patch" | "minor" | "major"; - -export interface CommitTypeRule { - /** - * Display title (e.g., "Features", "Bug Fixes") - */ - title: string; - - /** - * Commit types to include in this group (defaults to the map key) - */ - types?: string[]; -} - -export interface PackageJson { - name: string; - version: string; - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; - - private?: boolean; - - [key: string]: unknown; -} - -export interface PackageUpdateOrder { - package: WorkspacePackage; - level: number; -} - -export interface FindWorkspacePackagesOptions { - /** - * Package names to exclude - */ - exclude?: string[]; - - /** - * Only include these packages (if specified, all others are excluded) - */ - include?: string[]; - - /** - * Whether to exclude private packages (default: false) - */ - excludePrivate?: boolean; -} - -export interface PackageRelease { - /** - * The package being updated - */ - package: WorkspacePackage; - - /** - * Current version - */ - currentVersion: string; - - /** - * New version to release - */ - newVersion: string; - - /** - * Type of version bump - */ - bumpType: BumpKind; - - /** - * Whether this package has direct changes (vs being updated due to dependency changes) - */ - hasDirectChanges: boolean; - - /** - * Why/how this release entry exists. - */ - changeKind: "auto" | "manual" | "as-is" | "dependent"; -} - -export interface AuthorInfo { - commits: string[]; - login?: string; - email: string; - name: string; -} diff --git a/src/shared/version.ts b/src/shared/version.ts deleted file mode 100644 index 136d850..0000000 --- a/src/shared/version.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { WorkspacePackage } from "../services/workspace"; -import type { BumpKind, PackageRelease } from "./types"; -import type { GitCommit } from "commit-parser"; - -import { getNextVersion } from "./semver"; - -export function determineHighestBump(commits: GitCommit[]): BumpKind { - if (commits.length === 0) { - return "none"; - } - - let highestBump: BumpKind = "none"; - - for (const commit of commits) { - const bump = determineBumpType(commit); - - if (bump === "major") { - return "major"; - } - - if (bump === "minor") { - highestBump = "minor"; - } else if (bump === "patch" && highestBump === "none") { - highestBump = "patch"; - } - } - - return highestBump; -} - -export function createVersionUpdate( - pkg: WorkspacePackage, - bump: BumpKind, - hasDirectChanges: boolean, -): PackageRelease { - const newVersion = getNextVersion(pkg.version, bump); - - return { - package: pkg, - currentVersion: pkg.version, - newVersion, - bumpType: bump, - hasDirectChanges, - changeKind: "dependent", - }; -} - -function determineBumpType(commit: GitCommit): BumpKind { - if (!commit.isConventional) { - return "none"; - } - - if (commit.isBreaking) { - return "major"; - } - - if (commit.type === "feat") { - return "minor"; - } - - if (commit.type === "fix" || commit.type === "perf") { - return "patch"; - } - - return "none"; -} diff --git a/src/types.ts b/src/types.ts index ef42e62..e864555 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,18 +1,51 @@ -import type { PackageRelease } from "#shared/types"; +import type { WorkspacePackage } from "./services/workspace"; + +export type BumpKind = "none" | "patch" | "minor" | "major"; + +export interface CommitTypeRule { + title: string; + types?: string[]; +} + +export interface PackageJson { + name: string; + version: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + private?: boolean; + [key: string]: unknown; +} + +export interface PackageUpdateOrder { + package: WorkspacePackage; + level: number; +} + +export interface FindWorkspacePackagesOptions { + exclude?: string[]; + include?: string[]; + excludePrivate?: boolean; +} + +export interface PackageRelease { + package: WorkspacePackage; + currentVersion: string; + newVersion: string; + bumpType: BumpKind; + hasDirectChanges: boolean; + changeKind: "auto" | "manual" | "as-is" | "dependent"; +} + +export interface AuthorInfo { + commits: string[]; + login?: string; + email: string; + name: string; +} export interface ReleaseResult { - /** - * Packages that will be updated - */ updates: PackageRelease[]; - - /** - * URL of the created or updated PR - */ prUrl?: string; - - /** - * Whether a new PR was created (vs updating existing) - */ created: boolean; } diff --git a/src/release/verify.ts b/src/verify.ts similarity index 91% rename from src/release/verify.ts rename to src/verify.ts index 5d139d3..1f98986 100644 --- a/src/release/verify.ts +++ b/src/verify.ts @@ -1,13 +1,12 @@ import { join, relative } from "node:path"; -import { GitHubService } from "../services/github"; -import { type GitError, GitService } from "../services/git"; -import { type WorkspaceError, WorkspaceService, type WorkspacePackage } from "../services/workspace"; -import type { PackageRelease } from "../shared/types"; -import { calculateUpdates, ensureHasPackages } from "./calculate"; -import { exitWithError, formatUnknownError } from "../shared/errors"; -import { ReleaseOptions } from "../options"; -import { logger, ucdjsReleaseOverridesPath } from "../shared/utils"; +import { GitHubService } from "./services/github"; +import { type GitError, GitService } from "./services/git"; +import { type WorkspaceError, WorkspaceService, type WorkspacePackage } from "./services/workspace"; +import type { BumpKind, PackageRelease } from "./types"; +import { calculateUpdates, ensureHasPackages } from "./packages"; +import { exitWithError, formatUnknownError, logger, ucdjsReleaseOverridesPath } from "./errors"; +import { ReleaseOptions } from "./options"; import { Effect } from "effect"; import { gt } from "semver"; @@ -71,7 +70,7 @@ export const verifyWorkflow = Effect.fn("verifyWorkflow")(function* () { let existingOverrides: Record< string, - { version: string; type: import("#shared/types").BumpKind } + { version: string; type: BumpKind } > = {}; try { const overridesContent = yield* git.readFileFromGit( diff --git a/src/versioning/version.ts b/src/versions.ts similarity index 76% rename from src/versioning/version.ts rename to src/versions.ts index a3eb3bd..f40b6d8 100644 --- a/src/versioning/version.ts +++ b/src/versions.ts @@ -1,15 +1,174 @@ import { join } from "node:path"; -import { PromptService } from "../services/prompts"; +import { PromptService } from "./services/prompts"; import { Effect, FileSystem } from "effect"; -import type { WorkspacePackage } from "../services/workspace"; -import { calculateBumpType, getNextVersion } from "../shared/semver"; -import { determineHighestBump } from "../shared/version"; -import type { BumpKind, PackageJson, PackageRelease } from "../shared/types"; -import { getIsCI, logger } from "../shared/utils"; -import { buildPackageDependencyGraph, createDependentUpdates } from "./package"; +import type { WorkspacePackage } from "./services/workspace"; +import type { BumpKind, PackageJson, PackageRelease } from "./types"; +import { getIsCI, logger } from "./errors"; +import { buildPackageDependencyGraph, createDependentUpdates } from "./packages"; import type { GitCommit } from "commit-parser"; import farver from "farver"; +import semver from "semver"; + +export function isValidSemver(version: string): boolean { + return semver.valid(version) != null; +} + +export function getPrereleaseIdentifier(version: string): string | undefined { + const parsed = semver.parse(version); + if (!parsed || parsed.prerelease.length === 0) { + return undefined; + } + + const identifier = parsed.prerelease[0]; + return typeof identifier === "string" ? identifier : undefined; +} + +export function getNextVersion(currentVersion: string, bump: BumpKind): string { + if (bump === "none") { + return currentVersion; + } + + if (!isValidSemver(currentVersion)) { + throw new Error(`Cannot bump version for invalid semver: ${currentVersion}`); + } + + if (semver.prerelease(currentVersion)) { + const identifier = getPrereleaseIdentifier(currentVersion); + const next = identifier + ? semver.inc(currentVersion, "prerelease", identifier) + : semver.inc(currentVersion, "prerelease"); + if (!next) { + throw new Error(`Failed to bump prerelease version ${currentVersion}`); + } + return next; + } + + const next = semver.inc(currentVersion, bump); + if (!next) { + throw new Error(`Failed to bump version ${currentVersion} with bump ${bump}`); + } + + return next; +} + +export function getNextStableVersion( + currentVersion: string, + bump: Exclude, +): string { + if (!isValidSemver(currentVersion)) { + throw new Error(`Cannot bump version for invalid semver: ${currentVersion}`); + } + + const next = semver.inc(currentVersion, bump); + if (!next) { + throw new Error(`Failed to bump version ${currentVersion} with bump ${bump}`); + } + + return next; +} + +export function calculateBumpType(oldVersion: string, newVersion: string): BumpKind { + if (!isValidSemver(oldVersion) || !isValidSemver(newVersion)) { + throw new Error( + `Cannot calculate bump type for invalid semver: ${oldVersion} or ${newVersion}`, + ); + } + + const diff = semver.diff(oldVersion, newVersion); + if (!diff) { + return "none"; + } + + if (diff === "major" || diff === "premajor") return "major"; + if (diff === "minor" || diff === "preminor") return "minor"; + if (diff === "patch" || diff === "prepatch" || diff === "prerelease") return "patch"; + + if (semver.gt(newVersion, oldVersion)) { + return "patch"; + } + + return "none"; +} + +export function getNextPrereleaseVersion( + currentVersion: string, + mode: "next" | "prepatch" | "preminor" | "premajor", + identifier?: string, +): string { + if (!isValidSemver(currentVersion)) { + throw new Error(`Cannot bump prerelease for invalid semver: ${currentVersion}`); + } + + const releaseType = mode === "next" ? "prerelease" : mode; + const next = identifier + ? semver.inc(currentVersion, releaseType, identifier) + : semver.inc(currentVersion, releaseType); + if (!next) { + throw new Error(`Failed to compute prerelease version for ${currentVersion}`); + } + + return next; +} + +export function determineHighestBump(commits: GitCommit[]): BumpKind { + if (commits.length === 0) { + return "none"; + } + + let highestBump: BumpKind = "none"; + + for (const commit of commits) { + const bump = determineBumpType(commit); + + if (bump === "major") { + return "major"; + } + + if (bump === "minor") { + highestBump = "minor"; + } else if (bump === "patch" && highestBump === "none") { + highestBump = "patch"; + } + } + + return highestBump; +} + +export function createVersionUpdate( + pkg: WorkspacePackage, + bump: BumpKind, + hasDirectChanges: boolean, +): PackageRelease { + return { + package: pkg, + currentVersion: pkg.version, + newVersion: getNextVersion(pkg.version, bump), + bumpType: bump, + hasDirectChanges, + changeKind: "dependent", + }; +} + +function determineBumpType(commit: GitCommit): BumpKind { + if (!commit.isConventional) { + return "none"; + } + + if (commit.isBreaking) { + return "major"; + } + + if (commit.type === "feat") { + return "minor"; + } + + if (commit.type === "fix" || commit.type === "perf") { + return "patch"; + } + + return "none"; +} const messageColorMap: Record string> = { feat: farver.green, diff --git a/test/core/changelog.test.ts b/test/core/changelog.test.ts index 371ad3b..447eba4 100644 --- a/test/core/changelog.test.ts +++ b/test/core/changelog.test.ts @@ -7,7 +7,7 @@ import { ChangelogService, ChangelogServiceLive, parseChangelog } from "../../sr import { GitHubService } from "../../src/services/github"; import type { NormalizedReleaseScriptsOptions } from "../../src/options"; import { expect, it } from "@effect/vitest"; -import { runEffect } from "#shared/utils"; +import { runEffect } from "../../src/errors"; import { dedent } from "@luxass/utils"; import { Effect, Layer } from "effect"; import type { GitCommit } from "commit-parser"; @@ -16,11 +16,11 @@ import { testdir } from "vitest-testdirs"; import { DEFAULT_TYPES } from "../../src/options"; import { createChangelogTestContext, createCommit, createGitHubServiceStub } from "../_shared"; -import type { CommitTypeRule } from "../../src/shared/types"; +import type { CommitTypeRule } from "../../src/types"; import type { WorkspacePackage } from "../../src/services/workspace"; -vi.mock("#shared/utils", async () => { - const actual = await vi.importActual("#shared/utils"); +vi.mock("../../src/errors", async () => { + const actual = await vi.importActual("../../src/errors"); return { ...actual, runEffect: vi.fn(), diff --git a/test/core/git.test.ts b/test/core/git.test.ts index 21e8a15..958bde7 100644 --- a/test/core/git.test.ts +++ b/test/core/git.test.ts @@ -3,13 +3,13 @@ import { GitService, GitServiceLive, } from "../../src/services/git"; -import { runEffect, runIfNotDryEffect } from "#shared/utils"; +import { runEffect, runIfNotDryEffect } from "../../src/errors"; import { expect, it, layer } from "@effect/vitest"; import { Cause, Effect, Layer } from "effect"; import { afterEach, assert, beforeEach, describe, vi } from "vitest"; -vi.mock("#shared/utils", async () => { - const actual = await vi.importActual("#shared/utils"); +vi.mock("../../src/errors", async () => { + const actual = await vi.importActual("../../src/errors"); return { ...actual, runEffect: vi.fn(), diff --git a/test/core/npm.test.ts b/test/core/npm.test.ts index 2f9d742..967520c 100644 --- a/test/core/npm.test.ts +++ b/test/core/npm.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { NpmService, NpmServiceLive } from "../../src/services/npm"; -import { runIfNotDryEffect } from "#shared/utils"; +import { runIfNotDryEffect } from "../../src/errors"; import { expect, it, layer } from "@effect/vitest"; import { Cause, Effect, Layer } from "effect"; import { HttpResponse } from "msw"; @@ -9,8 +9,8 @@ import { afterEach, assert, beforeEach, vi } from "vitest"; import { mockFetch, NPM_REGISTRY } from "../_msw"; import { createNormalizedReleaseOptions } from "../_shared"; -vi.mock("#shared/utils", async () => { - const actual = await vi.importActual("#shared/utils"); +vi.mock("../../src/errors", async () => { + const actual = await vi.importActual("../../src/errors"); return { ...actual, runIfNotDryEffect: vi.fn(), diff --git a/test/operations/branch.test.ts b/test/operations/branch.test.ts index 7e4504b..75067fa 100644 --- a/test/operations/branch.test.ts +++ b/test/operations/branch.test.ts @@ -1,6 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { GitService } from "../../src/services/git"; -import { prepareReleaseBranch } from "../../src/release/branch"; +import { prepareReleaseBranch } from "../../src/prepare"; import { expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { afterEach, beforeEach, describe, vi } from "vitest"; diff --git a/test/operations/changelog-format.test.ts b/test/operations/changelog-format.test.ts index a188ce1..765c970 100644 --- a/test/operations/changelog-format.test.ts +++ b/test/operations/changelog-format.test.ts @@ -1,5 +1,5 @@ -import { buildTemplateGroups } from "#shared/changelog-format"; -import type { AuthorInfo, CommitTypeRule } from "#shared/types"; +import { buildTemplateGroups } from "../../src/services/changelog"; +import type { AuthorInfo, CommitTypeRule } from "../../src/types"; import type { GitCommit } from "commit-parser"; import { describe, expect, it } from "vitest"; diff --git a/test/operations/pr.test.ts b/test/operations/pr.test.ts index 579e611..3eea8bd 100644 --- a/test/operations/pr.test.ts +++ b/test/operations/pr.test.ts @@ -1,7 +1,7 @@ import { GitHubServiceLive } from "../../src/services/github"; import { NodeServices } from "@effect/platform-node"; import { ReleaseOptions } from "../../src/options"; -import { syncPullRequest } from "../../src/release/pr"; +import { syncPullRequest } from "../../src/prepare"; import { expect, it, layer } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { HttpResponse } from "msw"; diff --git a/test/operations/semver.test.ts b/test/operations/semver.test.ts index 38aefb8..31b81f4 100644 --- a/test/operations/semver.test.ts +++ b/test/operations/semver.test.ts @@ -4,7 +4,7 @@ import { getNextVersion, getPrereleaseIdentifier, isValidSemver, -} from "#shared/semver"; +} from "../../src/versions"; import { describe, expect, it } from "vitest"; describe("semver operations", () => { diff --git a/test/operations/version.test.ts b/test/operations/version.test.ts index 9b7fff2..f438427 100644 --- a/test/operations/version.test.ts +++ b/test/operations/version.test.ts @@ -1,4 +1,4 @@ -import { determineHighestBump } from "#shared/version"; +import { determineHighestBump } from "../../src/versions"; import { describe, expect, it } from "vitest"; import { createCommit } from "../_shared"; diff --git a/test/options.test.ts b/test/options.test.ts index 200ce70..e4eb343 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -1,4 +1,4 @@ -import { ReleaseError } from "#shared/errors"; +import { ReleaseError } from "../src/errors"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_TYPES, normalizeReleaseScriptsOptions } from "../src/options"; diff --git a/test/shared/errors.test.ts b/test/shared/errors.test.ts index 6297815..13b9c9b 100644 --- a/test/shared/errors.test.ts +++ b/test/shared/errors.test.ts @@ -5,7 +5,7 @@ import { formatUnknownError, printReleaseError, ReleaseError, -} from "../../src/shared/errors"; +} from "../../src/errors"; describe("formatUnknownError", () => { it("handles Error instances", () => { diff --git a/test/shared/runtime.test.ts b/test/shared/runtime.test.ts index 7d1b76c..a95bf4d 100644 --- a/test/shared/runtime.test.ts +++ b/test/shared/runtime.test.ts @@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Option } from "effect"; -import { CommandError, runCommandEffect } from "../../src/shared/utils"; +import { CommandError, runCommandEffect } from "../../src/errors"; it.effect("runCommandEffect captures stdout with pipe stdio", () => Effect.scoped( diff --git a/test/shared/utils.test.ts b/test/shared/utils.test.ts index b771730..5351916 100644 --- a/test/shared/utils.test.ts +++ b/test/shared/utils.test.ts @@ -2,7 +2,7 @@ import { Effect } from "effect"; import { afterEach, beforeEach, describe } from "vitest"; import { expect, it } from "@effect/vitest"; -import { getIsCI } from "../../src/shared/utils"; +import { getIsCI } from "../../src/errors"; describe("getIsCI", () => { let originalCI: string | undefined; diff --git a/test/versioning/commits.test.ts b/test/versioning/commits.test.ts index 3fe3d94..289c3a9 100644 --- a/test/versioning/commits.test.ts +++ b/test/versioning/commits.test.ts @@ -1,4 +1,4 @@ -import { determineHighestBump } from "#shared/version"; +import { determineHighestBump } from "../../src/versions"; import { describe, expect, it } from "vitest"; import { createCommit } from "../_shared"; diff --git a/test/versioning/dependency-range.test.ts b/test/versioning/dependency-range.test.ts index 358f299..dd53e76 100644 --- a/test/versioning/dependency-range.test.ts +++ b/test/versioning/dependency-range.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { computeDependencyRange } from "../../src/versioning/version"; +import { computeDependencyRange } from "../../src/versions"; describe("computeDependencyRange", () => { it("returns null for workspace:* ranges", () => { diff --git a/test/versioning/global-commits.test.ts b/test/versioning/global-commits.test.ts index de91dec..fcee338 100644 --- a/test/versioning/global-commits.test.ts +++ b/test/versioning/global-commits.test.ts @@ -6,7 +6,7 @@ import { filterGlobalCommits, findCommitRange, isGlobalCommit, -} from "../../src/versioning/commits"; +} from "../../src/commits"; import { createCommit } from "../_shared"; describe("fileMatchesPackageFolder", () => { diff --git a/test/versioning/package-graph.test.ts b/test/versioning/package-graph.test.ts index 7ff9576..7d72d18 100644 --- a/test/versioning/package-graph.test.ts +++ b/test/versioning/package-graph.test.ts @@ -1,11 +1,11 @@ -import { getNextVersion } from "#shared/semver"; -import type { PackageRelease } from "#shared/types"; +import { getNextVersion } from "../../src/versions"; +import type { PackageRelease } from "../../src/types"; import { buildPackageDependencyGraph, createDependentUpdates, getAllAffectedPackages, getPackagePublishOrder, -} from "#versioning/package"; +} from "../../src/packages"; import { describe, expect, it } from "vitest"; import { createWorkspacePackage } from "../_shared"; diff --git a/test/versioning/resolve-version.test.ts b/test/versioning/resolve-version.test.ts index 6095eb2..2f0d99d 100644 --- a/test/versioning/resolve-version.test.ts +++ b/test/versioning/resolve-version.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveAutoVersion } from "../../src/versioning/version"; +import { resolveAutoVersion } from "../../src/versions"; import { createCommit, createWorkspacePackage } from "../_shared"; describe("resolveAutoVersion", () => { diff --git a/test/versioning/version-dependent-updates.test.ts b/test/versioning/version-dependent-updates.test.ts index 21f70a5..2b3846e 100644 --- a/test/versioning/version-dependent-updates.test.ts +++ b/test/versioning/version-dependent-updates.test.ts @@ -1,6 +1,6 @@ import { PromptServiceLive } from "../../src/services/prompts"; -import type { PackageRelease } from "#shared/types"; -import { calculateAndPrepareVersionUpdates } from "#versioning/version"; +import type { PackageRelease } from "../../src/types"; +import { calculateAndPrepareVersionUpdates } from "../../src/versions"; import { Effect } from "effect"; import { expect, it } from "@effect/vitest"; import { describe, vi } from "vitest"; From c52e5729bca39458d611f279b7ba043a1e7c9f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20N=C3=B8rg=C3=A5rd?= Date: Mon, 11 May 2026 19:44:59 +0200 Subject: [PATCH 6/6] test: mirror src module structure --- ...global-commits.test.ts => commits.test.ts} | 93 ++++- test/{shared => }/errors.test.ts | 105 +++++- test/operations/branch.test.ts | 110 ------ test/operations/pr.test.ts | 235 ------------ test/operations/semver.test.ts | 45 --- test/operations/version.test.ts | 27 -- test/packages.test.ts | 267 ++++++++++++++ test/prepare.test.ts | 333 ++++++++++++++++++ .../changelog-format.test.ts | 0 .../changelog.authors.test.ts | 0 test/{core => services}/changelog.test.ts | 0 test/{core => services}/git.test.ts | 0 test/{core => services}/github.test.ts | 0 test/{core => services}/npm.test.ts | 0 test/shared/runtime.test.ts | 48 --- test/shared/utils.test.ts | 57 --- test/{core => }/types.test.ts | 6 +- test/versioning/commits.test.ts | 80 ----- test/versioning/dependency-range.test.ts | 30 -- test/versioning/package-graph.test.ts | 142 -------- test/versioning/resolve-version.test.ts | 75 ---- .../version-dependent-updates.test.ts | 129 ------- test/versions.test.ts | 168 +++++++++ 23 files changed, 959 insertions(+), 991 deletions(-) rename test/{versioning/global-commits.test.ts => commits.test.ts} (67%) rename test/{shared => }/errors.test.ts (56%) delete mode 100644 test/operations/branch.test.ts delete mode 100644 test/operations/pr.test.ts delete mode 100644 test/operations/semver.test.ts delete mode 100644 test/operations/version.test.ts create mode 100644 test/packages.test.ts create mode 100644 test/prepare.test.ts rename test/{operations => services}/changelog-format.test.ts (100%) rename test/{core => services}/changelog.authors.test.ts (100%) rename test/{core => services}/changelog.test.ts (100%) rename test/{core => services}/git.test.ts (100%) rename test/{core => services}/github.test.ts (100%) rename test/{core => services}/npm.test.ts (100%) delete mode 100644 test/shared/runtime.test.ts delete mode 100644 test/shared/utils.test.ts rename test/{core => }/types.test.ts (81%) delete mode 100644 test/versioning/commits.test.ts delete mode 100644 test/versioning/dependency-range.test.ts delete mode 100644 test/versioning/package-graph.test.ts delete mode 100644 test/versioning/resolve-version.test.ts delete mode 100644 test/versioning/version-dependent-updates.test.ts create mode 100644 test/versions.test.ts diff --git a/test/versioning/global-commits.test.ts b/test/commits.test.ts similarity index 67% rename from test/versioning/global-commits.test.ts rename to test/commits.test.ts index fcee338..a441c6b 100644 --- a/test/versioning/global-commits.test.ts +++ b/test/commits.test.ts @@ -6,8 +6,85 @@ import { filterGlobalCommits, findCommitRange, isGlobalCommit, -} from "../../src/commits"; -import { createCommit } from "../_shared"; +} from "../src/commits"; +import { determineHighestBump } from "../src/versions"; +import { createCommit } from "./_shared"; + +describe("determineHighestBump", () => { + it("should return 'none' for empty commit list", () => { + const result = determineHighestBump([]); + expect(result).toBe("none"); + }); + + it("should return 'patch' if only patch commits are present", () => { + const result = determineHighestBump([ + createCommit({ + message: "fix: bug fix", + type: "fix", + isConventional: true, + }), + createCommit({ + message: "chore: update dependencies", + type: "fix", + isConventional: true, + }), + ]); + + expect(result).toBe("patch"); + }); + + it("should return 'minor' if minor and patch commits are present", () => { + const result = determineHighestBump([ + createCommit({ + message: "feat: new feature", + type: "feat", + isConventional: true, + }), + createCommit({ + message: "fix: bug fix", + type: "fix", + isConventional: true, + }), + ]); + + expect(result).toBe("minor"); + }); + + it("should return 'major' if a breaking change commit is present", () => { + const result = determineHighestBump([ + createCommit({ + message: "feat: new feature\n\nBREAKING CHANGE: changes API", + type: "feat", + isConventional: true, + isBreaking: true, + }), + createCommit({ + message: "fix: bug fix", + type: "fix", + isConventional: true, + }), + ]); + + expect(result).toBe("major"); + }); + + it("should ignore non-conventional commits", () => { + const result = determineHighestBump([ + createCommit({ + message: "Some random commit message", + isConventional: false, + type: "", + }), + createCommit({ + message: "fix: bug fix", + type: "fix", + isConventional: true, + }), + ]); + + expect(result).toBe("patch"); + }); +}); describe("fileMatchesPackageFolder", () => { it("matches files inside package folder", () => { @@ -93,9 +170,9 @@ describe("filterGlobalCommits", () => { createCommit({ shortHash: "c3" }), ]; const filesMap = new Map([ - ["c1", ["package.json"]], // global - ["c2", ["packages/a/src/index.ts"]], // touches package - ["c3", ["tsconfig.json"]], // global + ["c1", ["package.json"]], + ["c2", ["packages/a/src/index.ts"]], + ["c3", ["tsconfig.json"]], ]); const result = filterGlobalCommits(commits, filesMap, packagePaths, "/repo", "all"); @@ -110,9 +187,9 @@ describe("filterGlobalCommits", () => { createCommit({ shortHash: "c3" }), ]; const filesMap = new Map([ - ["c1", ["package.json"]], // global + dependency file - ["c2", ["packages/a/src/index.ts"]], // touches package - ["c3", ["tsconfig.json"]], // global but NOT a dependency file + ["c1", ["package.json"]], + ["c2", ["packages/a/src/index.ts"]], + ["c3", ["tsconfig.json"]], ]); const result = filterGlobalCommits(commits, filesMap, packagePaths, "/repo", "dependencies"); diff --git a/test/shared/errors.test.ts b/test/errors.test.ts similarity index 56% rename from test/shared/errors.test.ts rename to test/errors.test.ts index 13b9c9b..67757b7 100644 --- a/test/shared/errors.test.ts +++ b/test/errors.test.ts @@ -1,11 +1,19 @@ -import { describe, expect, it, vi } from "vitest"; +import process from "node:process"; + +import { NodeServices } from "@effect/platform-node"; +import { expect as effectExpect, it as effectIt } from "@effect/vitest"; +import { Cause, Effect, Exit, Option } from "effect"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + CommandError, exitWithError, formatUnknownError, + getIsCI, printReleaseError, ReleaseError, -} from "../../src/errors"; + runCommandEffect, +} from "../src/errors"; describe("formatUnknownError", () => { it("handles Error instances", () => { @@ -118,3 +126,96 @@ describe("printReleaseError", () => { spy.mockRestore(); }); }); + +effectIt.effect("runCommandEffect captures stdout with pipe stdio", () => + Effect.scoped( + Effect.gen(function* () { + const result = yield* runCommandEffect( + process.execPath, + ["-e", "process.stdout.write('ok')"], + { + nodeOptions: { + stdio: "pipe", + }, + }, + ).pipe(Effect.provide(NodeServices.layer)); + + effectExpect(result.stdout).toBe("ok"); + effectExpect(result.stderr).toBe(""); + effectExpect(result.exitCode).toBe(0); + }), + )); + +effectIt.effect("runCommandEffect fails with CommandError on non-zero exit", () => + Effect.scoped( + Effect.gen(function* () { + const exit = yield* Effect.exit( + runCommandEffect(process.execPath, ["-e", "process.stderr.write('boom'); process.exit(2)"], { + nodeOptions: { + stdio: "pipe", + }, + }).pipe(Effect.provide(NodeServices.layer)), + ); + + effectExpect(Exit.isFailure(exit)).toBe(true); + + if (Exit.isFailure(exit)) { + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); + effectExpect(error).toBeInstanceOf(CommandError); + effectExpect(error?.stderr).toContain("boom"); + effectExpect(error?.exitCode).toBe(2); + } + }), + )); + +describe("getIsCI", () => { + let originalCI: string | undefined; + + beforeEach(() => { + originalCI = process.env.CI; + }); + + afterEach(() => { + if (originalCI === undefined) { + delete process.env.CI; + } else { + process.env.CI = originalCI; + } + }); + + effectIt.effect("returns true when CI=true", () => + Effect.sync(() => { + process.env.CI = "true"; + effectExpect(getIsCI()).toBe(true); + })); + + effectIt.effect("returns true when CI is non-empty string", () => + Effect.sync(() => { + process.env.CI = "1"; + effectExpect(getIsCI()).toBe(true); + })); + + effectIt.effect("returns false when CI is unset", () => + Effect.sync(() => { + delete process.env.CI; + effectExpect(getIsCI()).toBe(false); + })); + + effectIt.effect("returns false when CI=false", () => + Effect.sync(() => { + process.env.CI = "false"; + effectExpect(getIsCI()).toBe(false); + })); + + effectIt.effect("returns false when CI is empty string", () => + Effect.sync(() => { + process.env.CI = ""; + effectExpect(getIsCI()).toBe(false); + })); + + effectIt.effect("returns false when CI=FALSE (case insensitive)", () => + Effect.sync(() => { + process.env.CI = "FALSE"; + effectExpect(getIsCI()).toBe(false); + })); +}); diff --git a/test/operations/branch.test.ts b/test/operations/branch.test.ts deleted file mode 100644 index 75067fa..0000000 --- a/test/operations/branch.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { NodeServices } from "@effect/platform-node"; -import { GitService } from "../../src/services/git"; -import { prepareReleaseBranch } from "../../src/prepare"; -import { expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { afterEach, beforeEach, describe, vi } from "vitest"; - -const mockedGit = { - isWorkingDirectoryClean: vi.fn(), - doesRemoteBranchExist: vi.fn(), - doesBranchExist: vi.fn(), - getDefaultBranch: vi.fn(), - getCurrentBranch: vi.fn(), - checkoutBranch: vi.fn(), - pullLatestChanges: vi.fn(), - rebaseBranch: vi.fn(), - isBranchAheadOfRemote: vi.fn(), - pushBranch: vi.fn(), - readFileFromGit: vi.fn(), - getMostRecentPackageStableTag: vi.fn(), - createAndPushPackageTag: vi.fn(), - createBranch: vi.fn(), - commitPaths: vi.fn(), - commitChanges: vi.fn(), - getMostRecentPackageTag: vi.fn(), - getGroupedFilesByCommitSha: vi.fn(), -}; -const withNode = (effect: Effect.Effect): any => - effect.pipe( - Effect.provide(NodeServices.layer as any), - Effect.provideService(GitService, { - isWorkingDirectoryClean: mockedGit.isWorkingDirectoryClean, - doesRemoteBranchExist: mockedGit.doesRemoteBranchExist, - doesBranchExist: mockedGit.doesBranchExist, - getDefaultBranch: mockedGit.getDefaultBranch, - getCurrentBranch: mockedGit.getCurrentBranch, - checkoutBranch: mockedGit.checkoutBranch, - pullLatestChanges: mockedGit.pullLatestChanges, - rebaseBranch: mockedGit.rebaseBranch, - isBranchAheadOfRemote: mockedGit.isBranchAheadOfRemote, - pushBranch: mockedGit.pushBranch, - readFileFromGit: mockedGit.readFileFromGit, - getMostRecentPackageStableTag: mockedGit.getMostRecentPackageStableTag, - createAndPushPackageTag: mockedGit.createAndPushPackageTag, - createBranch: mockedGit.createBranch, - commitPaths: mockedGit.commitPaths, - commitChanges: mockedGit.commitChanges, - getMostRecentPackageTag: mockedGit.getMostRecentPackageTag, - getGroupedFilesByCommitSha: mockedGit.getGroupedFilesByCommitSha, - } as any), - ); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.resetAllMocks(); -}); - -describe("prepareReleaseBranch", () => { - const baseOptions = { - workspaceRoot: "/workspace", - releaseBranch: "release/next", - defaultBranch: "main", - }; - - it.effect("skips pull when remote branch does not exist", () => - withNode(Effect.gen(function* () { - mockedGit.getCurrentBranch.mockReturnValue(Effect.succeed("main") as any); - mockedGit.doesBranchExist.mockReturnValue(Effect.succeed(true) as any); - mockedGit.doesRemoteBranchExist.mockReturnValue(Effect.succeed(false) as any); - mockedGit.checkoutBranch.mockReturnValue(Effect.succeed(true) as any); - mockedGit.rebaseBranch.mockReturnValue(Effect.succeed(undefined) as any); - - yield* prepareReleaseBranch(baseOptions); - - expect(mockedGit.doesRemoteBranchExist).toHaveBeenCalledWith("release/next", "/workspace"); - expect(mockedGit.pullLatestChanges).not.toHaveBeenCalled(); - }))); - - it.effect("pulls when remote branch exists", () => - withNode(Effect.gen(function* () { - mockedGit.getCurrentBranch.mockReturnValue(Effect.succeed("main") as any); - mockedGit.doesBranchExist.mockReturnValue(Effect.succeed(true) as any); - mockedGit.doesRemoteBranchExist.mockReturnValue(Effect.succeed(true) as any); - mockedGit.checkoutBranch.mockReturnValue(Effect.succeed(true) as any); - mockedGit.pullLatestChanges.mockReturnValue(Effect.succeed(true) as any); - mockedGit.rebaseBranch.mockReturnValue(Effect.succeed(undefined) as any); - - yield* prepareReleaseBranch(baseOptions); - - expect(mockedGit.pullLatestChanges).toHaveBeenCalledWith("release/next", "/workspace"); - }))); - - it.effect("creates branch when it does not exist locally", () => - withNode(Effect.gen(function* () { - mockedGit.getCurrentBranch.mockReturnValue(Effect.succeed("main") as any); - mockedGit.doesBranchExist.mockReturnValue(Effect.succeed(false) as any); - mockedGit.createBranch.mockReturnValue(Effect.succeed(undefined) as any); - mockedGit.checkoutBranch.mockReturnValue(Effect.succeed(true) as any); - mockedGit.rebaseBranch.mockReturnValue(Effect.succeed(undefined) as any); - - yield* prepareReleaseBranch(baseOptions); - - expect(mockedGit.createBranch).toHaveBeenCalledWith("release/next", "main", "/workspace"); - expect(mockedGit.doesRemoteBranchExist).not.toHaveBeenCalled(); - expect(mockedGit.pullLatestChanges).not.toHaveBeenCalled(); - }))); -}); diff --git a/test/operations/pr.test.ts b/test/operations/pr.test.ts deleted file mode 100644 index 3eea8bd..0000000 --- a/test/operations/pr.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { GitHubServiceLive } from "../../src/services/github"; -import { NodeServices } from "@effect/platform-node"; -import { ReleaseOptions } from "../../src/options"; -import { syncPullRequest } from "../../src/prepare"; -import { expect, it, layer } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { HttpResponse } from "msw"; -import { describe } from "vitest"; - -import { GITHUB_API_BASE, mockFetch } from "../_msw"; -import { createNormalizedReleaseOptions, createWorkspacePackage } from "../_shared"; - -const OWNER = "ucdjs"; -const REPO = "test-repo"; - -const NO_UPDATES = [ - { - package: createWorkspacePackage("/repo/packages/a", { name: "@ucdjs/a", version: "1.0.0" }), - currentVersion: "1.0.0", - newVersion: "1.1.0", - bumpType: "minor" as const, - hasDirectChanges: true, - changeKind: "auto" as const, - }, -]; - -layer( - Layer.mergeAll( - NodeServices.layer, - Layer.provide( - GitHubServiceLive, - Layer.succeed(ReleaseOptions, createNormalizedReleaseOptions({ owner: OWNER, repo: REPO })), - ), - ), -)("syncPullRequest", (it) => { - it.effect("creates a new PR when none exists and returns created: true", () => - Effect.gen(function* () { - mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => - HttpResponse.json([]), - ); - mockFetch("POST", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => - HttpResponse.json( - { - number: 10, - title: "chore: release", - body: "", - draft: true, - html_url: `https://github.com/${OWNER}/${REPO}/pull/10`, - head: { sha: "abc1234" }, - }, - { status: 201 }, - ), - ); - - const result = yield* syncPullRequest({ - releaseBranch: "release/next", - defaultBranch: "main", - pullRequestTitle: "chore: release", - updates: NO_UPDATES, - }); - - expect(result.created).toBe(true); - expect(result.pullRequest?.number).toBe(10); - })); - - it.effect("updates an existing PR and returns created: false", () => - Effect.gen(function* () { - mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => - HttpResponse.json([ - { - number: 5, - title: "chore: existing release", - body: "old body", - draft: false, - html_url: `https://github.com/${OWNER}/${REPO}/pull/5`, - head: { sha: "def5678" }, - }, - ]), - ); - mockFetch("PATCH", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls/5`, () => - HttpResponse.json({ - number: 5, - title: "chore: existing release", - body: "updated body", - draft: false, - html_url: `https://github.com/${OWNER}/${REPO}/pull/5`, - head: { sha: "def5678" }, - }), - ); - - const result = yield* syncPullRequest({ - releaseBranch: "release/next", - defaultBranch: "main", - updates: NO_UPDATES, - }); - - expect(result.created).toBe(false); - expect(result.pullRequest?.number).toBe(5); - })); - - it.effect("preserves the existing PR title instead of overriding it", () => - Effect.gen(function* () { - let capturedTitle: string | undefined; - - mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => - HttpResponse.json([ - { - number: 7, - title: "chore: preserved title", - body: "", - draft: false, - html_url: `https://github.com/${OWNER}/${REPO}/pull/7`, - head: { sha: "aaa0001" }, - }, - ]), - ); - mockFetch("PATCH", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls/7`, async ({ request }) => { - const body = (await request.json()) as { title?: string }; - capturedTitle = body.title; - return HttpResponse.json({ - number: 7, - title: capturedTitle, - body: "", - draft: false, - html_url: `https://github.com/${OWNER}/${REPO}/pull/7`, - head: { sha: "aaa0001" }, - }); - }); - - yield* syncPullRequest({ - releaseBranch: "release/next", - defaultBranch: "main", - pullRequestTitle: "chore: caller title", - updates: NO_UPDATES, - }); - - expect(capturedTitle).toBe("chore: preserved title"); - })); - - it.effect("uses pullRequestTitle when there is no existing PR", () => - Effect.gen(function* () { - let capturedTitle: string | undefined; - - mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => - HttpResponse.json([]), - ); - mockFetch("POST", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, async ({ request }) => { - const body = (await request.json()) as { title?: string }; - capturedTitle = body.title; - return HttpResponse.json( - { - number: 11, - title: capturedTitle ?? "", - body: "", - draft: true, - html_url: `https://github.com/${OWNER}/${REPO}/pull/11`, - head: { sha: "bbb0002" }, - }, - { status: 201 }, - ); - }); - - yield* syncPullRequest({ - releaseBranch: "release/next", - defaultBranch: "main", - pullRequestTitle: "chore: caller title", - updates: NO_UPDATES, - }); - - expect(capturedTitle).toBe("chore: caller title"); - })); - - it.effect("falls back to default title when neither existing PR nor caller title is present", () => - Effect.gen(function* () { - let capturedTitle: string | undefined; - - mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => - HttpResponse.json([]), - ); - mockFetch("POST", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, async ({ request }) => { - const body = (await request.json()) as { title?: string }; - capturedTitle = body.title; - return HttpResponse.json( - { - number: 12, - title: capturedTitle ?? "", - body: "", - draft: true, - html_url: `https://github.com/${OWNER}/${REPO}/pull/12`, - head: { sha: "ccc0003" }, - }, - { status: 201 }, - ); - }); - - yield* syncPullRequest({ - releaseBranch: "release/next", - defaultBranch: "main", - updates: NO_UPDATES, - }); - - expect(capturedTitle).toBe("chore: update package versions"); - })); - - it.effect("returns err when getExistingPullRequest fails", () => - Effect.gen(function* () { - mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => - HttpResponse.json({ message: "Bad credentials" }, { status: 401 }), - ); - - const exit = yield* Effect.exit(syncPullRequest({ - releaseBranch: "release/next", - defaultBranch: "main", - updates: NO_UPDATES, - })); - expect(exit._tag).toBe("Failure"); - })); - - it.effect("returns err when upsertPullRequest fails", () => - Effect.gen(function* () { - mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => - HttpResponse.json([]), - ); - mockFetch("POST", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => - HttpResponse.json({ message: "Validation failed" }, { status: 422 }), - ); - - const exit = yield* Effect.exit(syncPullRequest({ - releaseBranch: "release/next", - defaultBranch: "main", - updates: NO_UPDATES, - })); - expect(exit._tag).toBe("Failure"); - })); -}); diff --git a/test/operations/semver.test.ts b/test/operations/semver.test.ts deleted file mode 100644 index 31b81f4..0000000 --- a/test/operations/semver.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - calculateBumpType, - getNextPrereleaseVersion, - getNextVersion, - getPrereleaseIdentifier, - isValidSemver, -} from "../../src/versions"; -import { describe, expect, it } from "vitest"; - -describe("semver operations", () => { - it("validates semver strings", () => { - expect(isValidSemver("1.2.3")).toBe(true); - expect(isValidSemver("1.2.3-beta.1")).toBe(true); - expect(isValidSemver("1.2")).toBe(false); - }); - - it("calculates next versions", () => { - expect(getNextVersion("1.0.0", "major")).toBe("2.0.0"); - expect(getNextVersion("1.0.0", "minor")).toBe("1.1.0"); - expect(getNextVersion("1.0.0", "patch")).toBe("1.0.1"); - expect(getNextVersion("1.0.0", "none")).toBe("1.0.0"); - }); - - it("calculates bump types", () => { - expect(calculateBumpType("1.0.0", "2.0.0")).toBe("major"); - expect(calculateBumpType("1.0.0", "1.1.0")).toBe("minor"); - expect(calculateBumpType("1.0.0", "1.0.1")).toBe("patch"); - expect(calculateBumpType("1.0.0", "1.0.0")).toBe("none"); - }); - - it("supports prerelease helpers", () => { - expect(getPrereleaseIdentifier("0.1.0-beta.46")).toBe("beta"); - expect(getPrereleaseIdentifier("0.1.0")).toBeUndefined(); - - expect(getNextPrereleaseVersion("0.1.0-beta.46", "next", "beta")).toBe("0.1.0-beta.47"); - expect(getNextPrereleaseVersion("0.1.0", "prepatch", "beta")).toBe("0.1.1-beta.0"); - expect(getNextPrereleaseVersion("0.1.0", "preminor", "alpha")).toBe("0.2.0-alpha.0"); - }); - - it("maps prerelease bumps to semantic bump kinds", () => { - expect(calculateBumpType("0.1.0-beta.46", "0.1.0-beta.47")).toBe("patch"); - expect(calculateBumpType("0.1.0-beta.46", "0.1.1-beta.0")).toBe("patch"); - expect(calculateBumpType("0.1.0-beta.46", "0.2.0-beta.0")).toBe("minor"); - }); -}); diff --git a/test/operations/version.test.ts b/test/operations/version.test.ts deleted file mode 100644 index f438427..0000000 --- a/test/operations/version.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { determineHighestBump } from "../../src/versions"; -import { describe, expect, it } from "vitest"; - -import { createCommit } from "../_shared"; - -describe("version operations", () => { - it("returns none for empty commits", () => { - expect(determineHighestBump([])).toBe("none"); - }); - - it("returns patch for fix commits", () => { - const result = determineHighestBump([createCommit({ type: "fix", isConventional: true })]); - expect(result).toBe("patch"); - }); - - it("returns minor for feat commits", () => { - const result = determineHighestBump([createCommit({ type: "feat", isConventional: true })]); - expect(result).toBe("minor"); - }); - - it("returns major for breaking commits", () => { - const result = determineHighestBump([ - createCommit({ type: "feat", isBreaking: true, isConventional: true }), - ]); - expect(result).toBe("major"); - }); -}); diff --git a/test/packages.test.ts b/test/packages.test.ts new file mode 100644 index 0000000..21c4cb0 --- /dev/null +++ b/test/packages.test.ts @@ -0,0 +1,267 @@ +import { PromptServiceLive } from "../src/services/prompts"; +import { getNextVersion } from "../src/versions"; +import type { PackageRelease } from "../src/types"; +import { + buildPackageDependencyGraph, + createDependentUpdates, + getAllAffectedPackages, + getPackagePublishOrder, +} from "../src/packages"; +import { calculateAndPrepareVersionUpdates } from "../src/versions"; +import { Effect } from "effect"; +import { expect, it } from "@effect/vitest"; +import { describe, vi } from "vitest"; + +import { createWorkspacePackage } from "./_shared"; + +vi.mock("../src/services/prompts", async () => { + const actual = await vi.importActual("../src/services/prompts"); + return { + ...actual, + confirmOverridePrompt: vi.fn(), + selectVersionPrompt: vi.fn(), + }; +}); + +function createRelease( + pkg: ReturnType, + bump: PackageRelease["bumpType"], + hasDirectChanges = true, +): PackageRelease { + return { + package: pkg, + currentVersion: pkg.version, + newVersion: getNextVersion(pkg.version, bump), + bumpType: bump, + hasDirectChanges, + changeKind: "auto", + }; +} + +function createWorkspaceFixture() { + const pkgD = createWorkspacePackage("/repo/packages/d", { + name: "pkg-d", + version: "1.0.0", + }); + const pkgB = createWorkspacePackage("/repo/packages/b", { + name: "pkg-b", + version: "1.0.0", + workspaceDependencies: ["pkg-d"], + }); + const pkgC = createWorkspacePackage("/repo/packages/c", { + name: "pkg-c", + version: "1.0.0", + workspaceDependencies: ["pkg-d"], + }); + const pkgA = createWorkspacePackage("/repo/packages/a", { + name: "pkg-a", + version: "1.0.0", + workspaceDependencies: ["pkg-b", "pkg-c"], + }); + const pkgE = createWorkspacePackage("/repo/packages/e", { + name: "pkg-e", + version: "1.0.0", + workspaceDevDependencies: ["pkg-a"], + }); + + return { + pkgA, + pkgB, + pkgC, + pkgD, + pkgE, + packages: [pkgA, pkgB, pkgC, pkgD, pkgE], + }; +} + +describe("package dependency graph", () => { + it("builds dependents mapping from workspace deps", () => { + const { packages } = createWorkspaceFixture(); + const graph = buildPackageDependencyGraph(packages); + + expect(graph.packages.size).toBe(5); + expect([...graph.dependents.get("pkg-d")!]).toEqual(["pkg-b", "pkg-c"]); + expect([...graph.dependents.get("pkg-b")!]).toEqual(["pkg-a"]); + expect([...graph.dependents.get("pkg-c")!]).toEqual(["pkg-a"]); + expect([...graph.dependents.get("pkg-a")!]).toEqual(["pkg-e"]); + expect([...graph.dependents.get("pkg-e")!]).toEqual([]); + }); + + it("calculates transitive affected packages", () => { + const { packages } = createWorkspaceFixture(); + const graph = buildPackageDependencyGraph(packages); + + const affectedFromD = getAllAffectedPackages(graph, new Set(["pkg-d"])); + expect([...affectedFromD].toSorted()).toEqual( + ["pkg-a", "pkg-b", "pkg-c", "pkg-d", "pkg-e"].toSorted(), + ); + + const affectedFromB = getAllAffectedPackages(graph, new Set(["pkg-b"])); + expect([...affectedFromB].toSorted()).toEqual(["pkg-a", "pkg-b", "pkg-e"].toSorted()); + }); + + it("orders publish list by dependency level (stable)", () => { + const { packages } = createWorkspaceFixture(); + const graph = buildPackageDependencyGraph(packages); + + const order = getPackagePublishOrder(graph, new Set(["pkg-b", "pkg-c"])); + expect(order.map((entry) => `${entry.package.name}:${entry.level}`)).toEqual([ + "pkg-b:0", + "pkg-c:0", + "pkg-a:1", + ]); + + const orderFromD = getPackagePublishOrder(graph, new Set(["pkg-d"])); + expect(orderFromD.map((entry) => `${entry.package.name}:${entry.level}`)).toEqual([ + "pkg-d:0", + "pkg-b:1", + "pkg-c:1", + ]); + + const orderFromA = getPackagePublishOrder(graph, new Set(["pkg-a"])); + expect(orderFromA.map((entry) => `${entry.package.name}:${entry.level}`)).toEqual([ + "pkg-a:0", + "pkg-e:1", + ]); + }); + + it("creates dependent updates with patch bumps", () => { + const { packages, pkgB, pkgC } = createWorkspaceFixture(); + const graph = buildPackageDependencyGraph(packages); + const directUpdates = [createRelease(pkgB, "minor"), createRelease(pkgC, "patch")]; + + const updates = createDependentUpdates(graph, packages, directUpdates); + const byName = new Map(updates.map((update) => [update.package.name, update])); + + expect(updates).toHaveLength(4); + expect(byName.get("pkg-a")?.bumpType).toBe("patch"); + expect(byName.get("pkg-a")?.newVersion).toBe("1.0.1"); + expect(byName.get("pkg-a")?.hasDirectChanges).toBe(false); + expect(byName.get("pkg-e")?.bumpType).toBe("patch"); + expect(byName.get("pkg-e")?.newVersion).toBe("1.0.1"); + expect(byName.get("pkg-e")?.hasDirectChanges).toBe(false); + }); + + it("respects excluded packages for dependent bumps", () => { + const { packages, pkgB, pkgC } = createWorkspaceFixture(); + const graph = buildPackageDependencyGraph(packages); + const directUpdates = [createRelease(pkgB, "minor"), createRelease(pkgC, "patch")]; + + const updates = createDependentUpdates(graph, packages, directUpdates, new Set(["pkg-a"])); + const updatedNames = updates.map((update) => update.package.name).toSorted(); + + expect(updatedNames).toEqual(["pkg-b", "pkg-c", "pkg-e"].toSorted()); + }); +}); + +describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { + it.effect("adds dependent patch bumps and preserves direct updates", () => + Effect.gen(function* () { + const pkgD = createWorkspacePackage("/repo/packages/d", { + name: "pkg-d", + version: "1.0.0", + }); + const pkgB = createWorkspacePackage("/repo/packages/b", { + name: "pkg-b", + version: "1.0.0", + workspaceDependencies: ["pkg-d"], + }); + const pkgC = createWorkspacePackage("/repo/packages/c", { + name: "pkg-c", + version: "1.0.0", + workspaceDependencies: ["pkg-d"], + }); + const pkgA = createWorkspacePackage("/repo/packages/a", { + name: "pkg-a", + version: "1.0.0", + workspaceDependencies: ["pkg-b", "pkg-c"], + }); + + const workspacePackages = [pkgA, pkgB, pkgC, pkgD]; + const packageCommits = new Map([ + ["pkg-b", [{ type: "feat", isConventional: true, isBreaking: false } as any]], + ["pkg-c", [{ type: "fix", isConventional: true, isBreaking: false } as any]], + ]); + const globalCommitsPerPackage = new Map(); + + const result = yield* calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits, + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage, + overrides: {}, + }).pipe(Effect.provide(PromptServiceLive)); + + const byName = new Map(result.allUpdates.map((update) => [update.package.name, update])); + + expect(result.allUpdates.map((update) => update.package.name).toSorted()).toEqual( + ["pkg-a", "pkg-b", "pkg-c"].toSorted(), + ); + + expect(byName.get("pkg-b")?.bumpType).toBe("minor"); + expect(byName.get("pkg-b")?.newVersion).toBe("1.1.0"); + expect(byName.get("pkg-c")?.bumpType).toBe("patch"); + expect(byName.get("pkg-c")?.newVersion).toBe("1.0.1"); + expect(byName.get("pkg-a")?.bumpType).toBe("patch"); + expect(byName.get("pkg-a")?.newVersion).toBe("1.0.1"); + })); + + it.effect("respects overrides that exclude dependent bumps", () => + Effect.gen(function* () { + const pkgD = createWorkspacePackage("/repo/packages/d", { + name: "pkg-d", + version: "1.0.0", + }); + const pkgB = createWorkspacePackage("/repo/packages/b", { + name: "pkg-b", + version: "1.0.0", + workspaceDependencies: ["pkg-d"], + }); + const pkgA = createWorkspacePackage("/repo/packages/a", { + name: "pkg-a", + version: "1.0.0", + workspaceDependencies: ["pkg-b"], + }); + + const workspacePackages = [pkgA, pkgB, pkgD]; + const packageCommits = new Map([ + ["pkg-b", [{ type: "feat", isConventional: true, isBreaking: false } as any]], + ]); + const globalCommitsPerPackage = new Map(); + + const result = yield* calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits, + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage, + overrides: { + "pkg-a": { type: "none", version: "1.0.0" }, + }, + }).pipe(Effect.provide(PromptServiceLive)); + + const updatedNames = result.allUpdates.map((update) => update.package.name).toSorted(); + expect(updatedNames).toEqual(["pkg-b"]); + })); + + it.effect("does not add dependents when there are no direct updates", () => + Effect.gen(function* () { + const pkgA = createWorkspacePackage("/repo/packages/a", { + name: "pkg-a", + version: "1.0.0", + }); + const workspacePackages = [pkgA]; + + const result = yield* calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits: new Map(), + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage: new Map(), + overrides: {}, + }).pipe(Effect.provide(PromptServiceLive)); + + expect(result.allUpdates).toEqual([] as PackageRelease[]); + })); +}); diff --git a/test/prepare.test.ts b/test/prepare.test.ts new file mode 100644 index 0000000..805c4e1 --- /dev/null +++ b/test/prepare.test.ts @@ -0,0 +1,333 @@ +import { NodeServices } from "@effect/platform-node"; +import { GitHubServiceLive } from "../src/services/github"; +import { GitService } from "../src/services/git"; +import { ReleaseOptions } from "../src/options"; +import { prepareReleaseBranch, syncPullRequest } from "../src/prepare"; +import { expect, it, layer } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpResponse } from "msw"; +import { afterEach, beforeEach, describe, vi } from "vitest"; + +import { GITHUB_API_BASE, mockFetch } from "./_msw"; +import { createNormalizedReleaseOptions, createWorkspacePackage } from "./_shared"; + +const OWNER = "ucdjs"; +const REPO = "test-repo"; + +const NO_UPDATES = [ + { + package: createWorkspacePackage("/repo/packages/a", { name: "@ucdjs/a", version: "1.0.0" }), + currentVersion: "1.0.0", + newVersion: "1.1.0", + bumpType: "minor" as const, + hasDirectChanges: true, + changeKind: "auto" as const, + }, +]; + +const mockedGit = { + isWorkingDirectoryClean: vi.fn(), + doesRemoteBranchExist: vi.fn(), + doesBranchExist: vi.fn(), + getDefaultBranch: vi.fn(), + getCurrentBranch: vi.fn(), + checkoutBranch: vi.fn(), + pullLatestChanges: vi.fn(), + rebaseBranch: vi.fn(), + isBranchAheadOfRemote: vi.fn(), + pushBranch: vi.fn(), + readFileFromGit: vi.fn(), + getMostRecentPackageStableTag: vi.fn(), + createAndPushPackageTag: vi.fn(), + createBranch: vi.fn(), + commitPaths: vi.fn(), + commitChanges: vi.fn(), + getMostRecentPackageTag: vi.fn(), + getGroupedFilesByCommitSha: vi.fn(), +}; + +const withNode = (effect: Effect.Effect): any => + effect.pipe( + Effect.provide(NodeServices.layer as any), + Effect.provideService(GitService, { + isWorkingDirectoryClean: mockedGit.isWorkingDirectoryClean, + doesRemoteBranchExist: mockedGit.doesRemoteBranchExist, + doesBranchExist: mockedGit.doesBranchExist, + getDefaultBranch: mockedGit.getDefaultBranch, + getCurrentBranch: mockedGit.getCurrentBranch, + checkoutBranch: mockedGit.checkoutBranch, + pullLatestChanges: mockedGit.pullLatestChanges, + rebaseBranch: mockedGit.rebaseBranch, + isBranchAheadOfRemote: mockedGit.isBranchAheadOfRemote, + pushBranch: mockedGit.pushBranch, + readFileFromGit: mockedGit.readFileFromGit, + getMostRecentPackageStableTag: mockedGit.getMostRecentPackageStableTag, + createAndPushPackageTag: mockedGit.createAndPushPackageTag, + createBranch: mockedGit.createBranch, + commitPaths: mockedGit.commitPaths, + commitChanges: mockedGit.commitChanges, + getMostRecentPackageTag: mockedGit.getMostRecentPackageTag, + getGroupedFilesByCommitSha: mockedGit.getGroupedFilesByCommitSha, + } as any), + ); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe("prepareReleaseBranch", () => { + const baseOptions = { + workspaceRoot: "/workspace", + releaseBranch: "release/next", + defaultBranch: "main", + }; + + it.effect("skips pull when remote branch does not exist", () => + withNode(Effect.gen(function* () { + mockedGit.getCurrentBranch.mockReturnValue(Effect.succeed("main") as any); + mockedGit.doesBranchExist.mockReturnValue(Effect.succeed(true) as any); + mockedGit.doesRemoteBranchExist.mockReturnValue(Effect.succeed(false) as any); + mockedGit.checkoutBranch.mockReturnValue(Effect.succeed(true) as any); + mockedGit.rebaseBranch.mockReturnValue(Effect.succeed(undefined) as any); + + yield* prepareReleaseBranch(baseOptions); + + expect(mockedGit.doesRemoteBranchExist).toHaveBeenCalledWith("release/next", "/workspace"); + expect(mockedGit.pullLatestChanges).not.toHaveBeenCalled(); + }))); + + it.effect("pulls when remote branch exists", () => + withNode(Effect.gen(function* () { + mockedGit.getCurrentBranch.mockReturnValue(Effect.succeed("main") as any); + mockedGit.doesBranchExist.mockReturnValue(Effect.succeed(true) as any); + mockedGit.doesRemoteBranchExist.mockReturnValue(Effect.succeed(true) as any); + mockedGit.checkoutBranch.mockReturnValue(Effect.succeed(true) as any); + mockedGit.pullLatestChanges.mockReturnValue(Effect.succeed(true) as any); + mockedGit.rebaseBranch.mockReturnValue(Effect.succeed(undefined) as any); + + yield* prepareReleaseBranch(baseOptions); + + expect(mockedGit.pullLatestChanges).toHaveBeenCalledWith("release/next", "/workspace"); + }))); + + it.effect("creates branch when it does not exist locally", () => + withNode(Effect.gen(function* () { + mockedGit.getCurrentBranch.mockReturnValue(Effect.succeed("main") as any); + mockedGit.doesBranchExist.mockReturnValue(Effect.succeed(false) as any); + mockedGit.createBranch.mockReturnValue(Effect.succeed(undefined) as any); + mockedGit.checkoutBranch.mockReturnValue(Effect.succeed(true) as any); + mockedGit.rebaseBranch.mockReturnValue(Effect.succeed(undefined) as any); + + yield* prepareReleaseBranch(baseOptions); + + expect(mockedGit.createBranch).toHaveBeenCalledWith("release/next", "main", "/workspace"); + expect(mockedGit.doesRemoteBranchExist).not.toHaveBeenCalled(); + expect(mockedGit.pullLatestChanges).not.toHaveBeenCalled(); + }))); +}); + +layer( + Layer.mergeAll( + NodeServices.layer, + Layer.provide( + GitHubServiceLive, + Layer.succeed(ReleaseOptions, createNormalizedReleaseOptions({ owner: OWNER, repo: REPO })), + ), + ), +)("syncPullRequest", (it) => { + it.effect("creates a new PR when none exists and returns created: true", () => + Effect.gen(function* () { + mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => HttpResponse.json([])); + mockFetch("POST", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => + HttpResponse.json( + { + number: 10, + title: "chore: release", + body: "", + draft: true, + html_url: `https://github.com/${OWNER}/${REPO}/pull/10`, + head: { sha: "abc1234" }, + }, + { status: 201 }, + ), + ); + + const result = yield* syncPullRequest({ + releaseBranch: "release/next", + defaultBranch: "main", + pullRequestTitle: "chore: release", + updates: NO_UPDATES, + }); + + expect(result.created).toBe(true); + expect(result.pullRequest?.number).toBe(10); + })); + + it.effect("updates an existing PR and returns created: false", () => + Effect.gen(function* () { + mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => + HttpResponse.json([ + { + number: 5, + title: "chore: existing release", + body: "old body", + draft: false, + html_url: `https://github.com/${OWNER}/${REPO}/pull/5`, + head: { sha: "def5678" }, + }, + ]), + ); + mockFetch("PATCH", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls/5`, () => + HttpResponse.json({ + number: 5, + title: "chore: existing release", + body: "updated body", + draft: false, + html_url: `https://github.com/${OWNER}/${REPO}/pull/5`, + head: { sha: "def5678" }, + }), + ); + + const result = yield* syncPullRequest({ + releaseBranch: "release/next", + defaultBranch: "main", + updates: NO_UPDATES, + }); + + expect(result.created).toBe(false); + expect(result.pullRequest?.number).toBe(5); + })); + + it.effect("preserves the existing PR title instead of overriding it", () => + Effect.gen(function* () { + let capturedTitle: string | undefined; + + mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => + HttpResponse.json([ + { + number: 7, + title: "chore: preserved title", + body: "", + draft: false, + html_url: `https://github.com/${OWNER}/${REPO}/pull/7`, + head: { sha: "aaa0001" }, + }, + ]), + ); + mockFetch("PATCH", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls/7`, async ({ request }) => { + const body = (await request.json()) as { title?: string }; + capturedTitle = body.title; + return HttpResponse.json({ + number: 7, + title: capturedTitle, + body: "", + draft: false, + html_url: `https://github.com/${OWNER}/${REPO}/pull/7`, + head: { sha: "aaa0001" }, + }); + }); + + yield* syncPullRequest({ + releaseBranch: "release/next", + defaultBranch: "main", + pullRequestTitle: "chore: caller title", + updates: NO_UPDATES, + }); + + expect(capturedTitle).toBe("chore: preserved title"); + })); + + it.effect("uses pullRequestTitle when there is no existing PR", () => + Effect.gen(function* () { + let capturedTitle: string | undefined; + + mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => HttpResponse.json([])); + mockFetch("POST", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, async ({ request }) => { + const body = (await request.json()) as { title?: string }; + capturedTitle = body.title; + return HttpResponse.json( + { + number: 11, + title: capturedTitle ?? "", + body: "", + draft: true, + html_url: `https://github.com/${OWNER}/${REPO}/pull/11`, + head: { sha: "bbb0002" }, + }, + { status: 201 }, + ); + }); + + yield* syncPullRequest({ + releaseBranch: "release/next", + defaultBranch: "main", + pullRequestTitle: "chore: caller title", + updates: NO_UPDATES, + }); + + expect(capturedTitle).toBe("chore: caller title"); + })); + + it.effect("falls back to default title when neither existing PR nor caller title is present", () => + Effect.gen(function* () { + let capturedTitle: string | undefined; + + mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => HttpResponse.json([])); + mockFetch("POST", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, async ({ request }) => { + const body = (await request.json()) as { title?: string }; + capturedTitle = body.title; + return HttpResponse.json( + { + number: 12, + title: capturedTitle ?? "", + body: "", + draft: true, + html_url: `https://github.com/${OWNER}/${REPO}/pull/12`, + head: { sha: "ccc0003" }, + }, + { status: 201 }, + ); + }); + + yield* syncPullRequest({ + releaseBranch: "release/next", + defaultBranch: "main", + updates: NO_UPDATES, + }); + + expect(capturedTitle).toBe("chore: update package versions"); + })); + + it.effect("returns err when getExistingPullRequest fails", () => + Effect.gen(function* () { + mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => + HttpResponse.json({ message: "Bad credentials" }, { status: 401 }), + ); + + const exit = yield* Effect.exit(syncPullRequest({ + releaseBranch: "release/next", + defaultBranch: "main", + updates: NO_UPDATES, + })); + expect(exit._tag).toBe("Failure"); + })); + + it.effect("returns err when upsertPullRequest fails", () => + Effect.gen(function* () { + mockFetch("GET", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => HttpResponse.json([])); + mockFetch("POST", `${GITHUB_API_BASE}/repos/${OWNER}/${REPO}/pulls`, () => + HttpResponse.json({ message: "Validation failed" }, { status: 422 }), + ); + + const exit = yield* Effect.exit(syncPullRequest({ + releaseBranch: "release/next", + defaultBranch: "main", + updates: NO_UPDATES, + })); + expect(exit._tag).toBe("Failure"); + })); +}); diff --git a/test/operations/changelog-format.test.ts b/test/services/changelog-format.test.ts similarity index 100% rename from test/operations/changelog-format.test.ts rename to test/services/changelog-format.test.ts diff --git a/test/core/changelog.authors.test.ts b/test/services/changelog.authors.test.ts similarity index 100% rename from test/core/changelog.authors.test.ts rename to test/services/changelog.authors.test.ts diff --git a/test/core/changelog.test.ts b/test/services/changelog.test.ts similarity index 100% rename from test/core/changelog.test.ts rename to test/services/changelog.test.ts diff --git a/test/core/git.test.ts b/test/services/git.test.ts similarity index 100% rename from test/core/git.test.ts rename to test/services/git.test.ts diff --git a/test/core/github.test.ts b/test/services/github.test.ts similarity index 100% rename from test/core/github.test.ts rename to test/services/github.test.ts diff --git a/test/core/npm.test.ts b/test/services/npm.test.ts similarity index 100% rename from test/core/npm.test.ts rename to test/services/npm.test.ts diff --git a/test/shared/runtime.test.ts b/test/shared/runtime.test.ts deleted file mode 100644 index a95bf4d..0000000 --- a/test/shared/runtime.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import process from "node:process"; - -import { NodeServices } from "@effect/platform-node"; -import { expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; - -import { CommandError, runCommandEffect } from "../../src/errors"; - -it.effect("runCommandEffect captures stdout with pipe stdio", () => - Effect.scoped( - Effect.gen(function* () { - const result = yield* runCommandEffect( - process.execPath, - ["-e", "process.stdout.write('ok')"], - { - nodeOptions: { - stdio: "pipe", - }, - }, - ).pipe(Effect.provide(NodeServices.layer)); - - expect(result.stdout).toBe("ok"); - expect(result.stderr).toBe(""); - expect(result.exitCode).toBe(0); - }), - )); - -it.effect("runCommandEffect fails with CommandError on non-zero exit", () => - Effect.scoped( - Effect.gen(function* () { - const exit = yield* Effect.exit( - runCommandEffect(process.execPath, ["-e", "process.stderr.write('boom'); process.exit(2)"], { - nodeOptions: { - stdio: "pipe", - }, - }).pipe(Effect.provide(NodeServices.layer)), - ); - - expect(Exit.isFailure(exit)).toBe(true); - - if (Exit.isFailure(exit)) { - const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); - expect(error).toBeInstanceOf(CommandError); - expect(error?.stderr).toContain("boom"); - expect(error?.exitCode).toBe(2); - } - }), - )); diff --git a/test/shared/utils.test.ts b/test/shared/utils.test.ts deleted file mode 100644 index 5351916..0000000 --- a/test/shared/utils.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Effect } from "effect"; -import { afterEach, beforeEach, describe } from "vitest"; -import { expect, it } from "@effect/vitest"; - -import { getIsCI } from "../../src/errors"; - -describe("getIsCI", () => { - let originalCI: string | undefined; - - beforeEach(() => { - originalCI = process.env.CI; - }); - - afterEach(() => { - if (originalCI === undefined) { - delete process.env.CI; - } else { - process.env.CI = originalCI; - } - }); - - it.effect("returns true when CI=true", () => - Effect.sync(() => { - process.env.CI = "true"; - expect(getIsCI()).toBe(true); - })); - - it.effect("returns true when CI is non-empty string", () => - Effect.sync(() => { - process.env.CI = "1"; - expect(getIsCI()).toBe(true); - })); - - it.effect("returns false when CI is unset", () => - Effect.sync(() => { - delete process.env.CI; - expect(getIsCI()).toBe(false); - })); - - it.effect("returns false when CI=false", () => - Effect.sync(() => { - process.env.CI = "false"; - expect(getIsCI()).toBe(false); - })); - - it.effect("returns false when CI is empty string", () => - Effect.sync(() => { - process.env.CI = ""; - expect(getIsCI()).toBe(false); - })); - - it.effect("returns false when CI=FALSE (case insensitive)", () => - Effect.sync(() => { - process.env.CI = "FALSE"; - expect(getIsCI()).toBe(false); - })); -}); diff --git a/test/core/types.test.ts b/test/types.test.ts similarity index 81% rename from test/core/types.test.ts rename to test/types.test.ts index 2beb253..4faf576 100644 --- a/test/core/types.test.ts +++ b/test/types.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { GitError } from "../../src/services/git"; -import { GitHubError } from "../../src/services/github"; -import { WorkspaceError } from "../../src/services/workspace"; +import { GitError } from "../src/services/git"; +import { GitHubError } from "../src/services/github"; +import { WorkspaceError } from "../src/services/workspace"; describe("core types", () => { it("matches git error shape", () => { diff --git a/test/versioning/commits.test.ts b/test/versioning/commits.test.ts deleted file mode 100644 index 289c3a9..0000000 --- a/test/versioning/commits.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { determineHighestBump } from "../../src/versions"; -import { describe, expect, it } from "vitest"; - -import { createCommit } from "../_shared"; - -describe("determineHighestBump", () => { - it("should return 'none' for empty commit list", () => { - const result = determineHighestBump([]); - expect(result).toBe("none"); - }); - - it("should return 'patch' if only patch commits are present", () => { - const result = determineHighestBump([ - createCommit({ - message: "fix: bug fix", - type: "fix", - isConventional: true, - }), - createCommit({ - message: "chore: update dependencies", - type: "fix", - isConventional: true, - }), - ]); - - expect(result).toBe("patch"); - }); - - it("should return 'minor' if minor and patch commits are present", () => { - const result = determineHighestBump([ - createCommit({ - message: "feat: new feature", - type: "feat", - isConventional: true, - }), - createCommit({ - message: "fix: bug fix", - type: "fix", - isConventional: true, - }), - ]); - - expect(result).toBe("minor"); - }); - - it("should return 'major' if a breaking change commit is present", () => { - const result = determineHighestBump([ - createCommit({ - message: "feat: new feature\n\nBREAKING CHANGE: changes API", - type: "feat", - isConventional: true, - isBreaking: true, - }), - createCommit({ - message: "fix: bug fix", - type: "fix", - isConventional: true, - }), - ]); - - expect(result).toBe("major"); - }); - - it("should ignore non-conventional commits", () => { - const result = determineHighestBump([ - createCommit({ - message: "Some random commit message", - isConventional: false, - type: "", - }), - createCommit({ - message: "fix: bug fix", - type: "fix", - isConventional: true, - }), - ]); - - expect(result).toBe("patch"); - }); -}); diff --git a/test/versioning/dependency-range.test.ts b/test/versioning/dependency-range.test.ts deleted file mode 100644 index dd53e76..0000000 --- a/test/versioning/dependency-range.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { computeDependencyRange } from "../../src/versions"; - -describe("computeDependencyRange", () => { - it("returns null for workspace:* ranges", () => { - expect(computeDependencyRange("workspace:*", "1.0.0", false)).toBeNull(); - }); - - it("returns ^version for regular dependencies", () => { - expect(computeDependencyRange("^0.5.0", "1.0.0", false)).toBe("^1.0.0"); - }); - - it("returns range for peer dependencies", () => { - expect(computeDependencyRange("^1.0.0", "2.0.0", true)).toBe(">=2.0.0 <3.0.0"); - }); - - it("handles 0.x peer dependencies", () => { - expect(computeDependencyRange("^0.1.0", "0.2.0", true)).toBe(">=0.2.0 <1.0.0"); - }); - - it("ignores old range value for regular deps", () => { - expect(computeDependencyRange("~0.5.0", "1.0.0", false)).toBe("^1.0.0"); - expect(computeDependencyRange(">=0.5.0", "1.0.0", false)).toBe("^1.0.0"); - }); - - it("handles peer dependency with large major", () => { - expect(computeDependencyRange("^10.0.0", "11.0.0", true)).toBe(">=11.0.0 <12.0.0"); - }); -}); diff --git a/test/versioning/package-graph.test.ts b/test/versioning/package-graph.test.ts deleted file mode 100644 index 7d72d18..0000000 --- a/test/versioning/package-graph.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { getNextVersion } from "../../src/versions"; -import type { PackageRelease } from "../../src/types"; -import { - buildPackageDependencyGraph, - createDependentUpdates, - getAllAffectedPackages, - getPackagePublishOrder, -} from "../../src/packages"; -import { describe, expect, it } from "vitest"; - -import { createWorkspacePackage } from "../_shared"; - -function createRelease( - pkg: ReturnType, - bump: PackageRelease["bumpType"], - hasDirectChanges = true, -): PackageRelease { - return { - package: pkg, - currentVersion: pkg.version, - newVersion: getNextVersion(pkg.version, bump), - bumpType: bump, - hasDirectChanges, - changeKind: "auto", - }; -} - -function createWorkspaceFixture() { - const pkgD = createWorkspacePackage("/repo/packages/d", { - name: "pkg-d", - version: "1.0.0", - }); - const pkgB = createWorkspacePackage("/repo/packages/b", { - name: "pkg-b", - version: "1.0.0", - workspaceDependencies: ["pkg-d"], - }); - const pkgC = createWorkspacePackage("/repo/packages/c", { - name: "pkg-c", - version: "1.0.0", - workspaceDependencies: ["pkg-d"], - }); - const pkgA = createWorkspacePackage("/repo/packages/a", { - name: "pkg-a", - version: "1.0.0", - workspaceDependencies: ["pkg-b", "pkg-c"], - }); - const pkgE = createWorkspacePackage("/repo/packages/e", { - name: "pkg-e", - version: "1.0.0", - workspaceDevDependencies: ["pkg-a"], - }); - - return { - pkgA, - pkgB, - pkgC, - pkgD, - pkgE, - packages: [pkgA, pkgB, pkgC, pkgD, pkgE], - }; -} - -describe("package dependency graph", () => { - it("builds dependents mapping from workspace deps", () => { - const { packages } = createWorkspaceFixture(); - const graph = buildPackageDependencyGraph(packages); - - expect(graph.packages.size).toBe(5); - expect([...graph.dependents.get("pkg-d")!]).toEqual(["pkg-b", "pkg-c"]); - expect([...graph.dependents.get("pkg-b")!]).toEqual(["pkg-a"]); - expect([...graph.dependents.get("pkg-c")!]).toEqual(["pkg-a"]); - expect([...graph.dependents.get("pkg-a")!]).toEqual(["pkg-e"]); - expect([...graph.dependents.get("pkg-e")!]).toEqual([]); - }); - - it("calculates transitive affected packages", () => { - const { packages } = createWorkspaceFixture(); - const graph = buildPackageDependencyGraph(packages); - - const affectedFromD = getAllAffectedPackages(graph, new Set(["pkg-d"])); - expect([...affectedFromD].toSorted()).toEqual( - ["pkg-a", "pkg-b", "pkg-c", "pkg-d", "pkg-e"].toSorted(), - ); - - const affectedFromB = getAllAffectedPackages(graph, new Set(["pkg-b"])); - expect([...affectedFromB].toSorted()).toEqual(["pkg-a", "pkg-b", "pkg-e"].toSorted()); - }); - - it("orders publish list by dependency level (stable)", () => { - const { packages } = createWorkspaceFixture(); - const graph = buildPackageDependencyGraph(packages); - - const order = getPackagePublishOrder(graph, new Set(["pkg-b", "pkg-c"])); - expect(order.map((entry) => `${entry.package.name}:${entry.level}`)).toEqual([ - "pkg-b:0", - "pkg-c:0", - "pkg-a:1", - ]); - - const orderFromD = getPackagePublishOrder(graph, new Set(["pkg-d"])); - expect(orderFromD.map((entry) => `${entry.package.name}:${entry.level}`)).toEqual([ - "pkg-d:0", - "pkg-b:1", - "pkg-c:1", - ]); - - const orderFromA = getPackagePublishOrder(graph, new Set(["pkg-a"])); - expect(orderFromA.map((entry) => `${entry.package.name}:${entry.level}`)).toEqual([ - "pkg-a:0", - "pkg-e:1", - ]); - }); - - it("creates dependent updates with patch bumps", () => { - const { packages, pkgB, pkgC } = createWorkspaceFixture(); - const graph = buildPackageDependencyGraph(packages); - const directUpdates = [createRelease(pkgB, "minor"), createRelease(pkgC, "patch")]; - - const updates = createDependentUpdates(graph, packages, directUpdates); - const byName = new Map(updates.map((update) => [update.package.name, update])); - - expect(updates).toHaveLength(4); - expect(byName.get("pkg-a")?.bumpType).toBe("patch"); - expect(byName.get("pkg-a")?.newVersion).toBe("1.0.1"); - expect(byName.get("pkg-a")?.hasDirectChanges).toBe(false); - expect(byName.get("pkg-e")?.bumpType).toBe("patch"); - expect(byName.get("pkg-e")?.newVersion).toBe("1.0.1"); - expect(byName.get("pkg-e")?.hasDirectChanges).toBe(false); - }); - - it("respects excluded packages for dependent bumps", () => { - const { packages, pkgB, pkgC } = createWorkspaceFixture(); - const graph = buildPackageDependencyGraph(packages); - const directUpdates = [createRelease(pkgB, "minor"), createRelease(pkgC, "patch")]; - - const updates = createDependentUpdates(graph, packages, directUpdates, new Set(["pkg-a"])); - const updatedNames = updates.map((update) => update.package.name).toSorted(); - - expect(updatedNames).toEqual(["pkg-b", "pkg-c", "pkg-e"].toSorted()); - }); -}); diff --git a/test/versioning/resolve-version.test.ts b/test/versioning/resolve-version.test.ts deleted file mode 100644 index 2f0d99d..0000000 --- a/test/versioning/resolve-version.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveAutoVersion } from "../../src/versions"; -import { createCommit, createWorkspacePackage } from "../_shared"; - -describe("resolveAutoVersion", () => { - it("returns none bump for empty commits", () => { - const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); - const result = resolveAutoVersion(pkg, [], []); - expect(result.determinedBump).toBe("none"); - expect(result.resolvedVersion).toBe("1.0.0"); - expect(result.autoVersion).toBe("1.0.0"); - }); - - it("returns minor for feat commits", () => { - const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); - const commits = [createCommit({ type: "feat" })]; - const result = resolveAutoVersion(pkg, commits, []); - expect(result.determinedBump).toBe("minor"); - expect(result.autoVersion).toBe("1.1.0"); - expect(result.resolvedVersion).toBe("1.1.0"); - }); - - it("returns patch for fix commits", () => { - const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); - const commits = [createCommit({ type: "fix" })]; - const result = resolveAutoVersion(pkg, commits, []); - expect(result.determinedBump).toBe("patch"); - expect(result.autoVersion).toBe("1.0.1"); - }); - - it("returns major for breaking change commits", () => { - const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); - const commits = [createCommit({ type: "feat", isBreaking: true })]; - const result = resolveAutoVersion(pkg, commits, []); - expect(result.determinedBump).toBe("major"); - expect(result.autoVersion).toBe("2.0.0"); - }); - - it("combines package and global commits", () => { - const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); - const pkgCommits = [createCommit({ type: "fix", shortHash: "abc0001" })]; - const globalCommits = [createCommit({ type: "feat", shortHash: "abc0002" })]; - const result = resolveAutoVersion(pkg, pkgCommits, globalCommits); - expect(result.determinedBump).toBe("minor"); - }); - - it("applies override version when present", () => { - const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); - const commits = [createCommit({ type: "fix" })]; - const result = resolveAutoVersion(pkg, commits, [], { type: "major", version: "2.0.0" }); - expect(result.effectiveBump).toBe("major"); - expect(result.resolvedVersion).toBe("2.0.0"); - // determinedBump still reflects the actual commits - expect(result.determinedBump).toBe("patch"); - }); - - it("applies override type without version", () => { - const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); - const commits = [createCommit({ type: "fix" })]; - const result = resolveAutoVersion(pkg, commits, [], { type: "minor", version: "" }); - expect(result.effectiveBump).toBe("minor"); - // resolved version falls back to auto since override version is empty - expect(result.resolvedVersion).toBe("1.0.1"); - }); - - it("uses override type when set to none (as-is)", () => { - const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); - const commits = [createCommit({ type: "feat" })]; - const result = resolveAutoVersion(pkg, commits, [], { type: "none", version: "1.0.0" }); - // "none" is a truthy string, so effectiveBump = "none" - expect(result.effectiveBump).toBe("none"); - expect(result.resolvedVersion).toBe("1.0.0"); - }); -}); diff --git a/test/versioning/version-dependent-updates.test.ts b/test/versioning/version-dependent-updates.test.ts deleted file mode 100644 index 2b3846e..0000000 --- a/test/versioning/version-dependent-updates.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { PromptServiceLive } from "../../src/services/prompts"; -import type { PackageRelease } from "../../src/types"; -import { calculateAndPrepareVersionUpdates } from "../../src/versions"; -import { Effect } from "effect"; -import { expect, it } from "@effect/vitest"; -import { describe, vi } from "vitest"; - -import { createWorkspacePackage } from "../_shared"; - -vi.mock("../../src/services/prompts", async () => { - const actual = await vi.importActual("../../src/services/prompts"); - return { - ...actual, - confirmOverridePrompt: vi.fn(), - selectVersionPrompt: vi.fn(), - }; -}); - -describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { - it.effect("adds dependent patch bumps and preserves direct updates", () => - Effect.gen(function* () { - const pkgD = createWorkspacePackage("/repo/packages/d", { - name: "pkg-d", - version: "1.0.0", - }); - const pkgB = createWorkspacePackage("/repo/packages/b", { - name: "pkg-b", - version: "1.0.0", - workspaceDependencies: ["pkg-d"], - }); - const pkgC = createWorkspacePackage("/repo/packages/c", { - name: "pkg-c", - version: "1.0.0", - workspaceDependencies: ["pkg-d"], - }); - const pkgA = createWorkspacePackage("/repo/packages/a", { - name: "pkg-a", - version: "1.0.0", - workspaceDependencies: ["pkg-b", "pkg-c"], - }); - - const workspacePackages = [pkgA, pkgB, pkgC, pkgD]; - const packageCommits = new Map([ - ["pkg-b", [{ type: "feat", isConventional: true, isBreaking: false } as any]], - ["pkg-c", [{ type: "fix", isConventional: true, isBreaking: false } as any]], - ]); - const globalCommitsPerPackage = new Map(); - - const result = yield* calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits, - workspaceRoot: "/repo", - showPrompt: false, - globalCommitsPerPackage, - overrides: {}, - }).pipe(Effect.provide(PromptServiceLive)); - - const byName = new Map(result.allUpdates.map((update) => [update.package.name, update])); - - expect(result.allUpdates.map((update) => update.package.name).toSorted()).toEqual( - ["pkg-a", "pkg-b", "pkg-c"].toSorted(), - ); - - expect(byName.get("pkg-b")?.bumpType).toBe("minor"); - expect(byName.get("pkg-b")?.newVersion).toBe("1.1.0"); - expect(byName.get("pkg-c")?.bumpType).toBe("patch"); - expect(byName.get("pkg-c")?.newVersion).toBe("1.0.1"); - expect(byName.get("pkg-a")?.bumpType).toBe("patch"); - expect(byName.get("pkg-a")?.newVersion).toBe("1.0.1"); - })); - - it.effect("respects overrides that exclude dependent bumps", () => - Effect.gen(function* () { - const pkgD = createWorkspacePackage("/repo/packages/d", { - name: "pkg-d", - version: "1.0.0", - }); - const pkgB = createWorkspacePackage("/repo/packages/b", { - name: "pkg-b", - version: "1.0.0", - workspaceDependencies: ["pkg-d"], - }); - const pkgA = createWorkspacePackage("/repo/packages/a", { - name: "pkg-a", - version: "1.0.0", - workspaceDependencies: ["pkg-b"], - }); - - const workspacePackages = [pkgA, pkgB, pkgD]; - const packageCommits = new Map([ - ["pkg-b", [{ type: "feat", isConventional: true, isBreaking: false } as any]], - ]); - const globalCommitsPerPackage = new Map(); - - const result = yield* calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits, - workspaceRoot: "/repo", - showPrompt: false, - globalCommitsPerPackage, - overrides: { - "pkg-a": { type: "none", version: "1.0.0" }, - }, - }).pipe(Effect.provide(PromptServiceLive)); - - const updatedNames = result.allUpdates.map((update) => update.package.name).toSorted(); - expect(updatedNames).toEqual(["pkg-b"]); - })); - - it.effect("does not add dependents when there are no direct updates", () => - Effect.gen(function* () { - const pkgA = createWorkspacePackage("/repo/packages/a", { - name: "pkg-a", - version: "1.0.0", - }); - const workspacePackages = [pkgA]; - - const result = yield* calculateAndPrepareVersionUpdates({ - workspacePackages, - packageCommits: new Map(), - workspaceRoot: "/repo", - showPrompt: false, - globalCommitsPerPackage: new Map(), - overrides: {}, - }).pipe(Effect.provide(PromptServiceLive)); - - expect(result.allUpdates).toEqual([] as PackageRelease[]); - })); -}); diff --git a/test/versions.test.ts b/test/versions.test.ts new file mode 100644 index 0000000..0d4d051 --- /dev/null +++ b/test/versions.test.ts @@ -0,0 +1,168 @@ +import { + calculateBumpType, + computeDependencyRange, + determineHighestBump, + getNextPrereleaseVersion, + getNextVersion, + getPrereleaseIdentifier, + isValidSemver, + resolveAutoVersion, +} from "../src/versions"; +import { describe, expect, it } from "vitest"; + +import { createCommit, createWorkspacePackage } from "./_shared"; + +describe("semver operations", () => { + it("validates semver strings", () => { + expect(isValidSemver("1.2.3")).toBe(true); + expect(isValidSemver("1.2.3-beta.1")).toBe(true); + expect(isValidSemver("1.2")).toBe(false); + }); + + it("calculates next versions", () => { + expect(getNextVersion("1.0.0", "major")).toBe("2.0.0"); + expect(getNextVersion("1.0.0", "minor")).toBe("1.1.0"); + expect(getNextVersion("1.0.0", "patch")).toBe("1.0.1"); + expect(getNextVersion("1.0.0", "none")).toBe("1.0.0"); + }); + + it("calculates bump types", () => { + expect(calculateBumpType("1.0.0", "2.0.0")).toBe("major"); + expect(calculateBumpType("1.0.0", "1.1.0")).toBe("minor"); + expect(calculateBumpType("1.0.0", "1.0.1")).toBe("patch"); + expect(calculateBumpType("1.0.0", "1.0.0")).toBe("none"); + }); + + it("supports prerelease helpers", () => { + expect(getPrereleaseIdentifier("0.1.0-beta.46")).toBe("beta"); + expect(getPrereleaseIdentifier("0.1.0")).toBeUndefined(); + + expect(getNextPrereleaseVersion("0.1.0-beta.46", "next", "beta")).toBe("0.1.0-beta.47"); + expect(getNextPrereleaseVersion("0.1.0", "prepatch", "beta")).toBe("0.1.1-beta.0"); + expect(getNextPrereleaseVersion("0.1.0", "preminor", "alpha")).toBe("0.2.0-alpha.0"); + }); + + it("maps prerelease bumps to semantic bump kinds", () => { + expect(calculateBumpType("0.1.0-beta.46", "0.1.0-beta.47")).toBe("patch"); + expect(calculateBumpType("0.1.0-beta.46", "0.1.1-beta.0")).toBe("patch"); + expect(calculateBumpType("0.1.0-beta.46", "0.2.0-beta.0")).toBe("minor"); + }); +}); + +describe("version operations", () => { + it("returns none for empty commits", () => { + expect(determineHighestBump([])).toBe("none"); + }); + + it("returns patch for fix commits", () => { + const result = determineHighestBump([createCommit({ type: "fix", isConventional: true })]); + expect(result).toBe("patch"); + }); + + it("returns minor for feat commits", () => { + const result = determineHighestBump([createCommit({ type: "feat", isConventional: true })]); + expect(result).toBe("minor"); + }); + + it("returns major for breaking commits", () => { + const result = determineHighestBump([ + createCommit({ type: "feat", isBreaking: true, isConventional: true }), + ]); + expect(result).toBe("major"); + }); +}); + +describe("computeDependencyRange", () => { + it("returns null for workspace:* ranges", () => { + expect(computeDependencyRange("workspace:*", "1.0.0", false)).toBeNull(); + }); + + it("returns ^version for regular dependencies", () => { + expect(computeDependencyRange("^0.5.0", "1.0.0", false)).toBe("^1.0.0"); + }); + + it("returns range for peer dependencies", () => { + expect(computeDependencyRange("^1.0.0", "2.0.0", true)).toBe(">=2.0.0 <3.0.0"); + }); + + it("handles 0.x peer dependencies", () => { + expect(computeDependencyRange("^0.1.0", "0.2.0", true)).toBe(">=0.2.0 <1.0.0"); + }); + + it("ignores old range value for regular deps", () => { + expect(computeDependencyRange("~0.5.0", "1.0.0", false)).toBe("^1.0.0"); + expect(computeDependencyRange(">=0.5.0", "1.0.0", false)).toBe("^1.0.0"); + }); + + it("handles peer dependency with large major", () => { + expect(computeDependencyRange("^10.0.0", "11.0.0", true)).toBe(">=11.0.0 <12.0.0"); + }); +}); + +describe("resolveAutoVersion", () => { + it("returns none bump for empty commits", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const result = resolveAutoVersion(pkg, [], []); + expect(result.determinedBump).toBe("none"); + expect(result.resolvedVersion).toBe("1.0.0"); + expect(result.autoVersion).toBe("1.0.0"); + }); + + it("returns minor for feat commits", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "feat" })]; + const result = resolveAutoVersion(pkg, commits, []); + expect(result.determinedBump).toBe("minor"); + expect(result.autoVersion).toBe("1.1.0"); + expect(result.resolvedVersion).toBe("1.1.0"); + }); + + it("returns patch for fix commits", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "fix" })]; + const result = resolveAutoVersion(pkg, commits, []); + expect(result.determinedBump).toBe("patch"); + expect(result.autoVersion).toBe("1.0.1"); + }); + + it("returns major for breaking change commits", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "feat", isBreaking: true })]; + const result = resolveAutoVersion(pkg, commits, []); + expect(result.determinedBump).toBe("major"); + expect(result.autoVersion).toBe("2.0.0"); + }); + + it("combines package and global commits", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const pkgCommits = [createCommit({ type: "fix", shortHash: "abc0001" })]; + const globalCommits = [createCommit({ type: "feat", shortHash: "abc0002" })]; + const result = resolveAutoVersion(pkg, pkgCommits, globalCommits); + expect(result.determinedBump).toBe("minor"); + }); + + it("applies override version when present", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "fix" })]; + const result = resolveAutoVersion(pkg, commits, [], { type: "major", version: "2.0.0" }); + expect(result.effectiveBump).toBe("major"); + expect(result.resolvedVersion).toBe("2.0.0"); + expect(result.determinedBump).toBe("patch"); + }); + + it("applies override type without version", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "fix" })]; + const result = resolveAutoVersion(pkg, commits, [], { type: "minor", version: "" }); + expect(result.effectiveBump).toBe("minor"); + expect(result.resolvedVersion).toBe("1.0.1"); + }); + + it("uses override type when set to none (as-is)", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "feat" })]; + const result = resolveAutoVersion(pkg, commits, [], { type: "none", version: "1.0.0" }); + expect(result.effectiveBump).toBe("none"); + expect(result.resolvedVersion).toBe("1.0.0"); + }); +});