diff --git a/apps/app/server-bundle/package-lock.json b/apps/app/server-bundle/package-lock.json deleted file mode 100644 index 1c773f35..00000000 --- a/apps/app/server-bundle/package-lock.json +++ /dev/null @@ -1,1282 +0,0 @@ -{ - "name": "@automaker/server-bundle", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@automaker/server-bundle", - "version": "0.1.0", - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.61", - "cors": "^2.8.5", - "dotenv": "^17.2.3", - "express": "^5.1.0", - "morgan": "^1.10.1", - "node-pty": "1.1.0-beta41", - "ws": "^8.18.0" - } - }, - "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.1.69", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.69.tgz", - "integrity": "sha512-T6mb8xKGYIH0g3drS0VRxDHemj8kmWD37nuB+ENoD9sZfi/lomnugWLWBjq9Cjw10WBewE5hjv+i8swM34nkAA==", - "license": "SEE LICENSE IN README.md", - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.33.5", - "@img/sharp-darwin-x64": "^0.33.5", - "@img/sharp-linux-arm": "^0.33.5", - "@img/sharp-linux-arm64": "^0.33.5", - "@img/sharp-linux-x64": "^0.33.5", - "@img/sharp-linuxmusl-arm64": "^0.33.5", - "@img/sharp-linuxmusl-x64": "^0.33.5", - "@img/sharp-win32-x64": "^0.33.5" - }, - "peerDependencies": { - "zod": "^3.24.1" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/morgan": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", - "license": "MIT", - "dependencies": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.3.0", - "on-headers": "~1.1.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/morgan/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/morgan/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" - }, - "node_modules/node-pty": { - "version": "1.1.0-beta41", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta41.tgz", - "integrity": "sha512-OUT29KMnzh1IS0b2YcUwVz56D4iAXDsl2PtIKP3zHMljiUBq2WcaHEFfhzQfgkhWs2SExcXvfdlBPANDVU9SnQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^7.1.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "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 - } - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/apps/app/server-bundle/package.json b/apps/app/server-bundle/package.json deleted file mode 100644 index becf118f..00000000 --- a/apps/app/server-bundle/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@automaker/server-bundle", - "version": "0.1.0", - "type": "module", - "main": "dist/index.js", - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.61", - "cors": "^2.8.5", - "dotenv": "^17.2.3", - "express": "^5.1.0", - "morgan": "^1.10.1", - "node-pty": "1.1.0-beta41", - "ws": "^8.18.0" - } -} \ No newline at end of file diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 98f4561c..548f4629 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -48,6 +48,7 @@ import { createClaudeRoutes } from './routes/claude/index.js'; import { ClaudeUsageService } from './services/claude-usage-service.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; +import { cleanupStaleValidations } from './routes/github/routes/validation-common.js'; // Load environment variables dotenv.config(); @@ -123,6 +124,15 @@ const claudeUsageService = new ClaudeUsageService(); console.log('[Server] Agent service initialized'); })(); +// Run stale validation cleanup every hour to prevent memory leaks from crashed validations +const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +setInterval(() => { + const cleaned = cleanupStaleValidations(); + if (cleaned > 0) { + console.log(`[Server] Cleaned up ${cleaned} stale validation entries`); + } +}, VALIDATION_CLEANUP_INTERVAL_MS); + // Mount API routes - health is unauthenticated for monitoring app.use('/api/health', createHealthRoutes()); @@ -147,7 +157,7 @@ app.use('/api/templates', createTemplatesRoutes()); app.use('/api/terminal', createTerminalRoutes()); app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); -app.use('/api/github', createGitHubRoutes()); +app.use('/api/github', createGitHubRoutes(events)); app.use('/api/context', createContextRoutes()); // Create HTTP server diff --git a/apps/server/src/lib/validation-storage.ts b/apps/server/src/lib/validation-storage.ts new file mode 100644 index 00000000..1ca66653 --- /dev/null +++ b/apps/server/src/lib/validation-storage.ts @@ -0,0 +1,181 @@ +/** + * Validation Storage - CRUD operations for GitHub issue validation results + * + * Stores validation results in .automaker/validations/{issueNumber}/validation.json + * Results include the validation verdict, metadata, and timestamp for cache invalidation. + */ + +import * as secureFs from './secure-fs.js'; +import { getValidationsDir, getValidationDir, getValidationPath } from '@automaker/platform'; +import type { StoredValidation } from '@automaker/types'; + +// Re-export StoredValidation for convenience +export type { StoredValidation }; + +/** Number of hours before a validation is considered stale */ +const VALIDATION_CACHE_TTL_HOURS = 24; + +/** + * Write validation result to storage + * + * Creates the validation directory if needed and stores the result as JSON. + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @param data - Validation data to store + */ +export async function writeValidation( + projectPath: string, + issueNumber: number, + data: StoredValidation +): Promise { + const validationDir = getValidationDir(projectPath, issueNumber); + const validationPath = getValidationPath(projectPath, issueNumber); + + // Ensure directory exists + await secureFs.mkdir(validationDir, { recursive: true }); + + // Write validation result + await secureFs.writeFile(validationPath, JSON.stringify(data, null, 2), 'utf-8'); +} + +/** + * Read validation result from storage + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns Stored validation or null if not found + */ +export async function readValidation( + projectPath: string, + issueNumber: number +): Promise { + try { + const validationPath = getValidationPath(projectPath, issueNumber); + const content = (await secureFs.readFile(validationPath, 'utf-8')) as string; + return JSON.parse(content) as StoredValidation; + } catch { + // File doesn't exist or can't be read + return null; + } +} + +/** + * Get all stored validations for a project + * + * @param projectPath - Absolute path to project directory + * @returns Array of stored validations + */ +export async function getAllValidations(projectPath: string): Promise { + const validationsDir = getValidationsDir(projectPath); + + try { + const dirs = await secureFs.readdir(validationsDir, { withFileTypes: true }); + + // Read all validation files in parallel for better performance + const promises = dirs + .filter((dir) => dir.isDirectory()) + .map((dir) => { + const issueNumber = parseInt(dir.name, 10); + if (!isNaN(issueNumber)) { + return readValidation(projectPath, issueNumber); + } + return Promise.resolve(null); + }); + + const results = await Promise.all(promises); + const validations = results.filter((v): v is StoredValidation => v !== null); + + // Sort by issue number + validations.sort((a, b) => a.issueNumber - b.issueNumber); + + return validations; + } catch { + // Directory doesn't exist + return []; + } +} + +/** + * Delete a validation from storage + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns true if validation was deleted, false if not found + */ +export async function deleteValidation(projectPath: string, issueNumber: number): Promise { + try { + const validationDir = getValidationDir(projectPath, issueNumber); + await secureFs.rm(validationDir, { recursive: true, force: true }); + return true; + } catch { + return false; + } +} + +/** + * Check if a validation is stale (older than TTL) + * + * @param validation - Stored validation to check + * @returns true if validation is older than 24 hours + */ +export function isValidationStale(validation: StoredValidation): boolean { + const validatedAt = new Date(validation.validatedAt); + const now = new Date(); + const hoursDiff = (now.getTime() - validatedAt.getTime()) / (1000 * 60 * 60); + return hoursDiff > VALIDATION_CACHE_TTL_HOURS; +} + +/** + * Get validation with freshness info + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns Object with validation and isStale flag, or null if not found + */ +export async function getValidationWithFreshness( + projectPath: string, + issueNumber: number +): Promise<{ validation: StoredValidation; isStale: boolean } | null> { + const validation = await readValidation(projectPath, issueNumber); + if (!validation) { + return null; + } + + return { + validation, + isStale: isValidationStale(validation), + }; +} + +/** + * Mark a validation as viewed by the user + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns true if validation was marked as viewed, false if not found + */ +export async function markValidationViewed( + projectPath: string, + issueNumber: number +): Promise { + const validation = await readValidation(projectPath, issueNumber); + if (!validation) { + return false; + } + + validation.viewedAt = new Date().toISOString(); + await writeValidation(projectPath, issueNumber, validation); + return true; +} + +/** + * Get count of unviewed, non-stale validations for a project + * + * @param projectPath - Absolute path to project directory + * @returns Number of unviewed validations + */ +export async function getUnviewedValidationsCount(projectPath: string): Promise { + const validations = await getAllValidations(projectPath); + return validations.filter((v) => !v.viewedAt && !isValidationStale(v)).length; +} diff --git a/apps/server/src/routes/github/index.ts b/apps/server/src/routes/github/index.ts index bda4d217..e8088159 100644 --- a/apps/server/src/routes/github/index.ts +++ b/apps/server/src/routes/github/index.ts @@ -3,16 +3,50 @@ */ import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'; import { createListIssuesHandler } from './routes/list-issues.js'; import { createListPRsHandler } from './routes/list-prs.js'; +import { createValidateIssueHandler } from './routes/validate-issue.js'; +import { + createValidationStatusHandler, + createValidationStopHandler, + createGetValidationsHandler, + createDeleteValidationHandler, + createMarkViewedHandler, +} from './routes/validation-endpoints.js'; -export function createGitHubRoutes(): Router { +export function createGitHubRoutes(events: EventEmitter): Router { const router = Router(); - router.post('/check-remote', createCheckGitHubRemoteHandler()); - router.post('/issues', createListIssuesHandler()); - router.post('/prs', createListPRsHandler()); + router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler()); + router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler()); + router.post('/prs', validatePathParams('projectPath'), createListPRsHandler()); + router.post( + '/validate-issue', + validatePathParams('projectPath'), + createValidateIssueHandler(events) + ); + + // Validation management endpoints + router.post( + '/validation-status', + validatePathParams('projectPath'), + createValidationStatusHandler() + ); + router.post('/validation-stop', validatePathParams('projectPath'), createValidationStopHandler()); + router.post('/validations', validatePathParams('projectPath'), createGetValidationsHandler()); + router.post( + '/validation-delete', + validatePathParams('projectPath'), + createDeleteValidationHandler() + ); + router.post( + '/validation-mark-viewed', + validatePathParams('projectPath'), + createMarkViewedHandler(events) + ); return router; } diff --git a/apps/server/src/routes/github/routes/list-issues.ts b/apps/server/src/routes/github/routes/list-issues.ts index 08f94135..c4ed58f1 100644 --- a/apps/server/src/routes/github/routes/list-issues.ts +++ b/apps/server/src/routes/github/routes/list-issues.ts @@ -2,6 +2,7 @@ * POST /list-issues endpoint - List GitHub issues for a project */ +import { spawn } from 'child_process'; import type { Request, Response } from 'express'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; import { checkGitHubRemote } from './check-github-remote.js'; @@ -13,6 +14,19 @@ export interface GitHubLabel { export interface GitHubAuthor { login: string; + avatarUrl?: string; +} + +export interface GitHubAssignee { + login: string; + avatarUrl?: string; +} + +export interface LinkedPullRequest { + number: number; + title: string; + state: string; + url: string; } export interface GitHubIssue { @@ -24,6 +38,8 @@ export interface GitHubIssue { labels: GitHubLabel[]; url: string; body: string; + assignees: GitHubAssignee[]; + linkedPRs?: LinkedPullRequest[]; } export interface ListIssuesResult { @@ -33,6 +49,146 @@ export interface ListIssuesResult { error?: string; } +/** + * Fetch linked PRs for a list of issues using GitHub GraphQL API + */ +async function fetchLinkedPRs( + projectPath: string, + owner: string, + repo: string, + issueNumbers: number[] +): Promise> { + const linkedPRsMap = new Map(); + + if (issueNumbers.length === 0) { + return linkedPRsMap; + } + + // Build GraphQL query for batch fetching linked PRs + // We fetch up to 20 issues at a time to avoid query limits + const batchSize = 20; + for (let i = 0; i < issueNumbers.length; i += batchSize) { + const batch = issueNumbers.slice(i, i + batchSize); + + const issueQueries = batch + .map( + (num, idx) => ` + issue${idx}: issue(number: ${num}) { + number + timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } + } + } + } + } + }` + ) + .join('\n'); + + const query = `{ + repository(owner: "${owner}", name: "${repo}") { + ${issueQueries} + } + }`; + + try { + // Use spawn with stdin to avoid shell injection vulnerabilities + // --input - reads the JSON request body from stdin + const requestBody = JSON.stringify({ query }); + const response = await new Promise>((resolve, reject) => { + const gh = spawn('gh', ['api', 'graphql', '--input', '-'], { + cwd: projectPath, + env: execEnv, + }); + + let stdout = ''; + let stderr = ''; + gh.stdout.on('data', (data: Buffer) => (stdout += data.toString())); + gh.stderr.on('data', (data: Buffer) => (stderr += data.toString())); + + gh.on('close', (code) => { + if (code !== 0) { + return reject(new Error(`gh process exited with code ${code}: ${stderr}`)); + } + try { + resolve(JSON.parse(stdout)); + } catch (e) { + reject(e); + } + }); + + gh.stdin.write(requestBody); + gh.stdin.end(); + }); + + const repoData = (response?.data as Record)?.repository as Record< + string, + unknown + > | null; + + if (repoData) { + batch.forEach((issueNum, idx) => { + const issueData = repoData[`issue${idx}`] as { + timelineItems?: { + nodes?: Array<{ + source?: { number?: number; title?: string; state?: string; url?: string }; + subject?: { number?: number; title?: string; state?: string; url?: string }; + }>; + }; + } | null; + if (issueData?.timelineItems?.nodes) { + const linkedPRs: LinkedPullRequest[] = []; + const seenPRs = new Set(); + + for (const node of issueData.timelineItems.nodes) { + const pr = node?.source || node?.subject; + if (pr?.number && !seenPRs.has(pr.number)) { + seenPRs.add(pr.number); + linkedPRs.push({ + number: pr.number, + title: pr.title || '', + state: (pr.state || '').toLowerCase(), + url: pr.url || '', + }); + } + } + + if (linkedPRs.length > 0) { + linkedPRsMap.set(issueNum, linkedPRs); + } + } + }); + } + } catch (error) { + // If GraphQL fails, continue without linked PRs + console.warn( + 'Failed to fetch linked PRs via GraphQL:', + error instanceof Error ? error.message : error + ); + } + } + + return linkedPRsMap; +} + export function createListIssuesHandler() { return async (req: Request, res: Response): Promise => { try { @@ -53,17 +209,17 @@ export function createListIssuesHandler() { return; } - // Fetch open and closed issues in parallel + // Fetch open and closed issues in parallel (now including assignees) const [openResult, closedResult] = await Promise.all([ execAsync( - 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100', + 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100', { cwd: projectPath, env: execEnv, } ), execAsync( - 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50', + 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50', { cwd: projectPath, env: execEnv, @@ -77,6 +233,24 @@ export function createListIssuesHandler() { const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]'); const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]'); + // Fetch linked PRs for open issues (more relevant for active work) + if (remoteStatus.owner && remoteStatus.repo && openIssues.length > 0) { + const linkedPRsMap = await fetchLinkedPRs( + projectPath, + remoteStatus.owner, + remoteStatus.repo, + openIssues.map((i) => i.number) + ); + + // Attach linked PRs to issues + for (const issue of openIssues) { + const linkedPRs = linkedPRsMap.get(issue.number); + if (linkedPRs) { + issue.linkedPRs = linkedPRs; + } + } + } + res.json({ success: true, openIssues, diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts new file mode 100644 index 00000000..3e75098e --- /dev/null +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -0,0 +1,287 @@ +/** + * POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK (async) + * + * Scans the codebase to determine if an issue is valid, invalid, or needs clarification. + * Runs asynchronously and emits events for progress and completion. + */ + +import type { Request, Response } from 'express'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types'; +import { createSuggestionsOptions } from '../../../lib/sdk-options.js'; +import { writeValidation } from '../../../lib/validation-storage.js'; +import { + issueValidationSchema, + ISSUE_VALIDATION_SYSTEM_PROMPT, + buildValidationPrompt, +} from './validation-schema.js'; +import { + trySetValidationRunning, + clearValidationStatus, + getErrorMessage, + logError, + logger, +} from './validation-common.js'; + +/** Valid model values for validation */ +const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const; + +/** + * Request body for issue validation + */ +interface ValidateIssueRequestBody { + projectPath: string; + issueNumber: number; + issueTitle: string; + issueBody: string; + issueLabels?: string[]; + /** Model to use for validation (opus, sonnet, haiku) */ + model?: AgentModel; +} + +/** + * Run the validation asynchronously + * + * Emits events for start, progress, complete, and error. + * Stores result on completion. + */ +async function runValidation( + projectPath: string, + issueNumber: number, + issueTitle: string, + issueBody: string, + issueLabels: string[] | undefined, + model: AgentModel, + events: EventEmitter, + abortController: AbortController +): Promise { + // Emit start event + const startEvent: IssueValidationEvent = { + type: 'issue_validation_start', + issueNumber, + issueTitle, + projectPath, + }; + events.emit('issue-validation:event', startEvent); + + // Set up timeout (6 minutes) + const VALIDATION_TIMEOUT_MS = 360000; + const timeoutId = setTimeout(() => { + logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`); + abortController.abort(); + }, VALIDATION_TIMEOUT_MS); + + try { + // Build the prompt + const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels); + + // Create SDK options with structured output and abort controller + const options = createSuggestionsOptions({ + cwd: projectPath, + model, + systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT, + abortController, + outputFormat: { + type: 'json_schema', + schema: issueValidationSchema as Record, + }, + }); + + // Execute the query + const stream = query({ prompt, options }); + let validationResult: IssueValidationResult | null = null; + let responseText = ''; + + for await (const msg of stream) { + // Collect assistant text for debugging and emit progress + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + responseText += block.text; + + // Emit progress event + const progressEvent: IssueValidationEvent = { + type: 'issue_validation_progress', + issueNumber, + content: block.text, + projectPath, + }; + events.emit('issue-validation:event', progressEvent); + } + } + } + + // Extract structured output on success + if (msg.type === 'result' && msg.subtype === 'success') { + const resultMsg = msg as { structured_output?: IssueValidationResult }; + if (resultMsg.structured_output) { + validationResult = resultMsg.structured_output; + logger.debug('Received structured output:', validationResult); + } + } + + // Handle errors + if (msg.type === 'result') { + const resultMsg = msg as { subtype?: string }; + if (resultMsg.subtype === 'error_max_structured_output_retries') { + logger.error('Failed to produce valid structured output after retries'); + throw new Error('Could not produce valid validation output'); + } + } + } + + // Clear timeout + clearTimeout(timeoutId); + + // Require structured output + if (!validationResult) { + logger.error('No structured output received from Claude SDK'); + logger.debug('Raw response text:', responseText); + throw new Error('Validation failed: no structured output received'); + } + + logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`); + + // Store the result + await writeValidation(projectPath, issueNumber, { + issueNumber, + issueTitle, + validatedAt: new Date().toISOString(), + model, + result: validationResult, + }); + + // Emit completion event + const completeEvent: IssueValidationEvent = { + type: 'issue_validation_complete', + issueNumber, + issueTitle, + result: validationResult, + projectPath, + model, + }; + events.emit('issue-validation:event', completeEvent); + } catch (error) { + clearTimeout(timeoutId); + + const errorMessage = getErrorMessage(error); + logError(error, `Issue #${issueNumber} validation failed`); + + // Emit error event + const errorEvent: IssueValidationEvent = { + type: 'issue_validation_error', + issueNumber, + error: errorMessage, + projectPath, + }; + events.emit('issue-validation:event', errorEvent); + + throw error; + } +} + +/** + * Creates the handler for validating GitHub issues against the codebase. + * + * Uses Claude SDK with: + * - Read-only tools (Read, Glob, Grep) for codebase analysis + * - JSON schema structured output for reliable parsing + * - System prompt guiding the validation process + * - Async execution with event emission + */ +export function createValidateIssueHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { + projectPath, + issueNumber, + issueTitle, + issueBody, + issueLabels, + model = 'opus', + } = req.body as ValidateIssueRequestBody; + + // Validate required fields + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!issueNumber || typeof issueNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'issueNumber is required and must be a number' }); + return; + } + + if (!issueTitle || typeof issueTitle !== 'string') { + res.status(400).json({ success: false, error: 'issueTitle is required' }); + return; + } + + if (typeof issueBody !== 'string') { + res.status(400).json({ success: false, error: 'issueBody must be a string' }); + return; + } + + // Validate model parameter at runtime + if (!VALID_MODELS.includes(model)) { + res.status(400).json({ + success: false, + error: `Invalid model. Must be one of: ${VALID_MODELS.join(', ')}`, + }); + return; + } + + logger.info(`Starting async validation for issue #${issueNumber}: ${issueTitle}`); + + // Create abort controller and atomically try to claim validation slot + // This prevents TOCTOU race conditions + const abortController = new AbortController(); + if (!trySetValidationRunning(projectPath, issueNumber, abortController)) { + res.json({ + success: false, + error: `Validation is already running for issue #${issueNumber}`, + }); + return; + } + + // Start validation in background (fire-and-forget) + runValidation( + projectPath, + issueNumber, + issueTitle, + issueBody, + issueLabels, + model, + events, + abortController + ) + .catch((error) => { + // Error is already handled inside runValidation (event emitted) + logger.debug('Validation error caught in background handler:', error); + }) + .finally(() => { + clearValidationStatus(projectPath, issueNumber); + }); + + // Return immediately + res.json({ + success: true, + message: `Validation started for issue #${issueNumber}`, + issueNumber, + }); + } catch (error) { + logError(error, `Issue validation failed`); + logger.error('Issue validation error:', error); + + if (!res.headersSent) { + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + } + }; +} diff --git a/apps/server/src/routes/github/routes/validation-common.ts b/apps/server/src/routes/github/routes/validation-common.ts new file mode 100644 index 00000000..63d34ceb --- /dev/null +++ b/apps/server/src/routes/github/routes/validation-common.ts @@ -0,0 +1,174 @@ +/** + * Common utilities and state for issue validation routes + * + * Tracks running validation status per issue to support: + * - Checking if a validation is in progress + * - Cancelling a running validation + * - Preventing duplicate validations for the same issue + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../../common.js'; + +const logger = createLogger('IssueValidation'); + +/** + * Status of a validation in progress + */ +interface ValidationStatus { + isRunning: boolean; + abortController: AbortController; + startedAt: Date; +} + +/** + * Map of issue number to validation status + * Key format: `${projectPath}||${issueNumber}` to support multiple projects + * Note: Using `||` as delimiter since `:` appears in Windows paths (e.g., C:\) + */ +const validationStatusMap = new Map(); + +/** Maximum age for stale validation entries before cleanup (1 hour) */ +const MAX_VALIDATION_AGE_MS = 60 * 60 * 1000; + +/** + * Create a unique key for a validation + * Uses `||` as delimiter since `:` appears in Windows paths + */ +function getValidationKey(projectPath: string, issueNumber: number): string { + return `${projectPath}||${issueNumber}`; +} + +/** + * Check if a validation is currently running for an issue + */ +export function isValidationRunning(projectPath: string, issueNumber: number): boolean { + const key = getValidationKey(projectPath, issueNumber); + const status = validationStatusMap.get(key); + return status?.isRunning ?? false; +} + +/** + * Get validation status for an issue + */ +export function getValidationStatus( + projectPath: string, + issueNumber: number +): { isRunning: boolean; startedAt?: Date } | null { + const key = getValidationKey(projectPath, issueNumber); + const status = validationStatusMap.get(key); + if (!status) { + return null; + } + return { + isRunning: status.isRunning, + startedAt: status.startedAt, + }; +} + +/** + * Get all running validations for a project + */ +export function getRunningValidations(projectPath: string): number[] { + const runningIssues: number[] = []; + const prefix = `${projectPath}||`; + for (const [key, status] of validationStatusMap.entries()) { + if (status.isRunning && key.startsWith(prefix)) { + const issueNumber = parseInt(key.slice(prefix.length), 10); + if (!isNaN(issueNumber)) { + runningIssues.push(issueNumber); + } + } + } + return runningIssues; +} + +/** + * Set a validation as running + */ +export function setValidationRunning( + projectPath: string, + issueNumber: number, + abortController: AbortController +): void { + const key = getValidationKey(projectPath, issueNumber); + validationStatusMap.set(key, { + isRunning: true, + abortController, + startedAt: new Date(), + }); +} + +/** + * Atomically try to set a validation as running (check-and-set) + * Prevents TOCTOU race conditions when starting validations + * + * @returns true if successfully claimed, false if already running + */ +export function trySetValidationRunning( + projectPath: string, + issueNumber: number, + abortController: AbortController +): boolean { + const key = getValidationKey(projectPath, issueNumber); + if (validationStatusMap.has(key)) { + return false; // Already running + } + validationStatusMap.set(key, { + isRunning: true, + abortController, + startedAt: new Date(), + }); + return true; // Successfully claimed +} + +/** + * Cleanup stale validation entries (e.g., from crashed validations) + * Should be called periodically to prevent memory leaks + */ +export function cleanupStaleValidations(): number { + const now = Date.now(); + let cleanedCount = 0; + for (const [key, status] of validationStatusMap.entries()) { + if (now - status.startedAt.getTime() > MAX_VALIDATION_AGE_MS) { + status.abortController.abort(); + validationStatusMap.delete(key); + cleanedCount++; + } + } + if (cleanedCount > 0) { + logger.info(`Cleaned up ${cleanedCount} stale validation entries`); + } + return cleanedCount; +} + +/** + * Clear validation status (call when validation completes or errors) + */ +export function clearValidationStatus(projectPath: string, issueNumber: number): void { + const key = getValidationKey(projectPath, issueNumber); + validationStatusMap.delete(key); +} + +/** + * Abort a running validation + * + * @returns true if validation was aborted, false if not running + */ +export function abortValidation(projectPath: string, issueNumber: number): boolean { + const key = getValidationKey(projectPath, issueNumber); + const status = validationStatusMap.get(key); + + if (!status || !status.isRunning) { + return false; + } + + status.abortController.abort(); + validationStatusMap.delete(key); + return true; +} + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); +export { logger }; diff --git a/apps/server/src/routes/github/routes/validation-endpoints.ts b/apps/server/src/routes/github/routes/validation-endpoints.ts new file mode 100644 index 00000000..21859737 --- /dev/null +++ b/apps/server/src/routes/github/routes/validation-endpoints.ts @@ -0,0 +1,236 @@ +/** + * Additional validation endpoints for status, stop, and retrieving stored validations + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { IssueValidationEvent } from '@automaker/types'; +import { + isValidationRunning, + getValidationStatus, + getRunningValidations, + abortValidation, + getErrorMessage, + logError, + logger, +} from './validation-common.js'; +import { + readValidation, + getAllValidations, + getValidationWithFreshness, + deleteValidation, + markValidationViewed, +} from '../../../lib/validation-storage.js'; + +/** + * POST /validation-status - Check if validation is running for an issue + */ +export function createValidationStatusHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber?: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // If issueNumber provided, check specific issue + if (issueNumber !== undefined) { + const status = getValidationStatus(projectPath, issueNumber); + res.json({ + success: true, + isRunning: status?.isRunning ?? false, + startedAt: status?.startedAt?.toISOString(), + }); + return; + } + + // Otherwise, return all running validations for the project + const runningIssues = getRunningValidations(projectPath); + res.json({ + success: true, + runningIssues, + }); + } catch (error) { + logError(error, 'Validation status check failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * POST /validation-stop - Cancel a running validation + */ +export function createValidationStopHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!issueNumber || typeof issueNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'issueNumber is required and must be a number' }); + return; + } + + const wasAborted = abortValidation(projectPath, issueNumber); + + if (wasAborted) { + logger.info(`Validation for issue #${issueNumber} was stopped`); + res.json({ + success: true, + message: `Validation for issue #${issueNumber} has been stopped`, + }); + } else { + res.json({ + success: false, + error: `No validation is running for issue #${issueNumber}`, + }); + } + } catch (error) { + logError(error, 'Validation stop failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * POST /validations - Get stored validations for a project + */ +export function createGetValidationsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber?: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // If issueNumber provided, get specific validation with freshness info + if (issueNumber !== undefined) { + const result = await getValidationWithFreshness(projectPath, issueNumber); + + if (!result) { + res.json({ + success: true, + validation: null, + }); + return; + } + + res.json({ + success: true, + validation: result.validation, + isStale: result.isStale, + }); + return; + } + + // Otherwise, get all validations for the project + const validations = await getAllValidations(projectPath); + + res.json({ + success: true, + validations, + }); + } catch (error) { + logError(error, 'Get validations failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * POST /validation-delete - Delete a stored validation + */ +export function createDeleteValidationHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!issueNumber || typeof issueNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'issueNumber is required and must be a number' }); + return; + } + + const deleted = await deleteValidation(projectPath, issueNumber); + + res.json({ + success: true, + deleted, + }); + } catch (error) { + logError(error, 'Delete validation failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * POST /validation-mark-viewed - Mark a validation as viewed by the user + */ +export function createMarkViewedHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!issueNumber || typeof issueNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'issueNumber is required and must be a number' }); + return; + } + + const success = await markValidationViewed(projectPath, issueNumber); + + if (success) { + // Emit event so UI can update the unviewed count + const viewedEvent: IssueValidationEvent = { + type: 'issue_validation_viewed', + issueNumber, + projectPath, + }; + events.emit('issue-validation:event', viewedEvent); + } + + res.json({ success }); + } catch (error) { + logError(error, 'Mark validation viewed failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/github/routes/validation-schema.ts b/apps/server/src/routes/github/routes/validation-schema.ts new file mode 100644 index 00000000..50812082 --- /dev/null +++ b/apps/server/src/routes/github/routes/validation-schema.ts @@ -0,0 +1,138 @@ +/** + * Issue Validation Schema and System Prompt + * + * Defines the JSON schema for Claude's structured output and + * the system prompt that guides the validation process. + */ + +/** + * JSON Schema for issue validation structured output. + * Used with Claude SDK's outputFormat option to ensure reliable parsing. + */ +export const issueValidationSchema = { + type: 'object', + properties: { + verdict: { + type: 'string', + enum: ['valid', 'invalid', 'needs_clarification'], + description: 'The validation verdict for the issue', + }, + confidence: { + type: 'string', + enum: ['high', 'medium', 'low'], + description: 'How confident the AI is in its assessment', + }, + reasoning: { + type: 'string', + description: 'Detailed explanation of the verdict', + }, + bugConfirmed: { + type: 'boolean', + description: 'For bug reports: whether the bug was confirmed in the codebase', + }, + relatedFiles: { + type: 'array', + items: { type: 'string' }, + description: 'Files related to the issue found during analysis', + }, + suggestedFix: { + type: 'string', + description: 'Suggested approach to fix or implement the issue', + }, + missingInfo: { + type: 'array', + items: { type: 'string' }, + description: 'Information needed when verdict is needs_clarification', + }, + estimatedComplexity: { + type: 'string', + enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'], + description: 'Estimated effort to address the issue', + }, + }, + required: ['verdict', 'confidence', 'reasoning'], + additionalProperties: false, +} as const; + +/** + * System prompt that guides Claude in validating GitHub issues. + * Instructs the model to use read-only tools to analyze the codebase. + */ +export const ISSUE_VALIDATION_SYSTEM_PROMPT = `You are an expert code analyst validating GitHub issues against a codebase. + +Your task is to analyze a GitHub issue and determine if it's valid by scanning the codebase. + +## Validation Process + +1. **Read the issue carefully** - Understand what is being reported or requested +2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords +3. **Examine the code** - Use Read to look at the actual implementation in relevant files +4. **Form your verdict** - Based on your analysis, determine if the issue is valid + +## Verdicts + +- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable. + +- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior. + +- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field. + +## For Bug Reports, Check: +- Do the referenced files/components exist? +- Does the code match what the issue describes? +- Is the described behavior actually a bug or expected? +- Can you locate the code that would cause the reported issue? + +## For Feature Requests, Check: +- Does the feature already exist? +- Is the implementation location clear? +- Is the request technically feasible given the codebase structure? + +## Response Guidelines + +- **Always include relatedFiles** when you find relevant code +- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code +- **Provide a suggestedFix** when you have a clear idea of how to address the issue +- **Use missingInfo** when the verdict is needs_clarification to list what's needed +- **Set estimatedComplexity** to help prioritize: + - trivial: Simple text changes, one-line fixes + - simple: Small changes to one file + - moderate: Changes to multiple files or moderate logic changes + - complex: Significant refactoring or new feature implementation + - very_complex: Major architectural changes or cross-cutting concerns + +Be thorough in your analysis but focus on files that are directly relevant to the issue.`; + +/** + * Build the user prompt for issue validation. + * + * Creates a structured prompt that includes the issue details for Claude + * to analyze against the codebase. + * + * @param issueNumber - The GitHub issue number + * @param issueTitle - The issue title + * @param issueBody - The issue body/description + * @param issueLabels - Optional array of label names + * @returns Formatted prompt string for the validation request + */ +export function buildValidationPrompt( + issueNumber: number, + issueTitle: string, + issueBody: string, + issueLabels?: string[] +): string { + const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : ''; + + return `Please validate the following GitHub issue by analyzing the codebase: + +## Issue #${issueNumber}: ${issueTitle} +${labelsSection} + +### Description + +${issueBody || '(No description provided)'} + +--- + +Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.`; +} diff --git a/apps/server/tests/unit/lib/validation-storage.test.ts b/apps/server/tests/unit/lib/validation-storage.test.ts new file mode 100644 index 00000000..f135da76 --- /dev/null +++ b/apps/server/tests/unit/lib/validation-storage.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + writeValidation, + readValidation, + getAllValidations, + deleteValidation, + isValidationStale, + getValidationWithFreshness, + markValidationViewed, + getUnviewedValidationsCount, + type StoredValidation, +} from '@/lib/validation-storage.js'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +describe('validation-storage.ts', () => { + let testProjectPath: string; + + beforeEach(async () => { + testProjectPath = path.join(os.tmpdir(), `validation-storage-test-${Date.now()}`); + await fs.mkdir(testProjectPath, { recursive: true }); + }); + + afterEach(async () => { + try { + await fs.rm(testProjectPath, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + const createMockValidation = (overrides: Partial = {}): StoredValidation => ({ + issueNumber: 123, + issueTitle: 'Test Issue', + validatedAt: new Date().toISOString(), + model: 'haiku', + result: { + verdict: 'valid', + confidence: 'high', + reasoning: 'Test reasoning', + }, + ...overrides, + }); + + describe('writeValidation', () => { + it('should write validation to storage', async () => { + const validation = createMockValidation(); + + await writeValidation(testProjectPath, 123, validation); + + // Verify file was created + const validationPath = path.join( + testProjectPath, + '.automaker', + 'validations', + '123', + 'validation.json' + ); + const content = await fs.readFile(validationPath, 'utf-8'); + expect(JSON.parse(content)).toEqual(validation); + }); + + it('should create nested directories if they do not exist', async () => { + const validation = createMockValidation({ issueNumber: 456 }); + + await writeValidation(testProjectPath, 456, validation); + + const validationPath = path.join( + testProjectPath, + '.automaker', + 'validations', + '456', + 'validation.json' + ); + const content = await fs.readFile(validationPath, 'utf-8'); + expect(JSON.parse(content)).toEqual(validation); + }); + }); + + describe('readValidation', () => { + it('should read validation from storage', async () => { + const validation = createMockValidation(); + await writeValidation(testProjectPath, 123, validation); + + const result = await readValidation(testProjectPath, 123); + + expect(result).toEqual(validation); + }); + + it('should return null when validation does not exist', async () => { + const result = await readValidation(testProjectPath, 999); + + expect(result).toBeNull(); + }); + }); + + describe('getAllValidations', () => { + it('should return all validations for a project', async () => { + const validation1 = createMockValidation({ issueNumber: 1, issueTitle: 'Issue 1' }); + const validation2 = createMockValidation({ issueNumber: 2, issueTitle: 'Issue 2' }); + const validation3 = createMockValidation({ issueNumber: 3, issueTitle: 'Issue 3' }); + + await writeValidation(testProjectPath, 1, validation1); + await writeValidation(testProjectPath, 2, validation2); + await writeValidation(testProjectPath, 3, validation3); + + const result = await getAllValidations(testProjectPath); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual(validation1); + expect(result[1]).toEqual(validation2); + expect(result[2]).toEqual(validation3); + }); + + it('should return empty array when no validations exist', async () => { + const result = await getAllValidations(testProjectPath); + + expect(result).toEqual([]); + }); + + it('should skip non-numeric directories', async () => { + const validation = createMockValidation({ issueNumber: 1 }); + await writeValidation(testProjectPath, 1, validation); + + // Create a non-numeric directory + const invalidDir = path.join(testProjectPath, '.automaker', 'validations', 'invalid'); + await fs.mkdir(invalidDir, { recursive: true }); + + const result = await getAllValidations(testProjectPath); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(validation); + }); + }); + + describe('deleteValidation', () => { + it('should delete validation from storage', async () => { + const validation = createMockValidation(); + await writeValidation(testProjectPath, 123, validation); + + const result = await deleteValidation(testProjectPath, 123); + + expect(result).toBe(true); + + const readResult = await readValidation(testProjectPath, 123); + expect(readResult).toBeNull(); + }); + + it('should return true even when validation does not exist', async () => { + const result = await deleteValidation(testProjectPath, 999); + + expect(result).toBe(true); + }); + }); + + describe('isValidationStale', () => { + it('should return false for recent validation', () => { + const validation = createMockValidation({ + validatedAt: new Date().toISOString(), + }); + + const result = isValidationStale(validation); + + expect(result).toBe(false); + }); + + it('should return true for validation older than 24 hours', () => { + const oldDate = new Date(); + oldDate.setHours(oldDate.getHours() - 25); // 25 hours ago + + const validation = createMockValidation({ + validatedAt: oldDate.toISOString(), + }); + + const result = isValidationStale(validation); + + expect(result).toBe(true); + }); + + it('should return false for validation exactly at 24 hours', () => { + const exactDate = new Date(); + exactDate.setHours(exactDate.getHours() - 24); + + const validation = createMockValidation({ + validatedAt: exactDate.toISOString(), + }); + + const result = isValidationStale(validation); + + expect(result).toBe(false); + }); + }); + + describe('getValidationWithFreshness', () => { + it('should return validation with isStale false for recent validation', async () => { + const validation = createMockValidation({ + validatedAt: new Date().toISOString(), + }); + await writeValidation(testProjectPath, 123, validation); + + const result = await getValidationWithFreshness(testProjectPath, 123); + + expect(result).not.toBeNull(); + expect(result!.validation).toEqual(validation); + expect(result!.isStale).toBe(false); + }); + + it('should return validation with isStale true for old validation', async () => { + const oldDate = new Date(); + oldDate.setHours(oldDate.getHours() - 25); + + const validation = createMockValidation({ + validatedAt: oldDate.toISOString(), + }); + await writeValidation(testProjectPath, 123, validation); + + const result = await getValidationWithFreshness(testProjectPath, 123); + + expect(result).not.toBeNull(); + expect(result!.isStale).toBe(true); + }); + + it('should return null when validation does not exist', async () => { + const result = await getValidationWithFreshness(testProjectPath, 999); + + expect(result).toBeNull(); + }); + }); + + describe('markValidationViewed', () => { + it('should mark validation as viewed', async () => { + const validation = createMockValidation(); + await writeValidation(testProjectPath, 123, validation); + + const result = await markValidationViewed(testProjectPath, 123); + + expect(result).toBe(true); + + const updated = await readValidation(testProjectPath, 123); + expect(updated).not.toBeNull(); + expect(updated!.viewedAt).toBeDefined(); + }); + + it('should return false when validation does not exist', async () => { + const result = await markValidationViewed(testProjectPath, 999); + + expect(result).toBe(false); + }); + }); + + describe('getUnviewedValidationsCount', () => { + it('should return count of unviewed non-stale validations', async () => { + const validation1 = createMockValidation({ issueNumber: 1 }); + const validation2 = createMockValidation({ issueNumber: 2 }); + const validation3 = createMockValidation({ + issueNumber: 3, + viewedAt: new Date().toISOString(), + }); + + await writeValidation(testProjectPath, 1, validation1); + await writeValidation(testProjectPath, 2, validation2); + await writeValidation(testProjectPath, 3, validation3); + + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(2); + }); + + it('should not count stale validations', async () => { + const oldDate = new Date(); + oldDate.setHours(oldDate.getHours() - 25); + + const validation1 = createMockValidation({ issueNumber: 1 }); + const validation2 = createMockValidation({ + issueNumber: 2, + validatedAt: oldDate.toISOString(), + }); + + await writeValidation(testProjectPath, 1, validation1); + await writeValidation(testProjectPath, 2, validation2); + + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(1); + }); + + it('should return 0 when no validations exist', async () => { + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(0); + }); + + it('should return 0 when all validations are viewed', async () => { + const validation = createMockValidation({ + issueNumber: 1, + viewedAt: new Date().toISOString(), + }); + + await writeValidation(testProjectPath, 1, validation); + + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(0); + }); + }); +}); diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 0e87d03b..12a20113 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -30,6 +30,7 @@ import { useSetupDialog, useTrashDialog, useProjectTheme, + useUnviewedValidations, } from './sidebar/hooks'; export function Sidebar() { @@ -127,6 +128,9 @@ export function Sidebar() { // Running agents count const { runningAgentsCount } = useRunningAgents(); + // Unviewed validations count + const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject); + // Trash dialog and operations const { showTrashDialog, @@ -235,6 +239,7 @@ export function Sidebar() { setIsProjectPickerOpen, cyclePrevProject, cycleNextProject, + unviewedValidationsCount, }); // Register keyboard shortcuts diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index b22dd8c1..002530f5 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -78,14 +78,29 @@ export function SidebarNavigation({ title={!sidebarOpen ? item.label : undefined} data-testid={`nav-${item.id}`} > - + + {/* Count badge for collapsed state */} + {!sidebarOpen && item.count !== undefined && item.count > 0 && ( + + {item.count > 99 ? '99' : item.count} + )} - /> + {item.label} - {item.shortcut && sidebarOpen && ( + {/* Count badge */} + {item.count !== undefined && item.count > 0 && sidebarOpen && ( + + {item.count > 99 ? '99+' : item.count} + + )} + {item.shortcut && sidebarOpen && !item.count && ( boolean)) => void; cyclePrevProject: () => void; cycleNextProject: () => void; + /** Count of unviewed validations to show on GitHub Issues nav item */ + unviewedValidationsCount?: number; } export function useNavigation({ @@ -61,6 +63,7 @@ export function useNavigation({ setIsProjectPickerOpen, cyclePrevProject, cycleNextProject, + unviewedValidationsCount, }: UseNavigationProps) { // Track if current project has a GitHub remote const [hasGitHubRemote, setHasGitHubRemote] = useState(false); @@ -169,6 +172,7 @@ export function useNavigation({ id: 'github-issues', label: 'Issues', icon: CircleDot, + count: unviewedValidationsCount, }, { id: 'github-prs', @@ -180,7 +184,15 @@ export function useNavigation({ } return sections; - }, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]); + }, [ + shortcuts, + hideSpecEditor, + hideContext, + hideTerminal, + hideAiProfiles, + hasGitHubRemote, + unviewedValidationsCount, + ]); // Build keyboard shortcuts for navigation const navigationShortcuts: KeyboardShortcut[] = useMemo(() => { diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-unviewed-validations.ts b/apps/ui/src/components/layout/sidebar/hooks/use-unviewed-validations.ts new file mode 100644 index 00000000..ac5add46 --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-unviewed-validations.ts @@ -0,0 +1,82 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { getElectronAPI } from '@/lib/electron'; +import type { Project, StoredValidation } from '@/lib/electron'; + +/** + * Hook to track the count of unviewed (fresh) issue validations for a project. + * Also provides a function to decrement the count when a validation is viewed. + */ +export function useUnviewedValidations(currentProject: Project | null) { + const [count, setCount] = useState(0); + const projectPathRef = useRef(null); + + // Keep project path in ref for use in async functions + useEffect(() => { + projectPathRef.current = currentProject?.path ?? null; + }, [currentProject?.path]); + + // Fetch and update count from server + const fetchUnviewedCount = useCallback(async () => { + const projectPath = projectPathRef.current; + if (!projectPath) return; + + try { + const api = getElectronAPI(); + if (api.github?.getValidations) { + const result = await api.github.getValidations(projectPath); + if (result.success && result.validations) { + const unviewed = result.validations.filter((v: StoredValidation) => { + if (v.viewedAt) return false; + // Check if not stale (< 24 hours) + const hoursSince = (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60); + return hoursSince <= 24; + }); + // Only update count if we're still on the same project (guard against race condition) + if (projectPathRef.current === projectPath) { + setCount(unviewed.length); + } + } + } + } catch (err) { + console.error('[useUnviewedValidations] Failed to load count:', err); + } + }, []); + + // Load initial count and subscribe to events + useEffect(() => { + if (!currentProject?.path) { + setCount(0); + return; + } + + // Load initial count + fetchUnviewedCount(); + + // Subscribe to validation events to update count + const api = getElectronAPI(); + if (api.github?.onValidationEvent) { + const unsubscribe = api.github.onValidationEvent((event) => { + if (event.projectPath === currentProject.path) { + if (event.type === 'issue_validation_complete') { + // New validation completed - refresh count from server for consistency + fetchUnviewedCount(); + } else if (event.type === 'issue_validation_viewed') { + // Validation was viewed - refresh count from server for consistency + fetchUnviewedCount(); + } + } + }); + return () => unsubscribe(); + } + }, [currentProject?.path, fetchUnviewedCount]); + + // Function to decrement count when a validation is viewed + const decrementCount = useCallback(() => { + setCount((prev) => Math.max(0, prev - 1)); + }, []); + + // Expose refreshCount as an alias to fetchUnviewedCount for external use + const refreshCount = fetchUnviewedCount; + + return { count, decrementCount, refreshCount }; +} diff --git a/apps/ui/src/components/layout/sidebar/types.ts b/apps/ui/src/components/layout/sidebar/types.ts index 4d9ecc35..25192d04 100644 --- a/apps/ui/src/components/layout/sidebar/types.ts +++ b/apps/ui/src/components/layout/sidebar/types.ts @@ -11,6 +11,8 @@ export interface NavItem { label: string; icon: React.ComponentType<{ className?: string }>; shortcut?: string; + /** Optional count badge to display next to the nav item */ + count?: number; } export interface SortableProjectItemProps { diff --git a/apps/ui/src/components/ui/confirm-dialog.tsx b/apps/ui/src/components/ui/confirm-dialog.tsx new file mode 100644 index 00000000..28570d1a --- /dev/null +++ b/apps/ui/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,83 @@ +import type { ReactNode } from 'react'; +import { LucideIcon } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { HotkeyButton } from '@/components/ui/hotkey-button'; + +interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + title: string; + description: string; + /** Optional icon to show in the title */ + icon?: LucideIcon; + /** Icon color class. Defaults to "text-primary" */ + iconClassName?: string; + /** Optional content to show between description and buttons */ + children?: ReactNode; + /** Text for the confirm button. Defaults to "Confirm" */ + confirmText?: string; + /** Text for the cancel button. Defaults to "Cancel" */ + cancelText?: string; + /** Variant for the confirm button. Defaults to "default" */ + confirmVariant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; +} + +export function ConfirmDialog({ + open, + onOpenChange, + onConfirm, + title, + description, + icon: Icon, + iconClassName = 'text-primary', + children, + confirmText = 'Confirm', + cancelText = 'Cancel', + confirmVariant = 'default', +}: ConfirmDialogProps) { + const handleConfirm = () => { + onConfirm(); + onOpenChange(false); + }; + + return ( + + + + + {Icon && } + {title} + + {description} + + + {children} + + + + + {Icon && } + {confirmText} + + + + + ); +} diff --git a/apps/ui/src/components/ui/error-state.tsx b/apps/ui/src/components/ui/error-state.tsx new file mode 100644 index 00000000..3ac336af --- /dev/null +++ b/apps/ui/src/components/ui/error-state.tsx @@ -0,0 +1,36 @@ +import { CircleDot, RefreshCw } from 'lucide-react'; +import { Button } from './button'; + +interface ErrorStateProps { + /** Error message to display */ + error: string; + /** Title for the error state (default: "Failed to Load") */ + title?: string; + /** Callback when retry button is clicked */ + onRetry?: () => void; + /** Text for the retry button (default: "Try Again") */ + retryText?: string; +} + +export function ErrorState({ + error, + title = 'Failed to Load', + onRetry, + retryText = 'Try Again', +}: ErrorStateProps) { + return ( +
+
+ +
+

{title}

+

{error}

+ {onRetry && ( + + )} +
+ ); +} diff --git a/apps/ui/src/components/ui/loading-state.tsx b/apps/ui/src/components/ui/loading-state.tsx new file mode 100644 index 00000000..9ae6ff3b --- /dev/null +++ b/apps/ui/src/components/ui/loading-state.tsx @@ -0,0 +1,17 @@ +import { Loader2 } from 'lucide-react'; + +interface LoadingStateProps { + /** Optional custom message to display below the spinner */ + message?: string; + /** Optional custom size class for the spinner (default: h-8 w-8) */ + size?: string; +} + +export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) { + return ( +
+ + {message &&

{message}

} +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 941b5db8..10876e38 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -1,93 +1,126 @@ -import { useState, useEffect, useCallback } from 'react'; -import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react'; -import { getElectronAPI, GitHubIssue } from '@/lib/electron'; +import { useState, useCallback, useMemo } from 'react'; +import { CircleDot, RefreshCw } from 'lucide-react'; +import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { Button } from '@/components/ui/button'; -import { Markdown } from '@/components/ui/markdown'; -import { cn } from '@/lib/utils'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { LoadingState } from '@/components/ui/loading-state'; +import { ErrorState } from '@/components/ui/error-state'; +import { cn, pathsEqual } from '@/lib/utils'; +import { toast } from 'sonner'; +import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks'; +import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components'; +import { ValidationDialog } from './github-issues-view/dialogs'; +import { formatDate, getFeaturePriority } from './github-issues-view/utils'; export function GitHubIssuesView() { - const [openIssues, setOpenIssues] = useState([]); - const [closedIssues, setClosedIssues] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); const [selectedIssue, setSelectedIssue] = useState(null); - const { currentProject } = useAppStore(); + const [validationResult, setValidationResult] = useState(null); + const [showValidationDialog, setShowValidationDialog] = useState(false); + const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false); - const fetchIssues = useCallback(async () => { - if (!currentProject?.path) { - setError('No project selected'); - setLoading(false); - return; - } + const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } = + useAppStore(); - try { - setError(null); - const api = getElectronAPI(); - if (api.github) { - const result = await api.github.listIssues(currentProject.path); - if (result.success) { - setOpenIssues(result.openIssues || []); - setClosedIssues(result.closedIssues || []); - } else { - setError(result.error || 'Failed to fetch issues'); - } - } - } catch (err) { - console.error('[GitHubIssuesView] Error fetching issues:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch issues'); - } finally { - setLoading(false); - setRefreshing(false); - } - }, [currentProject?.path]); + const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues(); - useEffect(() => { - fetchIssues(); - }, [fetchIssues]); + const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } = + useIssueValidation({ + selectedIssue, + showValidationDialog, + onValidationResultChange: setValidationResult, + onShowValidationDialogChange: setShowValidationDialog, + }); - const handleRefresh = useCallback(() => { - setRefreshing(true); - fetchIssues(); - }, [fetchIssues]); + // Get default AI profile for task creation + const defaultProfile = useMemo(() => { + if (!defaultAIProfileId) return null; + return aiProfiles.find((p) => p.id === defaultAIProfileId) ?? null; + }, [defaultAIProfileId, aiProfiles]); + + // Get current branch from selected worktree + const currentBranch = useMemo(() => { + if (!currentProject?.path) return ''; + const currentWorktreeInfo = getCurrentWorktree(currentProject.path); + const worktrees = worktreesByProject[currentProject.path] ?? []; + const currentWorktreePath = currentWorktreeInfo?.path ?? null; + + const selectedWorktree = + currentWorktreePath === null + ? worktrees.find((w) => w.isMain) + : worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)); + + return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || ''; + }, [currentProject?.path, getCurrentWorktree, worktreesByProject]); const handleOpenInGitHub = useCallback((url: string) => { const api = getElectronAPI(); api.openExternalLink(url); }, []); - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); - }; + const handleConvertToTask = useCallback( + async (issue: GitHubIssue, validation: IssueValidationResult) => { + if (!currentProject?.path) { + toast.error('No project selected'); + return; + } + + try { + const api = getElectronAPI(); + if (api.features?.create) { + // Build description from issue body + validation info + const description = [ + `**From GitHub Issue #${issue.number}**`, + '', + issue.body || 'No description provided.', + '', + '---', + '', + '**AI Validation Analysis:**', + validation.reasoning, + validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '', + validation.relatedFiles?.length + ? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}` + : '', + ] + .filter(Boolean) + .join('\n'); + + const feature = { + id: `issue-${issue.number}-${crypto.randomUUID()}`, + title: issue.title, + description, + category: 'From GitHub', + status: 'backlog' as const, + passes: false, + priority: getFeaturePriority(validation.estimatedComplexity), + model: defaultProfile?.model ?? 'opus', + thinkingLevel: defaultProfile?.thinkingLevel ?? 'none', + branchName: currentBranch, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const result = await api.features.create(currentProject.path, feature); + if (result.success) { + toast.success(`Created task: ${issue.title}`); + } else { + toast.error(result.error || 'Failed to create task'); + } + } + } catch (err) { + console.error('[GitHubIssuesView] Convert to task error:', err); + toast.error(err instanceof Error ? err.message : 'Failed to create task'); + } + }, + [currentProject?.path, defaultProfile, currentBranch] + ); if (loading) { - return ( -
- -
- ); + return ; } if (error) { - return ( -
-
- -
-

Failed to Load Issues

-

{error}

- -
- ); + return ; } const totalIssues = openIssues.length + closedIssues.length; @@ -102,24 +135,12 @@ export function GitHubIssuesView() { )} > {/* Header */} -
-
-
- -
-
-

Issues

-

- {totalIssues === 0 - ? 'No issues found' - : `${openIssues.length} open, ${closedIssues.length} closed`} -

-
-
- -
+ {/* Issues List */}
@@ -142,6 +163,8 @@ export function GitHubIssuesView() { onClick={() => setSelectedIssue(issue)} onOpenExternal={() => handleOpenInGitHub(issue.url)} formatDate={formatDate} + cachedValidation={cachedValidations.get(issue.number)} + isValidating={validatingIssues.has(issue.number)} /> ))} @@ -159,6 +182,8 @@ export function GitHubIssuesView() { onClick={() => setSelectedIssue(issue)} onOpenExternal={() => handleOpenInGitHub(issue.url)} formatDate={formatDate} + cachedValidation={cachedValidations.get(issue.number)} + isValidating={validatingIssues.has(issue.number)} /> ))} @@ -170,164 +195,43 @@ export function GitHubIssuesView() { {/* Issue Detail Panel */} {selectedIssue && ( -
- {/* Detail Header */} -
-
- {selectedIssue.state === 'OPEN' ? ( - - ) : ( - - )} - - #{selectedIssue.number} {selectedIssue.title} - -
-
- - -
-
- - {/* Issue Detail Content */} -
- {/* Title */} -

{selectedIssue.title}

- - {/* Meta info */} -
- - {selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'} - - - #{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '} - {selectedIssue.author.login} - -
- - {/* Labels */} - {selectedIssue.labels.length > 0 && ( -
- {selectedIssue.labels.map((label) => ( - - {label.name} - - ))} -
- )} - - {/* Body */} - {selectedIssue.body ? ( - {selectedIssue.body} - ) : ( -

No description provided.

- )} - - {/* Open in GitHub CTA */} -
-

- View comments, add reactions, and more on GitHub. -

- -
-
-
- )} -
- ); -} - -interface IssueRowProps { - issue: GitHubIssue; - isSelected: boolean; - onClick: () => void; - onOpenExternal: () => void; - formatDate: (date: string) => string; -} - -function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) { - return ( -
- {issue.state === 'OPEN' ? ( - - ) : ( - + setSelectedIssue(null)} + onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)} + formatDate={formatDate} + /> )} -
-
- {issue.title} -
+ {/* Validation Dialog */} + -
- - #{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login} - -
- - {issue.labels.length > 0 && ( -
- {issue.labels.map((label) => ( - - {label.name} - - ))} -
- )} -
- - + />
); } diff --git a/apps/ui/src/components/views/github-issues-view/components/index.ts b/apps/ui/src/components/views/github-issues-view/components/index.ts new file mode 100644 index 00000000..b3af9f84 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/index.ts @@ -0,0 +1,3 @@ +export { IssueRow } from './issue-row'; +export { IssueDetailPanel } from './issue-detail-panel'; +export { IssuesListHeader } from './issues-list-header'; diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx new file mode 100644 index 00000000..7969da38 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx @@ -0,0 +1,242 @@ +import { + Circle, + CheckCircle2, + X, + Wand2, + ExternalLink, + Loader2, + CheckCircle, + Clock, + GitPullRequest, + User, + RefreshCw, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Markdown } from '@/components/ui/markdown'; +import { cn } from '@/lib/utils'; +import type { IssueDetailPanelProps } from '../types'; +import { isValidationStale } from '../utils'; + +export function IssueDetailPanel({ + issue, + validatingIssues, + cachedValidations, + onValidateIssue, + onViewCachedValidation, + onOpenInGitHub, + onClose, + onShowRevalidateConfirm, + formatDate, +}: IssueDetailPanelProps) { + const isValidating = validatingIssues.has(issue.number); + const cached = cachedValidations.get(issue.number); + const isStale = cached ? isValidationStale(cached.validatedAt) : false; + + return ( +
+ {/* Detail Header */} +
+
+ {issue.state === 'OPEN' ? ( + + ) : ( + + )} + + #{issue.number} {issue.title} + +
+
+ {(() => { + if (isValidating) { + return ( + + ); + } + + if (cached && !isStale) { + return ( + <> + + + + ); + } + + if (cached && isStale) { + return ( + <> + + + + ); + } + + return ( + + ); + })()} + + +
+
+ + {/* Issue Detail Content */} +
+ {/* Title */} +

{issue.title}

+ + {/* Meta info */} +
+ + {issue.state === 'OPEN' ? 'Open' : 'Closed'} + + + #{issue.number} opened {formatDate(issue.createdAt)} by{' '} + {issue.author.login} + +
+ + {/* Labels */} + {issue.labels.length > 0 && ( +
+ {issue.labels.map((label) => ( + + {label.name} + + ))} +
+ )} + + {/* Assignees */} + {issue.assignees && issue.assignees.length > 0 && ( +
+ + Assigned to: +
+ {issue.assignees.map((assignee) => ( + + {assignee.avatarUrl && ( + {assignee.login} + )} + {assignee.login} + + ))} +
+
+ )} + + {/* Linked Pull Requests */} + {issue.linkedPRs && issue.linkedPRs.length > 0 && ( +
+
+ + Linked Pull Requests +
+
+ {issue.linkedPRs.map((pr) => ( +
+
+ + {pr.state === 'open' ? 'Open' : pr.state === 'merged' ? 'Merged' : 'Closed'} + + #{pr.number} + {pr.title} +
+ +
+ ))} +
+
+ )} + + {/* Body */} + {issue.body ? ( + {issue.body} + ) : ( +

No description provided.

+ )} + + {/* Open in GitHub CTA */} +
+

+ View comments, add reactions, and more on GitHub. +

+ +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx new file mode 100644 index 00000000..bf6496f1 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx @@ -0,0 +1,136 @@ +import { + Circle, + CheckCircle2, + ExternalLink, + Loader2, + CheckCircle, + Sparkles, + GitPullRequest, + User, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { IssueRowProps } from '../types'; +import { isValidationStale } from '../utils'; + +export function IssueRow({ + issue, + isSelected, + onClick, + onOpenExternal, + formatDate, + cachedValidation, + isValidating, +}: IssueRowProps) { + // Check if validation exists and calculate staleness + const validationHoursSince = cachedValidation + ? (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60) + : null; + const isValidationStaleValue = + validationHoursSince !== null && isValidationStale(cachedValidation!.validatedAt); + + // Check if validation is unviewed (exists, not stale, not viewed) + const hasUnviewedValidation = + cachedValidation && !cachedValidation.viewedAt && !isValidationStaleValue; + + // Check if validation has been viewed (exists and was viewed) + const hasViewedValidation = + cachedValidation && cachedValidation.viewedAt && !isValidationStaleValue; + + return ( +
+ {issue.state === 'OPEN' ? ( + + ) : ( + + )} + +
+
+ {issue.title} +
+ +
+ + #{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login} + +
+ +
+ {/* Labels */} + {issue.labels.map((label) => ( + + {label.name} + + ))} + + {/* Linked PR indicator */} + {issue.linkedPRs && issue.linkedPRs.length > 0 && ( + + + {issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''} + + )} + + {/* Assignee indicator */} + {issue.assignees && issue.assignees.length > 0 && ( + + + {issue.assignees.map((a) => a.login).join(', ')} + + )} + + {/* Validating indicator */} + {isValidating && ( + + + Analyzing... + + )} + + {/* Unviewed validation indicator */} + {!isValidating && hasUnviewedValidation && ( + + + Analysis Ready + + )} + + {/* Viewed validation indicator */} + {!isValidating && hasViewedValidation && ( + + + Validated + + )} +
+
+ + +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx new file mode 100644 index 00000000..5529b30c --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx @@ -0,0 +1,38 @@ +import { CircleDot, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface IssuesListHeaderProps { + openCount: number; + closedCount: number; + refreshing: boolean; + onRefresh: () => void; +} + +export function IssuesListHeader({ + openCount, + closedCount, + refreshing, + onRefresh, +}: IssuesListHeaderProps) { + const totalIssues = openCount + closedCount; + + return ( +
+
+
+ +
+
+

Issues

+

+ {totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`} +

+
+
+ +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/constants.ts b/apps/ui/src/components/views/github-issues-view/constants.ts new file mode 100644 index 00000000..22a6785a --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/constants.ts @@ -0,0 +1 @@ +export const VALIDATION_STALENESS_HOURS = 24; diff --git a/apps/ui/src/components/views/github-issues-view/dialogs/index.ts b/apps/ui/src/components/views/github-issues-view/dialogs/index.ts new file mode 100644 index 00000000..886b09b2 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/dialogs/index.ts @@ -0,0 +1 @@ +export { ValidationDialog } from './validation-dialog'; diff --git a/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx b/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx new file mode 100644 index 00000000..fba1a9ea --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx @@ -0,0 +1,231 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Markdown } from '@/components/ui/markdown'; +import { + CheckCircle2, + XCircle, + AlertCircle, + FileCode, + Lightbulb, + AlertTriangle, + Plus, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { + IssueValidationResult, + IssueValidationVerdict, + IssueValidationConfidence, + IssueComplexity, + GitHubIssue, +} from '@/lib/electron'; + +interface ValidationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + issue: GitHubIssue | null; + validationResult: IssueValidationResult | null; + onConvertToTask?: (issue: GitHubIssue, validation: IssueValidationResult) => void; +} + +const verdictConfig: Record< + IssueValidationVerdict, + { label: string; color: string; bgColor: string; icon: typeof CheckCircle2 } +> = { + valid: { + label: 'Valid', + color: 'text-green-500', + bgColor: 'bg-green-500/10', + icon: CheckCircle2, + }, + invalid: { + label: 'Invalid', + color: 'text-red-500', + bgColor: 'bg-red-500/10', + icon: XCircle, + }, + needs_clarification: { + label: 'Needs Clarification', + color: 'text-yellow-500', + bgColor: 'bg-yellow-500/10', + icon: AlertCircle, + }, +}; + +const confidenceConfig: Record = { + high: { label: 'High Confidence', color: 'text-green-500' }, + medium: { label: 'Medium Confidence', color: 'text-yellow-500' }, + low: { label: 'Low Confidence', color: 'text-orange-500' }, +}; + +const complexityConfig: Record = { + trivial: { label: 'Trivial', color: 'text-green-500' }, + simple: { label: 'Simple', color: 'text-blue-500' }, + moderate: { label: 'Moderate', color: 'text-yellow-500' }, + complex: { label: 'Complex', color: 'text-orange-500' }, + very_complex: { label: 'Very Complex', color: 'text-red-500' }, +}; + +export function ValidationDialog({ + open, + onOpenChange, + issue, + validationResult, + onConvertToTask, +}: ValidationDialogProps) { + if (!issue) return null; + + const handleConvertToTask = () => { + if (validationResult && onConvertToTask) { + onConvertToTask(issue, validationResult); + onOpenChange(false); + } + }; + + return ( + + + + Issue Validation Result + + #{issue.number}: {issue.title} + + + + {validationResult ? ( +
+ {/* Verdict Badge */} +
+
+ {(() => { + const config = verdictConfig[validationResult.verdict]; + const Icon = config.icon; + return ( + <> +
+ +
+
+

{config.label}

+

+ {confidenceConfig[validationResult.confidence].label} +

+
+ + ); + })()} +
+ {validationResult.estimatedComplexity && ( +
+

Estimated Complexity

+

+ {complexityConfig[validationResult.estimatedComplexity].label} +

+
+ )} +
+ + {/* Bug Confirmed Badge */} + {validationResult.bugConfirmed && ( +
+ + Bug Confirmed in Codebase +
+ )} + + {/* Reasoning */} +
+

+ + Analysis +

+
+ {validationResult.reasoning} +
+
+ + {/* Related Files */} + {validationResult.relatedFiles && validationResult.relatedFiles.length > 0 && ( +
+

+ + Related Files +

+
+ {validationResult.relatedFiles.map((file, index) => ( +
+ {file} +
+ ))} +
+
+ )} + + {/* Suggested Fix */} + {validationResult.suggestedFix && ( +
+

Suggested Approach

+
+ {validationResult.suggestedFix} +
+
+ )} + + {/* Missing Info (for needs_clarification) */} + {validationResult.missingInfo && validationResult.missingInfo.length > 0 && ( +
+

+ + Missing Information +

+
    + {validationResult.missingInfo.map((info, index) => ( +
  • + {info} +
  • + ))} +
+
+ )} +
+ ) : ( +
+ +

No validation result available.

+
+ )} + + + + {validationResult?.verdict === 'valid' && onConvertToTask && ( + + )} + +
+
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/hooks/index.ts b/apps/ui/src/components/views/github-issues-view/hooks/index.ts new file mode 100644 index 00000000..c3417416 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/index.ts @@ -0,0 +1,2 @@ +export { useGithubIssues } from './use-github-issues'; +export { useIssueValidation } from './use-issue-validation'; diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts new file mode 100644 index 00000000..74b4b0b2 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts @@ -0,0 +1,76 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { getElectronAPI, GitHubIssue } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; + +export function useGithubIssues() { + const { currentProject } = useAppStore(); + const [openIssues, setOpenIssues] = useState([]); + const [closedIssues, setClosedIssues] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const isMountedRef = useRef(true); + + const fetchIssues = useCallback(async () => { + if (!currentProject?.path) { + if (isMountedRef.current) { + setError('No project selected'); + setLoading(false); + } + return; + } + + try { + if (isMountedRef.current) { + setError(null); + } + const api = getElectronAPI(); + if (api.github) { + const result = await api.github.listIssues(currentProject.path); + if (isMountedRef.current) { + if (result.success) { + setOpenIssues(result.openIssues || []); + setClosedIssues(result.closedIssues || []); + } else { + setError(result.error || 'Failed to fetch issues'); + } + } + } + } catch (err) { + if (isMountedRef.current) { + console.error('[GitHubIssuesView] Error fetching issues:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch issues'); + } + } finally { + if (isMountedRef.current) { + setLoading(false); + setRefreshing(false); + } + } + }, [currentProject?.path]); + + useEffect(() => { + isMountedRef.current = true; + fetchIssues(); + + return () => { + isMountedRef.current = false; + }; + }, [fetchIssues]); + + const refresh = useCallback(() => { + if (isMountedRef.current) { + setRefreshing(true); + } + fetchIssues(); + }, [fetchIssues]); + + return { + openIssues, + closedIssues, + loading, + refreshing, + error, + refresh, + }; +} diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts new file mode 100644 index 00000000..136185c5 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts @@ -0,0 +1,317 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + getElectronAPI, + GitHubIssue, + IssueValidationResult, + IssueValidationEvent, + StoredValidation, +} from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; +import { toast } from 'sonner'; +import { isValidationStale } from '../utils'; + +interface UseIssueValidationOptions { + selectedIssue: GitHubIssue | null; + showValidationDialog: boolean; + onValidationResultChange: (result: IssueValidationResult | null) => void; + onShowValidationDialogChange: (show: boolean) => void; +} + +export function useIssueValidation({ + selectedIssue, + showValidationDialog, + onValidationResultChange, + onShowValidationDialogChange, +}: UseIssueValidationOptions) { + const { currentProject, validationModel, muteDoneSound } = useAppStore(); + const [validatingIssues, setValidatingIssues] = useState>(new Set()); + const [cachedValidations, setCachedValidations] = useState>( + new Map() + ); + const audioRef = useRef(null); + // Refs for stable event handler (avoids re-subscribing on state changes) + const selectedIssueRef = useRef(null); + const showValidationDialogRef = useRef(false); + + // Keep refs in sync with state for stable event handler + useEffect(() => { + selectedIssueRef.current = selectedIssue; + }, [selectedIssue]); + + useEffect(() => { + showValidationDialogRef.current = showValidationDialog; + }, [showValidationDialog]); + + // Load cached validations on mount + useEffect(() => { + let isMounted = true; + + const loadCachedValidations = async () => { + if (!currentProject?.path) return; + + try { + const api = getElectronAPI(); + if (api.github?.getValidations) { + const result = await api.github.getValidations(currentProject.path); + if (isMounted && result.success && result.validations) { + const map = new Map(); + for (const v of result.validations) { + map.set(v.issueNumber, v); + } + setCachedValidations(map); + } + } + } catch (err) { + if (isMounted) { + console.error('[GitHubIssuesView] Failed to load cached validations:', err); + } + } + }; + + loadCachedValidations(); + + return () => { + isMounted = false; + }; + }, [currentProject?.path]); + + // Load running validations on mount (restore validatingIssues state) + useEffect(() => { + let isMounted = true; + + const loadRunningValidations = async () => { + if (!currentProject?.path) return; + + try { + const api = getElectronAPI(); + if (api.github?.getValidationStatus) { + const result = await api.github.getValidationStatus(currentProject.path); + if (isMounted && result.success && result.runningIssues) { + setValidatingIssues(new Set(result.runningIssues)); + } + } + } catch (err) { + if (isMounted) { + console.error('[GitHubIssuesView] Failed to load running validations:', err); + } + } + }; + + loadRunningValidations(); + + return () => { + isMounted = false; + }; + }, [currentProject?.path]); + + // Subscribe to validation events + useEffect(() => { + const api = getElectronAPI(); + if (!api.github?.onValidationEvent) return; + + const handleValidationEvent = (event: IssueValidationEvent) => { + // Only handle events for current project + if (event.projectPath !== currentProject?.path) return; + + switch (event.type) { + case 'issue_validation_start': + setValidatingIssues((prev) => new Set([...prev, event.issueNumber])); + break; + + case 'issue_validation_complete': + setValidatingIssues((prev) => { + const next = new Set(prev); + next.delete(event.issueNumber); + return next; + }); + + // Update cached validations (use event.model to avoid stale closure race condition) + setCachedValidations((prev) => { + const next = new Map(prev); + next.set(event.issueNumber, { + issueNumber: event.issueNumber, + issueTitle: event.issueTitle, + validatedAt: new Date().toISOString(), + model: event.model, + result: event.result, + }); + return next; + }); + + // Show toast notification + toast.success(`Issue #${event.issueNumber} validated: ${event.result.verdict}`, { + description: + event.result.verdict === 'valid' + ? 'Issue is ready to be converted to a task' + : event.result.verdict === 'invalid' + ? 'Issue may have problems' + : 'Issue needs clarification', + }); + + // Play audio notification (if not muted) + if (!muteDoneSound) { + try { + if (!audioRef.current) { + audioRef.current = new Audio('/sounds/ding.mp3'); + } + audioRef.current.play().catch(() => { + // Audio play might fail due to browser restrictions + }); + } catch { + // Ignore audio errors + } + } + + // If validation dialog is open for this issue, update the result + if ( + selectedIssueRef.current?.number === event.issueNumber && + showValidationDialogRef.current + ) { + onValidationResultChange(event.result); + } + break; + + case 'issue_validation_error': + setValidatingIssues((prev) => { + const next = new Set(prev); + next.delete(event.issueNumber); + return next; + }); + toast.error(`Validation failed for issue #${event.issueNumber}`, { + description: event.error, + }); + if ( + selectedIssueRef.current?.number === event.issueNumber && + showValidationDialogRef.current + ) { + onShowValidationDialogChange(false); + } + break; + } + }; + + const unsubscribe = api.github.onValidationEvent(handleValidationEvent); + return () => unsubscribe(); + }, [currentProject?.path, muteDoneSound, onValidationResultChange, onShowValidationDialogChange]); + + // Cleanup audio element on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + }; + }, []); + + const handleValidateIssue = useCallback( + async (issue: GitHubIssue, options: { forceRevalidate?: boolean } = {}) => { + const { forceRevalidate = false } = options; + + if (!currentProject?.path) { + toast.error('No project selected'); + return; + } + + // Check if already validating this issue + if (validatingIssues.has(issue.number)) { + toast.info(`Validation already in progress for issue #${issue.number}`); + return; + } + + // Check for cached result - if fresh, show it directly (unless force revalidate) + const cached = cachedValidations.get(issue.number); + if (cached && !forceRevalidate && !isValidationStale(cached.validatedAt)) { + // Show cached result directly + onValidationResultChange(cached.result); + onShowValidationDialogChange(true); + return; + } + + // Start async validation in background (no dialog - user will see badge when done) + toast.info(`Starting validation for issue #${issue.number}`, { + description: 'You will be notified when the analysis is complete', + }); + + try { + const api = getElectronAPI(); + if (api.github?.validateIssue) { + const result = await api.github.validateIssue( + currentProject.path, + { + issueNumber: issue.number, + issueTitle: issue.title, + issueBody: issue.body || '', + issueLabels: issue.labels.map((l) => l.name), + }, + validationModel + ); + + if (!result.success) { + toast.error(result.error || 'Failed to start validation'); + } + // On success, the result will come through the event stream + } + } catch (err) { + console.error('[GitHubIssuesView] Validation error:', err); + toast.error(err instanceof Error ? err.message : 'Failed to validate issue'); + } + }, + [ + currentProject?.path, + validatingIssues, + cachedValidations, + validationModel, + onValidationResultChange, + onShowValidationDialogChange, + ] + ); + + // View cached validation result + const handleViewCachedValidation = useCallback( + async (issue: GitHubIssue) => { + const cached = cachedValidations.get(issue.number); + if (cached) { + onValidationResultChange(cached.result); + onShowValidationDialogChange(true); + + // Mark as viewed if not already viewed + if (!cached.viewedAt && currentProject?.path) { + try { + const api = getElectronAPI(); + if (api.github?.markValidationViewed) { + await api.github.markValidationViewed(currentProject.path, issue.number); + // Update local state + setCachedValidations((prev) => { + const next = new Map(prev); + const updated = prev.get(issue.number); + if (updated) { + next.set(issue.number, { + ...updated, + viewedAt: new Date().toISOString(), + }); + } + return next; + }); + } + } catch (err) { + console.error('[GitHubIssuesView] Failed to mark validation as viewed:', err); + } + } + } + }, + [ + cachedValidations, + currentProject?.path, + onValidationResultChange, + onShowValidationDialogChange, + ] + ); + + return { + validatingIssues, + cachedValidations, + handleValidateIssue, + handleViewCachedValidation, + }; +} diff --git a/apps/ui/src/components/views/github-issues-view/types.ts b/apps/ui/src/components/views/github-issues-view/types.ts new file mode 100644 index 00000000..9fce6d53 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/types.ts @@ -0,0 +1,28 @@ +import type { GitHubIssue, StoredValidation } from '@/lib/electron'; + +export interface IssueRowProps { + issue: GitHubIssue; + isSelected: boolean; + onClick: () => void; + onOpenExternal: () => void; + formatDate: (date: string) => string; + /** Cached validation for this issue (if any) */ + cachedValidation?: StoredValidation | null; + /** Whether validation is currently running for this issue */ + isValidating?: boolean; +} + +export interface IssueDetailPanelProps { + issue: GitHubIssue; + validatingIssues: Set; + cachedValidations: Map; + onValidateIssue: ( + issue: GitHubIssue, + options?: { showDialog?: boolean; forceRevalidate?: boolean } + ) => Promise; + onViewCachedValidation: (issue: GitHubIssue) => Promise; + onOpenInGitHub: (url: string) => void; + onClose: () => void; + onShowRevalidateConfirm: () => void; + formatDate: (date: string) => string; +} diff --git a/apps/ui/src/components/views/github-issues-view/utils.ts b/apps/ui/src/components/views/github-issues-view/utils.ts new file mode 100644 index 00000000..ad313317 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/utils.ts @@ -0,0 +1,33 @@ +import type { IssueComplexity } from '@/lib/electron'; +import { VALIDATION_STALENESS_HOURS } from './constants'; + +/** + * Map issue complexity to feature priority. + * Lower complexity issues get higher priority (1 = high, 2 = medium). + */ +export function getFeaturePriority(complexity: IssueComplexity | undefined): number { + switch (complexity) { + case 'trivial': + case 'simple': + return 1; // High priority for easy wins + case 'moderate': + case 'complex': + case 'very_complex': + default: + return 2; // Medium priority for larger efforts + } +} + +export function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export function isValidationStale(validatedAt: string): boolean { + const hoursSinceValidation = (Date.now() - new Date(validatedAt).getTime()) / (1000 * 60 * 60); + return hoursSinceValidation > VALIDATION_STALENESS_HOURS; +} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 0580c18e..f57735bf 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -45,6 +45,8 @@ export function SettingsView() { setDefaultAIProfileId, aiProfiles, apiKeys, + validationModel, + setValidationModel, } = useAppStore(); // Hide usage tracking when using API key (only show for Claude Code CLI users) @@ -134,6 +136,7 @@ export function SettingsView() { defaultRequirePlanApproval={defaultRequirePlanApproval} defaultAIProfileId={defaultAIProfileId} aiProfiles={aiProfiles} + validationModel={validationModel} onShowProfilesOnlyChange={setShowProfilesOnly} onDefaultSkipTestsChange={setDefaultSkipTests} onEnableDependencyBlockingChange={setEnableDependencyBlocking} @@ -141,6 +144,7 @@ export function SettingsView() { onDefaultPlanningModeChange={setDefaultPlanningMode} onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval} onDefaultAIProfileIdChange={setDefaultAIProfileId} + onValidationModelChange={setValidationModel} /> ); case 'danger': diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx index 24ebe15b..d924c676 100644 --- a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -12,6 +12,7 @@ import { ScrollText, ShieldCheck, User, + Sparkles, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { @@ -22,6 +23,7 @@ import { SelectValue, } from '@/components/ui/select'; import type { AIProfile } from '@/store/app-store'; +import type { AgentModel } from '@automaker/types'; type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; @@ -34,6 +36,7 @@ interface FeatureDefaultsSectionProps { defaultRequirePlanApproval: boolean; defaultAIProfileId: string | null; aiProfiles: AIProfile[]; + validationModel: AgentModel; onShowProfilesOnlyChange: (value: boolean) => void; onDefaultSkipTestsChange: (value: boolean) => void; onEnableDependencyBlockingChange: (value: boolean) => void; @@ -41,6 +44,7 @@ interface FeatureDefaultsSectionProps { onDefaultPlanningModeChange: (value: PlanningMode) => void; onDefaultRequirePlanApprovalChange: (value: boolean) => void; onDefaultAIProfileIdChange: (value: string | null) => void; + onValidationModelChange: (value: AgentModel) => void; } export function FeatureDefaultsSection({ @@ -52,6 +56,7 @@ export function FeatureDefaultsSection({ defaultRequirePlanApproval, defaultAIProfileId, aiProfiles, + validationModel, onShowProfilesOnlyChange, onDefaultSkipTestsChange, onEnableDependencyBlockingChange, @@ -59,6 +64,7 @@ export function FeatureDefaultsSection({ onDefaultPlanningModeChange, onDefaultRequirePlanApprovalChange, onDefaultAIProfileIdChange, + onValidationModelChange, }: FeatureDefaultsSectionProps) { // Find the selected profile name for display const selectedProfile = defaultAIProfileId @@ -227,6 +233,45 @@ export function FeatureDefaultsSection({ {/* Separator */}
+ {/* Issue Validation Model */} +
+
+ +
+
+
+ + +
+

+ Model used for validating GitHub issues. Opus provides the most thorough analysis, + while Haiku is faster and more cost-effective. +

+
+
+ + {/* Separator */} +
+ {/* Profiles Only Setting */}
{ defaultAIProfileId: state.defaultAIProfileId, muteDoneSound: state.muteDoneSound, enhancementModel: state.enhancementModel, + validationModel: state.validationModel, keyboardShortcuts: state.keyboardShortcuts, aiProfiles: state.aiProfiles, projects: state.projects, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index f5b3e922..698f915e 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,8 +1,31 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from '@/types/electron'; import type { ClaudeUsageResponse } from '@/store/app-store'; +import type { + IssueValidationVerdict, + IssueValidationConfidence, + IssueComplexity, + IssueValidationInput, + IssueValidationResult, + IssueValidationResponse, + IssueValidationEvent, + StoredValidation, + AgentModel, +} from '@automaker/types'; import { getJSON, setJSON, removeItem } from './storage'; +// Re-export issue validation types for use in components +export type { + IssueValidationVerdict, + IssueValidationConfidence, + IssueComplexity, + IssueValidationInput, + IssueValidationResult, + IssueValidationResponse, + IssueValidationEvent, + StoredValidation, +}; + export interface FileEntry { name: string; isDirectory: boolean; @@ -100,6 +123,19 @@ export interface GitHubLabel { export interface GitHubAuthor { login: string; + avatarUrl?: string; +} + +export interface GitHubAssignee { + login: string; + avatarUrl?: string; +} + +export interface LinkedPullRequest { + number: number; + title: string; + state: string; + url: string; } export interface GitHubIssue { @@ -111,6 +147,8 @@ export interface GitHubIssue { labels: GitHubLabel[]; url: string; body: string; + assignees: GitHubAssignee[]; + linkedPRs?: LinkedPullRequest[]; } export interface GitHubPR { @@ -156,6 +194,46 @@ export interface GitHubAPI { mergedPRs?: GitHubPR[]; error?: string; }>; + /** Start async validation of a GitHub issue */ + validateIssue: ( + projectPath: string, + issue: IssueValidationInput, + model?: AgentModel + ) => Promise<{ success: boolean; message?: string; issueNumber?: number; error?: string }>; + /** Check validation status for an issue or all issues */ + getValidationStatus: ( + projectPath: string, + issueNumber?: number + ) => Promise<{ + success: boolean; + isRunning?: boolean; + startedAt?: string; + runningIssues?: number[]; + error?: string; + }>; + /** Stop a running validation */ + stopValidation: ( + projectPath: string, + issueNumber: number + ) => Promise<{ success: boolean; message?: string; error?: string }>; + /** Get stored validations for a project */ + getValidations: ( + projectPath: string, + issueNumber?: number + ) => Promise<{ + success: boolean; + validation?: StoredValidation | null; + validations?: StoredValidation[]; + isStale?: boolean; + error?: string; + }>; + /** Mark a validation as viewed by the user */ + markValidationViewed: ( + projectPath: string, + issueNumber: number + ) => Promise<{ success: boolean; error?: string }>; + /** Subscribe to validation events */ + onValidationEvent: (callback: (event: IssueValidationEvent) => void) => () => void; } // Feature Suggestions types @@ -2603,6 +2681,8 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI { } // Mock GitHub API implementation +let mockValidationCallbacks: ((event: IssueValidationEvent) => void)[] = []; + function createMockGitHubAPI(): GitHubAPI { return { checkRemote: async (projectPath: string) => { @@ -2631,6 +2711,81 @@ function createMockGitHubAPI(): GitHubAPI { mergedPRs: [], }; }, + validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: AgentModel) => { + console.log('[Mock] Starting async validation:', { projectPath, issue, model }); + + // Simulate async validation in background + setTimeout(() => { + mockValidationCallbacks.forEach((cb) => + cb({ + type: 'issue_validation_start', + issueNumber: issue.issueNumber, + issueTitle: issue.issueTitle, + projectPath, + }) + ); + + setTimeout(() => { + mockValidationCallbacks.forEach((cb) => + cb({ + type: 'issue_validation_complete', + issueNumber: issue.issueNumber, + issueTitle: issue.issueTitle, + result: { + verdict: 'valid' as const, + confidence: 'medium' as const, + reasoning: + 'This is a mock validation. In production, Claude SDK would analyze the codebase to validate this issue.', + relatedFiles: ['src/components/example.tsx'], + estimatedComplexity: 'moderate' as const, + }, + projectPath, + model: model || 'sonnet', + }) + ); + }, 2000); + }, 100); + + return { + success: true, + message: `Validation started for issue #${issue.issueNumber}`, + issueNumber: issue.issueNumber, + }; + }, + getValidationStatus: async (projectPath: string, issueNumber?: number) => { + console.log('[Mock] Getting validation status:', { projectPath, issueNumber }); + return { + success: true, + isRunning: false, + runningIssues: [], + }; + }, + stopValidation: async (projectPath: string, issueNumber: number) => { + console.log('[Mock] Stopping validation:', { projectPath, issueNumber }); + return { + success: true, + message: `Validation for issue #${issueNumber} stopped`, + }; + }, + getValidations: async (projectPath: string, issueNumber?: number) => { + console.log('[Mock] Getting validations:', { projectPath, issueNumber }); + return { + success: true, + validations: [], + }; + }, + markValidationViewed: async (projectPath: string, issueNumber: number) => { + console.log('[Mock] Marking validation as viewed:', { projectPath, issueNumber }); + return { + success: true, + }; + }, + onValidationEvent: (callback: (event: IssueValidationEvent) => void) => { + mockValidationCallbacks.push(callback); + return () => { + mockValidationCallbacks = mockValidationCallbacks.filter((cb) => cb !== callback); + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 00b96d6b..24bbe104 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -24,6 +24,8 @@ import type { GitHubAPI, GitHubIssue, GitHubPR, + IssueValidationInput, + IssueValidationEvent, } from './electron'; import type { Message, SessionListItem } from '@/types/electron'; import type { Feature, ClaudeUsageResponse } from '@/store/app-store'; @@ -51,7 +53,8 @@ type EventType = | 'agent:stream' | 'auto-mode:event' | 'suggestions:event' - | 'spec-regeneration:event'; + | 'spec-regeneration:event' + | 'issue-validation:event'; type EventCallback = (payload: unknown) => void; @@ -751,6 +754,18 @@ export class HttpApiClient implements ElectronAPI { checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }), listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }), listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }), + validateIssue: (projectPath: string, issue: IssueValidationInput, model?: string) => + this.post('/api/github/validate-issue', { projectPath, ...issue, model }), + getValidationStatus: (projectPath: string, issueNumber?: number) => + this.post('/api/github/validation-status', { projectPath, issueNumber }), + stopValidation: (projectPath: string, issueNumber: number) => + this.post('/api/github/validation-stop', { projectPath, issueNumber }), + getValidations: (projectPath: string, issueNumber?: number) => + this.post('/api/github/validations', { projectPath, issueNumber }), + markValidationViewed: (projectPath: string, issueNumber: number) => + this.post('/api/github/validation-mark-viewed', { projectPath, issueNumber }), + onValidationEvent: (callback: (event: IssueValidationEvent) => void) => + this.subscribeToEvent('issue-validation:event', callback as EventCallback), }; // Workspace API diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 80ad7019..978d67cc 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -475,6 +475,9 @@ export interface AppState { // Enhancement Model Settings enhancementModel: AgentModel; // Model used for feature enhancement (default: sonnet) + // Validation Model Settings + validationModel: AgentModel; // Model used for GitHub issue validation (default: opus) + // Project Analysis projectAnalysis: ProjectAnalysis | null; isAnalyzing: boolean; @@ -745,6 +748,9 @@ export interface AppActions { // Enhancement Model actions setEnhancementModel: (model: AgentModel) => void; + // Validation Model actions + setValidationModel: (model: AgentModel) => void; + // AI Profile actions addAIProfile: (profile: Omit) => void; updateAIProfile: (id: string, updates: Partial) => void; @@ -915,6 +921,7 @@ const initialState: AppState = { keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts muteDoneSound: false, // Default to sound enabled (not muted) enhancementModel: 'sonnet', // Default to sonnet for feature enhancement + validationModel: 'opus', // Default to opus for GitHub issue validation aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, isAnalyzing: false, @@ -1537,6 +1544,9 @@ export const useAppStore = create()( // Enhancement Model actions setEnhancementModel: (model) => set({ enhancementModel: model }), + // Validation Model actions + setValidationModel: (model) => set({ validationModel: model }), + // AI Profile actions addAIProfile: (profile) => { const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -2679,6 +2689,7 @@ export const useAppStore = create()( keyboardShortcuts: state.keyboardShortcuts, muteDoneSound: state.muteDoneSound, enhancementModel: state.enhancementModel, + validationModel: state.validationModel, // Profiles and sessions aiProfiles: state.aiProfiles, chatSessions: state.chatSessions, diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index ae4cc0d8..eba84101 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -13,6 +13,9 @@ export { getImagesDir, getContextDir, getWorktreesDir, + getValidationsDir, + getValidationDir, + getValidationPath, getAppSpecPath, getBranchTrackingPath, ensureAutomakerDir, diff --git a/libs/platform/src/paths.ts b/libs/platform/src/paths.ts index da1fa517..6fea2200 100644 --- a/libs/platform/src/paths.ts +++ b/libs/platform/src/paths.ts @@ -111,6 +111,44 @@ export function getWorktreesDir(projectPath: string): string { return path.join(getAutomakerDir(projectPath), 'worktrees'); } +/** + * Get the validations directory for a project + * + * Stores GitHub issue validation results, organized by issue number. + * + * @param projectPath - Absolute path to project directory + * @returns Absolute path to {projectPath}/.automaker/validations + */ +export function getValidationsDir(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), 'validations'); +} + +/** + * Get the directory for a specific issue validation + * + * Contains validation result and metadata for a GitHub issue. + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns Absolute path to {projectPath}/.automaker/validations/{issueNumber} + */ +export function getValidationDir(projectPath: string, issueNumber: number): string { + return path.join(getValidationsDir(projectPath), String(issueNumber)); +} + +/** + * Get the validation result file path for a GitHub issue + * + * Stores the JSON validation result including verdict, analysis, and metadata. + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns Absolute path to {projectPath}/.automaker/validations/{issueNumber}/validation.json + */ +export function getValidationPath(projectPath: string, issueNumber: number): string { + return path.join(getValidationDir(projectPath, issueNumber), 'validation.json'); +} + /** * Get the app spec file path for a project * diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts index a455a6d8..1581841d 100644 --- a/libs/types/src/event.ts +++ b/libs/types/src/event.ts @@ -24,6 +24,7 @@ export type EventType = | 'project:analysis-completed' | 'project:analysis-error' | 'suggestions:event' - | 'spec-regeneration:event'; + | 'spec-regeneration:event' + | 'issue-validation:event'; export type EventCallback = (type: EventType, payload: unknown) => void; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 6e173075..2e74d372 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -81,3 +81,17 @@ export { THINKING_LEVEL_LABELS, getModelDisplayName, } from './model-display.js'; + +// Issue validation types +export type { + IssueValidationVerdict, + IssueValidationConfidence, + IssueComplexity, + IssueValidationInput, + IssueValidationRequest, + IssueValidationResult, + IssueValidationResponse, + IssueValidationErrorResponse, + IssueValidationEvent, + StoredValidation, +} from './issue-validation.js'; diff --git a/libs/types/src/issue-validation.ts b/libs/types/src/issue-validation.ts new file mode 100644 index 00000000..2c0d2f64 --- /dev/null +++ b/libs/types/src/issue-validation.ts @@ -0,0 +1,135 @@ +/** + * Issue Validation Types + * + * Types for validating GitHub issues against the codebase using Claude SDK. + */ + +import type { AgentModel } from './model.js'; + +/** + * Verdict from issue validation + */ +export type IssueValidationVerdict = 'valid' | 'invalid' | 'needs_clarification'; + +/** + * Confidence level of the validation + */ +export type IssueValidationConfidence = 'high' | 'medium' | 'low'; + +/** + * Complexity estimation for valid issues + */ +export type IssueComplexity = 'trivial' | 'simple' | 'moderate' | 'complex' | 'very_complex'; + +/** + * Issue data for validation (without projectPath) + * Used by UI when calling the validation API + */ +export interface IssueValidationInput { + issueNumber: number; + issueTitle: string; + issueBody: string; + issueLabels?: string[]; +} + +/** + * Full request payload for issue validation endpoint + * Includes projectPath for server-side handling + */ +export interface IssueValidationRequest extends IssueValidationInput { + projectPath: string; +} + +/** + * Result from Claude's issue validation analysis + */ +export interface IssueValidationResult { + /** Whether the issue is valid, invalid, or needs clarification */ + verdict: IssueValidationVerdict; + /** How confident the AI is in its assessment */ + confidence: IssueValidationConfidence; + /** Detailed explanation of the verdict */ + reasoning: string; + /** For bug reports: whether the bug was confirmed in the codebase */ + bugConfirmed?: boolean; + /** Files related to the issue found during analysis */ + relatedFiles?: string[]; + /** Suggested approach to fix or implement */ + suggestedFix?: string; + /** Information that's missing and needed for validation (when verdict = needs_clarification) */ + missingInfo?: string[]; + /** Estimated effort to address the issue */ + estimatedComplexity?: IssueComplexity; +} + +/** + * Successful response from validate-issue endpoint + */ +export interface IssueValidationResponse { + success: true; + issueNumber: number; + validation: IssueValidationResult; +} + +/** + * Error response from validate-issue endpoint + */ +export interface IssueValidationErrorResponse { + success: false; + error: string; +} + +/** + * Events emitted during async issue validation + */ +export type IssueValidationEvent = + | { + type: 'issue_validation_start'; + issueNumber: number; + issueTitle: string; + projectPath: string; + } + | { + type: 'issue_validation_progress'; + issueNumber: number; + content: string; + projectPath: string; + } + | { + type: 'issue_validation_complete'; + issueNumber: number; + issueTitle: string; + result: IssueValidationResult; + projectPath: string; + /** Model used for validation (opus, sonnet, haiku) */ + model: AgentModel; + } + | { + type: 'issue_validation_error'; + issueNumber: number; + error: string; + projectPath: string; + } + | { + type: 'issue_validation_viewed'; + issueNumber: number; + projectPath: string; + }; + +/** + * Stored validation data with metadata for cache + */ +export interface StoredValidation { + /** GitHub issue number */ + issueNumber: number; + /** Issue title at time of validation */ + issueTitle: string; + /** ISO timestamp when validation was performed */ + validatedAt: string; + /** Model used for validation (opus, sonnet, haiku) */ + model: AgentModel; + /** The validation result */ + result: IssueValidationResult; + /** ISO timestamp when user viewed this validation (undefined = not yet viewed) */ + viewedAt?: string; +} diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 9bb96644..e18c2987 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -261,6 +261,8 @@ export interface GlobalSettings { // AI Model Selection /** Which model to use for feature name/description enhancement */ enhancementModel: AgentModel; + /** Which model to use for GitHub issue validation */ + validationModel: AgentModel; // Input Configuration /** User's keyboard shortcut bindings */ @@ -437,6 +439,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { defaultAIProfileId: null, muteDoneSound: false, enhancementModel: 'sonnet', + validationModel: 'opus', keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, aiProfiles: [], projects: [],