9 Commits

Author SHA1 Message Date
semantic-release-bot
8d6d167b33 chore(release): 1.3.1 [skip ci]
## [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](b6e6ee0927))
* **logs:** gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag ([3a97d67](3a97d673f6))
* **repo:** add structured diagnostics for repository list failures ([22b6032](22b603258a))
2026-03-26 15:52:17 +00:00
jeffusion
1e7c80ca9f docs: document LOG_LEVEL configuration and production defaults
Update all documentation to reflect new global LOG_LEVEL environment variable.

- Add LOG_LEVEL to configuration reference tables

- Update deployment guides with LOG_LEVEL=error examples

- Clarify dev (info) vs production (error) log level recommendations

- Add LOG_LEVEL to all .env examples and quick start guides

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
b92765ce7f chore(deploy): set production LOG_LEVEL to error
Configure production deployments to use LOG_LEVEL=error for minimal log volume.

- Add LOG_LEVEL=error to docker-compose.yml environment

- Add LOG_LEVEL: error to K8s ConfigMap

- Update .env.example with dev/prod LOG_LEVEL guidance

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
daae32ce07 chore(deps): add pino for structured logging
Add pino v10.3.1 and its dependencies to support global LOG_LEVEL logging.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
ab984ff415 refactor(logger): migrate to pino with global LOG_LEVEL control
Replace custom console-based logger with pino backend supporting LOG_LEVEL environment variable.

- Add pino dependency for structured JSON logging

- Implement LOG_LEVEL env var support (debug/info/warn/error, default: info)

- Remove REPO_LIST_DEBUG_LOGS special flag in favor of global LOG_LEVEL

- Preserve existing logger API compatibility (message, meta?)

- Add safe error serialization to prevent credential leakage

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
d49a16db6e test(db): add self-healing tests for missing repository prompt table
- Test runtime self-healing when repository_review_prompts table is dropped

- Test migration layer self-healing for inconsistent DB state

- Verify repository listing remains functional during schema recovery

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
3a97d673f6 fix(logs): gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag
- Add REPO_LIST_DEBUG_LOGS environment variable to control debug output

- Gate debug logs in admin controller and gitea service

- Keep error/warn logs always enabled for production visibility

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
b6e6ee0927 fix(db): self-heal missing repository prompt schema
Recover inconsistent SQLite states where migration v3 is marked applied but repository_review_prompts objects are absent, preventing admin repository listing failures in docker deployments.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
jeffusion
22b603258a fix(repo): add structured diagnostics for repository list failures
Capture request/runtime context plus nested error metadata so docker-only repository-list issues can be diagnosed quickly.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-03-26 23:50:59 +08:00
22 changed files with 564 additions and 96 deletions

View File

@@ -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=

View File

@@ -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)

View File

@@ -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.

View File

@@ -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=="],

View File

@@ -13,6 +13,8 @@ services:
- assistant_data:/app/data
env_file:
- .env
environment:
LOG_LEVEL: error
depends_on:
qdrant:
condition: service_healthy

View File

@@ -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:

View File

@@ -14,6 +14,7 @@
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥AES-256-GCM64 位十六进制) | - |
| `PORT` | 否 | 服务端口 | `5174` |
| `DATABASE_PATH` | 否 | SQLite 路径 | `./data/assistant.db` |
| `LOG_LEVEL` | 否 | 后端日志级别(`debug`/`info`/`warn`/`error`)。默认 `info`;生产环境建议 `error`。 | `info` |
生成密钥:

View File

@@ -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

View File

@@ -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) 创建命名空间与加密密钥

View File

@@ -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.

View File

@@ -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` 为必填项,缺失时服务会拒绝启动。

View File

@@ -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.

View File

@@ -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"

View File

@@ -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);
}
});

View File

@@ -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);
});
});

View File

@@ -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'
);
});
});

View File

@@ -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.
*/

View File

@@ -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)'
);
},
};

View File

@@ -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;
},
};

View File

@@ -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
View 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);
}

View File

@@ -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);
},
};