build: replace tslint with Biome for code quality

- Add @biomejs/biome as dev dependency
- Remove deprecated tslint dependency
- Add biome.json with project-specific rules
- Update lint script to use Biome
- Apply Biome auto-fixes across codebase
This commit is contained in:
jeffusion
2026-03-03 17:03:23 +08:00
parent 6f389fc1a9
commit 318e6d3688
43 changed files with 1005 additions and 619 deletions

50
biome.json Normal file
View File

@@ -0,0 +1,50 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "warn",
"noUnusedVariables": "warn"
},
"style": {
"noNonNullAssertion": "off",
"useImportType": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noExportsInTest": "off",
"noImplicitAnyLet": "off"
},
"complexity": {
"noForEach": "off"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always",
"trailingCommas": "es5"
}
},
"files": {
"ignore": [
"node_modules",
"dist",
"frontend",
"*.json",
"*.md"
]
}
}

View File

@@ -15,18 +15,32 @@
"zod-to-json-schema": "^3.25.1",
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.13.10",
"concurrently": "^9.2.1",
"tslint": "^6.1.3",
"typescript": "^5.8.2",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ=="],
@@ -50,18 +64,10 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.8.3", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"builtin-modules": ["builtin-modules@1.1.1", "", {}, "sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -74,16 +80,10 @@
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -100,10 +100,6 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
@@ -114,8 +110,6 @@
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@@ -124,8 +118,6 @@
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@@ -140,18 +132,8 @@
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -160,59 +142,33 @@
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openai": ["openai@4.87.3", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-d2D54fzMuBYTxMW8wcNmhT1rYKcTfMJ8t+4KjH2KtvYenygITiGBgHoIrzHwnDQWW+C5oCA+ikIR2jgPCFqcKQ=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"tslint": ["tslint@6.1.3", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "builtin-modules": "^1.1.1", "chalk": "^2.3.0", "commander": "^2.12.1", "diff": "^4.0.1", "glob": "^7.1.1", "js-yaml": "^3.13.1", "minimatch": "^3.0.4", "mkdirp": "^0.5.3", "resolve": "^1.3.2", "semver": "^5.3.0", "tslib": "^1.13.0", "tsutils": "^2.29.0" }, "peerDependencies": { "typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev || >= 4.0.0-dev" }, "bin": { "tslint": "bin/tslint" } }, "sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg=="],
"tsutils": ["tsutils@2.29.0", "", { "dependencies": { "tslib": "^1.8.1" }, "peerDependencies": { "typescript": ">=2.1.0 || >=2.1.0-dev || >=2.2.0-dev || >=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >= 3.0.0-dev || >= 3.1.0-dev" } }, "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
@@ -228,8 +184,6 @@
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -246,22 +200,8 @@
"openai/@types/node": ["@types/node@18.19.80", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ=="],
"rxjs/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tslint/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"tslint/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"tslint/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
"tslint/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"tslint/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
"tslint/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
}
}

View File

@@ -17,10 +17,10 @@
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.13.10",
"concurrently": "^9.2.1",
"tslint": "^6.1.3",
"typescript": "^5.8.2"
},
"files": [
@@ -35,7 +35,7 @@
"build": "rm -rf dist && tsc",
"start": "bun run src/index.ts",
"start:prod": "bun run dist/index.js",
"lint": "tslint -c tslint.json src/**/*.ts",
"lint": "biome check src/",
"test": "bun test"
},
"keywords": [

View File

@@ -11,27 +11,48 @@ declare module 'bun:test' {
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { unlink, readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { readFile, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { AppConfig } from '../config-manager';
// ── All env keys in the Zod schema ──────────────────────────────────────────
const SCHEMA_KEYS = [
'GITEA_API_URL', 'GITEA_ACCESS_TOKEN', 'GITEA_ADMIN_TOKEN',
'OPENAI_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_MODEL',
'CUSTOM_SUMMARY_PROMPT', 'CUSTOM_LINE_COMMENT_PROMPT',
'FEISHU_WEBHOOK_URL', 'FEISHU_WEBHOOK_SECRET',
'PORT', 'WEBHOOK_SECRET', 'ADMIN_PASSWORD', 'JWT_SECRET',
'REVIEW_ENGINE', 'REVIEW_WORKDIR', 'REVIEW_MODEL_PLANNER',
'REVIEW_MODEL_SPECIALIST', 'REVIEW_MODEL_JUDGE',
'REVIEW_MAX_PARALLEL_RUNS', 'REVIEW_MAX_FILES_PER_RUN',
'REVIEW_MAX_FILE_CONTENT_CHARS', 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
'REVIEW_ENABLE_HUMAN_GATE', 'REVIEW_ALLOWED_COMMANDS', 'REVIEW_COMMAND_TIMEOUT_MS',
'QDRANT_URL', 'ENABLE_MEMORY', 'FEW_SHOT_EXAMPLES_COUNT',
'ENABLE_REFLECTION', 'MAX_REFLECTION_ROUNDS', 'ENABLE_DEBATE', 'DEBATE_THRESHOLD',
'GITEA_API_URL',
'GITEA_ACCESS_TOKEN',
'GITEA_ADMIN_TOKEN',
'OPENAI_BASE_URL',
'OPENAI_API_KEY',
'OPENAI_MODEL',
'CUSTOM_SUMMARY_PROMPT',
'CUSTOM_LINE_COMMENT_PROMPT',
'FEISHU_WEBHOOK_URL',
'FEISHU_WEBHOOK_SECRET',
'PORT',
'WEBHOOK_SECRET',
'ADMIN_PASSWORD',
'JWT_SECRET',
'REVIEW_ENGINE',
'REVIEW_WORKDIR',
'REVIEW_MODEL_PLANNER',
'REVIEW_MODEL_SPECIALIST',
'REVIEW_MODEL_JUDGE',
'REVIEW_MAX_PARALLEL_RUNS',
'REVIEW_MAX_FILES_PER_RUN',
'REVIEW_MAX_FILE_CONTENT_CHARS',
'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
'REVIEW_ENABLE_HUMAN_GATE',
'REVIEW_ALLOWED_COMMANDS',
'REVIEW_COMMAND_TIMEOUT_MS',
'QDRANT_URL',
'ENABLE_MEMORY',
'FEW_SHOT_EXAMPLES_COUNT',
'ENABLE_REFLECTION',
'MAX_REFLECTION_ROUNDS',
'ENABLE_DEBATE',
'DEBATE_THRESHOLD',
] as const;
const CONTROL_KEYS = ['CONFIG_OVERRIDES_PATH', 'NODE_ENV'] as const;
@@ -80,7 +101,11 @@ describe('ConfigManager', () => {
process.env[key] = savedEnv[key]!;
}
}
try { await unlink(tmpPath); } catch { /* ok if missing */ }
try {
await unlink(tmpPath);
} catch {
/* ok if missing */
}
});
// ─── 1. Layering: defaults < env < override ─────────────────────────

View File

@@ -8,12 +8,12 @@
* Bun-friendly IO: reads via readFile, writes atomically via temp+rename.
*/
import { z } from 'zod';
import { dirname, resolve } from 'node:path';
import { rename, mkdir, writeFile, readFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { config as dotenvConfig } from 'dotenv';
import { z } from 'zod';
// Load .env before any process.env access (must precede singleton construction)
dotenvConfig();
@@ -79,7 +79,7 @@ const envSchema = z.object({
// Memory & learning
QDRANT_URL: z.preprocess(
(val) => (typeof val === 'string' && val.trim() === '' ? undefined : val),
z.string().url().optional(),
z.string().url().optional()
),
ENABLE_MEMORY: z
.enum(['true', 'false'])
@@ -204,7 +204,6 @@ const DEV_FALLBACK_CONFIG: AppConfig = {
},
};
// ---------------------------------------------------------------------------
// ConfigManager
// ---------------------------------------------------------------------------
@@ -394,7 +393,6 @@ class ConfigManager {
}
return 'default';
}
}
// ---------------------------------------------------------------------------

View File

@@ -193,7 +193,8 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
envKey: 'WEBHOOK_SECRET',
group: 'app',
label: 'Webhook 密钥',
description: '用于验证 Gitea Webhook 请求来源的 HMAC 密钥,修改需通过 .env 配置并同步更新 Gitea',
description:
'用于验证 Gitea Webhook 请求来源的 HMAC 密钥,修改需通过 .env 配置并同步更新 Gitea',
type: 'string',
sensitive: true,
readonly: true,

View File

@@ -1,9 +1,9 @@
import { Hono } from 'hono';
import { sign } from 'hono/jwt';
import config from '../config';
import { reviewEngine } from '../review/engine';
import { giteaService } from '../services/gitea';
import { logger } from '../utils/logger';
import { reviewEngine } from '../review/engine';
const publicRoutes = new Hono();
const protectedRoutes = new Hono();
@@ -26,13 +26,12 @@ publicRoutes.post('/login', async (c) => {
return c.json({ message: 'Invalid credentials' }, 401);
});
// --- Protected Routes ---
// 获取仓库列表及 Webhook 状态
protectedRoutes.get('/repositories', async (c) => {
try {
const page = parseInt(c.req.query('page') || '1', 10);
const page = Number.parseInt(c.req.query('page') || '1', 10);
const query = c.req.query('q');
const limit = 30; // 每页数量固定,或也可从查询参数获取
@@ -43,7 +42,7 @@ protectedRoutes.get('/repositories', async (c) => {
repos.map(async (repo) => {
const [owner, repoName] = repo.full_name.split('/');
const hooks = await giteaService.listWebhooks(owner, repoName);
const webhook = hooks.find(h => h.config.url === webhookUrl);
const webhook = hooks.find((h) => h.config.url === webhookUrl);
return {
name: repo.full_name,
webhook_status: webhook ? 'active' : 'inactive',
@@ -67,7 +66,7 @@ protectedRoutes.get('/repositories', async (c) => {
// 创建 Webhook
protectedRoutes.post('/repositories/:owner/:repo/webhook', async (c) => {
const { owner, repo } = c.req.param();
const webhookUrl = new URL(c.req.url).origin + '/webhook/gitea';
const webhookUrl = `${new URL(c.req.url).origin}/webhook/gitea`;
try {
await giteaService.createWebhook(owner, repo, webhookUrl);
@@ -83,7 +82,7 @@ protectedRoutes.delete('/repositories/:owner/:repo/webhook/:hookId', async (c) =
const { owner, repo, hookId } = c.req.param();
try {
await giteaService.deleteWebhook(owner, repo, parseInt(hookId, 10));
await giteaService.deleteWebhook(owner, repo, Number.parseInt(hookId, 10));
return c.json({ success: true });
} catch (error: any) {
logger.error(`删除 ${owner}/${repo} 的 Webhook 失败:`, error);
@@ -94,7 +93,7 @@ protectedRoutes.delete('/repositories/:owner/:repo/webhook/:hookId', async (c) =
// 查询审查任务
protectedRoutes.get('/review/runs', async (c) => {
try {
const limit = parseInt(c.req.query('limit') || '50', 10);
const limit = Number.parseInt(c.req.query('limit') || '50', 10);
const runs = await reviewEngine.listRuns(limit);
return c.json({ data: runs });
} catch (error: any) {

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono';
import { configManager, type AppConfig } from '../config/config-manager';
import { type AppConfig, configManager } from '../config/config-manager';
import { CONFIG_FIELDS, CONFIG_GROUPS, type ConfigFieldMeta } from '../config/config-schema';
import { logger } from '../utils/logger';
@@ -19,9 +19,7 @@ const INTEGER_FIELDS = new Set([
]);
/** Fast lookup from envKey → field metadata. */
const FIELDS_MAP = new Map<string, ConfigFieldMeta>(
CONFIG_FIELDS.map((f) => [f.envKey, f]),
);
const FIELDS_MAP = new Map<string, ConfigFieldMeta>(CONFIG_FIELDS.map((f) => [f.envKey, f]));
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -31,50 +29,84 @@ const FIELDS_MAP = new Map<string, ConfigFieldMeta>(
*/
function getEffectiveValue(
envKey: string,
current: AppConfig,
current: AppConfig
): string | number | boolean | undefined {
switch (envKey) {
// Gitea
case 'GITEA_API_URL': return current.gitea.apiUrl;
case 'GITEA_ACCESS_TOKEN': return current.gitea.accessToken;
case 'GITEA_ADMIN_TOKEN': return current.admin.giteaAdminToken;
case 'GITEA_API_URL':
return current.gitea.apiUrl;
case 'GITEA_ACCESS_TOKEN':
return current.gitea.accessToken;
case 'GITEA_ADMIN_TOKEN':
return current.admin.giteaAdminToken;
// OpenAI
case 'OPENAI_BASE_URL': return current.openai.baseUrl;
case 'OPENAI_API_KEY': return current.openai.apiKey;
case 'OPENAI_MODEL': return current.openai.model;
case 'CUSTOM_SUMMARY_PROMPT': return current.openai.customSummaryPrompt;
case 'CUSTOM_LINE_COMMENT_PROMPT': return current.openai.customLineCommentPrompt;
case 'OPENAI_BASE_URL':
return current.openai.baseUrl;
case 'OPENAI_API_KEY':
return current.openai.apiKey;
case 'OPENAI_MODEL':
return current.openai.model;
case 'CUSTOM_SUMMARY_PROMPT':
return current.openai.customSummaryPrompt;
case 'CUSTOM_LINE_COMMENT_PROMPT':
return current.openai.customLineCommentPrompt;
// Feishu
case 'FEISHU_WEBHOOK_URL': return current.feishu.webhookUrl;
case 'FEISHU_WEBHOOK_SECRET': return current.feishu.webhookSecret;
case 'FEISHU_WEBHOOK_URL':
return current.feishu.webhookUrl;
case 'FEISHU_WEBHOOK_SECRET':
return current.feishu.webhookSecret;
// App
case 'PORT': return current.app.port;
case 'WEBHOOK_SECRET': return current.app.webhookSecret;
case 'PORT':
return current.app.port;
case 'WEBHOOK_SECRET':
return current.app.webhookSecret;
// Admin
case 'ADMIN_PASSWORD': return current.admin.password;
case 'JWT_SECRET': return current.admin.jwtSecret;
case 'ADMIN_PASSWORD':
return current.admin.password;
case 'JWT_SECRET':
return current.admin.jwtSecret;
// Review
case 'REVIEW_ENGINE': return current.review.engine;
case 'REVIEW_WORKDIR': return current.review.workdir;
case 'REVIEW_MODEL_PLANNER': return current.review.modelPlanner;
case 'REVIEW_MODEL_SPECIALIST': return current.review.modelSpecialist;
case 'REVIEW_MODEL_JUDGE': return current.review.modelJudge;
case 'REVIEW_MAX_PARALLEL_RUNS': return current.review.maxParallelRuns;
case 'REVIEW_MAX_FILES_PER_RUN': return current.review.maxFilesPerRun;
case 'REVIEW_MAX_FILE_CONTENT_CHARS': return current.review.maxFileContentChars;
case 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE': return current.review.autoPublishMinConfidence;
case 'REVIEW_ENABLE_HUMAN_GATE': return current.review.enableHumanGate;
case 'REVIEW_ALLOWED_COMMANDS': return current.review.allowedCommands.join(',');
case 'REVIEW_COMMAND_TIMEOUT_MS': return current.review.commandTimeoutMs;
case 'REVIEW_ENGINE':
return current.review.engine;
case 'REVIEW_WORKDIR':
return current.review.workdir;
case 'REVIEW_MODEL_PLANNER':
return current.review.modelPlanner;
case 'REVIEW_MODEL_SPECIALIST':
return current.review.modelSpecialist;
case 'REVIEW_MODEL_JUDGE':
return current.review.modelJudge;
case 'REVIEW_MAX_PARALLEL_RUNS':
return current.review.maxParallelRuns;
case 'REVIEW_MAX_FILES_PER_RUN':
return current.review.maxFilesPerRun;
case 'REVIEW_MAX_FILE_CONTENT_CHARS':
return current.review.maxFileContentChars;
case 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE':
return current.review.autoPublishMinConfidence;
case 'REVIEW_ENABLE_HUMAN_GATE':
return current.review.enableHumanGate;
case 'REVIEW_ALLOWED_COMMANDS':
return current.review.allowedCommands.join(',');
case 'REVIEW_COMMAND_TIMEOUT_MS':
return current.review.commandTimeoutMs;
// Memory
case 'QDRANT_URL': return current.review.qdrantUrl;
case 'ENABLE_MEMORY': return current.review.enableMemory;
case 'FEW_SHOT_EXAMPLES_COUNT': return current.review.fewShotExamplesCount;
case 'ENABLE_REFLECTION': return current.review.enableReflection;
case 'MAX_REFLECTION_ROUNDS': return current.review.maxReflectionRounds;
case 'ENABLE_DEBATE': return current.review.enableDebate;
case 'DEBATE_THRESHOLD': return current.review.debateThreshold;
default: return undefined;
case 'QDRANT_URL':
return current.review.qdrantUrl;
case 'ENABLE_MEMORY':
return current.review.enableMemory;
case 'FEW_SHOT_EXAMPLES_COUNT':
return current.review.fewShotExamplesCount;
case 'ENABLE_REFLECTION':
return current.review.enableReflection;
case 'MAX_REFLECTION_ROUNDS':
return current.review.maxReflectionRounds;
case 'ENABLE_DEBATE':
return current.review.enableDebate;
case 'DEBATE_THRESHOLD':
return current.review.debateThreshold;
default:
return undefined;
}
}
@@ -106,7 +138,7 @@ function validateField(field: ConfigFieldMeta, key: string, value: string): stri
}
case 'number': {
const num = Number(value);
if (isNaN(num)) {
if (Number.isNaN(num)) {
return `${field.label}${key})必须是有效的数字`;
}
if (INTEGER_FIELDS.has(key) && !Number.isInteger(num)) {
@@ -255,7 +287,7 @@ configRouter.post('/reset', async (c) => {
if (unknownKeys.length > 0) {
return c.json(
{ message: '保存配置失败', error: `未知配置项: ${unknownKeys.join(', ')}` },
400,
400
);
}

View File

@@ -1,12 +1,12 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { FileReviewStore } from '../review/store/file-review-store';
import { VectorMemoryStore } from '../review/memory/vector-store';
import { LearningSystem } from '../review/learning/learning-system';
import { giteaService } from '../services/gitea';
import config from '../config';
import { Hono } from 'hono';
import OpenAI from 'openai';
import { z } from 'zod';
import config from '../config';
import { LearningSystem } from '../review/learning/learning-system';
import { VectorMemoryStore } from '../review/memory/vector-store';
import { FileReviewStore } from '../review/store/file-review-store';
import { giteaService } from '../services/gitea';
const feedbackRouter = new Hono();
@@ -68,7 +68,10 @@ feedbackRouter.post(
// 原子幂等性保护先标记finding为published原子check-and-set
// 只有第一个请求会得到true后续并发/重试请求会得到false
// 这解决了read-check-write竞态两个并发请求不会都发布评论
const wasUnpublished = await reviewStore.markFindingPublished(finding.runId, finding.fingerprint);
const wasUnpublished = await reviewStore.markFindingPublished(
finding.runId,
finding.fingerprint
);
if (!wasUnpublished) {
// finding已被标记为published但需验证是否真的发布成功
@@ -76,7 +79,7 @@ feedbackRouter.post(
// 检查是否存在已发布的comment记录来确认真实状态
// 关键必须通过fingerprint匹配而非仅path+line以区分同一位置的不同findings
const publishedComment = runDetails.comments.find(
c => c.status === 'published' && c.fingerprint === finding.fingerprint
(c) => c.status === 'published' && c.fingerprint === finding.fingerprint
);
if (publishedComment) {
@@ -88,15 +91,17 @@ feedbackRouter.post(
learningApplied: false,
published: true,
});
} else {
}
// published标记存在但无published comment记录
// 可能原因1) 并发请求正在发布中 2) 之前发布失败并回滚
// 不能声称成功,返回错误让用户稍后重试
return c.json({
return c.json(
{
error: 'Finding approval in progress or previously failed. Please retry in a moment.',
inProgress: true,
}, 409); // 409 Conflict
}
},
409
); // 409 Conflict
}
// 以下代码只会被第一个请求执行wasUnpublished=true
@@ -111,7 +116,12 @@ feedbackRouter.post(
if (approved) {
await learningSystem.learnFromApproval(finding, owner, repo);
} else {
await learningSystem.learnFromFalsePositive(finding, reason || '人工标记为误报', owner, repo);
await learningSystem.learnFromFalsePositive(
finding,
reason || '人工标记为误报',
owner,
repo
);
}
learningApplied = true;
@@ -146,19 +156,9 @@ _此问题已通过人工审批确认_`;
// 2. 再写本地record失败不回滚因为Gitea已成功重试不应重复发布
try {
if (runDetails.run.eventType === 'pull_request' && runDetails.run.prNumber) {
await giteaService.addPullRequestComment(
owner,
repo,
runDetails.run.prNumber,
comment
);
await giteaService.addPullRequestComment(owner, repo, runDetails.run.prNumber, comment);
} else if (runDetails.run.commitSha) {
await giteaService.addCommitComment(
owner,
repo,
runDetails.run.commitSha,
comment
);
await giteaService.addCommitComment(owner, repo, runDetails.run.commitSha, comment);
}
} catch (giteaError) {
// Gitea API失败回滚published状态允许用户重试发布
@@ -181,7 +181,10 @@ _此问题已通过人工审批确认_`;
} catch (storeError) {
// 本地store失败回滚published标记允许用户重试
// 如果用户立即重试可能导致重复Gitea评论可接受的权衡以避免永久卡死
console.error('Failed to persist comment record after successful Gitea publish, rolling back:', storeError);
console.error(
'Failed to persist comment record after successful Gitea publish, rolling back:',
storeError
);
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
throw new Error(
'Comment published to Gitea but failed to save locally. State rolled back, you may retry. Note: immediate retry may create duplicate comments.'

View File

@@ -1,11 +1,11 @@
import * as crypto from 'node:crypto';
import { Context } from 'hono';
import { map } from 'lodash-es';
import { giteaService, PullRequestFile, PullRequestDetails } from '../services/gitea';
import { aiReviewService } from '../services/ai-review';
import { feishuService } from '../services/feishu';
import config from '../config';
import { reviewEngine } from '../review/engine';
import * as crypto from 'crypto';
import { aiReviewService } from '../services/ai-review';
import { feishuService } from '../services/feishu';
import { PullRequestDetails, PullRequestFile, giteaService } from '../services/gitea';
import { logger } from '../utils/logger';
// 判断是否为开发环境
@@ -16,7 +16,7 @@ enum GiteaEventType {
PullRequest = 'pull_request',
Status = 'status',
Issue = 'issues',
Unknown = 'unknown'
Unknown = 'unknown',
}
/**
@@ -48,10 +48,7 @@ function verifyWebhookSignature(body: string, signature: string): boolean {
// Gitea的签名没有前缀直接比较
try {
// 使用timingSafeEqual进行常量时间比较防止时序攻击
return crypto.timingSafeEqual(
Buffer.from(calculatedSignature),
Buffer.from(signature)
);
return crypto.timingSafeEqual(Buffer.from(calculatedSignature), Buffer.from(signature));
} catch (error) {
logger.error('签名验证失败', error);
return false;
@@ -108,10 +105,7 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
}
// 从事件中提取必要信息
const {
pull_request: pullRequest,
repository: repo
} = body;
const { pull_request: pullRequest, repository: repo } = body;
if (!pullRequest || !repo) {
return c.json({ error: '无效的Webhook数据' }, 400);
@@ -123,18 +117,21 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
const prTitle = pullRequest.title;
const prUrl = pullRequest.html_url;
logger.info(`收到PR事件`, { owner, repo: repoName, prNumber, action: body.action });
logger.info('收到PR事件', { owner, repo: repoName, prNumber, action: body.action });
// 处理PR审阅者通知
try {
// 获取PR的审阅者列表
const reviewerUsernames = map(pullRequest.requested_reviewers, reviewer => reviewer.full_name || reviewer.login);
const reviewerUsernames = map(
pullRequest.requested_reviewers,
(reviewer) => reviewer.full_name || reviewer.login
);
// 记录审阅者信息
if (reviewerUsernames.length > 0) {
logger.info(`PR有指定审阅者`, {
logger.info('PR有指定审阅者', {
prNumber,
reviewers: reviewerUsernames.join(',')
reviewers: reviewerUsernames.join(','),
});
}
@@ -145,13 +142,16 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
// 处理审阅者指派事件
if (body.action === 'review_requested' && body.requested_reviewer) {
const newReviewerUsername = body.requested_reviewer.full_name || body.requested_reviewer.login;
const newReviewerUsername =
body.requested_reviewer.full_name || body.requested_reviewer.login;
if (newReviewerUsername) {
await feishuService.sendPrReviewerAssignedNotification(prTitle, prUrl, [newReviewerUsername]);
await feishuService.sendPrReviewerAssignedNotification(prTitle, prUrl, [
newReviewerUsername,
]);
}
}
} catch (error) {
logger.error(`处理PR审阅者通知失败:`, error);
logger.error('处理PR审阅者通知失败:', error);
// 继续执行代码审查流程,不因通知失败而中断
}
@@ -165,7 +165,9 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
}
// 检测fork PRhead.repo存在且与base repo不同
const headCloneUrl = pullRequest.head?.repo ? resolveCloneUrl(pullRequest.head.repo) : undefined;
const headCloneUrl = pullRequest.head?.repo
? resolveCloneUrl(pullRequest.head.repo)
: undefined;
const isForkPR = headCloneUrl && headCloneUrl !== baseCloneUrl;
// 包含baseSha以支持retarget场景相同headSha但baseSha变化时需要重新审查
@@ -193,7 +195,7 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
}
// Legacy模式开始异步审查流程
reviewPullRequest(owner, repoName, prNumber).catch(error => {
reviewPullRequest(owner, repoName, prNumber).catch((error) => {
logger.error(`审查PR ${owner}/${repoName}#${prNumber} 失败:`, error);
});
@@ -211,7 +213,7 @@ async function handleCommitStatusEvent(c: Context, body: any): Promise<Response>
sha: body.sha,
commit_id: body.commit?.id,
context: body.context,
repo: body.repository?.full_name
repo: body.repository?.full_name,
});
// 验证请求体中是否包含必要信息
@@ -250,10 +252,10 @@ async function handleCommitStatusEvent(c: Context, body: any): Promise<Response>
message: body.commit.message || '',
added: body.commit.added || [],
removed: body.commit.removed || [],
modified: body.commit.modified || []
modified: body.commit.modified || [],
};
logger.info(`收到提交状态更新事件`, {
logger.info('收到提交状态更新事件', {
owner,
repo: repoName,
commitSha,
@@ -261,7 +263,7 @@ async function handleCommitStatusEvent(c: Context, body: any): Promise<Response>
relatedPR: relatedPR?.number || 'unknown',
added: commitInfo.added.length,
modified: commitInfo.modified.length,
removed: commitInfo.removed.length
removed: commitInfo.removed.length,
});
// Agent模式优先处理从本地仓库派生diff不依赖webhook文件列表
@@ -294,13 +296,17 @@ async function handleCommitStatusEvent(c: Context, body: any): Promise<Response>
}
// Legacy模式需要webhook文件列表
if (commitInfo.added.length === 0 && commitInfo.modified.length === 0 && commitInfo.removed.length === 0) {
if (
commitInfo.added.length === 0 &&
commitInfo.modified.length === 0 &&
commitInfo.removed.length === 0
) {
logger.warn('提交没有文件变更信息,忽略审查', { commitSha });
return c.json({ status: 'ignored', message: '提交没有文件变更信息' }, 200);
}
// 开始异步审查流程传入关联的PR信息
reviewCommit(owner, repoName, commitSha, commitInfo, relatedPR).catch(error => {
reviewCommit(owner, repoName, commitSha, commitInfo, relatedPR).catch((error) => {
logger.error(`审查提交 ${owner}/${repoName}@${commitSha} 失败:`, error);
});
@@ -321,14 +327,17 @@ async function handleIssueEvent(c: Context, body: any): Promise<Response> {
const issueTitle = issue.title;
const issueUrl = issue.html_url;
const creatorUsername = issue.user.full_name || issue.user.login;
const assigneeUsernames = map(issue.assignees, assignee => assignee.full_name || assignee.login);
const assigneeUsernames = map(
issue.assignees,
(assignee) => assignee.full_name || assignee.login
);
logger.info(`收到工单事件`, {
logger.info('收到工单事件', {
action,
issueTitle,
issueUrl,
creatorUsername,
assigneeUsernames: assigneeUsernames.join(',')
assigneeUsernames: assigneeUsernames.join(','),
});
try {
@@ -371,16 +380,16 @@ async function reviewPullRequest(owner: string, repo: string, prNumber: number):
number: prNumber,
title: '测试PR',
head: {
sha: 'abcd1234abcd1234abcd1234abcd1234abcd1234'
sha: 'abcd1234abcd1234abcd1234abcd1234abcd1234',
},
base: {
repo: {
owner: {
login: owner
login: owner,
},
name: repo,
},
},
name: repo
}
}
};
// 测试用diff内容
@@ -404,7 +413,7 @@ index 1234567..abcdefg 100644
// 生产环境中从Gitea获取真实数据
[prDetails, diffContent] = await Promise.all([
giteaService.getPullRequestDetails(owner, repo, prNumber),
giteaService.getPullRequestDiff(owner, repo, prNumber)
giteaService.getPullRequestDiff(owner, repo, prNumber),
]);
}
@@ -421,21 +430,21 @@ index 1234567..abcdefg 100644
);
logger.info('代码审查结果', {
summary: reviewResult.summary.substring(0, 100) + '...',
commentCount: reviewResult.lineComments.length
summary: `${reviewResult.summary.substring(0, 100)}...`,
commentCount: reviewResult.lineComments.length,
});
// 添加总结评论
if (isDev) {
logger.info('开发环境: 模拟添加PR评论', {
comment: reviewResult.summary
comment: reviewResult.summary,
});
} else {
logger.info('生产环境: 添加PR评论', {
owner,
repo,
prNumber,
comment: reviewResult.summary
comment: reviewResult.summary,
});
await giteaService.addPullRequestComment(
owner,
@@ -450,7 +459,7 @@ index 1234567..abcdefg 100644
if (isDev) {
logger.info('开发环境: 模拟添加行评论', {
commentCount: reviewResult.lineComments.length,
comments: reviewResult.lineComments
comments: reviewResult.lineComments,
});
} else {
await giteaService.addLineComments(
@@ -465,7 +474,7 @@ index 1234567..abcdefg 100644
logger.info(`完成PR ${owner}/${repo}#${prNumber} 的代码审查`);
} catch (error) {
logger.error(`审查PR失败:`, error);
logger.error('审查PR失败:', error);
throw error;
}
}
@@ -478,21 +487,22 @@ async function reviewCommit(
repo: string,
commitSha: string,
commitInfo: {
sha: string,
message: string,
added: string[],
modified: string[],
removed: string[]
sha: string;
message: string;
added: string[];
modified: string[];
removed: string[];
},
relatedPR?: PullRequestDetails | null
): Promise<void> {
try {
logger.info(`开始审查提交 ${owner}/${repo}@${commitSha}`);
logger.info('提交信息', {
message: commitInfo.message.substring(0, 100) + (commitInfo.message.length > 100 ? '...' : ''),
message:
commitInfo.message.substring(0, 100) + (commitInfo.message.length > 100 ? '...' : ''),
added: commitInfo.added.length,
modified: commitInfo.modified.length,
removed: commitInfo.removed.length
removed: commitInfo.removed.length,
});
// 如果是开发环境,打印更多信息但不执行实际审查
@@ -503,47 +513,42 @@ async function reviewCommit(
commitSha,
added: commitInfo.added,
modified: commitInfo.modified,
removed: commitInfo.removed
removed: commitInfo.removed,
});
return;
}
// 创建自定义文件列表因为Gitea API不直接提供
const webhookFiles: PullRequestFile[] = [
...commitInfo.added.map(filename => ({
...commitInfo.added.map((filename) => ({
filename,
status: 'added',
additions: 0, // 不知道具体行数
deletions: 0,
changes: 0
changes: 0,
})),
...commitInfo.modified.map(filename => ({
...commitInfo.modified.map((filename) => ({
filename,
status: 'modified',
additions: 0,
deletions: 0,
changes: 0
changes: 0,
})),
...commitInfo.removed.map(filename => ({
...commitInfo.removed.map((filename) => ({
filename,
status: 'removed',
additions: 0,
deletions: 0,
changes: 0
}))
changes: 0,
})),
];
// 使用AI审查服务分析提交并传入webhook提供的文件列表
const reviewResult = await aiReviewService.reviewCommit(
owner,
repo,
commitSha,
webhookFiles
);
const reviewResult = await aiReviewService.reviewCommit(owner, repo, commitSha, webhookFiles);
logger.info('提交代码审查结果', {
summary: reviewResult.summary.substring(0, 100) + '...',
commentCount: reviewResult.lineComments.length
summary: `${reviewResult.summary.substring(0, 100)}...`,
commentCount: reviewResult.lineComments.length,
});
// 添加总结评论到提交
@@ -562,7 +567,7 @@ async function reviewCommit(
// 尝试使用传入的PR信息或者查找相关的PR
try {
// 如果已经有关联PR直接使用
if (relatedPR && relatedPR.number) {
if (relatedPR?.number) {
logger.info(`使用已知关联的PR #${relatedPR.number}`);
// 添加行级评论
@@ -579,7 +584,7 @@ async function reviewCommit(
// 否则尝试查找
logger.info('尝试查找与提交关联的PR');
const response = await giteaService.getRelatedPullRequest(owner, repo, commitSha);
if (response && response.number) {
if (response?.number) {
logger.info(`找到与提交关联的PR #${response.number}`);
// 添加行级评论
@@ -602,7 +607,7 @@ async function reviewCommit(
logger.info(`完成提交 ${owner}/${repo}@${commitSha} 的代码审查`);
} catch (error) {
logger.error(`审查提交失败:`, error);
logger.error('审查提交失败:', error);
throw error;
}
}

View File

@@ -1,13 +1,13 @@
import { Hono } from 'hono';
import { jwt } from 'hono/jwt';
import { serveStatic } from 'hono/bun';
import { handleGiteaWebhook } from './controllers/review';
import { adminController } from './controllers/admin';
import { feedbackRouter, initializeFeedbackSystem } from './controllers/feedback';
import { configRouter } from './controllers/config';
import config from './config';
import { reviewEngine } from './review/engine';
import { jwt } from 'hono/jwt';
import OpenAI from 'openai';
import config from './config';
import { adminController } from './controllers/admin';
import { configRouter } from './controllers/config';
import { feedbackRouter, initializeFeedbackSystem } from './controllers/feedback';
import { handleGiteaWebhook } from './controllers/review';
import { reviewEngine } from './review/engine';
// 创建Hono应用实例
const app = new Hono();
@@ -25,12 +25,12 @@ app.get('/', (c) => {
webhookSecurityEnabled: webhookSecretConfigured,
configuration: {
webhookEndpoints: {
unified: '/webhook/gitea (支持Pull Request和Commit Status事件)'
unified: '/webhook/gitea (支持Pull Request和Commit Status事件)',
},
signature: webhookSecretConfigured
? '签名验证已启用 (使用X-Gitea-Signature头)'
: '警告: 签名验证未配置建议设置WEBHOOK_SECRET环境变量'
}
: '警告: 签名验证未配置建议设置WEBHOOK_SECRET环境变量',
},
});
});
@@ -49,7 +49,6 @@ adminProtected.route('/feedback', feedbackRouter);
adminProtected.route('/config', configRouter);
app.route('/admin/api', adminProtected);
// --- 前端静态文件服务 ---
// 优先服务于 public 目录下的静态文件
@@ -58,7 +57,6 @@ app.use('/*', serveStatic({ root: './public' }));
// 对于所有未匹配到的GET请求返回 index.html以支持SPA路由
app.get('*', serveStatic({ path: './public/index.html' }));
// 启动服务器
const port = config.app.port;
console.log(`⚡️ 服务启动在 http://localhost:${port}`);

View File

@@ -1,13 +1,15 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { FileReviewStore } from '../store/file-review-store';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { FileReviewStore } from '../store/file-review-store';
import type { PullRequestReviewPayload } from '../types';
function makePRPayload(overrides: Partial<PullRequestReviewPayload> = {}): PullRequestReviewPayload {
function makePRPayload(
overrides: Partial<PullRequestReviewPayload> = {}
): PullRequestReviewPayload {
return {
idempotencyKey: 'idem-' + Math.random().toString(36).slice(2, 8),
idempotencyKey: `idem-${Math.random().toString(36).slice(2, 8)}`,
eventType: 'pull_request',
owner: 'test-owner',
repo: 'test-repo',
@@ -305,17 +307,37 @@ describe('FileReviewStore', () => {
await store.addFindings(run.id, [
{
id: 'f1', runId: run.id, fingerprint: 'fp1', category: 'correctness',
severity: 'high', confidence: 0.9, path: 'a.ts', line: 1,
title: 'Old', detail: 'd', evidence: 'e', suggestion: 's', published: false,
id: 'f1',
runId: run.id,
fingerprint: 'fp1',
category: 'correctness',
severity: 'high',
confidence: 0.9,
path: 'a.ts',
line: 1,
title: 'Old',
detail: 'd',
evidence: 'e',
suggestion: 's',
published: false,
},
]);
await store.addFindings(run.id, [
{
id: 'f2', runId: run.id, fingerprint: 'fp2', category: 'security',
severity: 'medium', confidence: 0.8, path: 'b.ts', line: 2,
title: 'New', detail: 'd', evidence: 'e', suggestion: 's', published: false,
id: 'f2',
runId: run.id,
fingerprint: 'fp2',
category: 'security',
severity: 'medium',
confidence: 0.8,
path: 'b.ts',
line: 2,
title: 'New',
detail: 'd',
evidence: 'e',
suggestion: 's',
published: false,
},
]);
@@ -329,9 +351,19 @@ describe('FileReviewStore', () => {
const { run } = await store.createOrReuseRun(payload);
await store.addFindings(run.id, [
{
id: 'f1', runId: run.id, fingerprint: 'fp1', category: 'correctness',
severity: 'high', confidence: 0.9, path: 'a.ts', line: 1,
title: 'Bug', detail: 'd', evidence: 'e', suggestion: 's', published: false,
id: 'f1',
runId: run.id,
fingerprint: 'fp1',
category: 'correctness',
severity: 'high',
confidence: 0.9,
path: 'a.ts',
line: 1,
title: 'Bug',
detail: 'd',
evidence: 'e',
suggestion: 's',
published: false,
},
]);
@@ -352,9 +384,9 @@ describe('FileReviewStore', () => {
await store.createOrReuseRun(p1);
// Ensure distinct timestamps for sorting
await new Promise(r => setTimeout(r, 5));
await new Promise((r) => setTimeout(r, 5));
await store.createOrReuseRun(p2);
await new Promise(r => setTimeout(r, 5));
await new Promise((r) => setTimeout(r, 5));
await store.createOrReuseRun(p3);
const runs = await store.listRuns();

View File

@@ -1,19 +1,17 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { FileReviewStore } from '../store/file-review-store';
import { JudgeAgent } from '../agents/judge-agent';
import { applyPublishPolicy } from '../policy/publish-policy';
import type {
PullRequestReviewPayload,
Finding,
ReviewRun,
} from '../types';
import { FileReviewStore } from '../store/file-review-store';
import type { Finding, PullRequestReviewPayload } from '../types';
type PartialFinding = Omit<Finding, 'id' | 'runId' | 'published'>;
function makePRPayload(overrides: Partial<PullRequestReviewPayload> = {}): PullRequestReviewPayload {
function makePRPayload(
overrides: Partial<PullRequestReviewPayload> = {}
): PullRequestReviewPayload {
return {
idempotencyKey: 'test/repo#1:aaa...bbb',
eventType: 'pull_request',
@@ -27,7 +25,10 @@ function makePRPayload(overrides: Partial<PullRequestReviewPayload> = {}): PullR
};
}
function makeAgentFindings(count: number, severity: 'high' | 'medium' | 'low' = 'high'): PartialFinding[] {
function makeAgentFindings(
count: number,
severity: 'high' | 'medium' | 'low' = 'high'
): PartialFinding[] {
return Array.from({ length: count }, (_, i) => ({
fingerprint: `fp-${severity}-${i}`,
category: 'correctness' as const,

View File

@@ -1,4 +1,4 @@
import { describe, test, expect } from 'bun:test';
import { describe, expect, test } from 'bun:test';
import { JudgeAgent } from '../agents/judge-agent';
import type { Finding } from '../types';
@@ -6,7 +6,7 @@ type TestFinding = Omit<Finding, 'id' | 'runId' | 'published'>;
function makeFinding(overrides: Partial<TestFinding> = {}): TestFinding {
return {
fingerprint: 'fp-' + Math.random().toString(36).slice(2, 8),
fingerprint: `fp-${Math.random().toString(36).slice(2, 8)}`,
category: 'correctness',
severity: 'medium',
confidence: 0.8,

View File

@@ -1,4 +1,4 @@
import { describe, test, expect } from 'bun:test';
import { describe, expect, test } from 'bun:test';
import { applyPublishPolicy } from '../policy/publish-policy';
import type { Finding } from '../types';
@@ -6,7 +6,7 @@ type TestFinding = Omit<Finding, 'id' | 'runId' | 'published'>;
function makeFinding(overrides: Partial<TestFinding> = {}): TestFinding {
return {
fingerprint: 'fp-' + Math.random().toString(36).slice(2, 8),
fingerprint: `fp-${Math.random().toString(36).slice(2, 8)}`,
category: 'correctness',
severity: 'medium',
confidence: 0.9,

View File

@@ -1,4 +1,4 @@
import { describe, test, expect } from 'bun:test';
import { describe, expect, test } from 'bun:test';
import { SandboxExec } from '../context/sandbox-exec';
describe('SandboxExec', () => {
@@ -157,6 +157,6 @@ describe('SandboxExec', () => {
});
expect(result.stdout).not.toContain('SUPER_SECRET_TOKEN');
expect(result.stdout).not.toContain('should-not-leak');
delete process.env.SUPER_SECRET_TOKEN;
process.env.SUPER_SECRET_TOKEN = undefined;
});
});

View File

@@ -1,9 +1,9 @@
import { describe, test, expect, mock } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
import { z } from 'zod';
import { SpecialistAgent } from '../agents/specialist-agent';
import { ToolRegistry } from '../tools/registry';
import { z } from 'zod';
import type { ReviewRun, ReviewContext, FindingCategory } from '../types';
import type { Tool } from '../tools/types';
import type { FindingCategory, ReviewContext, ReviewRun } from '../types';
function makeRun(overrides: Partial<ReviewRun> = {}): ReviewRun {
return {
@@ -139,9 +139,7 @@ describe('SpecialistAgent ReAct loop', () => {
suggestion: 'Use undefined',
};
const { client, getCalls } = createMockOpenAI([
() => jsonResponse({ findings: [finding] }),
]);
const { client, getCalls } = createMockOpenAI([() => jsonResponse({ findings: [finding] })]);
const agent = new SpecialistAgent(client as any, 'gpt-4', category, 'TestAgent', 'bugs');
const result = await agent.review(makeRun(), makeContext());
@@ -178,7 +176,12 @@ describe('SpecialistAgent ReAct loop', () => {
]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
const result = await agent.review(makeRun(), makeContext());
@@ -203,7 +206,12 @@ describe('SpecialistAgent ReAct loop', () => {
]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
await agent.review(makeRun(), makeContext());
@@ -222,16 +230,21 @@ describe('SpecialistAgent ReAct loop', () => {
const registry = new ToolRegistry();
registry.register(makeDummyTool());
let callCount = 0;
const _callCount = 0;
const { client, getCalls } = createMockOpenAI([
() => jsonResponse({ findings: [], need_more_investigation: true }),
() => jsonResponse({ findings: [], need_more_investigation: false }),
]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
const result = await agent.review(makeRun(), makeContext());
const _result = await agent.review(makeRun(), makeContext());
const calls = getCalls();
expect(calls.length).toBeGreaterThanOrEqual(2);
@@ -271,7 +284,12 @@ describe('SpecialistAgent ReAct loop', () => {
]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
const result = await agent.review(makeRun(), makeContext());
@@ -314,7 +332,12 @@ describe('SpecialistAgent ReAct loop', () => {
]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
const result = await agent.review(makeRun(), makeContext());
@@ -329,11 +352,18 @@ describe('SpecialistAgent ReAct loop', () => {
registry.register(makeDummyTool());
const { client } = createMockOpenAI([
() => { throw new Error('API rate limited'); },
() => {
throw new Error('API rate limited');
},
]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
const result = await agent.review(makeRun(), makeContext());
@@ -351,9 +381,14 @@ describe('SpecialistAgent ReAct loop', () => {
]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
const result = await agent.review(makeRun(), makeContext());
const _result = await agent.review(makeRun(), makeContext());
const calls = getCalls();
expect(calls).toHaveLength(2);
@@ -369,7 +404,9 @@ describe('SpecialistAgent ReAct loop', () => {
const registry = new ToolRegistry();
registry.register({
...makeDummyTool(),
execute: async () => { throw new Error('Sandbox timeout'); },
execute: async () => {
throw new Error('Sandbox timeout');
},
});
const { client, getCalls } = createMockOpenAI([
@@ -378,7 +415,12 @@ describe('SpecialistAgent ReAct loop', () => {
]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
await agent.review(makeRun(), makeContext());
@@ -397,7 +439,12 @@ describe('SpecialistAgent ReAct loop', () => {
const { client } = createMockOpenAI([() => emptyResponse()]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
const result = await agent.review(makeRun(), makeContext());
@@ -413,7 +460,12 @@ describe('SpecialistAgent ReAct loop', () => {
]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
const result = await agent.review(makeRun(), makeContext());
@@ -440,7 +492,12 @@ describe('SpecialistAgent ReAct loop', () => {
]);
const agent = new SpecialistAgent(
client as any, 'gpt-4', category, 'TestAgent', 'bugs', registry
client as any,
'gpt-4',
category,
'TestAgent',
'bugs',
registry
);
const result = await agent.review(makeRun(), makeContext());

View File

@@ -1,10 +1,23 @@
import OpenAI from 'openai';
import { SpecialistAgent } from './specialist-agent';
import { ToolRegistry } from '../tools/registry';
import type { LearningSystem } from '../learning/learning-system';
import { ToolRegistry } from '../tools/registry';
import { SpecialistAgent } from './specialist-agent';
export class CorrectnessAgent extends SpecialistAgent {
constructor(openai: OpenAI, model: string, toolRegistry?: ToolRegistry, learningSystem?: LearningSystem) {
super(openai, model, 'correctness', 'Correctness Agent', '业务逻辑正确性、边界条件、空值处理和明显bug', toolRegistry, learningSystem);
constructor(
openai: OpenAI,
model: string,
toolRegistry?: ToolRegistry,
learningSystem?: LearningSystem
) {
super(
openai,
model,
'correctness',
'Correctness Agent',
'业务逻辑正确性、边界条件、空值处理和明显bug',
toolRegistry,
learningSystem
);
}
}

View File

@@ -1,6 +1,6 @@
import OpenAI from 'openai';
import { Finding, ReviewContext } from '../types';
import { logger } from '../../utils/logger';
import { Finding, ReviewContext } from '../types';
export interface CritiqueResult {
qualityScore: number; // 0-1

View File

@@ -1,7 +1,7 @@
import OpenAI from 'openai';
import { SpecialistAgent } from './specialist-agent';
import { Finding, FindingSeverity } from '../types';
import { logger } from '../../utils/logger';
import { Finding, FindingSeverity } from '../types';
import { SpecialistAgent } from './specialist-agent';
interface AgentOpinion {
agentName: string;
@@ -23,7 +23,7 @@ export class DebateOrchestrator {
async conductDebate(
finding: Omit<Finding, 'id' | 'runId' | 'published'>,
agents: SpecialistAgent[],
maxRounds: number = 2
maxRounds = 2
): Promise<Omit<Finding, 'id' | 'runId' | 'published'>> {
if (agents.length < 2) {
logger.debug('Debate需要至少2个agents跳过');
@@ -213,13 +213,15 @@ ${otherOpinions
// 返回当前意见从opinions Map中获取
const currentOpinion = opinions.get(agentName);
return currentOpinion || {
return (
currentOpinion || {
agentName,
confidence: 0.5,
severity: 'medium',
reasoning: '修订失败',
isValid: true,
};
}
);
}
}
@@ -297,11 +299,15 @@ ${otherOpinions
severityVotes[vote.severity] += vote.confidence;
});
const agreedSeverity = (Object.entries(severityVotes).sort((a, b) => b[1] - a[1])[0][0] as FindingSeverity) || finding.severity;
const agreedSeverity =
(Object.entries(severityVotes).sort((a, b) => b[1] - a[1])[0][0] as FindingSeverity) ||
finding.severity;
// 综合推理
const synthesizedDetail = `${finding.detail}\n\n**专家Debate意见汇总**\n${validVotes
.map((v) => `- ${v.agentName} (${v.severity}, 置信度${v.confidence.toFixed(2)}): ${v.reasoning}`)
.map(
(v) => `- ${v.agentName} (${v.severity}, 置信度${v.confidence.toFixed(2)}): ${v.reasoning}`
)
.join('\n')}`;
logger.info('Debate达成共识', {

View File

@@ -1,4 +1,4 @@
import { ReviewDecision, Finding } from '../types';
import { Finding, ReviewDecision } from '../types';
const severityWeight: Record<Finding['severity'], number> = {
high: 3,

View File

@@ -1,10 +1,23 @@
import OpenAI from 'openai';
import { SpecialistAgent } from './specialist-agent';
import { ToolRegistry } from '../tools/registry';
import type { LearningSystem } from '../learning/learning-system';
import { ToolRegistry } from '../tools/registry';
import { SpecialistAgent } from './specialist-agent';
export class MaintainabilityAgent extends SpecialistAgent {
constructor(openai: OpenAI, model: string, toolRegistry?: ToolRegistry, learningSystem?: LearningSystem) {
super(openai, model, 'maintainability', 'Maintainability Agent', '可维护性、复杂度、接口破坏风险和可测试性不足', toolRegistry, learningSystem);
constructor(
openai: OpenAI,
model: string,
toolRegistry?: ToolRegistry,
learningSystem?: LearningSystem
) {
super(
openai,
model,
'maintainability',
'Maintainability Agent',
'可维护性、复杂度、接口破坏风险和可测试性不足',
toolRegistry,
learningSystem
);
}
}

View File

@@ -1,15 +1,18 @@
import OpenAI from 'openai';
import { SpecialistAgent } from './specialist-agent';
import { CriticAgent, CritiqueResult } from './critic-agent';
import { AgentResult, FindingCategory, ReviewContext, ReviewRun, Finding } from '../types';
import { ToolRegistry } from '../tools/registry';
import { LearningSystem } from '../learning/learning-system';
import { logger } from '../../utils/logger';
import { findingResponseSchema } from '../schema/finding-schema';
import { createHash } from 'node:crypto';
import OpenAI from 'openai';
import { logger } from '../../utils/logger';
import { LearningSystem } from '../learning/learning-system';
import { findingResponseSchema } from '../schema/finding-schema';
import { ToolRegistry } from '../tools/registry';
import { AgentResult, Finding, FindingCategory, ReviewContext, ReviewRun } from '../types';
import { CriticAgent, CritiqueResult } from './critic-agent';
import { SpecialistAgent } from './specialist-agent';
function buildFingerprint(category: string, path: string, line: number, title: string): string {
return createHash('sha256').update(`${category}:${path}:${line}:${title}`).digest('hex').slice(0, 24);
return createHash('sha256')
.update(`${category}:${path}:${line}:${title}`)
.digest('hex')
.slice(0, 24);
}
export class ReflexionAgent extends SpecialistAgent {
@@ -31,7 +34,7 @@ export class ReflexionAgent extends SpecialistAgent {
async reviewWithReflection(
run: ReviewRun,
context: ReviewContext,
maxReflectionRounds: number = 2
maxReflectionRounds = 2
): Promise<AgentResult> {
let bestFindings: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
let bestQualityScore = 0;
@@ -165,7 +168,9 @@ ${context.diff.slice(0, 3000)}
return validated.findings.map((finding) => ({
...finding,
category: this.category,
fingerprint: finding.fingerprint || buildFingerprint(this.category, finding.path, finding.line, finding.title),
fingerprint:
finding.fingerprint ||
buildFingerprint(this.category, finding.path, finding.line, finding.title),
}));
} catch (error) {
logger.error(`${this.agentName} Refine失败`, {

View File

@@ -1,10 +1,23 @@
import OpenAI from 'openai';
import { SpecialistAgent } from './specialist-agent';
import { ToolRegistry } from '../tools/registry';
import type { LearningSystem } from '../learning/learning-system';
import { ToolRegistry } from '../tools/registry';
import { SpecialistAgent } from './specialist-agent';
export class ReliabilityAgent extends SpecialistAgent {
constructor(openai: OpenAI, model: string, toolRegistry?: ToolRegistry, learningSystem?: LearningSystem) {
super(openai, model, 'reliability', 'Reliability Agent', '错误处理、重试策略、幂等性、并发一致性和资源释放', toolRegistry, learningSystem);
constructor(
openai: OpenAI,
model: string,
toolRegistry?: ToolRegistry,
learningSystem?: LearningSystem
) {
super(
openai,
model,
'reliability',
'Reliability Agent',
'错误处理、重试策略、幂等性、并发一致性和资源释放',
toolRegistry,
learningSystem
);
}
}

View File

@@ -1,10 +1,23 @@
import OpenAI from 'openai';
import { SpecialistAgent } from './specialist-agent';
import { ToolRegistry } from '../tools/registry';
import type { LearningSystem } from '../learning/learning-system';
import { ToolRegistry } from '../tools/registry';
import { SpecialistAgent } from './specialist-agent';
export class SecurityAgent extends SpecialistAgent {
constructor(openai: OpenAI, model: string, toolRegistry?: ToolRegistry, learningSystem?: LearningSystem) {
super(openai, model, 'security', 'Security Agent', '注入漏洞、权限绕过、敏感信息泄露、反序列化和输入校验缺失', toolRegistry, learningSystem);
constructor(
openai: OpenAI,
model: string,
toolRegistry?: ToolRegistry,
learningSystem?: LearningSystem
) {
super(
openai,
model,
'security',
'Security Agent',
'注入漏洞、权限绕过、敏感信息泄露、反序列化和输入校验缺失',
toolRegistry,
learningSystem
);
}
}

View File

@@ -1,14 +1,17 @@
import OpenAI from 'openai';
import { createHash } from 'node:crypto';
import OpenAI from 'openai';
import { logger } from '../../utils/logger';
import { findingResponseSchema } from '../schema/finding-schema';
import { AgentResult, Finding, FindingCategory, ReviewContext, ReviewRun } from '../types';
import { ToolRegistry } from '../tools/registry';
import type { ToolResult, ToolExecutionContext } from '../tools/types';
import type { LearningSystem } from '../learning/learning-system';
import { findingResponseSchema } from '../schema/finding-schema';
import { ToolRegistry } from '../tools/registry';
import type { ToolExecutionContext, ToolResult } from '../tools/types';
import { AgentResult, Finding, FindingCategory, ReviewContext, ReviewRun } from '../types';
function buildFingerprint(category: string, path: string, line: number, title: string): string {
return createHash('sha256').update(`${category}:${path}:${line}:${title}`).digest('hex').slice(0, 24);
return createHash('sha256')
.update(`${category}:${path}:${line}:${title}`)
.digest('hex')
.slice(0, 24);
}
function toCompactContext(context: ReviewContext): string {
@@ -58,7 +61,10 @@ function toCompactContext(context: ReviewContext): string {
let result = tryBuild(maxChangesPerFile, maxFileContentsEntries);
// 如果超过限制,逐步缩减
while (result.length > MAX_CONTEXT_CHARS && (maxChangesPerFile > 20 || maxFileContentsEntries > 0)) {
while (
result.length > MAX_CONTEXT_CHARS &&
(maxChangesPerFile > 20 || maxFileContentsEntries > 0)
) {
if (maxChangesPerFile > 20) {
maxChangesPerFile = Math.max(20, Math.floor(maxChangesPerFile * 0.7));
} else if (maxFileContentsEntries > 0) {
@@ -74,7 +80,7 @@ function toCompactContext(context: ReviewContext): string {
originalSize: result.length,
limit: MAX_CONTEXT_CHARS,
});
result = result.slice(0, MAX_CONTEXT_CHARS) + '\n... [truncated]';
result = `${result.slice(0, MAX_CONTEXT_CHARS)}\n... [truncated]`;
}
return result;
@@ -137,7 +143,8 @@ ${toCompactContext(context)}`;
const findings = parsed.findings.map((item) => ({
...item,
category: this.category,
fingerprint: item.fingerprint || buildFingerprint(this.category, item.path, item.line, item.title),
fingerprint:
item.fingerprint || buildFingerprint(this.category, item.path, item.line, item.title),
}));
return {
@@ -259,7 +266,9 @@ confidence取值范围0到1。line必须是正整数且引用新增行。`,
// 使用schema验证findings防止畸形数据流入发布系统
const validated = findingResponseSchema.parse({ findings: parsed.findings });
for (const item of validated.findings) {
const fp = item.fingerprint || buildFingerprint(this.category, item.path, item.line, item.title);
const fp =
item.fingerprint ||
buildFingerprint(this.category, item.path, item.line, item.title);
// 基于 fingerprint 去重:后续迭代产生的同一 finding 覆盖前一次
findingsMap.set(fp, {
...item,
@@ -278,7 +287,8 @@ confidence取值范围0到1。line必须是正整数且引用新增行。`,
messages.push(choice.message as OpenAI.Chat.ChatCompletionMessageParam);
messages.push({
role: 'user',
content: '请使用工具进行更深入的调查。如果你已经获得了足够的信息,请将 need_more_investigation 设为 false 并输出最终结果。',
content:
'请使用工具进行更深入的调查。如果你已经获得了足够的信息,请将 need_more_investigation 设为 false 并输出最终结果。',
});
} catch (parseError) {
logger.error(`${this.agentName} 解析响应失败`, {

View File

@@ -1,8 +1,8 @@
import { readFile, lstat } from 'node:fs/promises';
import { lstat, readFile } from 'node:fs/promises';
import path from 'node:path';
import { DiffFile, ReviewContext, ReviewRun, ChangedFile } from '../types';
import { SandboxExec } from './sandbox-exec';
import { ChangedFile, DiffFile, ReviewContext, ReviewRun } from '../types';
import { LocalRepoManager } from './local-repo-manager';
import { SandboxExec } from './sandbox-exec';
function toStatus(status: string): ChangedFile['status'] {
const value = status.trim().charAt(0).toUpperCase();
@@ -33,7 +33,11 @@ export class DiffExtractor {
return this.sandboxExec;
}
async buildContext(run: ReviewRun, mirrorPath: string, workspacePath: string): Promise<ReviewContext> {
async buildContext(
run: ReviewRun,
mirrorPath: string,
workspacePath: string
): Promise<ReviewContext> {
const targetSha = run.headSha || run.commitSha;
if (!targetSha) {
throw new Error('缺少 target sha无法构建审查上下文');
@@ -41,7 +45,8 @@ export class DiffExtractor {
let baseSha = run.baseSha;
if (!baseSha) {
baseSha = await this.localRepoManager.resolveCommitParent(workspacePath, targetSha) || undefined;
baseSha =
(await this.localRepoManager.resolveCommitParent(workspacePath, targetSha)) || undefined;
}
// Root commit场景没有parent使用git show获取完整diff
@@ -55,7 +60,7 @@ export class DiffExtractor {
: await this.getChangedFiles(workspacePath, baseSha!, targetSha);
// 构建允许的文件路径集合确保parsedDiff也受REVIEW_MAX_FILES_PER_RUN限制
const allowedPaths = new Set(changedFiles.map(f => f.path));
const allowedPaths = new Set(changedFiles.map((f) => f.path));
const parsedDiff = this.parseDiff(diff, allowedPaths);
const fileContents = await this.readChangedFileContents(workspacePath, changedFiles);
@@ -86,32 +91,51 @@ export class DiffExtractor {
targetSha: string
): Promise<string> {
if (eventType === 'pull_request') {
const response = await this.sandboxExec.run('git', ['diff', '--unified=3', `${baseSha}...${targetSha}`], {
const response = await this.sandboxExec.run(
'git',
['diff', '--unified=3', `${baseSha}...${targetSha}`],
{
cwd: workspacePath,
timeoutMs: this.commandTimeoutMs,
});
}
);
return response.stdout;
}
const response = await this.sandboxExec.run('git', ['show', '--format=', '--unified=3', targetSha], {
const response = await this.sandboxExec.run(
'git',
['show', '--format=', '--unified=3', targetSha],
{
cwd: workspacePath,
timeoutMs: this.commandTimeoutMs,
});
}
);
return response.stdout;
}
private async getRootCommitChangedFiles(workspacePath: string, sha: string): Promise<ChangedFile[]> {
private async getRootCommitChangedFiles(
workspacePath: string,
sha: string
): Promise<ChangedFile[]> {
// Root commit所有文件都是新增的A状态
// --root flag是必需的否则diff-tree对root commit返回空输出
const statusResult = await this.sandboxExec.run('git', ['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', sha], {
const statusResult = await this.sandboxExec.run(
'git',
['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', sha],
{
cwd: workspacePath,
timeoutMs: this.commandTimeoutMs,
});
}
);
const numStatResult = await this.sandboxExec.run('git', ['diff-tree', '--root', '--no-commit-id', '--numstat', '-r', sha], {
const numStatResult = await this.sandboxExec.run(
'git',
['diff-tree', '--root', '--no-commit-id', '--numstat', '-r', sha],
{
cwd: workspacePath,
timeoutMs: this.commandTimeoutMs,
});
}
);
const numMap = new Map<string, { additions: number; deletions: number }>();
for (const line of numStatResult.stdout.split('\n')) {
@@ -155,16 +179,28 @@ export class DiffExtractor {
return changedFiles;
}
private async getChangedFiles(workspacePath: string, baseSha: string, targetSha: string): Promise<ChangedFile[]> {
const statusResult = await this.sandboxExec.run('git', ['diff', '--name-status', `${baseSha}...${targetSha}`], {
private async getChangedFiles(
workspacePath: string,
baseSha: string,
targetSha: string
): Promise<ChangedFile[]> {
const statusResult = await this.sandboxExec.run(
'git',
['diff', '--name-status', `${baseSha}...${targetSha}`],
{
cwd: workspacePath,
timeoutMs: this.commandTimeoutMs,
});
}
);
const numStatResult = await this.sandboxExec.run('git', ['diff', '--numstat', `${baseSha}...${targetSha}`], {
const numStatResult = await this.sandboxExec.run(
'git',
['diff', '--numstat', `${baseSha}...${targetSha}`],
{
cwd: workspacePath,
timeoutMs: this.commandTimeoutMs,
});
}
);
const numMap = new Map<string, { additions: number; deletions: number }>();
for (const line of numStatResult.stdout.split('\n')) {
@@ -229,9 +265,7 @@ export class DiffExtractor {
const content = await readFile(filePath, 'utf-8');
result[file.path] = content.slice(0, this.maxFileContentChars);
} catch {
continue;
}
} catch {}
}
return result;
@@ -277,7 +311,7 @@ export class DiffExtractor {
if (line.startsWith('@@')) {
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (match && match[1]) {
if (match?.[1]) {
lineNumber = Number.parseInt(match[1], 10) - 1;
inHunk = true;
}

View File

@@ -1,8 +1,8 @@
import { createHash } from 'node:crypto';
import { access, mkdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { SandboxExec } from './sandbox-exec';
import { logger } from '../../utils/logger';
import { SandboxExec } from './sandbox-exec';
export interface LocalRepoPaths {
mirrorPath: string;
@@ -91,16 +91,24 @@ export class LocalRepoManager {
if (!mirrorExists) {
logger.info('创建本地 mirror 仓库', { owner, repo, mirrorPath });
await this.sandboxExec.run('git', [...authArgs, 'clone', '--mirror', cloneUrl, mirrorPath], {
await this.sandboxExec.run(
'git',
[...authArgs, 'clone', '--mirror', cloneUrl, mirrorPath],
{
cwd: this.workDir,
timeoutMs: this.commandTimeoutMs,
});
}
);
} else {
// 更新remote URL不含认证信息
await this.sandboxExec.run('git', ['--git-dir', mirrorPath, 'remote', 'set-url', 'origin', cloneUrl], {
await this.sandboxExec.run(
'git',
['--git-dir', mirrorPath, 'remote', 'set-url', 'origin', cloneUrl],
{
cwd: this.workDir,
timeoutMs: this.commandTimeoutMs,
});
}
);
// fetch使用认证参数
await this.sandboxExec.run(
'git',
@@ -117,28 +125,47 @@ export class LocalRepoManager {
logger.info('Fork PR检测添加head remote', { owner, repo, headCloneUrl });
// 检查head remote是否已存在存在则更新URL
const remoteListResult = await this.sandboxExec.run('git', ['--git-dir', mirrorPath, 'remote'], {
const remoteListResult = await this.sandboxExec.run(
'git',
['--git-dir', mirrorPath, 'remote'],
{
cwd: this.workDir,
timeoutMs: this.commandTimeoutMs,
});
}
);
const hasHeadRemote = remoteListResult.stdout.includes('head');
if (hasHeadRemote) {
await this.sandboxExec.run('git', ['--git-dir', mirrorPath, 'remote', 'set-url', 'head', headCloneUrl], {
await this.sandboxExec.run(
'git',
['--git-dir', mirrorPath, 'remote', 'set-url', 'head', headCloneUrl],
{
cwd: this.workDir,
timeoutMs: this.commandTimeoutMs,
});
}
);
} else {
await this.sandboxExec.run('git', ['--git-dir', mirrorPath, 'remote', 'add', 'head', headCloneUrl], {
await this.sandboxExec.run(
'git',
['--git-dir', mirrorPath, 'remote', 'add', 'head', headCloneUrl],
{
cwd: this.workDir,
timeoutMs: this.commandTimeoutMs,
});
}
);
}
// Fetch head remote
await this.sandboxExec.run(
'git',
[...authArgs, '--git-dir', mirrorPath, 'fetch', 'head', '+refs/heads/*:refs/remotes/head/*'],
[
...authArgs,
'--git-dir',
mirrorPath,
'fetch',
'head',
'+refs/heads/*:refs/remotes/head/*',
],
{
cwd: this.workDir,
timeoutMs: this.commandTimeoutMs,
@@ -156,10 +183,14 @@ export class LocalRepoManager {
timeoutMs: this.commandTimeoutMs,
});
await this.sandboxExec.run('git', ['--git-dir', mirrorPath, 'worktree', 'add', '--detach', workspacePath, targetSha], {
await this.sandboxExec.run(
'git',
['--git-dir', mirrorPath, 'worktree', 'add', '--detach', workspacePath, targetSha],
{
cwd: this.workDir,
timeoutMs: this.commandTimeoutMs,
});
}
);
} finally {
// 确保锁总是被释放在所有mirror-mutating操作fetch/prune/add完成后释放
unlock();

View File

@@ -44,7 +44,11 @@ export class SandboxExec {
});
}
async run(command: string, args: string[], options: SandboxRunOptions): Promise<SandboxCommandResult> {
async run(
command: string,
args: string[],
options: SandboxRunOptions
): Promise<SandboxCommandResult> {
if (!this.allowedCommands.has(command)) {
throw new Error(`命令未在白名单中: ${command}`);
}

View File

@@ -23,7 +23,11 @@ class ReviewEngine {
config.review.maxFilesPerRun,
config.review.maxFileContentChars
);
private readonly orchestrator = new ReviewOrchestrator(this.store, this.localRepoManager, this.diffExtractor);
private readonly orchestrator = new ReviewOrchestrator(
this.store,
this.localRepoManager,
this.diffExtractor
);
private started = false;
private activeRunsCount = 0;
@@ -61,7 +65,9 @@ class ReviewEngine {
this.started = false;
}
async enqueuePullRequest(payload: PullRequestReviewPayload): Promise<{ run: ReviewRun; reused: boolean }> {
async enqueuePullRequest(
payload: PullRequestReviewPayload
): Promise<{ run: ReviewRun; reused: boolean }> {
await this.store.init();
return this.store.createOrReuseRun(payload);
}
@@ -75,7 +81,9 @@ class ReviewEngine {
return this.store.listRuns(limit);
}
async getRunDetails(runId: string): Promise<Awaited<ReturnType<FileReviewStore['getRunDetails']>>> {
async getRunDetails(
runId: string
): Promise<Awaited<ReturnType<FileReviewStore['getRunDetails']>>> {
return this.store.getRunDetails(runId);
}

View File

@@ -1,9 +1,9 @@
import OpenAI from 'openai';
import config from '../../config';
import { logger } from '../../utils/logger';
import { VectorMemoryStore } from '../memory/vector-store';
import { FileReviewStore } from '../store/file-review-store';
import { Finding, FindingCategory } from '../types';
import { logger } from '../../utils/logger';
import OpenAI from 'openai';
import config from '../../config';
export class LearningSystem {
constructor(
@@ -100,7 +100,9 @@ export class LearningSystem {
{ key: 'approved', match: { value: true } },
],
});
approved.push(...globalApproved.filter((a) => !approved.find((e) => e.entry.id === a.entry.id)));
approved.push(
...globalApproved.filter((a) => !approved.find((e) => e.entry.id === a.entry.id))
);
}
const examples: OpenAI.Chat.ChatCompletionMessageParam[] = [];
@@ -160,11 +162,7 @@ export class LearningSystem {
return [];
}
async learnFromApproval(
finding: Finding,
_owner: string,
_repo: string
): Promise<void> {
async learnFromApproval(finding: Finding, _owner: string, _repo: string): Promise<void> {
// 将已批准的finding存储为正样本
await this.memoryStore.storeFinding(finding, true, _owner, _repo);
@@ -199,9 +197,11 @@ export class LearningSystem {
if (maxSimilarity > 0.9) {
return -0.3; // 高度相似的误报,大幅降低置信度
} else if (maxSimilarity > 0.8) {
}
if (maxSimilarity > 0.8) {
return -0.15; // 中度相似,适度降低
} else if (maxSimilarity > 0.7) {
}
if (maxSimilarity > 0.7) {
return -0.05; // 低度相似,略微降低
}

View File

@@ -1,8 +1,8 @@
import { QdrantClient } from '@qdrant/js-client-rest';
import OpenAI from 'openai';
import { MemoryEntry, MemorySearchResult } from './types';
import { Finding } from '../types';
import { logger } from '../../utils/logger';
import { Finding } from '../types';
import { MemoryEntry, MemorySearchResult } from './types';
export class VectorMemoryStore {
private client: QdrantClient;
@@ -70,11 +70,7 @@ export class VectorMemoryStore {
});
}
async searchSimilar(
query: string,
limit: number = 5,
filter?: any
): Promise<MemorySearchResult[]> {
async searchSimilar(query: string, limit = 5, filter?: any): Promise<MemorySearchResult[]> {
await this.initialize();
const queryEmbedding = await this.getEmbedding(query);
@@ -121,7 +117,12 @@ export class VectorMemoryStore {
}
}
async storeFinding(finding: Finding, approved: boolean, owner: string, repo: string): Promise<void> {
async storeFinding(
finding: Finding,
approved: boolean,
owner: string,
repo: string
): Promise<void> {
const content = `${finding.title}\n${finding.detail}\nEvidence: ${finding.evidence}`;
// 使用repo-scoped ID防止不同仓库的findings相互覆盖

View File

@@ -3,20 +3,20 @@ import OpenAI from 'openai';
import config from '../config';
import { giteaService } from '../services/gitea';
import { logger } from '../utils/logger';
import { DebateOrchestrator } from './agents/debate-orchestrator';
import { JudgeAgent } from './agents/judge-agent';
import { ReflexionAgent } from './agents/reflexion-agent';
import { DebateOrchestrator } from './agents/debate-orchestrator';
import { DiffExtractor } from './context/diff-extractor';
import { LocalRepoManager, LocalRepoPaths } from './context/local-repo-manager';
import { LearningSystem } from './learning/learning-system';
import { VectorMemoryStore } from './memory/vector-store';
import { applyPublishPolicy } from './policy/publish-policy';
import { FileReviewStore } from './store/file-review-store';
import { Finding, ReviewRun } from './types';
import { ToolRegistry } from './tools/registry';
import { createCodeSearchTool } from './tools/code-search-tool';
import { createFunctionReferenceSearchTool } from './tools/function-reference-search-tool';
import { createFileReadTool } from './tools/file-read-tool';
import { VectorMemoryStore } from './memory/vector-store';
import { LearningSystem } from './learning/learning-system';
import { createFunctionReferenceSearchTool } from './tools/function-reference-search-tool';
import { ToolRegistry } from './tools/registry';
import { Finding, ReviewRun } from './types';
interface LineCommentInput {
path: string;
@@ -24,7 +24,9 @@ interface LineCommentInput {
comment: string;
}
function findingToLineComment(finding: Omit<Finding, 'id' | 'runId' | 'published'>): LineCommentInput {
function findingToLineComment(
finding: Omit<Finding, 'id' | 'runId' | 'published'>
): LineCommentInput {
return {
path: finding.path,
line: finding.line,
@@ -172,7 +174,11 @@ export class ReviewOrchestrator {
startedAt: new Date(contextStart).toISOString(),
});
const context = await this.diffExtractor.buildContext(run, repoPaths.mirrorPath, repoPaths.workspacePath);
const context = await this.diffExtractor.buildContext(
run,
repoPaths.mirrorPath,
repoPaths.workspacePath
);
await this.store.addStep({
runId: run.id,
@@ -291,17 +297,17 @@ export class ReviewOrchestrator {
// summary comment特征status='published' 且 path字段为空
// line comment特征status='published' 且 path字段存在
const runDetails = await this.store.getRunDetails(run.id);
const summaryPublished = runDetails?.comments.some(
(comment) => comment.status === 'published' && !comment.path
) || false;
const lineCommentsPublished = runDetails?.comments.some(
(comment) => comment.status === 'published' && comment.path
) || false;
const summaryPublished =
runDetails?.comments.some((comment) => comment.status === 'published' && !comment.path) ||
false;
const lineCommentsPublished =
runDetails?.comments.some((comment) => comment.status === 'published' && comment.path) ||
false;
if (lineCommentsPublished) {
logger.info('检测到重试且line comments已发布跳过line comments和findings标记', {
runId: run.id,
existingLineComments: runDetails?.comments.filter(c => c.path).length,
existingLineComments: runDetails?.comments.filter((c) => c.path).length,
});
// 重试场景line comments已发布跳过line comments发布步骤
// 注意不能return需要继续执行summary和pending gate记录即使summary已存在
@@ -362,7 +368,8 @@ export class ReviewOrchestrator {
// 关键即使summary已存在仍需添加gated findings到pending队列
// 防止crash发生在publishSummary之后、addCommentRecord之前时丢失待审批findings
// 使用幂等性检查防止retry时重复添加
const existingPendingComments = runDetails?.comments.filter(c => c.status === 'pending') || [];
const existingPendingComments =
runDetails?.comments.filter((c) => c.status === 'pending') || [];
// 跟踪本次循环中已添加的location防止同一run中多个findings在同一位置导致重复pending记录
const addedLocations = new Set<string>();
@@ -373,7 +380,7 @@ export class ReviewOrchestrator {
// 检查是否已存在相同的pending记录通过runId + path + line去重
// 需要同时检查1) 之前run的记录 2) 本次循环已添加的记录
const alreadyPending =
existingPendingComments.some(c => c.path === finding.path && c.line === finding.line) ||
existingPendingComments.some((c) => c.path === finding.path && c.line === finding.line) ||
addedLocations.has(locationKey);
if (!alreadyPending) {
@@ -398,10 +405,17 @@ export class ReviewOrchestrator {
// 将已发布的findings存储到向量记忆自动标记为已批准
if (this.memoryStore && policyResult.publishable.length > 0) {
for (const finding of policyResult.publishable) {
const persistedFinding = persistedFindings.find((f) => f.fingerprint === finding.fingerprint);
const persistedFinding = persistedFindings.find(
(f) => f.fingerprint === finding.fingerprint
);
if (persistedFinding) {
try {
await this.memoryStore.storeFinding(persistedFinding as Finding, true, run.owner, run.repo);
await this.memoryStore.storeFinding(
persistedFinding as Finding,
true,
run.owner,
run.repo
);
} catch (error) {
logger.warn('存储finding到向量记忆失败', {
findingId: persistedFinding.id,
@@ -456,10 +470,13 @@ export class ReviewOrchestrator {
body,
});
} catch (storeError) {
logger.error('Failed to persist summary comment record (non-fatal, may cause duplicate on retry)', {
logger.error(
'Failed to persist summary comment record (non-fatal, may cause duplicate on retry)',
{
runId: run.id,
error: storeError instanceof Error ? storeError.message : String(storeError),
});
}
);
// 不抛出,允许审查流程继续
}
return;
@@ -475,16 +492,22 @@ export class ReviewOrchestrator {
body,
});
} catch (storeError) {
logger.error('Failed to persist summary comment record (non-fatal, may cause duplicate on retry)', {
logger.error(
'Failed to persist summary comment record (non-fatal, may cause duplicate on retry)',
{
runId: run.id,
error: storeError instanceof Error ? storeError.message : String(storeError),
});
}
);
// 不抛出,允许审查流程继续
}
}
}
private async publishLineComments(run: ReviewRun, comments: LineCommentInput[]): Promise<boolean> {
private async publishLineComments(
run: ReviewRun,
comments: LineCommentInput[]
): Promise<boolean> {
if (comments.length === 0) {
return false;
}
@@ -518,12 +541,15 @@ export class ReviewOrchestrator {
body: comment.comment,
});
} catch (storeError) {
logger.error('Failed to persist line comment record (non-fatal, may cause duplicate on retry)', {
logger.error(
'Failed to persist line comment record (non-fatal, may cause duplicate on retry)',
{
runId: run.id,
path: comment.path,
line: comment.line,
error: storeError instanceof Error ? storeError.message : String(storeError),
});
}
);
// 不抛出继续处理下一条comment
}
}

View File

@@ -1,6 +1,6 @@
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
CommitReviewPayload,
Finding,
@@ -76,9 +76,7 @@ export class FileReviewStore {
this.data = createEmptyData();
await this.persist();
} else {
throw new Error(
`Store初始化失败 - 拒绝擦除数据: ${error.message || String(error)}`
);
throw new Error(`Store初始化失败 - 拒绝擦除数据: ${error.message || String(error)}`);
}
}
@@ -188,7 +186,10 @@ export class FileReviewStore {
await this.markRunFinished(runId, 'ignored', reason);
}
async markRunFailed(runId: string, error: string): Promise<{ requeued: boolean; run: ReviewRun | null }> {
async markRunFailed(
runId: string,
error: string
): Promise<{ requeued: boolean; run: ReviewRun | null }> {
await this.ensureInitialized();
const run = this.data.runs.find((item) => item.id === runId);
@@ -287,7 +288,12 @@ export class FileReviewStore {
return runs.slice(0, limit);
}
async getRunDetails(runId: string): Promise<{ run: ReviewRun; steps: ReviewStep[]; findings: Finding[]; comments: ReviewCommentRecord[] } | null> {
async getRunDetails(runId: string): Promise<{
run: ReviewRun;
steps: ReviewStep[];
findings: Finding[];
comments: ReviewCommentRecord[];
} | null> {
await this.ensureInitialized();
const run = this.data.runs.find((item) => item.id === runId);
@@ -356,7 +362,11 @@ export class FileReviewStore {
};
}
private async markRunFinished(runId: string, status: ReviewRunStatus, error?: string): Promise<void> {
private async markRunFinished(
runId: string,
status: ReviewRunStatus,
error?: string
): Promise<void> {
await this.ensureInitialized();
const run = this.data.runs.find((item) => item.id === runId);
@@ -382,8 +392,7 @@ export class FileReviewStore {
// 追踪当前write操作是否成功失败时立即抛出给调用者防止静默数据丢失
let currentWriteError: Error | null = null;
this.writeChain = this.writeChain
.then(async () => {
this.writeChain = this.writeChain.then(async () => {
try {
// 原子写入:先写临时文件,再 rename 覆盖目标文件
// POSIX rename 是原子操作,即使进程在 rename 中间崩溃,文件也不会损坏

View File

@@ -1,18 +1,14 @@
import { z } from 'zod';
import { Tool } from './types';
import { SandboxExec } from '../context/sandbox-exec';
import { Tool } from './types';
export function createCodeSearchTool(sandbox: SandboxExec): Tool {
return {
name: 'search_code',
description:
'在代码库中搜索匹配给定模式的代码,支持正则表达式。用于发现相似问题或影响范围。',
description: '在代码库中搜索匹配给定模式的代码,支持正则表达式。用于发现相似问题或影响范围。',
parameters: z.object({
pattern: z.string().describe('要搜索的正则表达式模式'),
file_types: z
.array(z.string())
.optional()
.describe('限制搜索的文件类型,如["ts", "js"]'),
file_types: z.array(z.string()).optional().describe('限制搜索的文件类型,如["ts", "js"]'),
max_results: z.number().default(20).describe('最大返回结果数'),
}),
execute: async (params, context) => {

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
import { Tool } from './types';
import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import { Tool } from './types';
export function createFileReadTool(): Tool {
return {

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { Tool } from './types';
import { SandboxExec } from '../context/sandbox-exec';
import { Tool } from './types';
// 转义正则元字符将identifier中的特殊字符转义为字面量
function escapeRegex(str: string): string {
@@ -10,7 +10,8 @@ function escapeRegex(str: string): string {
export function createFunctionReferenceSearchTool(sandbox: SandboxExec): Tool {
return {
name: 'search_function_references',
description: '搜索指定函数、方法或类的所有引用和定义(支持所有编程语言)。用于理解代码影响范围和调用关系。',
description:
'搜索指定函数、方法或类的所有引用和定义(支持所有编程语言)。用于理解代码影响范围和调用关系。',
parameters: z.object({
identifier: z.string().describe('要搜索的标识符(函数名、类名、方法名等)'),
file_types: z
@@ -133,7 +134,7 @@ export function createFunctionReferenceSearchTool(sandbox: SandboxExec): Tool {
}
// 去重(同一位置可能同时匹配调用和定义模式)
const uniqueRefs = new Map<string, typeof allReferences[0]>();
const uniqueRefs = new Map<string, (typeof allReferences)[0]>();
for (const ref of allReferences) {
const key = `${ref.path}:${ref.line}`;
if (!uniqueRefs.has(key)) {

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import zodToJsonSchema from 'zod-to-json-schema';
import type { JsonSchema7Type } from 'zod-to-json-schema';
import { z } from 'zod';
import { Tool } from './types';
export class ToolRegistry {

View File

@@ -2,20 +2,11 @@ export type ReviewEngineMode = 'legacy' | 'agent';
export type ReviewEventType = 'pull_request' | 'commit_status';
export type ReviewRunStatus =
| 'queued'
| 'in_progress'
| 'succeeded'
| 'failed'
| 'ignored';
export type ReviewRunStatus = 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'ignored';
export type FindingSeverity = 'high' | 'medium' | 'low';
export type FindingCategory =
| 'correctness'
| 'security'
| 'reliability'
| 'maintainability';
export type FindingCategory = 'correctness' | 'security' | 'reliability' | 'maintainability';
export interface ReviewRun {
id: string;

View File

@@ -1,7 +1,7 @@
import OpenAI from 'openai';
import config from '../config';
import { logger } from '../utils/logger';
import { giteaService, PullRequestFile } from './gitea';
import { PullRequestFile, giteaService } from './gitea';
// 创建OpenAI客户端
const openai = new OpenAI({
@@ -92,7 +92,7 @@ export const aiReviewService = {
logger.warn('提交差异为空,无法进行代码审查');
return {
summary: '提交差异为空,无法进行代码审查',
lineComments: []
lineComments: [],
};
}
@@ -112,7 +112,7 @@ export const aiReviewService = {
const context: ReviewContext = {
changedFiles: files,
fileContents,
diffContent
diffContent,
};
// 使用上下文进行总体评价
@@ -153,7 +153,7 @@ export const aiReviewService = {
return {
changedFiles,
fileContents,
diffContent
diffContent,
};
} catch (error: any) {
logger.error('获取审查上下文失败:', error);
@@ -161,7 +161,7 @@ export const aiReviewService = {
return {
changedFiles: [],
fileContents: {},
diffContent
diffContent,
};
}
},
@@ -174,13 +174,13 @@ export const aiReviewService = {
async generateSummary(context: ReviewContext): Promise<string> {
try {
// 准备上下文信息
const fileInfo = context.changedFiles.map(file => {
const fileInfo = context.changedFiles.map((file) => {
return {
path: file.filename,
status: file.status,
additions: file.additions,
deletions: file.deletions,
content: context.fileContents[file.filename] || '无法获取文件内容'
content: context.fileContents[file.filename] || '无法获取文件内容',
};
});
@@ -205,9 +205,10 @@ export const aiReviewService = {
messages: [
{
role: 'system',
content: '你是一个专业的代码审查助手擅长识别代码中的严重问题和bug。你会查看代码的完整上下文而不是为了评论而评论。如无明显问题应给予简短肯定。'
content:
'你是一个专业的代码审查助手擅长识别代码中的严重问题和bug。你会查看代码的完整上下文而不是为了评论而评论。如无明显问题应给予简短肯定。',
},
{ role: 'user', content: summaryPrompt }
{ role: 'user', content: summaryPrompt },
],
temperature: 0.1,
});
@@ -234,7 +235,7 @@ export const aiReviewService = {
// 对每个文件的变更行进行审查
for (const file of diffFiles) {
// 只对添加的行进行评论
const addedLines = file.changes.filter(change => change.type === 'add');
const addedLines = file.changes.filter((change) => change.type === 'add');
if (addedLines.length === 0) continue;
// 获取文件的完整内容作为上下文
@@ -257,7 +258,7 @@ export const aiReviewService = {
${fileContent}
变更部分上下文:
${file.changes.map(c => `${c.lineNumber}: ${c.content} (${c.type === 'add' ? '新增' : '上下文'})`).join('\n')}
${file.changes.map((c) => `${c.lineNumber}: ${c.content} (${c.type === 'add' ? '新增' : '上下文'})`).join('\n')}
请以JSON格式返回评论格式如下:
[
@@ -276,9 +277,10 @@ export const aiReviewService = {
messages: [
{
role: 'system',
content: '你是一个谨慎的代码审查助手只对有明显bug或严重问题的代码行提供评论。大多数情况下如果代码没有严重问题你应该返回空数组。请以JSON格式返回结果。'
content:
'你是一个谨慎的代码审查助手只对有明显bug或严重问题的代码行提供评论。大多数情况下如果代码没有严重问题你应该返回空数组。请以JSON格式返回结果。',
},
{ role: 'user', content: filePrompt }
{ role: 'user', content: filePrompt },
],
temperature: 0.1,
response_format: { type: 'json_object' },
@@ -290,7 +292,9 @@ export const aiReviewService = {
try {
// 解析JSON响应
const responseObject = JSON.parse(content);
const comments = Array.isArray(responseObject) ? responseObject : (responseObject.comments || []);
const comments = Array.isArray(responseObject)
? responseObject
: responseObject.comments || [];
// 添加到结果中
for (const comment of comments) {
@@ -298,7 +302,7 @@ export const aiReviewService = {
lineComments.push({
path: file.path,
line: comment.line,
comment: comment.comment
comment: comment.comment,
});
}
}
@@ -321,17 +325,17 @@ export const aiReviewService = {
*/
parseDiff(diffContent: string): Array<{
path: string;
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>;
}> {
const files: Array<{
path: string;
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>;
}> = [];
const diffLines = diffContent.split('\n');
let currentFile: {
path: string;
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>;
} | null = null;
let lineNumber = 0;
@@ -355,8 +359,8 @@ export const aiReviewService = {
// Hunk头记录起始行号
else if (line.startsWith('@@')) {
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (match && match[1]) {
lineNumber = parseInt(match[1], 10) - 1; // 因为下面会+1
if (match?.[1]) {
lineNumber = Number.parseInt(match[1], 10) - 1; // 因为下面会+1
inHunk = true;
}
}
@@ -368,7 +372,7 @@ export const aiReviewService = {
currentFile.changes.push({
lineNumber,
content: line.substring(1),
type: 'add'
type: 'add',
});
} else if (line.startsWith(' ')) {
// 上下文行
@@ -376,7 +380,7 @@ export const aiReviewService = {
currentFile.changes.push({
lineNumber,
content: line.substring(1),
type: 'context'
type: 'context',
});
} else if (line.startsWith('-')) {
// 删除的行,不增加行号
@@ -394,5 +398,5 @@ export const aiReviewService = {
}
return files;
}
},
};

View File

@@ -1,6 +1,6 @@
import { logger } from '../utils/logger';
import * as crypto from 'node:crypto';
import config from '../config';
import * as crypto from 'crypto';
import { logger } from '../utils/logger';
export class FeishuService {
private webhookUrl: string;
@@ -42,14 +42,14 @@ export class FeishuService {
const message: any = {
msg_type: 'text',
content: {
text: content
}
text: content,
},
};
// 如果需要@用户添加at信息
if (usernames.length > 0) {
message.content.text += '\n';
usernames.forEach(username => {
usernames.forEach((username) => {
message.content.text += `@${username} `;
});
}
@@ -85,7 +85,11 @@ export class FeishuService {
* @param issueUrl 工单链接
* @param assigneeUsernames 被指派人用户名列表
*/
async sendIssueCreatedNotification(issueTitle: string, issueUrl: string, assigneeUsernames: string[]): Promise<void> {
async sendIssueCreatedNotification(
issueTitle: string,
issueUrl: string,
assigneeUsernames: string[]
): Promise<void> {
const content = `📝 新工单已创建\n标题: ${issueTitle}\n链接: ${issueUrl}`;
await this.sendMessage(content, assigneeUsernames);
}
@@ -96,7 +100,11 @@ export class FeishuService {
* @param issueUrl 工单链接
* @param creatorUsername 创建者用户名
*/
async sendIssueClosedNotification(issueTitle: string, issueUrl: string, creatorUsername: string): Promise<void> {
async sendIssueClosedNotification(
issueTitle: string,
issueUrl: string,
creatorUsername: string
): Promise<void> {
const content = `✅ 工单已关闭\n标题: ${issueTitle}\n链接: ${issueUrl}`;
await this.sendMessage(content, [creatorUsername]);
}
@@ -107,7 +115,11 @@ export class FeishuService {
* @param issueUrl 工单链接
* @param assigneeUsernames 被指派人用户名列表
*/
async sendIssueAssignedNotification(issueTitle: string, issueUrl: string, assigneeUsernames: string[]): Promise<void> {
async sendIssueAssignedNotification(
issueTitle: string,
issueUrl: string,
assigneeUsernames: string[]
): Promise<void> {
const content = `👤 工单已指派给你\n标题: ${issueTitle}\n链接: ${issueUrl}`;
await this.sendMessage(content, assigneeUsernames);
}
@@ -118,7 +130,11 @@ export class FeishuService {
* @param prUrl PR链接
* @param reviewerUsernames 审阅者用户名列表
*/
async sendPrCreatedNotification(prTitle: string, prUrl: string, reviewerUsernames: string[]): Promise<void> {
async sendPrCreatedNotification(
prTitle: string,
prUrl: string,
reviewerUsernames: string[]
): Promise<void> {
const content = `🔄 新PR等待你审阅\n标题: ${prTitle}\n链接: ${prUrl}`;
await this.sendMessage(content, reviewerUsernames);
}
@@ -129,7 +145,11 @@ export class FeishuService {
* @param prUrl PR链接
* @param reviewerUsernames 审阅者用户名列表
*/
async sendPrReviewerAssignedNotification(prTitle: string, prUrl: string, reviewerUsernames: string[]): Promise<void> {
async sendPrReviewerAssignedNotification(
prTitle: string,
prUrl: string,
reviewerUsernames: string[]
): Promise<void> {
const content = `👀 你被指定为PR审阅者\n标题: ${prTitle}\n链接: ${prUrl}`;
await this.sendMessage(content, reviewerUsernames);
}

View File

@@ -12,7 +12,7 @@ export interface LineComment {
const giteaClient = axios.create({
baseURL: config.gitea.apiUrl,
headers: {
'Authorization': `token ${config.gitea.accessToken}`,
Authorization: `token ${config.gitea.accessToken}`,
'Content-Type': 'application/json',
},
});
@@ -21,7 +21,7 @@ const giteaClient = axios.create({
const giteaAdminClient = axios.create({
baseURL: config.gitea.apiUrl,
headers: {
'Authorization': `token ${config.admin.giteaAdminToken || config.gitea.accessToken}`,
Authorization: `token ${config.admin.giteaAdminToken || config.gitea.accessToken}`,
'Content-Type': 'application/json',
'User-Agent': 'curl/7.81.0', // 伪装成 curl
},
@@ -46,13 +46,22 @@ export interface GiteaService {
getCommitFiles(owner: string, repo: string, commitSha: string): Promise<PullRequestFile[]>;
// 获取与提交关联的Pull Request
getRelatedPullRequest(owner: string, repo: string, commitSha: string): Promise<PullRequestDetails | null>;
getRelatedPullRequest(
owner: string,
repo: string,
commitSha: string
): Promise<PullRequestDetails | null>;
// 获取文件内容
getFileContent(owner: string, repo: string, path: string, ref?: string): Promise<string>;
// 获取引用的相关文件
getRelatedFiles(owner: string, repo: string, files: PullRequestFile[], commitSha: string): Promise<Record<string, string>>;
getRelatedFiles(
owner: string,
repo: string,
files: PullRequestFile[],
commitSha: string
): Promise<Record<string, string>>;
// 添加PR评论
addPullRequestComment(owner: string, repo: string, prNumber: number, body: string): Promise<void>;
@@ -70,7 +79,11 @@ export interface GiteaService {
addCommitComment(owner: string, repo: string, commitSha: string, body: string): Promise<void>;
// 管理后台方法
listAllRepositories(page: number, limit: number, query?: string): Promise<{ repos: any[], totalCount: number }>;
listAllRepositories(
page: number,
limit: number,
query?: string
): Promise<{ repos: any[]; totalCount: number }>;
listWebhooks(owner: string, repo: string): Promise<any[]>;
createWebhook(owner: string, repo: string, webhookUrl: string): Promise<void>;
deleteWebhook(owner: string, repo: string, hookId: number): Promise<void>;
@@ -118,7 +131,11 @@ export const giteaService: GiteaService = {
},
// 获取PR详情
async getPullRequestDetails(owner: string, repo: string, prNumber: number): Promise<PullRequestDetails> {
async getPullRequestDetails(
owner: string,
repo: string,
prNumber: number
): Promise<PullRequestDetails> {
try {
const response = await giteaClient.get(`/repos/${owner}/${repo}/pulls/${prNumber}`);
return response.data;
@@ -129,7 +146,11 @@ export const giteaService: GiteaService = {
},
// 获取PR变更的文件列表
async getPullRequestFiles(owner: string, repo: string, prNumber: number): Promise<PullRequestFile[]> {
async getPullRequestFiles(
owner: string,
repo: string,
prNumber: number
): Promise<PullRequestFile[]> {
try {
const response = await giteaClient.get(`/repos/${owner}/${repo}/pulls/${prNumber}/files`);
return response.data || [];
@@ -153,7 +174,9 @@ export const giteaService: GiteaService = {
}
// 使用官方API获取差异使用diff格式
const diffResponse = await giteaClient.get(`/repos/${owner}/${repo}/git/commits/${commitSha}.diff`);
const diffResponse = await giteaClient.get(
`/repos/${owner}/${repo}/git/commits/${commitSha}.diff`
);
return diffResponse.data || '';
} catch (error: any) {
logger.error('获取提交差异失败:', error);
@@ -175,10 +198,9 @@ export const giteaService: GiteaService = {
if (response.data.files) {
// 如果API返回了文件列表则使用它
return response.data.files;
} else {
}
// 否则返回空数组依赖控制器中webhook提供的文件列表
return [];
}
} catch (error: any) {
logger.error('获取提交文件列表失败:', error);
throw new Error(`获取提交文件列表失败: ${error.message}`);
@@ -186,7 +208,11 @@ export const giteaService: GiteaService = {
},
// 获取与提交关联的Pull Request
async getRelatedPullRequest(owner: string, repo: string, commitSha: string): Promise<PullRequestDetails | null> {
async getRelatedPullRequest(
owner: string,
repo: string,
commitSha: string
): Promise<PullRequestDetails | null> {
try {
// 获取仓库中所有开放的PR
const response = await giteaClient.get(`/repos/${owner}/${repo}/pulls?state=open`);
@@ -198,7 +224,9 @@ export const giteaService: GiteaService = {
const prDetails = await giteaService.getPullRequestDetails(owner, repo, pr.number);
// 检查PR的提交列表
const commitsResponse = await giteaClient.get(`/repos/${owner}/${repo}/pulls/${pr.number}/commits`);
const commitsResponse = await giteaClient.get(
`/repos/${owner}/${repo}/pulls/${pr.number}/commits`
);
const commits = commitsResponse.data || [];
// 检查提交是否在PR中
@@ -239,7 +267,12 @@ export const giteaService: GiteaService = {
},
// 获取引用的相关文件
async getRelatedFiles(owner: string, repo: string, files: PullRequestFile[], commitSha: string): Promise<Record<string, string>> {
async getRelatedFiles(
owner: string,
repo: string,
files: PullRequestFile[],
commitSha: string
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
// 对每个修改过的文件,获取其完整内容
@@ -261,7 +294,12 @@ export const giteaService: GiteaService = {
},
// 添加PR评论
async addPullRequestComment(owner: string, repo: string, prNumber: number, body: string): Promise<void> {
async addPullRequestComment(
owner: string,
repo: string,
prNumber: number,
body: string
): Promise<void> {
try {
await giteaClient.post(`/repos/${owner}/${repo}/issues/${prNumber}/comments`, { body });
} catch (error: any) {
@@ -288,7 +326,7 @@ export const giteaService: GiteaService = {
await giteaClient.post(`/repos/${owner}/${repo}/pulls/${prNumber}/reviews`, {
event: 'COMMENT',
commit_id: commitId,
comments: comments.map(comment => ({
comments: comments.map((comment) => ({
path: comment.path,
body: comment.comment,
new_position: comment.line,
@@ -320,7 +358,12 @@ export const giteaService: GiteaService = {
},
// 添加提交评论
async addCommitComment(owner: string, repo: string, commitSha: string, body: string): Promise<void> {
async addCommitComment(
owner: string,
repo: string,
commitSha: string,
body: string
): Promise<void> {
try {
await giteaClient.post(`/repos/${owner}/${repo}/git/commits/${commitSha}/comments`, { body });
} catch (error: any) {
@@ -330,7 +373,11 @@ export const giteaService: GiteaService = {
},
// 获取所有仓库
async listAllRepositories(page: number = 1, limit: number = 30, query?: string): Promise<{ repos: any[], totalCount: number }> {
async listAllRepositories(
page = 1,
limit = 30,
query?: string
): Promise<{ repos: any[]; totalCount: number }> {
try {
const response = await giteaAdminClient.get('/repos/search', {
params: {
@@ -339,7 +386,7 @@ export const giteaService: GiteaService = {
q: query,
},
});
const totalCount = parseInt(response.headers['x-total-count'] || '0', 10);
const totalCount = Number.parseInt(response.headers['x-total-count'] || '0', 10);
return { repos: response.data.data, totalCount };
} catch (error: any) {
logger.error('获取所有仓库列表失败:', error);

View File

@@ -28,7 +28,7 @@ function formatMessage(level: LogLevel, message: string, meta?: any): string {
if (meta) {
try {
formattedMessage += ` - ${JSON.stringify(meta)}`;
} catch (error) {
} catch (_error) {
formattedMessage += ` - ${meta}`;
}
}