@aipper/zentao-mcp-server 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +21 -0
- package/README.md +92 -0
- package/package.json +18 -0
- package/scripts/release-npm.sh +481 -0
- package/scripts/smoke.mjs +37 -0
- package/src/index.js +91 -0
- package/src/tools.js +47 -0
- package/src/zentao.js +173 -0
package/.env.example
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# 禅道基础地址(不要带末尾 /)
|
|
2
|
+
ZENTAO_BASE_URL=http://zentao.example.com
|
|
3
|
+
|
|
4
|
+
# 必填:账号密码(MCP 启动后自动获取并缓存 Token)
|
|
5
|
+
ZENTAO_ACCOUNT=your_account
|
|
6
|
+
ZENTAO_PASSWORD=your_password
|
|
7
|
+
|
|
8
|
+
# API 前缀(多数禅道为 /api.php/v1)
|
|
9
|
+
ZENTAO_API_PREFIX=/api.php/v1
|
|
10
|
+
|
|
11
|
+
# 获取 Token 的路径(相对 ZENTAO_BASE_URL;通常为 /api.php/v1/tokens)
|
|
12
|
+
ZENTAO_TOKEN_PATH=/api.php/v1/tokens
|
|
13
|
+
|
|
14
|
+
# Token 缓存时间(毫秒)。不知道服务端过期时间时,先保守设置 30~50 分钟
|
|
15
|
+
ZENTAO_TOKEN_TTL_MS=3000000
|
|
16
|
+
|
|
17
|
+
# 可选:超时(毫秒)
|
|
18
|
+
ZENTAO_HTTP_TIMEOUT_MS=30000
|
|
19
|
+
|
|
20
|
+
# 可选:是否在 get_token 工具里回显完整 token(默认 false)
|
|
21
|
+
ZENTAO_EXPOSE_TOKEN=false
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# zentao-mcp-server(自建)
|
|
2
|
+
|
|
3
|
+
## 目标
|
|
4
|
+
- 提供一个可运行的 MCP Server(stdio),连接你的禅道 RESTful API v1。
|
|
5
|
+
- 最小功能:自动获取/缓存 Token + 通用 `call` 工具 + 少量便捷工具示例。
|
|
6
|
+
|
|
7
|
+
## 非目标
|
|
8
|
+
- 不在仓库内存任何密钥/Token。
|
|
9
|
+
- 不保证覆盖你禅道的全部 API;优先把“通用 call”跑通,再按你的流程补工具。
|
|
10
|
+
|
|
11
|
+
## 依赖
|
|
12
|
+
- Node.js 18+(需要内置 `fetch`)
|
|
13
|
+
|
|
14
|
+
## 配置
|
|
15
|
+
复制 `.env.example` 为 `.env` 并填写:
|
|
16
|
+
- `ZENTAO_BASE_URL`
|
|
17
|
+
- `ZENTAO_ACCOUNT`
|
|
18
|
+
- `ZENTAO_PASSWORD`
|
|
19
|
+
|
|
20
|
+
> 注意:不同禅道版本/部署方式的 token 端点与返回结构可能不同;可通过 `ZENTAO_TOKEN_PATH`/`ZENTAO_API_PREFIX` 调整。
|
|
21
|
+
|
|
22
|
+
## 安装与运行
|
|
23
|
+
```bash
|
|
24
|
+
npm i
|
|
25
|
+
cp .env.example .env
|
|
26
|
+
npm start
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 验证(不依赖 MCP 客户端)
|
|
30
|
+
```bash
|
|
31
|
+
npm i
|
|
32
|
+
cp .env.example .env
|
|
33
|
+
set -a; source .env; set +a
|
|
34
|
+
npm run smoke
|
|
35
|
+
```
|
|
36
|
+
期望结果:
|
|
37
|
+
- 输出一行 `token: xxxx…yyyy source: ...`
|
|
38
|
+
- 输出 `GET /projects status: 200`(或你的禅道实际返回码)
|
|
39
|
+
|
|
40
|
+
## Claude Desktop / Cursor 示例(stdio)
|
|
41
|
+
把启动命令指向 `node src/index.js`(或 `npm start`)。
|
|
42
|
+
|
|
43
|
+
示例(Claude Desktop 的 `mcpServers` 风格,按你的客户端实际字段为准):
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"zentao": {
|
|
48
|
+
"command": "node",
|
|
49
|
+
"args": ["src/index.js"],
|
|
50
|
+
"cwd": "/ABS/PATH/TO/zentao",
|
|
51
|
+
"env": {
|
|
52
|
+
"ZENTAO_BASE_URL": "https://zentao.example.com",
|
|
53
|
+
"ZENTAO_API_PREFIX": "/api.php/v1",
|
|
54
|
+
"ZENTAO_ACCOUNT": "your_account",
|
|
55
|
+
"ZENTAO_PASSWORD": "your_password"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 已实现工具
|
|
63
|
+
- `get_token`:获取/刷新 token(默认不回显完整 token)
|
|
64
|
+
- `call`:调用任意相对 API 路径(自动带 Token 头)
|
|
65
|
+
- `list_my_projects`:示例:列出“我参与的项目”(字段匹配基于常见返回结构,可能需按你的实例微调)
|
|
66
|
+
|
|
67
|
+
## 安全建议
|
|
68
|
+
- 使用最小权限账号(仅需要的项目权限),避免使用管理员账号。
|
|
69
|
+
- 默认 `get_token` 不回显完整 token;如确需调试,可设 `ZENTAO_EXPOSE_TOKEN=true`。
|
|
70
|
+
|
|
71
|
+
## 发布到 npm
|
|
72
|
+
脚本:`scripts/release-npm.sh`(参考 `aiws` 的发布流程,默认 dry-run)。
|
|
73
|
+
|
|
74
|
+
常用命令:
|
|
75
|
+
```bash
|
|
76
|
+
# dry-run:只检查 + npm pack --dry-run,不会发布
|
|
77
|
+
npm run release:npm
|
|
78
|
+
|
|
79
|
+
# 自动递增版本(patch/minor/major)+ commit + tag(不发布)
|
|
80
|
+
npm run release:npm -- --bump patch
|
|
81
|
+
|
|
82
|
+
# 发布(会二次确认;默认自动 patch 递增)
|
|
83
|
+
npm run release:npm -- --publish
|
|
84
|
+
|
|
85
|
+
# 版本对齐 + commit + tag(不发布)
|
|
86
|
+
npm run release:npm -- --release v0.1.0
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
注意:
|
|
90
|
+
- 若 `package.json` 为 `private: true`,发布前请改成 `false` 并确认包名可用。
|
|
91
|
+
- 可加 `--require-tag` 要求 HEAD 上有匹配版本的 tag。
|
|
92
|
+
- 若发布时报 `403`,通常是包名归属问题;建议改为 scoped 包名(如 `@yourname/zentao-mcp-server`)。
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aipper/zentao-mcp-server",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "A minimal MCP server for ZenTao (token + generic REST call + a few helper tools).",
|
|
7
|
+
"license": "UNLICENSED",
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/index.js",
|
|
11
|
+
"lint": "node -c src/index.js && node -c src/zentao.js && node -c src/tools.js && node -c scripts/smoke.mjs",
|
|
12
|
+
"smoke": "node scripts/smoke.mjs",
|
|
13
|
+
"release:npm": "bash scripts/release-npm.sh"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "latest"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
usage() {
|
|
5
|
+
cat <<'EOF'
|
|
6
|
+
release-npm.sh (zentao-mcp-server)
|
|
7
|
+
|
|
8
|
+
Default mode is dry-run: run checks + npm pack --dry-run, no npm publish.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
bash scripts/release-npm.sh
|
|
12
|
+
bash scripts/release-npm.sh --publish
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--release <tag> Set package version from tag (vX.Y.Z), then commit + tag
|
|
16
|
+
--bump <level> Auto bump version: patch | minor | major, then commit + tag
|
|
17
|
+
--publish Run npm publish after checks (defaults to --bump patch)
|
|
18
|
+
--yes Skip interactive confirmation
|
|
19
|
+
--require-tag Require a semver git tag on HEAD matching package version
|
|
20
|
+
--skip-secrets-scan Skip ripgrep-based secrets scan
|
|
21
|
+
--skip-whoami Skip npm whoami check
|
|
22
|
+
--no-git-clean-check Skip clean-worktree check (not recommended)
|
|
23
|
+
-h, --help Show help
|
|
24
|
+
|
|
25
|
+
Environment:
|
|
26
|
+
ZENTAO_NPM_CACHE_DIR Override npm cache dir used by this script
|
|
27
|
+
EOF
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
PUBLISH=0
|
|
31
|
+
YES=0
|
|
32
|
+
REQUIRE_TAG=0
|
|
33
|
+
RELEASE_TAG=""
|
|
34
|
+
BUMP_LEVEL=""
|
|
35
|
+
SKIP_SECRETS_SCAN=0
|
|
36
|
+
SKIP_WHOAMI=0
|
|
37
|
+
SKIP_GIT_CLEAN_CHECK=0
|
|
38
|
+
|
|
39
|
+
while [[ $# -gt 0 ]]; do
|
|
40
|
+
case "$1" in
|
|
41
|
+
--release)
|
|
42
|
+
shift
|
|
43
|
+
if [[ $# -lt 1 ]]; then
|
|
44
|
+
echo "error: --release requires <tag> (e.g. v0.1.0)" >&2
|
|
45
|
+
usage
|
|
46
|
+
exit 2
|
|
47
|
+
fi
|
|
48
|
+
RELEASE_TAG="$1"
|
|
49
|
+
;;
|
|
50
|
+
--bump)
|
|
51
|
+
shift
|
|
52
|
+
if [[ $# -lt 1 ]]; then
|
|
53
|
+
echo "error: --bump requires <level> (patch|minor|major)" >&2
|
|
54
|
+
usage
|
|
55
|
+
exit 2
|
|
56
|
+
fi
|
|
57
|
+
BUMP_LEVEL="$1"
|
|
58
|
+
;;
|
|
59
|
+
--publish) PUBLISH=1 ;;
|
|
60
|
+
--yes) YES=1 ;;
|
|
61
|
+
--require-tag) REQUIRE_TAG=1 ;;
|
|
62
|
+
--skip-secrets-scan) SKIP_SECRETS_SCAN=1 ;;
|
|
63
|
+
--skip-whoami) SKIP_WHOAMI=1 ;;
|
|
64
|
+
--no-git-clean-check) SKIP_GIT_CLEAN_CHECK=1 ;;
|
|
65
|
+
-h|--help) usage; exit 0 ;;
|
|
66
|
+
*) echo "error: unknown arg: $1" >&2; usage; exit 2 ;;
|
|
67
|
+
esac
|
|
68
|
+
shift
|
|
69
|
+
done
|
|
70
|
+
|
|
71
|
+
log() { echo "info: $*"; }
|
|
72
|
+
warn() { echo "warn: $*" >&2; }
|
|
73
|
+
die() { echo "error: $*" >&2; exit 2; }
|
|
74
|
+
|
|
75
|
+
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
76
|
+
cd "$repo_root"
|
|
77
|
+
|
|
78
|
+
command -v node >/dev/null 2>&1 || die "node is required"
|
|
79
|
+
command -v npm >/dev/null 2>&1 || die "npm is required"
|
|
80
|
+
|
|
81
|
+
DEFAULT_NPM_CACHE_DIR="${ZENTAO_NPM_CACHE_DIR:-/tmp/zentao-npm-cache}"
|
|
82
|
+
|
|
83
|
+
maybe_use_safe_npm_cache() {
|
|
84
|
+
if [[ -z "${npm_config_cache:-}" && -z "${NPM_CONFIG_CACHE:-}" ]]; then
|
|
85
|
+
export npm_config_cache="$DEFAULT_NPM_CACHE_DIR"
|
|
86
|
+
fi
|
|
87
|
+
local cache_dir="${npm_config_cache:-${NPM_CONFIG_CACHE:-}}"
|
|
88
|
+
if [[ -n "${cache_dir:-}" ]]; then
|
|
89
|
+
mkdir -p "$cache_dir" 2>/dev/null || true
|
|
90
|
+
log "npm_cache=$cache_dir"
|
|
91
|
+
fi
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
maybe_use_safe_npm_cache
|
|
95
|
+
|
|
96
|
+
pkg_name=""
|
|
97
|
+
pkg_version=""
|
|
98
|
+
pkg_private=""
|
|
99
|
+
release_version=""
|
|
100
|
+
release_tag_name=""
|
|
101
|
+
release_tmpdir=""
|
|
102
|
+
release_restore_on_exit=0
|
|
103
|
+
|
|
104
|
+
cleanup_release() {
|
|
105
|
+
if [[ "${release_restore_on_exit:-0}" == "1" && -n "${release_tmpdir:-}" ]]; then
|
|
106
|
+
cp "$release_tmpdir/package.json" package.json 2>/dev/null || true
|
|
107
|
+
fi
|
|
108
|
+
if [[ -n "${release_tmpdir:-}" ]]; then
|
|
109
|
+
rm -rf "$release_tmpdir" 2>/dev/null || true
|
|
110
|
+
fi
|
|
111
|
+
}
|
|
112
|
+
trap cleanup_release EXIT
|
|
113
|
+
|
|
114
|
+
is_semver() {
|
|
115
|
+
printf '%s' "$1" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.+].+)?$'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
normalize_release_tag() {
|
|
119
|
+
local input="$1"
|
|
120
|
+
if [[ "$input" == v* ]]; then
|
|
121
|
+
release_tag_name="$input"
|
|
122
|
+
release_version="${input#v}"
|
|
123
|
+
else
|
|
124
|
+
release_tag_name="v$input"
|
|
125
|
+
release_version="$input"
|
|
126
|
+
fi
|
|
127
|
+
if ! is_semver "$release_version"; then
|
|
128
|
+
die "--release requires semver tag (vX.Y.Z); got: $input"
|
|
129
|
+
fi
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
calc_bumped_version() {
|
|
133
|
+
local current="$1"
|
|
134
|
+
local level="$2"
|
|
135
|
+
ZENTAO_CURRENT_VERSION="$current" ZENTAO_BUMP_LEVEL="$level" node - <<'NODE'
|
|
136
|
+
const current = process.env.ZENTAO_CURRENT_VERSION || "";
|
|
137
|
+
const level = process.env.ZENTAO_BUMP_LEVEL || "";
|
|
138
|
+
const m = current.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
|
|
139
|
+
if (!m) {
|
|
140
|
+
console.error(`error: current version is not semver-like: ${current}`);
|
|
141
|
+
process.exit(2);
|
|
142
|
+
}
|
|
143
|
+
let major = Number(m[1]);
|
|
144
|
+
let minor = Number(m[2]);
|
|
145
|
+
let patch = Number(m[3]);
|
|
146
|
+
if (level === "patch") {
|
|
147
|
+
patch += 1;
|
|
148
|
+
} else if (level === "minor") {
|
|
149
|
+
minor += 1;
|
|
150
|
+
patch = 0;
|
|
151
|
+
} else if (level === "major") {
|
|
152
|
+
major += 1;
|
|
153
|
+
minor = 0;
|
|
154
|
+
patch = 0;
|
|
155
|
+
} else {
|
|
156
|
+
console.error(`error: unsupported bump level: ${level} (expected patch|minor|major)`);
|
|
157
|
+
process.exit(2);
|
|
158
|
+
}
|
|
159
|
+
process.stdout.write(`${major}.${minor}.${patch}`);
|
|
160
|
+
NODE
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
read_pkg() {
|
|
164
|
+
pkg_name="$(node -p "require('./package.json').name")"
|
|
165
|
+
pkg_version="$(node -p "require('./package.json').version")"
|
|
166
|
+
pkg_private="$(node -p "Boolean(require('./package.json').private)")"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
write_version() {
|
|
170
|
+
local v="$1"
|
|
171
|
+
ZENTAO_RELEASE_VERSION="$v" node - <<'NODE'
|
|
172
|
+
const fs = require("node:fs");
|
|
173
|
+
const file = "package.json";
|
|
174
|
+
const v = process.env.ZENTAO_RELEASE_VERSION;
|
|
175
|
+
if (!v) {
|
|
176
|
+
console.error("missing ZENTAO_RELEASE_VERSION");
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
const src = fs.readFileSync(file, "utf8");
|
|
180
|
+
const pkg = JSON.parse(src);
|
|
181
|
+
pkg.version = v;
|
|
182
|
+
fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n");
|
|
183
|
+
NODE
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
backup_package_for_restore() {
|
|
187
|
+
release_tmpdir="$(mktemp -d)"
|
|
188
|
+
cp package.json "$release_tmpdir/package.json"
|
|
189
|
+
release_restore_on_exit=1
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
check_publish_ownership() {
|
|
193
|
+
local npm_user="$1"
|
|
194
|
+
local package_name="$2"
|
|
195
|
+
|
|
196
|
+
local view_out=""
|
|
197
|
+
local view_code=0
|
|
198
|
+
set +e
|
|
199
|
+
view_out="$(npm view "$package_name" name 2>&1)"
|
|
200
|
+
view_code=$?
|
|
201
|
+
set -e
|
|
202
|
+
|
|
203
|
+
if [[ "$view_code" != "0" ]]; then
|
|
204
|
+
if echo "$view_out" | grep -Eqi "(E404|404|not found|not in this registry)"; then
|
|
205
|
+
log "publish ownership precheck: package not found on registry ($package_name), treated as first publish"
|
|
206
|
+
return 0
|
|
207
|
+
fi
|
|
208
|
+
warn "publish ownership precheck skipped: cannot query package metadata"
|
|
209
|
+
return 0
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
local owners_out=""
|
|
213
|
+
local owners_code=0
|
|
214
|
+
set +e
|
|
215
|
+
owners_out="$(npm owner ls "$package_name" 2>&1)"
|
|
216
|
+
owners_code=$?
|
|
217
|
+
set -e
|
|
218
|
+
|
|
219
|
+
if [[ "$owners_code" != "0" ]]; then
|
|
220
|
+
warn "publish ownership precheck skipped: cannot query package owners"
|
|
221
|
+
return 0
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
if echo "$owners_out" | grep -Eiq "^${npm_user}[[:space:]]"; then
|
|
225
|
+
log "publish ownership precheck: ok ($npm_user owns $package_name)"
|
|
226
|
+
return 0
|
|
227
|
+
fi
|
|
228
|
+
|
|
229
|
+
die "npm user '$npm_user' is not an owner of '$package_name' (likely 403). Use a scoped name like '@$npm_user/zentao-mcp-server' or ask current owner to add you."
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
read_pkg
|
|
233
|
+
|
|
234
|
+
# Default behavior: publishing without explicit release/bump will auto-bump patch.
|
|
235
|
+
if [[ "$PUBLISH" == "1" && -z "${RELEASE_TAG:-}" && -z "${BUMP_LEVEL:-}" ]]; then
|
|
236
|
+
BUMP_LEVEL="patch"
|
|
237
|
+
log "publish requested without version args; defaulting to --bump patch"
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
log "repo_root=$repo_root"
|
|
241
|
+
log "package=$pkg_name"
|
|
242
|
+
log "version=$pkg_version"
|
|
243
|
+
log "mode=$(
|
|
244
|
+
if [[ "$PUBLISH" == "1" ]]; then
|
|
245
|
+
echo publish
|
|
246
|
+
elif [[ -n "${RELEASE_TAG:-}" || -n "${BUMP_LEVEL:-}" ]]; then
|
|
247
|
+
echo prepare
|
|
248
|
+
else
|
|
249
|
+
echo dry-run
|
|
250
|
+
fi
|
|
251
|
+
)"
|
|
252
|
+
|
|
253
|
+
if [[ -n "${RELEASE_TAG:-}" && -n "${BUMP_LEVEL:-}" ]]; then
|
|
254
|
+
die "cannot combine --release and --bump"
|
|
255
|
+
fi
|
|
256
|
+
|
|
257
|
+
VERSION_UPDATE=0
|
|
258
|
+
if [[ -n "${RELEASE_TAG:-}" ]]; then
|
|
259
|
+
normalize_release_tag "$RELEASE_TAG"
|
|
260
|
+
VERSION_UPDATE=1
|
|
261
|
+
fi
|
|
262
|
+
if [[ -n "${BUMP_LEVEL:-}" ]]; then
|
|
263
|
+
release_version="$(calc_bumped_version "$pkg_version" "$BUMP_LEVEL")"
|
|
264
|
+
release_tag_name="v$release_version"
|
|
265
|
+
VERSION_UPDATE=1
|
|
266
|
+
log "bump_level=$BUMP_LEVEL"
|
|
267
|
+
fi
|
|
268
|
+
|
|
269
|
+
if [[ "$VERSION_UPDATE" == "1" && "$REQUIRE_TAG" == "1" ]]; then
|
|
270
|
+
die "cannot combine --release/--bump with --require-tag (release flow creates a tag)"
|
|
271
|
+
fi
|
|
272
|
+
|
|
273
|
+
if [[ "$VERSION_UPDATE" == "1" ]]; then
|
|
274
|
+
command -v git >/dev/null 2>&1 || die "--release/--bump requires git"
|
|
275
|
+
log "release_tag=$release_tag_name"
|
|
276
|
+
log "release_version=$release_version"
|
|
277
|
+
|
|
278
|
+
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
279
|
+
die "--release requires running inside a git repository"
|
|
280
|
+
fi
|
|
281
|
+
|
|
282
|
+
if git rev-parse -q --verify "refs/tags/$release_tag_name" >/dev/null 2>&1; then
|
|
283
|
+
die "--release tag already exists: $release_tag_name"
|
|
284
|
+
fi
|
|
285
|
+
|
|
286
|
+
if [[ "$SKIP_GIT_CLEAN_CHECK" != "1" ]]; then
|
|
287
|
+
dirty="$(git status --porcelain || true)"
|
|
288
|
+
if [[ -n "${dirty:-}" ]]; then
|
|
289
|
+
die "--release requires a clean git working tree; commit or stash changes first"
|
|
290
|
+
fi
|
|
291
|
+
fi
|
|
292
|
+
|
|
293
|
+
backup_package_for_restore
|
|
294
|
+
write_version "$release_version"
|
|
295
|
+
read_pkg
|
|
296
|
+
log "(after release) version=$pkg_version"
|
|
297
|
+
fi
|
|
298
|
+
|
|
299
|
+
if [[ "$REQUIRE_TAG" == "1" ]]; then
|
|
300
|
+
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
301
|
+
die "--require-tag requires running inside a git repository"
|
|
302
|
+
fi
|
|
303
|
+
git_tag="$(git describe --tags --exact-match 2>/dev/null || true)"
|
|
304
|
+
if [[ -z "${git_tag:-}" ]]; then
|
|
305
|
+
die "--require-tag expected a semver tag on HEAD (e.g. v$pkg_version); found none"
|
|
306
|
+
fi
|
|
307
|
+
tag_version="${git_tag#v}"
|
|
308
|
+
if ! is_semver "$tag_version"; then
|
|
309
|
+
die "--require-tag expected tag in vX.Y.Z (or X.Y.Z) form; got: $git_tag"
|
|
310
|
+
fi
|
|
311
|
+
if [[ "$tag_version" != "$pkg_version" ]]; then
|
|
312
|
+
die "--require-tag tag version mismatch: tag=$git_tag (=> $tag_version) but package version=$pkg_version"
|
|
313
|
+
fi
|
|
314
|
+
log "git_tag=$git_tag"
|
|
315
|
+
fi
|
|
316
|
+
|
|
317
|
+
if [[ "$PUBLISH" == "1" && "$pkg_version" == "0.0.0" ]]; then
|
|
318
|
+
die "refusing to publish version 0.0.0; bump version first"
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
if [[ "$VERSION_UPDATE" != "1" ]]; then
|
|
322
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
323
|
+
if [[ "$SKIP_GIT_CLEAN_CHECK" != "1" ]]; then
|
|
324
|
+
dirty="$(git status --porcelain || true)"
|
|
325
|
+
if [[ -n "${dirty:-}" ]]; then
|
|
326
|
+
if [[ "$PUBLISH" == "1" ]]; then
|
|
327
|
+
die "git working tree is not clean; commit or stash changes before publishing"
|
|
328
|
+
fi
|
|
329
|
+
warn "git working tree is not clean (ok for dry-run):"
|
|
330
|
+
echo "$dirty" | sed -n '1,20p'
|
|
331
|
+
if [[ "$(echo "$dirty" | wc -l | tr -d ' ')" -gt 20 ]]; then
|
|
332
|
+
warn "(truncated)"
|
|
333
|
+
fi
|
|
334
|
+
fi
|
|
335
|
+
fi
|
|
336
|
+
else
|
|
337
|
+
warn "not a git repository; skip git clean check"
|
|
338
|
+
fi
|
|
339
|
+
fi
|
|
340
|
+
|
|
341
|
+
if [[ "$SKIP_SECRETS_SCAN" != "1" ]]; then
|
|
342
|
+
if command -v rg >/dev/null 2>&1; then
|
|
343
|
+
log "secrets scan (filenames only):"
|
|
344
|
+
secret_re='(_authToken\\s*=\\s*[^\\s#]{16,}|sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|AIzaSy[A-Za-z0-9_-]{35}|-----BEGIN [A-Z ]*PRIVATE KEY-----|Authorization:\\s*Bearer\\s+[^\\s<]{20,}|Bearer\\s+[A-Za-z0-9._-]{20,}|(api[_-]?key|token|password|secret)\\s*[:=]\\s*[\\x27\\\"][^\\x27\\\"<]{12,}[\\x27\\\"])'
|
|
345
|
+
matches="$(rg -i -l "$secret_re" . --glob '!node_modules/**' --glob '!.git/**' --glob '!dist/**' --glob '!.env' --glob '!.env.*' || true)"
|
|
346
|
+
if [[ -n "${matches:-}" ]]; then
|
|
347
|
+
if [[ "$PUBLISH" == "1" ]]; then
|
|
348
|
+
echo "$matches"
|
|
349
|
+
die "potential secret-like patterns found; inspect files above before publishing"
|
|
350
|
+
fi
|
|
351
|
+
warn "potential secret-like patterns found (dry-run continues):"
|
|
352
|
+
echo "$matches"
|
|
353
|
+
else
|
|
354
|
+
log "secrets scan: ok"
|
|
355
|
+
fi
|
|
356
|
+
else
|
|
357
|
+
warn "rg not found; skip secrets scan"
|
|
358
|
+
fi
|
|
359
|
+
fi
|
|
360
|
+
|
|
361
|
+
if [[ "$SKIP_WHOAMI" == "1" ]]; then
|
|
362
|
+
warn "npm whoami skipped (--skip-whoami)"
|
|
363
|
+
else
|
|
364
|
+
set +e
|
|
365
|
+
whoami_out="$(npm whoami 2>&1)"
|
|
366
|
+
whoami_code=$?
|
|
367
|
+
set -e
|
|
368
|
+
if [[ "$whoami_code" == "0" ]]; then
|
|
369
|
+
npm_user="$(printf '%s' "$whoami_out" | tr -d '\r\n')"
|
|
370
|
+
log "npm whoami: ok ($npm_user)"
|
|
371
|
+
else
|
|
372
|
+
warn "npm whoami failed:"
|
|
373
|
+
echo "$whoami_out" >&2
|
|
374
|
+
if [[ "$PUBLISH" == "1" ]]; then
|
|
375
|
+
warn "continue publish flow despite whoami failure; final publish result will be authoritative"
|
|
376
|
+
fi
|
|
377
|
+
fi
|
|
378
|
+
fi
|
|
379
|
+
|
|
380
|
+
if [[ "$PUBLISH" == "1" ]]; then
|
|
381
|
+
if [[ "$SKIP_WHOAMI" == "1" ]]; then
|
|
382
|
+
warn "publish ownership precheck skipped because --skip-whoami is enabled"
|
|
383
|
+
else
|
|
384
|
+
if [[ -z "${npm_user:-}" ]]; then
|
|
385
|
+
warn "publish ownership precheck skipped: npm username unavailable"
|
|
386
|
+
else
|
|
387
|
+
check_publish_ownership "$npm_user" "$pkg_name"
|
|
388
|
+
fi
|
|
389
|
+
fi
|
|
390
|
+
fi
|
|
391
|
+
|
|
392
|
+
log "pack preview: npm pack --dry-run"
|
|
393
|
+
pack_out=""
|
|
394
|
+
set +e
|
|
395
|
+
pack_out="$(npm pack --dry-run 2>&1)"
|
|
396
|
+
pack_code=$?
|
|
397
|
+
set -e
|
|
398
|
+
if [[ "$pack_code" != "0" ]]; then
|
|
399
|
+
if echo "$pack_out" | grep -Eqi "(EPERM|EACCES)" && [[ "${npm_config_cache:-${NPM_CONFIG_CACHE:-}}" != "$DEFAULT_NPM_CACHE_DIR" ]]; then
|
|
400
|
+
warn "npm pack failed with EPERM/EACCES; retry with npm_config_cache=$DEFAULT_NPM_CACHE_DIR"
|
|
401
|
+
export npm_config_cache="$DEFAULT_NPM_CACHE_DIR"
|
|
402
|
+
mkdir -p "$DEFAULT_NPM_CACHE_DIR" 2>/dev/null || true
|
|
403
|
+
npm pack --dry-run >/dev/null
|
|
404
|
+
else
|
|
405
|
+
echo "$pack_out" >&2
|
|
406
|
+
exit "$pack_code"
|
|
407
|
+
fi
|
|
408
|
+
fi
|
|
409
|
+
log "pack preview: ok"
|
|
410
|
+
|
|
411
|
+
publish_cmd=(npm publish)
|
|
412
|
+
if [[ "$pkg_name" == @*/* ]]; then
|
|
413
|
+
publish_cmd=(npm publish --access public)
|
|
414
|
+
fi
|
|
415
|
+
log "publish command: ${publish_cmd[*]}"
|
|
416
|
+
|
|
417
|
+
if [[ "$PUBLISH" != "1" ]]; then
|
|
418
|
+
if [[ "$VERSION_UPDATE" == "1" ]]; then
|
|
419
|
+
if [[ "$YES" != "1" ]]; then
|
|
420
|
+
echo ""
|
|
421
|
+
echo "About to prepare git release:"
|
|
422
|
+
echo " - set version to $release_version"
|
|
423
|
+
echo " - git commit: chore(release): $release_tag_name"
|
|
424
|
+
echo " - git tag: $release_tag_name"
|
|
425
|
+
echo ""
|
|
426
|
+
read -r -p "Type 'release' to continue: " confirm_release
|
|
427
|
+
if [[ "${confirm_release:-}" != "release" ]]; then
|
|
428
|
+
die "aborted"
|
|
429
|
+
fi
|
|
430
|
+
fi
|
|
431
|
+
if ! git diff --quiet -- package.json; then
|
|
432
|
+
git add package.json
|
|
433
|
+
git commit -m "chore(release): $release_tag_name"
|
|
434
|
+
else
|
|
435
|
+
log "release: no version changes to commit"
|
|
436
|
+
fi
|
|
437
|
+
git tag "$release_tag_name"
|
|
438
|
+
log "release: created git tag $release_tag_name"
|
|
439
|
+
release_restore_on_exit=0
|
|
440
|
+
log "prepare complete (no npm publish performed)"
|
|
441
|
+
exit 0
|
|
442
|
+
fi
|
|
443
|
+
log "dry-run complete (no publish performed)"
|
|
444
|
+
exit 0
|
|
445
|
+
fi
|
|
446
|
+
|
|
447
|
+
if [[ "$pkg_private" == "true" ]]; then
|
|
448
|
+
die "package.json has private=true; set private=false before publishing"
|
|
449
|
+
fi
|
|
450
|
+
|
|
451
|
+
if [[ "$YES" != "1" ]]; then
|
|
452
|
+
echo ""
|
|
453
|
+
echo "About to publish to npm:"
|
|
454
|
+
echo " - $pkg_name@$pkg_version"
|
|
455
|
+
if [[ "$VERSION_UPDATE" == "1" ]]; then
|
|
456
|
+
echo ""
|
|
457
|
+
echo "And prepare git release:"
|
|
458
|
+
echo " - git commit: chore(release): $release_tag_name"
|
|
459
|
+
echo " - git tag: $release_tag_name"
|
|
460
|
+
fi
|
|
461
|
+
echo ""
|
|
462
|
+
read -r -p "Type 'publish' to continue: " confirm_publish
|
|
463
|
+
if [[ "${confirm_publish:-}" != "publish" ]]; then
|
|
464
|
+
die "aborted"
|
|
465
|
+
fi
|
|
466
|
+
fi
|
|
467
|
+
|
|
468
|
+
if [[ "$VERSION_UPDATE" == "1" ]]; then
|
|
469
|
+
if ! git diff --quiet -- package.json; then
|
|
470
|
+
git add package.json
|
|
471
|
+
git commit -m "chore(release): $release_tag_name"
|
|
472
|
+
else
|
|
473
|
+
log "release: no version changes to commit"
|
|
474
|
+
fi
|
|
475
|
+
git tag "$release_tag_name"
|
|
476
|
+
log "release: created git tag $release_tag_name"
|
|
477
|
+
release_restore_on_exit=0
|
|
478
|
+
fi
|
|
479
|
+
|
|
480
|
+
"${publish_cmd[@]}"
|
|
481
|
+
log "publish complete"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createZenTaoClient } from "../src/zentao.js";
|
|
2
|
+
|
|
3
|
+
function requireEnv(name) {
|
|
4
|
+
const value = process.env[name];
|
|
5
|
+
if (!value) throw new Error(`Missing required env: ${name}`);
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getConfigFromEnv() {
|
|
10
|
+
const baseUrl = requireEnv("ZENTAO_BASE_URL").replace(/\/+$/, "");
|
|
11
|
+
const apiPrefix = (process.env.ZENTAO_API_PREFIX || "/api.php/v1").replace(/\/+$/, "");
|
|
12
|
+
const tokenPath = process.env.ZENTAO_TOKEN_PATH || `${apiPrefix}/tokens`;
|
|
13
|
+
const tokenTtlMs = Number(process.env.ZENTAO_TOKEN_TTL_MS || "3000000");
|
|
14
|
+
const timeoutMs = Number(process.env.ZENTAO_HTTP_TIMEOUT_MS || "30000");
|
|
15
|
+
|
|
16
|
+
const account = requireEnv("ZENTAO_ACCOUNT");
|
|
17
|
+
const password = requireEnv("ZENTAO_PASSWORD");
|
|
18
|
+
|
|
19
|
+
return { baseUrl, apiPrefix, tokenPath, tokenTtlMs, timeoutMs, auth: { account, password } };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
const zentao = createZenTaoClient(getConfigFromEnv());
|
|
24
|
+
|
|
25
|
+
const token = await zentao.getToken({ force: false });
|
|
26
|
+
console.log("token:", token.token ? `${token.token.slice(0, 6)}…${token.token.slice(-4)}` : "", "source:", token.source);
|
|
27
|
+
|
|
28
|
+
// 尝试拉一个最常用的列表接口,验证 apiPrefix/path 是否正确
|
|
29
|
+
const projects = await zentao.call({ path: "/projects", method: "GET" });
|
|
30
|
+
console.log("GET /projects status:", projects.status);
|
|
31
|
+
console.log("projects keys:", projects.data && typeof projects.data === "object" ? Object.keys(projects.data) : typeof projects.data);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
main().catch((err) => {
|
|
35
|
+
console.error(String(err?.stack || err));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import {
|
|
4
|
+
TOOLS,
|
|
5
|
+
assertToolArgs,
|
|
6
|
+
toMcpTextResult,
|
|
7
|
+
} from "./tools.js";
|
|
8
|
+
import { createZenTaoClient } from "./zentao.js";
|
|
9
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
|
|
11
|
+
function requireEnv(name) {
|
|
12
|
+
const value = process.env[name];
|
|
13
|
+
if (!value) throw new Error(`Missing required env: ${name}`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getConfigFromEnv() {
|
|
18
|
+
const baseUrl = requireEnv("ZENTAO_BASE_URL").replace(/\/+$/, "");
|
|
19
|
+
const apiPrefix = (process.env.ZENTAO_API_PREFIX || "/api.php/v1").replace(/\/+$/, "");
|
|
20
|
+
const tokenPath = process.env.ZENTAO_TOKEN_PATH || `${apiPrefix}/tokens`;
|
|
21
|
+
const tokenTtlMs = Number(process.env.ZENTAO_TOKEN_TTL_MS || "3000000");
|
|
22
|
+
const timeoutMs = Number(process.env.ZENTAO_HTTP_TIMEOUT_MS || "30000");
|
|
23
|
+
const exposeToken = String(process.env.ZENTAO_EXPOSE_TOKEN || "false").toLowerCase() === "true";
|
|
24
|
+
|
|
25
|
+
const account = requireEnv("ZENTAO_ACCOUNT");
|
|
26
|
+
const password = requireEnv("ZENTAO_PASSWORD");
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
baseUrl,
|
|
30
|
+
apiPrefix,
|
|
31
|
+
tokenPath,
|
|
32
|
+
tokenTtlMs,
|
|
33
|
+
timeoutMs,
|
|
34
|
+
exposeToken,
|
|
35
|
+
auth: { account, password },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function main() {
|
|
40
|
+
const config = getConfigFromEnv();
|
|
41
|
+
const zentao = createZenTaoClient(config);
|
|
42
|
+
|
|
43
|
+
const server = new Server(
|
|
44
|
+
{ name: "zentao-mcp-server", version: "0.1.0" },
|
|
45
|
+
{ capabilities: { tools: {} } }
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
49
|
+
return { tools: TOOLS };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
53
|
+
const toolName = req.params?.name;
|
|
54
|
+
const args = req.params?.arguments || {};
|
|
55
|
+
assertToolArgs(toolName, args);
|
|
56
|
+
|
|
57
|
+
if (toolName === "get_token") {
|
|
58
|
+
const force = Boolean(args.force);
|
|
59
|
+
const result = await zentao.getToken({ force });
|
|
60
|
+
const output = config.exposeToken
|
|
61
|
+
? result
|
|
62
|
+
: {
|
|
63
|
+
...result,
|
|
64
|
+
token: result.token ? `${result.token.slice(0, 6)}…${result.token.slice(-4)}` : "",
|
|
65
|
+
};
|
|
66
|
+
return toMcpTextResult(JSON.stringify(output, null, 2));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (toolName === "call") {
|
|
70
|
+
const { path, method, query, body } = args;
|
|
71
|
+
const resp = await zentao.call({ path, method, query, body });
|
|
72
|
+
return toMcpTextResult(JSON.stringify(resp, null, 2));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (toolName === "list_my_projects") {
|
|
76
|
+
const resp = await zentao.listMyProjects({ keyword: args.keyword || "" });
|
|
77
|
+
return toMcpTextResult(JSON.stringify(resp, null, 2));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const transport = new StdioServerTransport();
|
|
84
|
+
await server.connect(transport);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
main().catch((err) => {
|
|
88
|
+
// stderr 方便客户端看到启动失败原因
|
|
89
|
+
process.stderr.write(String(err?.stack || err) + "\n");
|
|
90
|
+
process.exit(1);
|
|
91
|
+
});
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function toMcpTextResult(text) {
|
|
2
|
+
return { content: [{ type: "text", text }] };
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export const TOOLS = [
|
|
6
|
+
{
|
|
7
|
+
name: "get_token",
|
|
8
|
+
description: "Get or refresh ZenTao API token (cached).",
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: { force: { type: "boolean" } },
|
|
12
|
+
additionalProperties: false,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "call",
|
|
17
|
+
description: "Call ZenTao REST API v1 path with Token header.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
path: { type: "string", minLength: 1, description: "API path, e.g. /projects or bugs/123" },
|
|
22
|
+
method: { type: "string", description: "GET/POST/PUT/DELETE..." },
|
|
23
|
+
query: { type: "object", additionalProperties: true },
|
|
24
|
+
body: {},
|
|
25
|
+
},
|
|
26
|
+
required: ["path"],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "list_my_projects",
|
|
32
|
+
description: "List projects I participate in (heuristic filtering).",
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: { keyword: { type: "string" } },
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export function assertToolArgs(name, args) {
|
|
42
|
+
if (args == null) return;
|
|
43
|
+
if (typeof args !== "object") throw new Error(`Invalid arguments for ${name}: expected object`);
|
|
44
|
+
if (name === "call" && typeof args.path !== "string") {
|
|
45
|
+
throw new Error("call.path must be a string");
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/zentao.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
function sleep(ms) {
|
|
2
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function isProbablyAbsoluteUrl(value) {
|
|
6
|
+
return /^https?:\/\//i.test(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function buildUrl({ baseUrl, apiPrefix, path, query }) {
|
|
10
|
+
if (!path) throw new Error("path is required");
|
|
11
|
+
if (isProbablyAbsoluteUrl(path)) {
|
|
12
|
+
throw new Error("path must be relative (absolute URL is not allowed)");
|
|
13
|
+
}
|
|
14
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
15
|
+
const prefix = apiPrefix.startsWith("/") ? apiPrefix : `/${apiPrefix}`;
|
|
16
|
+
const url = new URL(`${baseUrl}${prefix}${normalizedPath}`);
|
|
17
|
+
|
|
18
|
+
if (query && typeof query === "object") {
|
|
19
|
+
for (const [k, v] of Object.entries(query)) {
|
|
20
|
+
if (v === undefined || v === null) continue;
|
|
21
|
+
url.searchParams.set(k, String(v));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return url;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createAbortSignal(timeoutMs) {
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const timer = setTimeout(() => controller.abort(new Error("Request timeout")), timeoutMs);
|
|
30
|
+
return { signal: controller.signal, cleanup: () => clearTimeout(timer) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createZenTaoClient(config) {
|
|
34
|
+
const { baseUrl, apiPrefix, tokenPath, tokenTtlMs, timeoutMs, auth } = config;
|
|
35
|
+
|
|
36
|
+
let cachedToken = "";
|
|
37
|
+
let cachedAt = 0;
|
|
38
|
+
|
|
39
|
+
async function fetchJson(url, { method, headers, body }) {
|
|
40
|
+
const { signal, cleanup } = createAbortSignal(timeoutMs);
|
|
41
|
+
try {
|
|
42
|
+
const resp = await fetch(url, { method, headers, body, signal });
|
|
43
|
+
const text = await resp.text();
|
|
44
|
+
const contentType = resp.headers.get("content-type") || "";
|
|
45
|
+
const data = contentType.includes("application/json") ? safeJsonParse(text) : text;
|
|
46
|
+
if (!resp.ok) {
|
|
47
|
+
const err = new Error(`Request failed ${resp.status}: ${truncate(String(text), 2000)}`);
|
|
48
|
+
err.status = resp.status;
|
|
49
|
+
err.data = data;
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
return { status: resp.status, headers: Object.fromEntries(resp.headers.entries()), data };
|
|
53
|
+
} finally {
|
|
54
|
+
cleanup();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeJsonParse(text) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(text);
|
|
61
|
+
} catch {
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function truncate(s, max) {
|
|
67
|
+
if (s.length <= max) return s;
|
|
68
|
+
return s.slice(0, max) + "...(truncated)";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function tokenExpired() {
|
|
72
|
+
if (!cachedToken) return true;
|
|
73
|
+
if (!cachedAt) return true;
|
|
74
|
+
return Date.now() - cachedAt > tokenTtlMs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function getToken({ force } = {}) {
|
|
78
|
+
if (!force && !tokenExpired()) {
|
|
79
|
+
return { token: cachedToken, source: "cache" };
|
|
80
|
+
}
|
|
81
|
+
if (!auth.account || !auth.password) {
|
|
82
|
+
throw new Error("Need ZENTAO_ACCOUNT and ZENTAO_PASSWORD");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 兼容性:不同禅道版本 token 接口可能不同;先按常见 v1 约定尝试
|
|
86
|
+
const url = new URL(tokenPath, baseUrl);
|
|
87
|
+
const payload = { account: auth.account, password: auth.password };
|
|
88
|
+
const resp = await fetchJson(url, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
body: JSON.stringify(payload),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const token =
|
|
95
|
+
resp?.data?.token ||
|
|
96
|
+
resp?.data?.data?.token ||
|
|
97
|
+
resp?.data?.data?.session?.token ||
|
|
98
|
+
"";
|
|
99
|
+
|
|
100
|
+
if (!token) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
"Token response does not contain token field; adjust parsing in src/zentao.js#getToken()"
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
cachedToken = token;
|
|
106
|
+
cachedAt = Date.now();
|
|
107
|
+
return { token: cachedToken, source: "login" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function call({ path, method = "GET", query, body } = {}) {
|
|
111
|
+
const tokenInfo = await getToken();
|
|
112
|
+
const url = buildUrl({ baseUrl, apiPrefix, path, query });
|
|
113
|
+
|
|
114
|
+
const headers = { Token: tokenInfo.token };
|
|
115
|
+
let payload;
|
|
116
|
+
if (body !== undefined) {
|
|
117
|
+
headers["Content-Type"] = "application/json";
|
|
118
|
+
payload = JSON.stringify(body);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return fetchJson(url, { method: method.toUpperCase(), headers, body: payload });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function looksLikeMyProject(p, keyword) {
|
|
125
|
+
if (!p || typeof p !== "object") return false;
|
|
126
|
+
const k = (keyword || "").trim().toLowerCase();
|
|
127
|
+
const fields = [
|
|
128
|
+
p.name,
|
|
129
|
+
p.code,
|
|
130
|
+
p.desc,
|
|
131
|
+
p.pm,
|
|
132
|
+
p.po,
|
|
133
|
+
p.qd,
|
|
134
|
+
p.rd,
|
|
135
|
+
p.status,
|
|
136
|
+
]
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.map((x) => String(x).toLowerCase());
|
|
139
|
+
if (k && !fields.some((x) => x.includes(k))) return false;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function listMyProjects({ keyword } = {}) {
|
|
144
|
+
// 这里的路径可能需要按你的禅道实例调整:如 /projects 或 /projects?limit=...
|
|
145
|
+
const resp = await call({ path: "/projects", method: "GET" });
|
|
146
|
+
const list = Array.isArray(resp?.data?.projects)
|
|
147
|
+
? resp.data.projects
|
|
148
|
+
: Array.isArray(resp?.data?.data)
|
|
149
|
+
? resp.data.data
|
|
150
|
+
: Array.isArray(resp?.data)
|
|
151
|
+
? resp.data
|
|
152
|
+
: [];
|
|
153
|
+
const filtered = list.filter((p) => looksLikeMyProject(p, keyword));
|
|
154
|
+
return { total: list.length, matched: filtered.length, projects: filtered };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 轻量重试:禅道偶发 502/网关问题时可用;默认不用,保留扩展点
|
|
158
|
+
async function callWithRetry(args, { retries = 0, backoffMs = 200 } = {}) {
|
|
159
|
+
let lastErr;
|
|
160
|
+
for (let i = 0; i <= retries; i++) {
|
|
161
|
+
try {
|
|
162
|
+
return await call(args);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
lastErr = e;
|
|
165
|
+
if (i === retries) break;
|
|
166
|
+
await sleep(backoffMs * (i + 1));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
throw lastErr;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { getToken, call, callWithRetry, listMyProjects };
|
|
173
|
+
}
|