mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d6d167b33 | ||
|
|
1e7c80ca9f | ||
|
|
b92765ce7f | ||
|
|
daae32ce07 | ||
|
|
ab984ff415 | ||
|
|
d49a16db6e | ||
|
|
3a97d673f6 | ||
|
|
b6e6ee0927 | ||
|
|
22b603258a |
@@ -2,6 +2,10 @@
|
||||
PORT=5174
|
||||
# 可选,默认为 ./data/assistant.db
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# 可选,默认 info,可选值:debug/info/warn/error
|
||||
# 开发环境建议:LOG_LEVEL=info
|
||||
# 生产环境建议:LOG_LEVEL=error
|
||||
# LOG_LEVEL=info
|
||||
# 必填,运行 openssl rand -hex 32 生成
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
## [1.3.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.0...v1.3.1) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **db:** self-heal missing repository prompt schema ([b6e6ee0](https://github.com/jeffusion/gitea-ai-assistant/commit/b6e6ee0927eb757b86ee426bf8eed84ae633621a))
|
||||
* **logs:** gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag ([3a97d67](https://github.com/jeffusion/gitea-ai-assistant/commit/3a97d673f671752a2e7f676fb0074b413c2e40cc))
|
||||
* **repo:** add structured diagnostics for repository list failures ([22b6032](https://github.com/jeffusion/gitea-ai-assistant/commit/22b603258ac32e70653aa1a05032a91e8ad23f89))
|
||||
|
||||
# [1.3.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.1...v1.3.0) (2026-03-26)
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ bun run bootstrap
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # required, 64 hex chars (openssl rand -hex 32)
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # dev default; use LOG_LEVEL=error in production
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` is mandatory. The app refuses to start without it.
|
||||
|
||||
27
bun.lock
27
bun.lock
@@ -14,6 +14,7 @@
|
||||
"hono": "^4.11.9",
|
||||
"lodash-es": "^4.17.21",
|
||||
"openai": "^4.87.3",
|
||||
"pino": "^10.3.1",
|
||||
"tokenlens": "^1.3.1",
|
||||
"zod": "^3.25.1",
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
@@ -90,6 +91,8 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="],
|
||||
@@ -190,6 +193,8 @@
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
@@ -532,6 +537,8 @@
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"openai": ["openai@4.104.0", "", { "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-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
|
||||
@@ -584,18 +591,28 @@
|
||||
|
||||
"pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="],
|
||||
|
||||
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="],
|
||||
@@ -604,6 +621,8 @@
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
@@ -618,6 +637,8 @@
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"semantic-release": ["semantic-release@24.2.9", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^11.0.0", "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.0.0-beta.1", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^8.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^11.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^17.5.1" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -638,6 +659,8 @@
|
||||
|
||||
"skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"spawn-error-forwarder": ["spawn-error-forwarder@1.0.0", "", {}, "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g=="],
|
||||
@@ -684,6 +707,8 @@
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||
|
||||
"through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="],
|
||||
|
||||
"time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="],
|
||||
@@ -1164,6 +1189,8 @@
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
|
||||
|
||||
"pino-abstract-transport/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
|
||||
|
||||
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
@@ -13,6 +13,8 @@ services:
|
||||
- assistant_data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
LOG_LEVEL: error
|
||||
depends_on:
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -14,6 +14,7 @@ This project uses a DB-first runtime configuration model:
|
||||
| `ENCRYPTION_KEY` | Yes | AES-256-GCM master key (64 hex chars) for API key encryption | - |
|
||||
| `PORT` | No | Service port | `5174` |
|
||||
| `DATABASE_PATH` | No | SQLite path | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | No | Backend log level (`debug`/`info`/`warn`/`error`). Default is `info`; use `error` in production. | `info` |
|
||||
|
||||
Generate key:
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥(AES-256-GCM,64 位十六进制) | - |
|
||||
| `PORT` | 否 | 服务端口 | `5174` |
|
||||
| `DATABASE_PATH` | 否 | SQLite 路径 | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | 否 | 后端日志级别(`debug`/`info`/`warn`/`error`)。默认 `info`;生产环境建议 `error`。 | `info` |
|
||||
|
||||
生成密钥:
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 gitea-assistant
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error gitea-assistant
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
@@ -18,11 +18,14 @@ docker compose up -d
|
||||
- `gitea-assistant`
|
||||
- `qdrant`
|
||||
|
||||
Production default in compose sets `LOG_LEVEL=error`.
|
||||
|
||||
If you do not use memory features, Qdrant can be optional in custom compose setups.
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes manifests are in `k8s/`.
|
||||
The default ConfigMap sets `LOG_LEVEL=error` for production.
|
||||
|
||||
### 1) Create namespace and encryption secret
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 gitea-assistant
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error gitea-assistant
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
@@ -18,11 +18,14 @@ docker compose up -d
|
||||
- `gitea-assistant`
|
||||
- `qdrant`
|
||||
|
||||
Compose 生产默认日志级别已设置为 `LOG_LEVEL=error`。
|
||||
|
||||
如果不使用记忆能力,可在自定义编排中将 Qdrant 设为可选。
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes 清单位于 `k8s/` 目录。
|
||||
默认 ConfigMap 已将生产日志级别设置为 `LOG_LEVEL=error`。
|
||||
|
||||
### 1) 创建命名空间与加密密钥
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ Create `.env`:
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # required, generate with: openssl rand -hex 32
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # local dev default; use LOG_LEVEL=error in production
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` is required. Application startup fails when it is missing.
|
||||
|
||||
@@ -30,6 +30,7 @@ bun run bootstrap
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # 必填,使用 openssl rand -hex 32 生成
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # 本地开发默认;生产环境请使用 LOG_LEVEL=error
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` 为必填项,缺失时服务会拒绝启动。
|
||||
|
||||
@@ -10,6 +10,7 @@ metadata:
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
data:
|
||||
PORT: "5174"
|
||||
LOG_LEVEL: "error"
|
||||
# All settings (Gitea connection, webhook secret, admin password, review engine,
|
||||
# Feishu, memory, etc.) are managed through the Admin Dashboard Web UI.
|
||||
# They are auto-seeded with secure defaults on first boot.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"hono": "^4.11.9",
|
||||
"lodash-es": "^4.17.21",
|
||||
"openai": "^4.87.3",
|
||||
"pino": "^10.3.1",
|
||||
"tokenlens": "^1.3.1",
|
||||
"zod": "^3.25.1",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
|
||||
@@ -4,6 +4,7 @@ import config from '../config';
|
||||
import { repositoryReviewPromptRepo } from '../db/repositories/repository-review-prompt-repo';
|
||||
import { reviewEngine } from '../review/engine';
|
||||
import { giteaService } from '../services/gitea';
|
||||
import { toErrorLogMeta } from '../utils/error-log';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const publicRoutes = new Hono();
|
||||
@@ -31,17 +32,55 @@ publicRoutes.post('/login', async (c) => {
|
||||
|
||||
// 获取仓库列表及 Webhook 状态
|
||||
protectedRoutes.get('/repositories', async (c) => {
|
||||
const page = Number.parseInt(c.req.query('page') || '1', 10);
|
||||
const query = c.req.query('q');
|
||||
const limit = 30; // 每页数量固定,或也可从查询参数获取
|
||||
const requestContext = {
|
||||
page,
|
||||
limit,
|
||||
query: query ?? null,
|
||||
requestUrl: c.req.url,
|
||||
method: c.req.method,
|
||||
runtime: process.versions.bun ? `bun-${process.versions.bun}` : process.version,
|
||||
nodeEnv: process.env.NODE_ENV ?? null,
|
||||
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
|
||||
};
|
||||
|
||||
try {
|
||||
const page = Number.parseInt(c.req.query('page') || '1', 10);
|
||||
const query = c.req.query('q');
|
||||
const limit = 30; // 每页数量固定,或也可从查询参数获取
|
||||
logger.debug('开始获取仓库列表', requestContext);
|
||||
|
||||
const { repos, totalCount } = await giteaService.listAllRepositories(page, limit, query);
|
||||
logger.debug('仓库搜索接口返回成功', {
|
||||
...requestContext,
|
||||
reposCount: repos.length,
|
||||
totalCount,
|
||||
sampleRepos: repos
|
||||
.slice(0, 3)
|
||||
.map((repo) => (typeof repo.full_name === 'string' ? repo.full_name : null)),
|
||||
});
|
||||
|
||||
const webhookUrl = c.req.url.replace(/\/admin\/api\/repositories.*$/, '/webhook/gitea');
|
||||
const fullNames = repos
|
||||
.map((repo) => (typeof repo.full_name === 'string' ? repo.full_name : null))
|
||||
.filter((name): name is string => name !== null);
|
||||
const promptMap = repositoryReviewPromptRepo.listProjectPrompts(fullNames);
|
||||
logger.debug('准备批量读取项目级提示词', {
|
||||
...requestContext,
|
||||
fullNamesCount: fullNames.length,
|
||||
fullNamesSample: fullNames.slice(0, 5),
|
||||
});
|
||||
|
||||
let promptMap: Record<string, string>;
|
||||
try {
|
||||
promptMap = repositoryReviewPromptRepo.listProjectPrompts(fullNames);
|
||||
} catch (error: unknown) {
|
||||
logger.error('批量读取项目级提示词失败', {
|
||||
...requestContext,
|
||||
fullNamesCount: fullNames.length,
|
||||
fullNamesSample: fullNames.slice(0, 5),
|
||||
error: toErrorLogMeta(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reposWithStatus = await Promise.all(
|
||||
repos.map(async (repo) => {
|
||||
@@ -70,9 +109,13 @@ protectedRoutes.get('/repositories', async (c) => {
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('获取仓库列表失败:', error);
|
||||
return c.json({ message: 'Failed to fetch repositories', error: error.message }, 500);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取仓库列表失败:', {
|
||||
...requestContext,
|
||||
error: toErrorLogMeta(error),
|
||||
});
|
||||
return c.json({ message: 'Failed to fetch repositories', error: errorMessage }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, getDatabase, initDatabase } from '../database';
|
||||
|
||||
function createInconsistentMigrationState(dbPath: string): void {
|
||||
const db = new Database(dbPath);
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.query('INSERT INTO _migrations (version, name) VALUES (?, ?)').run(
|
||||
1,
|
||||
'init_llm_provider_schema'
|
||||
);
|
||||
db.query('INSERT INTO _migrations (version, name) VALUES (?, ?)').run(
|
||||
2,
|
||||
'remove_legacy_review_mode'
|
||||
);
|
||||
db.query('INSERT INTO _migrations (version, name) VALUES (?, ?)').run(
|
||||
3,
|
||||
'add_repository_review_prompts'
|
||||
);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe('migration self-heal for repository review prompts', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `db-migration-heal-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
createInconsistentMigrationState(dbPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('rebuilds missing repository_review_prompts table even when migration 3 is marked applied', () => {
|
||||
initDatabase();
|
||||
const db = getDatabase();
|
||||
|
||||
const tableRow = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get('repository_review_prompts') as { name: string } | null;
|
||||
expect(tableRow?.name).toBe('repository_review_prompts');
|
||||
|
||||
const migrationCountRow = db
|
||||
.query('SELECT COUNT(*) AS count FROM _migrations WHERE version = ?')
|
||||
.get(3) as { count: number } | null;
|
||||
expect(migrationCountRow?.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../database';
|
||||
import { closeDatabase, getDatabase, initDatabase } from '../database';
|
||||
import { repositoryReviewPromptRepo } from '../repositories/repository-review-prompt-repo';
|
||||
|
||||
describe('repository-review-prompt-repo', () => {
|
||||
@@ -64,4 +64,30 @@ describe('repository-review-prompt-repo', () => {
|
||||
'acme/b': 'prompt-b',
|
||||
});
|
||||
});
|
||||
|
||||
test('self-heals missing prompt table and keeps repository listing readable', () => {
|
||||
const db = getDatabase();
|
||||
db.exec('DROP TABLE repository_review_prompts');
|
||||
|
||||
const map = repositoryReviewPromptRepo.listProjectPrompts(['acme/a']);
|
||||
expect(map).toEqual({});
|
||||
|
||||
repositoryReviewPromptRepo.setProjectPrompt('acme', 'a', 'prompt-a');
|
||||
expect(repositoryReviewPromptRepo.getProjectPrompt('acme', 'a')).toBe('prompt-a');
|
||||
});
|
||||
|
||||
test('self-heals missing prompt table for direct prompt write path', () => {
|
||||
const db = getDatabase();
|
||||
db.exec('DROP TABLE repository_review_prompts');
|
||||
|
||||
const row = repositoryReviewPromptRepo.setProjectPrompt(
|
||||
'acme',
|
||||
'direct-write',
|
||||
'prompt-direct'
|
||||
);
|
||||
expect(row.project_prompt).toBe('prompt-direct');
|
||||
expect(repositoryReviewPromptRepo.getProjectPrompt('acme', 'direct-write')).toBe(
|
||||
'prompt-direct'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ const MIGRATIONS: Migration[] = [
|
||||
migration003RepositoryReviewPrompts,
|
||||
];
|
||||
|
||||
const REPOSITORY_REVIEW_PROMPTS_TABLE = 'repository_review_prompts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database singleton
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -72,11 +74,39 @@ export function initDatabase(): Database {
|
||||
|
||||
// Run migrations
|
||||
runMigrations(db);
|
||||
ensureRepositoryReviewPromptsSchema(db);
|
||||
|
||||
console.log(`📦 Database initialized at ${dbPath}`);
|
||||
return db;
|
||||
}
|
||||
|
||||
function doesTableExist(database: Database, tableName: string): boolean {
|
||||
const row = database
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { name?: string } | null;
|
||||
return row?.name === tableName;
|
||||
}
|
||||
|
||||
export function ensureRepositoryReviewPromptsSchema(database: Database = getDatabase()): void {
|
||||
if (doesTableExist(database, REPOSITORY_REVIEW_PROMPTS_TABLE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`⚠️ Detected inconsistent DB state: table '${REPOSITORY_REVIEW_PROMPTS_TABLE}' is missing. Rebuilding schema.`
|
||||
);
|
||||
|
||||
database.transaction(() => {
|
||||
migration003RepositoryReviewPrompts.up(database);
|
||||
|
||||
if (doesTableExist(database, '_migrations')) {
|
||||
database
|
||||
.query('INSERT OR IGNORE INTO _migrations (version, name) VALUES (?, ?)')
|
||||
.run(migration003RepositoryReviewPrompts.version, migration003RepositoryReviewPrompts.name);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database instance. Throws if not initialized.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ export const migration003RepositoryReviewPrompts: Migration = {
|
||||
|
||||
up(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE repository_review_prompts (
|
||||
CREATE TABLE IF NOT EXISTS repository_review_prompts (
|
||||
full_name TEXT PRIMARY KEY,
|
||||
project_prompt TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
@@ -15,7 +15,7 @@ export const migration003RepositoryReviewPrompts: Migration = {
|
||||
`);
|
||||
|
||||
db.exec(
|
||||
'CREATE INDEX idx_repository_review_prompts_updated_at ON repository_review_prompts(updated_at)'
|
||||
'CREATE INDEX IF NOT EXISTS idx_repository_review_prompts_updated_at ON repository_review_prompts(updated_at)'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getDatabase } from '../database';
|
||||
import { toErrorLogMeta } from '../../utils/error-log';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ensureRepositoryReviewPromptsSchema, getDatabase } from '../database';
|
||||
|
||||
export interface RepositoryReviewPromptRow {
|
||||
full_name: string;
|
||||
@@ -10,16 +12,43 @@ function toFullName(owner: string, repo: string): string {
|
||||
return `${owner}/${repo}`;
|
||||
}
|
||||
|
||||
function isMissingPromptTableError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && error.message.includes('no such table: repository_review_prompts')
|
||||
);
|
||||
}
|
||||
|
||||
function withPromptTableHeal<T>(operation: string, run: () => T): T {
|
||||
try {
|
||||
return run();
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingPromptTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.warn('检测到 repository_review_prompts 表缺失,尝试自愈建表后重试', {
|
||||
operation,
|
||||
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
|
||||
error: toErrorLogMeta(error),
|
||||
});
|
||||
|
||||
ensureRepositoryReviewPromptsSchema();
|
||||
return run();
|
||||
}
|
||||
}
|
||||
|
||||
export const repositoryReviewPromptRepo = {
|
||||
getByFullName(fullName: string): RepositoryReviewPromptRow | null {
|
||||
const db = getDatabase();
|
||||
return (
|
||||
(db
|
||||
.query(
|
||||
'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?'
|
||||
)
|
||||
.get(fullName) as RepositoryReviewPromptRow | null) || null
|
||||
);
|
||||
return withPromptTableHeal('getByFullName', () => {
|
||||
const db = getDatabase();
|
||||
return (
|
||||
(db
|
||||
.query(
|
||||
'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?'
|
||||
)
|
||||
.get(fullName) as RepositoryReviewPromptRow | null) || null
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
getProjectPrompt(owner: string, repo: string): string | undefined {
|
||||
@@ -30,25 +59,27 @@ export const repositoryReviewPromptRepo = {
|
||||
},
|
||||
|
||||
upsertByFullName(fullName: string, projectPrompt: string): RepositoryReviewPromptRow {
|
||||
const db = getDatabase();
|
||||
const normalized = projectPrompt.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('projectPrompt must be non-empty');
|
||||
}
|
||||
|
||||
db.query(
|
||||
`INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(full_name) DO UPDATE SET
|
||||
project_prompt = excluded.project_prompt,
|
||||
updated_at = datetime('now')`
|
||||
).run(fullName, normalized);
|
||||
return withPromptTableHeal('upsertByFullName', () => {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(full_name) DO UPDATE SET
|
||||
project_prompt = excluded.project_prompt,
|
||||
updated_at = datetime('now')`
|
||||
).run(fullName, normalized);
|
||||
|
||||
const row = this.getByFullName(fullName);
|
||||
if (!row) {
|
||||
throw new Error('Failed to load repository review prompt after upsert');
|
||||
}
|
||||
return row;
|
||||
const row = this.getByFullName(fullName);
|
||||
if (!row) {
|
||||
throw new Error('Failed to load repository review prompt after upsert');
|
||||
}
|
||||
return row;
|
||||
});
|
||||
},
|
||||
|
||||
setProjectPrompt(owner: string, repo: string, projectPrompt: string): RepositoryReviewPromptRow {
|
||||
@@ -56,11 +87,13 @@ export const repositoryReviewPromptRepo = {
|
||||
},
|
||||
|
||||
deleteByFullName(fullName: string): boolean {
|
||||
const db = getDatabase();
|
||||
const result = db
|
||||
.query('DELETE FROM repository_review_prompts WHERE full_name = ?')
|
||||
.run(fullName);
|
||||
return result.changes > 0;
|
||||
return withPromptTableHeal('deleteByFullName', () => {
|
||||
const db = getDatabase();
|
||||
const result = db
|
||||
.query('DELETE FROM repository_review_prompts WHERE full_name = ?')
|
||||
.run(fullName);
|
||||
return result.changes > 0;
|
||||
});
|
||||
},
|
||||
|
||||
clearProjectPrompt(owner: string, repo: string): boolean {
|
||||
@@ -73,22 +106,83 @@ export const repositoryReviewPromptRepo = {
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const placeholders = fullNames.map(() => '?').join(', ');
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT full_name, project_prompt
|
||||
FROM repository_review_prompts
|
||||
WHERE full_name IN (${placeholders})`
|
||||
)
|
||||
.all(...fullNames) as Array<Pick<RepositoryReviewPromptRow, 'full_name' | 'project_prompt'>>;
|
||||
const loadPromptMap = (): Record<string, string> => {
|
||||
const placeholders = fullNames.map(() => '?').join(', ');
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT full_name, project_prompt
|
||||
FROM repository_review_prompts
|
||||
WHERE full_name IN (${placeholders})`
|
||||
)
|
||||
.all(...fullNames) as Array<
|
||||
Pick<RepositoryReviewPromptRow, 'full_name' | 'project_prompt'>
|
||||
>;
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
const normalized = row.project_prompt.trim();
|
||||
if (normalized) {
|
||||
map[row.full_name] = normalized;
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
const normalized = row.project_prompt.trim();
|
||||
if (normalized) {
|
||||
map[row.full_name] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
try {
|
||||
return loadPromptMap();
|
||||
} catch (error: unknown) {
|
||||
if (isMissingPromptTableError(error)) {
|
||||
logger.warn('检测到 repository_review_prompts 表缺失,尝试自愈建表后重试', {
|
||||
fullNamesCount: fullNames.length,
|
||||
fullNamesSample: fullNames.slice(0, 5),
|
||||
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
|
||||
});
|
||||
|
||||
try {
|
||||
ensureRepositoryReviewPromptsSchema(db);
|
||||
return loadPromptMap();
|
||||
} catch (healError: unknown) {
|
||||
logger.error('自愈 repository_review_prompts 表后重试失败,降级返回空提示词映射', {
|
||||
fullNamesCount: fullNames.length,
|
||||
fullNamesSample: fullNames.slice(0, 5),
|
||||
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
|
||||
originalError: toErrorLogMeta(error),
|
||||
healError: toErrorLogMeta(healError),
|
||||
});
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
let tableExists: boolean | null = null;
|
||||
let latestMigrationVersion: number | null = null;
|
||||
|
||||
try {
|
||||
const tableRow = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get('repository_review_prompts') as { name?: string } | null;
|
||||
tableExists = tableRow?.name === 'repository_review_prompts';
|
||||
|
||||
const migrationRow = db
|
||||
.query('SELECT version FROM _migrations ORDER BY version DESC LIMIT 1')
|
||||
.get() as { version?: number } | null;
|
||||
latestMigrationVersion = migrationRow?.version ?? null;
|
||||
} catch (inspectError: unknown) {
|
||||
logger.warn('查询项目级提示词失败后,诊断数据库状态时发生错误', {
|
||||
inspectError: toErrorLogMeta(inspectError),
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('批量查询项目级提示词失败', {
|
||||
fullNamesCount: fullNames.length,
|
||||
fullNamesSample: fullNames.slice(0, 5),
|
||||
tableExists,
|
||||
latestMigrationVersion,
|
||||
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
|
||||
error: toErrorLogMeta(error),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import config from '../config';
|
||||
import { toErrorLogMeta } from '../utils/error-log';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface LineComment {
|
||||
@@ -32,6 +33,35 @@ giteaAdminClient.interceptors.request.use((req) => {
|
||||
return req;
|
||||
});
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function getResponseDataPreview(data: unknown): unknown {
|
||||
if (typeof data === 'string') {
|
||||
return data.length > 1000 ? `${data.slice(0, 1000)}...(truncated)` : data;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function getAxiosErrorMeta(error: unknown): Record<string, unknown> | null {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: error.code ?? null,
|
||||
status: error.response?.status ?? null,
|
||||
statusText: error.response?.statusText ?? null,
|
||||
method: error.config?.method ?? null,
|
||||
baseURL: error.config?.baseURL ?? null,
|
||||
url: error.config?.url ?? null,
|
||||
params: error.config?.params ?? null,
|
||||
responseHeaders: error.response?.headers ?? null,
|
||||
responseDataPreview: getResponseDataPreview(error.response?.data),
|
||||
};
|
||||
}
|
||||
|
||||
// Gitea服务接口定义
|
||||
export interface GiteaService {
|
||||
// 获取PR的文件差异
|
||||
@@ -382,7 +412,20 @@ export const giteaService: GiteaService = {
|
||||
limit = 30,
|
||||
query?: string
|
||||
): Promise<{ repos: any[]; totalCount: number }> {
|
||||
const requestContext = {
|
||||
page,
|
||||
limit,
|
||||
query: query ?? null,
|
||||
apiUrl: config.gitea.apiUrl,
|
||||
hasAdminToken: Boolean(config.admin.giteaAdminToken),
|
||||
hasAccessToken: Boolean(config.gitea.accessToken),
|
||||
runtime: process.versions.bun ? `bun-${process.versions.bun}` : process.version,
|
||||
nodeEnv: process.env.NODE_ENV ?? null,
|
||||
};
|
||||
|
||||
try {
|
||||
logger.debug('开始请求 Gitea 仓库搜索接口', requestContext);
|
||||
|
||||
const response = await giteaAdminClient.get('/repos/search', {
|
||||
params: {
|
||||
page,
|
||||
@@ -390,11 +433,51 @@ export const giteaService: GiteaService = {
|
||||
q: query,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('Gitea 仓库搜索接口返回成功', {
|
||||
...requestContext,
|
||||
status: response.status,
|
||||
contentType: response.headers['content-type'] ?? null,
|
||||
dataCount: Array.isArray(response.data?.data) ? response.data.data.length : null,
|
||||
headerTotalCount: response.headers['x-total-count'] ?? null,
|
||||
});
|
||||
|
||||
const totalCount = Number.parseInt(response.headers['x-total-count'] || '0', 10);
|
||||
return { repos: response.data.data, totalCount };
|
||||
} catch (error: any) {
|
||||
logger.error('获取所有仓库列表失败:', error);
|
||||
throw new Error(`获取所有仓库列表失败: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
let rawResponseProbe: Record<string, unknown> | null = null;
|
||||
|
||||
try {
|
||||
const probeResponse = await giteaAdminClient.get('/repos/search', {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
q: query,
|
||||
},
|
||||
responseType: 'text',
|
||||
transformResponse: [(data) => data],
|
||||
});
|
||||
|
||||
rawResponseProbe = {
|
||||
status: probeResponse.status,
|
||||
contentType: probeResponse.headers['content-type'] ?? null,
|
||||
bodyLength: typeof probeResponse.data === 'string' ? probeResponse.data.length : null,
|
||||
bodyPreview: getResponseDataPreview(probeResponse.data),
|
||||
};
|
||||
} catch (probeError: unknown) {
|
||||
rawResponseProbe = {
|
||||
probeError: toErrorLogMeta(probeError),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error('获取所有仓库列表失败:', {
|
||||
...requestContext,
|
||||
error: toErrorLogMeta(error),
|
||||
axiosError: getAxiosErrorMeta(error),
|
||||
rawResponseProbe,
|
||||
});
|
||||
|
||||
throw new Error(`获取所有仓库列表失败: ${getErrorMessage(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
30
src/utils/error-log.ts
Normal file
30
src/utils/error-log.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function toUnknownRecord(value: unknown): UnknownRecord {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value as UnknownRecord;
|
||||
}
|
||||
return { value };
|
||||
}
|
||||
|
||||
export function toErrorLogMeta(error: unknown): UnknownRecord {
|
||||
if (error instanceof Error) {
|
||||
const base: UnknownRecord = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
|
||||
const ownProps = Object.getOwnPropertyNames(error);
|
||||
for (const prop of ownProps) {
|
||||
if (prop in base) {
|
||||
continue;
|
||||
}
|
||||
base[prop] = (error as unknown as UnknownRecord)[prop];
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
return toUnknownRecord(error);
|
||||
}
|
||||
@@ -1,58 +1,90 @@
|
||||
// 简单的日志实用工具
|
||||
import pino from 'pino';
|
||||
|
||||
/**
|
||||
* 日志级别
|
||||
*/
|
||||
export enum LogLevel {
|
||||
DEBUG = 'DEBUG',
|
||||
INFO = 'INFO',
|
||||
WARN = 'WARN',
|
||||
ERROR = 'ERROR',
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
function formatTime(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
type LogMeta = Record<string, unknown>;
|
||||
type ErrorWithCode = Error & { code?: unknown };
|
||||
|
||||
/**
|
||||
* 格式化日志消息
|
||||
*/
|
||||
function formatMessage(level: LogLevel, message: string, meta?: any): string {
|
||||
const timestamp = formatTime();
|
||||
|
||||
let formattedMessage = `[${timestamp}] [${level}] ${message}`;
|
||||
|
||||
if (meta) {
|
||||
try {
|
||||
formattedMessage += ` - ${JSON.stringify(meta)}`;
|
||||
} catch (_error) {
|
||||
formattedMessage += ` - ${meta}`;
|
||||
}
|
||||
function resolveLogLevel(rawLevel: string | undefined): LogLevel {
|
||||
if (!rawLevel) {
|
||||
return LogLevel.INFO;
|
||||
}
|
||||
|
||||
return formattedMessage;
|
||||
const normalized = rawLevel.toLowerCase();
|
||||
if (normalized === LogLevel.DEBUG) return LogLevel.DEBUG;
|
||||
if (normalized === LogLevel.INFO) return LogLevel.INFO;
|
||||
if (normalized === LogLevel.WARN) return LogLevel.WARN;
|
||||
if (normalized === LogLevel.ERROR) return LogLevel.ERROR;
|
||||
return LogLevel.INFO;
|
||||
}
|
||||
|
||||
function toLogMeta(meta: unknown): LogMeta | undefined {
|
||||
if (meta === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (meta instanceof Error) {
|
||||
const maybeCode = (meta as ErrorWithCode).code;
|
||||
const code =
|
||||
typeof maybeCode === 'string' || typeof maybeCode === 'number' ? maybeCode : undefined;
|
||||
|
||||
return {
|
||||
error: {
|
||||
name: meta.name,
|
||||
message: meta.message,
|
||||
stack: meta.stack,
|
||||
...(code !== undefined ? { code } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof meta === 'object' && meta !== null) {
|
||||
return meta as LogMeta;
|
||||
}
|
||||
|
||||
return { meta };
|
||||
}
|
||||
|
||||
const baseLogger = pino({
|
||||
level: resolveLogLevel(process.env.LOG_LEVEL),
|
||||
base: null,
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
formatters: {
|
||||
level(label) {
|
||||
return { level: label.toUpperCase() };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function writeLog(level: LogLevel, message: string, meta?: unknown): void {
|
||||
const logMeta = toLogMeta(meta);
|
||||
if (logMeta) {
|
||||
baseLogger[level](logMeta, message);
|
||||
return;
|
||||
}
|
||||
|
||||
baseLogger[level](message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志实用工具
|
||||
*/
|
||||
export const logger = {
|
||||
debug(message: string, meta?: any) {
|
||||
console.debug(formatMessage(LogLevel.DEBUG, message, meta));
|
||||
debug(message: string, meta?: unknown): void {
|
||||
writeLog(LogLevel.DEBUG, message, meta);
|
||||
},
|
||||
|
||||
info(message: string, meta?: any) {
|
||||
console.info(formatMessage(LogLevel.INFO, message, meta));
|
||||
info(message: string, meta?: unknown): void {
|
||||
writeLog(LogLevel.INFO, message, meta);
|
||||
},
|
||||
|
||||
warn(message: string, meta?: any) {
|
||||
console.warn(formatMessage(LogLevel.WARN, message, meta));
|
||||
warn(message: string, meta?: unknown): void {
|
||||
writeLog(LogLevel.WARN, message, meta);
|
||||
},
|
||||
|
||||
error(message: string, meta?: any) {
|
||||
console.error(formatMessage(LogLevel.ERROR, message, meta));
|
||||
error(message: string, meta?: unknown): void {
|
||||
writeLog(LogLevel.ERROR, message, meta);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user