@mcohenchen/pingcode-mcp 0.0.0-stage → 0.3.2
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 +29 -0
- package/CHANGELOG.md +89 -0
- package/README.md +340 -2
- package/dist/client/client.js +124 -0
- package/dist/client/errors.js +11 -0
- package/dist/client/profile.js +18 -0
- package/dist/client/refs.js +13 -0
- package/dist/comments/index.js +81 -0
- package/dist/config/index.js +354 -0
- package/dist/index.js +68 -0
- package/dist/init.js +60 -0
- package/dist/pjm/index.js +275 -0
- package/dist/tools/comments.js +64 -0
- package/dist/tools/common.js +23 -0
- package/dist/tools/pjm.js +116 -0
- package/dist/tools/profiles.js +43 -0
- package/dist/tools/register.js +13 -0
- package/dist/tools/wiki.js +88 -0
- package/dist/wiki/index.js +132 -0
- package/package.json +56 -4
package/.env.example
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# 正式使用请写 ~/.config/pingcode-mcp/config.json(运行 `pingcode-mcp init` 生成模板),
|
|
2
|
+
# MCP 客户端只配 "command": "pingcode-mcp"。
|
|
3
|
+
# 本文件仅用于本机开发 / selftest:复制为 .env(已在 .gitignore 中),不要提交真实 token。
|
|
4
|
+
|
|
5
|
+
# --- 单令牌(兼容 / 本机开发;生成名为 "default" 的 profile,仅当没有其他 profile 时生效) ---
|
|
6
|
+
# PingCode Open API access token (Bearer)
|
|
7
|
+
PINGCODE_ACCESS_TOKEN=
|
|
8
|
+
# Optional, defaults to https://open.pingcode.com
|
|
9
|
+
PINGCODE_API_BASE_URL=https://open.pingcode.com
|
|
10
|
+
# Optional: your tenant's web address, used to match full page URLs
|
|
11
|
+
PINGCODE_WEB_BASE_URL=https://your-tenant.pingcode.com
|
|
12
|
+
# Optional: wiki space identifier used by pingcode_wiki_list_pages when no space is given
|
|
13
|
+
PINGCODE_DEFAULT_SPACE=DEMO
|
|
14
|
+
|
|
15
|
+
# --- 可选覆盖 ---
|
|
16
|
+
# Config file location (default: ~/.config/pingcode-mcp/config.json, or $XDG_CONFIG_HOME/pingcode-mcp/config.json)
|
|
17
|
+
# PINGCODE_CONFIG=/path/to/config.json
|
|
18
|
+
# Default profile name (overrides "defaultProfile" in config)
|
|
19
|
+
# PINGCODE_DEFAULT_PROFILE=demo
|
|
20
|
+
|
|
21
|
+
# Rate limiting: max retries on HTTP 429 (default 4; per-profile "maxRetries" overrides)
|
|
22
|
+
# PINGCODE_MAX_RETRIES=4
|
|
23
|
+
# Minimum gap between requests per profile client, in ms (default 0 = off)
|
|
24
|
+
# PINGCODE_MIN_INTERVAL_MS=0
|
|
25
|
+
|
|
26
|
+
# --- 高级 / 兼容,不推荐 ---
|
|
27
|
+
# PINGCODE_PROFILES 以 JSON 覆盖配置文件(同名 profile 按字段合并)。多 profile 请写进 config.json,
|
|
28
|
+
# 不要把这段 JSON 塞进 MCP 客户端的 env。
|
|
29
|
+
# PINGCODE_PROFILES=[{"name":"demo","webBaseUrl":"https://your-tenant.pingcode.com","accessToken":"pcpat_REPLACE_ME"}]
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
本文件遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
|
|
4
|
+
|
|
5
|
+
## [0.3.2] - 2026-11-05
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- npm 包名改为 `@mcohenchen/pingcode-mcp`(`publishConfig.access=public`);全局命令仍为 `pingcode-mcp`
|
|
10
|
+
- 目录结构重构(无行为变化):`src/pingcode.ts` 拆分为 `src/client/`(`client.ts` 客户端与 429 重试、`profile.ts` 按 profile 取客户端、`errors.ts`、`refs.ts`)、`src/wiki/`、`src/pjm/`、`src/comments/`;`src/config.ts` → `src/config/index.ts`;工具注册从 `src/index.ts` 移到 `src/tools/`(`common` / `profiles` / `wiki` / `pjm` / `comments` / `register`),`src/index.ts` 仅保留 CLI 参数、服务器创建与连接
|
|
11
|
+
- 离线单测 `scripts/unit-X.ts` → `scripts/unit/X.ts`,`npm run test:unit` 路径同步更新
|
|
12
|
+
|
|
13
|
+
## [0.3.1] - 2026-11-05
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- 工作项评论工具:`pingcode_work_item_comment_list`、`pingcode_work_item_comment_create`、`pingcode_work_item_comment_delete`(工作项解析与 profile 选择沿用 `pingcode_work_item_get`)
|
|
18
|
+
- `pingcode_work_item_comment_list` 默认过滤已删除评论(`is_deleted=1`、内容为空);`include_deleted=true` 时全部返回并标记 `deleted: true`
|
|
19
|
+
- `PingCodeClient.post` / `PingCodeClient.delete`:与 `get` 共用请求封装(429 重试、最小请求间隔、错误格式),错误信息带实际 HTTP 方法
|
|
20
|
+
- 离线单测 `scripts/unit-comments.ts`(已并入 `npm run test:unit`);selftest 增加 DEMO-29 评论新增 → 列出 → 删除冒烟(可用 `SELFTEST_WORK_ITEM` 指定工作项),并清理自己创建的评论
|
|
21
|
+
|
|
22
|
+
### Note
|
|
23
|
+
|
|
24
|
+
- **不提供修改评论的工具**:PingCode Open API 不支持更新评论(`OPTIONS /v1/comments/{id}` 返回 `Allow: HEAD,GET,DELETE`,PATCH/PUT 返回 405)。需要改内容时只能删除后重新创建。
|
|
25
|
+
- 列表使用 `GET /v1/comments?principal_type=work_item&principal_id=…`:`/v1/project/work_items/{id}/comments` 的响应不含 `is_deleted` 字段,无法可靠区分已删除评论。
|
|
26
|
+
|
|
27
|
+
## [0.3.0] - 2026-11-05
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- 项目管理工具:`pingcode_project_get`、`pingcode_work_item_get`、`pingcode_sprint_list`、`pingcode_sprint_work_items`
|
|
32
|
+
- PJM URL 解析:项目 / 看板 / 迭代 / 工作项完整 URL(按主机名匹配 profile,与 Wiki 一致)
|
|
33
|
+
- `incomplete_only`:按 `state.type !== completed` 过滤迭代未完成工作项
|
|
34
|
+
- 看板 URL 中 web short id 无法从 Open API 反查时的解析策略:仅当恰好一个 `in_progress` 迭代时自动选用,否则返回 `candidates` 供选择
|
|
35
|
+
- README「使用示例」与「多 profile / 多租户时 agent 怎么选」(区分 profile / wiki space / project)
|
|
36
|
+
- 离线单测 `scripts/unit-pjm.ts`;selftest 增加 DEMO-77 与 Sprint2 未完成列表冒烟
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- 工具描述补充多 profile 选择提示:有完整 URL 不问租户;仅有短标识且多 profile 时先 `pingcode_list_profiles` 再问;先定租户再定 space/project/sprint
|
|
41
|
+
|
|
42
|
+
## [0.2.2] - 2026-11-05
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- HTTP 429 限流等待与重试:统一请求封装读取 `x-pc-retry-after`,无 header 时按 1s/2s/4s/8s… 指数退避,单次等待上限 60s
|
|
47
|
+
- 环境变量 `PINGCODE_MAX_RETRIES`(默认 4;设为 `0` 关闭重试)与 profile 字段 `maxRetries` / `max_retries`(优先级:profile > 环境变量 > 默认)
|
|
48
|
+
- 可选客户端最小请求间隔 `PINGCODE_MIN_INTERVAL_MS`(默认 0;按 profile 客户端分别节流)
|
|
49
|
+
- 每次重试向 stderr 输出一行日志(次数、等待秒数、来自 header 或退避)
|
|
50
|
+
- 离线单测 `scripts/unit-ratelimit.ts`(已并入 `npm run test:unit`)
|
|
51
|
+
- README「限流与重试」说明;`.env.example` 补充上述环境变量
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- 429 重试耗尽后仍抛出原有 `PingCodeError`(`status` 为 429),错误文案注明已重试次数
|
|
56
|
+
- 401 / 403 / 404 / 5xx 与网络错误不重试
|
|
57
|
+
|
|
58
|
+
## [0.2.1] - 2026-11-05
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- 配置以 `~/.config/pingcode-mcp/config.json` 为主路径;MCP 客户端推荐只配 `"command": "pingcode-mcp"`
|
|
63
|
+
- `PINGCODE_PROFILES` 仍可用作覆盖,但文档明确不推荐塞进客户端 `env`
|
|
64
|
+
- 文档与 `.env.example` 改为「配置文件优先、`.env` 仅本机开发/selftest」
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- `pingcode-mcp init` 生成配置模板说明与引导(无 profile 时 stderr / 工具错误提示配置路径)
|
|
69
|
+
|
|
70
|
+
## [0.2.0] - 2026-11-05
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
- 多租户 profile:`name`、`apiBaseUrl`、`webBaseUrl`、`accessToken`、`defaultSpaceIdentifier`
|
|
75
|
+
- 配置文件 `~/.config/pingcode-mcp/config.json` 与环境变量 `PINGCODE_PROFILES` / `PINGCODE_DEFAULT_PROFILE`
|
|
76
|
+
- 工具 `pingcode_list_profiles`(令牌脱敏)
|
|
77
|
+
- `pingcode_wiki_read_page` 按完整 URL 主机名匹配 `webBaseUrl` 自动选 profile
|
|
78
|
+
- `pingcode_wiki_list_pages` 支持空间短标识(如 `DEMO`),结果缓存 5 分钟
|
|
79
|
+
- 全局可执行命令 `pingcode-mcp`(`npm i -g .` / `bin`)
|
|
80
|
+
- CLI:`--version` / `--help` / `init`
|
|
81
|
+
- 离线单测 `scripts/unit-profiles.ts`
|
|
82
|
+
|
|
83
|
+
## [0.1.0] - 2026-11-05
|
|
84
|
+
|
|
85
|
+
### Added
|
|
86
|
+
|
|
87
|
+
- 初版 MCP(stdio):`pingcode_check_auth`、`pingcode_wiki_read_page`、`pingcode_wiki_list_spaces`、`pingcode_wiki_list_pages`
|
|
88
|
+
- 页面短 ID / 长 ID / Wiki URL 解析;正文以 Markdown 拉取
|
|
89
|
+
- 单令牌环境变量 `PINGCODE_ACCESS_TOKEN` 等;`.env` 本机开发;`npm run selftest`
|
package/README.md
CHANGED
|
@@ -1,3 +1,341 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pingcode-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
基于 [Model Context Protocol](https://modelcontextprotocol.io) 的 PingCode MCP 服务器(stdio 传输),让 Claude 等 MCP 客户端可以读取 PingCode **知识库(Wiki)** 与 **项目管理(项目 / 工作项 / 迭代 / 工作项评论)**。支持多个 PingCode 租户(多 profile),可全局安装为 `pingcode-mcp` 命令;推荐把所有租户写在 `~/.config/pingcode-mcp/config.json`,MCP 客户端只配 `"command": "pingcode-mcp"`。
|
|
4
|
+
|
|
5
|
+
变更记录见 [CHANGELOG.md](./CHANGELOG.md)。
|
|
6
|
+
|
|
7
|
+
## 工具
|
|
8
|
+
|
|
9
|
+
所有工具都接受可选参数 `profile`(profile 名称 = 租户)。省略时:完整 `*.pingcode.com` URL 会按主机名匹配 `webBaseUrl`;否则使用默认 profile。选择规则见下方「多 profile / 多租户时 agent 怎么选」。
|
|
10
|
+
|
|
11
|
+
| 工具 | 说明 | 对应 API |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `pingcode_list_profiles` | 列出已配置的 profile:名称、是否默认、`apiBaseUrl`、`webBaseUrl`、`defaultSpaceIdentifier`,令牌脱敏(仅显示后 4 位) | — |
|
|
14
|
+
| `pingcode_check_auth` | 校验访问令牌,返回当前用户 | `GET /v1/myself` |
|
|
15
|
+
| `pingcode_wiki_read_page` | 读取页面 Markdown。`url_or_id` 支持完整 URL、短 ID 或 24 位长 ID | `GET /v1/wiki/pages/{id}` + `…/content?format_type=markdown` |
|
|
16
|
+
| `pingcode_wiki_list_spaces` | 列出知识库空间 | `GET /v1/wiki/spaces` |
|
|
17
|
+
| `pingcode_wiki_list_pages` | 列出空间下的页面(`space_id` / `space_identifier` / `space`,或 profile 的 `defaultSpaceIdentifier`) | `GET /v1/wiki/pages?space_id=...` |
|
|
18
|
+
| `pingcode_project_get` | 获取项目。支持 identifier(`DEMO`)、id,或 `…/pjm/projects/DEMO/…` URL | `GET /v1/project/projects?identifier=` / `{id}` |
|
|
19
|
+
| `pingcode_work_item_get` | 获取工作项。支持 `DEMO-77`、短/长 id,或 `…/pjm/workitems/…` URL | `GET /v1/project/work_items?identifier=` / `{id}` |
|
|
20
|
+
| `pingcode_work_item_comment_list` | 列出工作项评论;默认过滤已删除(`is_deleted=1`、内容为空),`include_deleted=true` 时全部返回并标 `deleted: true` | `GET /v1/comments?principal_type=work_item&principal_id=` |
|
|
21
|
+
| `pingcode_work_item_comment_create` | 给工作项新增评论(`content`) | `POST /v1/project/work_items/{id}/comments` |
|
|
22
|
+
| `pingcode_work_item_comment_delete` | 删除工作项评论(`comment_id`) | `DELETE /v1/comments/{id}?principal_type=work_item&principal_id=` |
|
|
23
|
+
| `pingcode_sprint_list` | 列出项目下的迭代 | `GET /v1/project/projects/{id}/sprints` |
|
|
24
|
+
| `pingcode_sprint_work_items` | 列出迭代工作项;`incomplete_only` 过滤未完成;也可传看板/迭代 URL | `GET /v1/project/work_items?project_id&sprint_id` |
|
|
25
|
+
|
|
26
|
+
> **不支持修改评论。** PingCode Open API 对评论没有 PATCH/PUT(`OPTIONS /v1/comments/{id}` 返回 `Allow: HEAD,GET,DELETE`,PATCH/PUT 返回 405),因此本服务器不提供 update/edit 工具。要改评论只能删掉再新增一条。
|
|
27
|
+
|
|
28
|
+
出错时工具返回 `isError: true`,消息包含 profile、HTTP 状态码和 PingCode 错误码,例如:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
PingCode API 错误 [profile demo]: GET /v1/wiki/pages/NOPE1234 -> HTTP 400, code=100903: 页面不存在或无权访问
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 使用示例(给人和 agent)
|
|
35
|
+
|
|
36
|
+
以下用演示租户 `https://wyav20261105054550583.pingcode.com`(项目/空间标识均为 `DEMO`)说明三类常见操作。
|
|
37
|
+
|
|
38
|
+
### 1. 读知识库页面
|
|
39
|
+
|
|
40
|
+
用户给了 Wiki URL 时,直接调用 `pingcode_wiki_read_page`,**不要**再问选哪个 profile(主机名会自动匹配):
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"url_or_id": "https://wyav20261105054550583.pingcode.com/wiki/spaces/DEMO/pages/gjui-JX2"
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
返回页面标题、Markdown 正文等(示例页名一般为 "Getting Started")。若只有短 ID `gjui-JX2` 且配置了多个 profile,先 `pingcode_list_profiles`,把「名称 + webBaseUrl」列给用户选租户。
|
|
49
|
+
|
|
50
|
+
### 2. 查看需求 / 工作项 DEMO-77
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"work_item": "DEMO-77"
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
或带完整 URL(自动选 profile):
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"work_item": "https://wyav20261105054550583.pingcode.com/pjm/workitems/t_SLq5Dw"
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`pingcode_work_item_get` 返回精简字段:`identifier`、`title`、`type`、`state`、`assignee`、`sprint`、`project`、`html_url`、`description_summary`。示例:`DEMO-77` / `[Server] Get a paginated product list` / 状态「进行中」(`in_progress`) / 迭代 Sprint2。
|
|
67
|
+
|
|
68
|
+
### 3. 看某个迭代还剩什么没完成
|
|
69
|
+
|
|
70
|
+
看板 URL 示例:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
https://wyav20261105054550583.pingcode.com/pjm/projects/DEMO/sprints/CU4DQFbH/board
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
调用 `pingcode_sprint_work_items`:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"url": "https://wyav20261105054550583.pingcode.com/pjm/projects/DEMO/sprints/CU4DQFbH/board",
|
|
81
|
+
"incomplete_only": true
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
或显式指定项目与迭代名:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"project": "DEMO",
|
|
90
|
+
"sprint": "Sprint2",
|
|
91
|
+
"incomplete_only": true
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
「未完成」= `state.type` 不是 `completed`(含 `pending` / `in_progress` 等)。
|
|
96
|
+
说明:看板路径里的 `CU4DQFbH` 是 Web short id,Open API 的 sprint 列表**不返回** `short_id`,无法反查。若项目里恰好只有一个 `in_progress` 迭代,会自动选用并在结果里加 `note`;否则返回 `needs_sprint_selection` + `candidates`(name/status/id),请列出选项让用户选,**不要瞎猜**。该演示项目的 boards 接口可能返回「项目不支持看板」,未完成项以 sprint + work_items 过滤即可。
|
|
97
|
+
|
|
98
|
+
## 多 profile / 多租户时 agent 怎么选
|
|
99
|
+
|
|
100
|
+
先分清三个概念,**不要混问**:
|
|
101
|
+
|
|
102
|
+
| 概念 | 是什么 | 例子 |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| **profile** | 一个 PingCode **租户**(不同 `*.pingcode.com` + 令牌) | `demo` → `https://wyav….pingcode.com` |
|
|
105
|
+
| **wiki space** | 某租户下的**知识库空间**标识 | `DEMO`(Wiki) |
|
|
106
|
+
| **project** | 某租户下的**项目**标识 | `DEMO`(PJM;可与 space 同名) |
|
|
107
|
+
|
|
108
|
+
选择顺序:**先定租户(profile),再定空间 / 项目 / 迭代**。
|
|
109
|
+
|
|
110
|
+
1. **用户给了完整 URL**(Wiki 或 `/pjm/projects/…` / `/pjm/workitems/…`)→ 用主机名匹配各 profile 的 `webBaseUrl`,**不要问**用户选哪个租户。
|
|
111
|
+
2. **用户显式说了 profile 名** → 用它。
|
|
112
|
+
3. **只有** `DEMO-77` / 短 id / 裸 `DEMO`,且配置了**多个** profile → **先**调用 `pingcode_list_profiles`,再**询问用户选哪个租户/profile**,选项列成 `名称 + webBaseUrl`。不要默默用 default,除非只有一个 profile,或用户已指定且匹配唯一合理。
|
|
113
|
+
4. **同一租户下**多个 wiki space、或多个 sprint 且无法从 URL/名称唯一确定 → 再问 space 或 sprint(列出候选)。看板 short id 无法映射时同理,看工具返回的 `candidates`。
|
|
114
|
+
5. **不要**把「问 space」和「问 profile」当成一回事:space/project 标识(如 `DEMO`)在不同租户里可能都存在;先定租户,再定空间/项目/迭代。
|
|
115
|
+
|
|
116
|
+
`pingcode_wiki_read_page` / 项目管理工具在传入完整 URL 且未传 `profile` 时的主机匹配细节,与下文「页面 URL 与 profile 的匹配」相同(PJM URL 同样按 host 选择)。
|
|
117
|
+
|
|
118
|
+
## 快速开始(推荐)
|
|
119
|
+
|
|
120
|
+
三步:写配置文件 → 全局安装 → MCP 客户端只配 `command`。
|
|
121
|
+
|
|
122
|
+
### 1. 写配置文件 `~/.config/pingcode-mcp/config.json`
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"defaultProfile": "demo",
|
|
127
|
+
"profiles": [
|
|
128
|
+
{
|
|
129
|
+
"name": "demo",
|
|
130
|
+
"apiBaseUrl": "https://open.pingcode.com",
|
|
131
|
+
"webBaseUrl": "https://your-tenant.pingcode.com",
|
|
132
|
+
"accessToken": "pcpat_REPLACE_ME",
|
|
133
|
+
"defaultSpaceIdentifier": "DEMO"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"name": "other",
|
|
137
|
+
"webBaseUrl": "https://other.pingcode.com",
|
|
138
|
+
"accessToken": "pcpat_REPLACE_ME"
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
可以用 `pingcode-mcp init` 生成模板(见第 2 步之后),再把 `accessToken`、`webBaseUrl` 换成自己的。配置文件包含令牌,请 `chmod 600 ~/.config/pingcode-mcp/config.json`。
|
|
145
|
+
|
|
146
|
+
### 2. 全局安装
|
|
147
|
+
|
|
148
|
+
推荐从 npm 安装(包名 `@mcohenchen/pingcode-mcp`,命令仍是 `pingcode-mcp`):
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
npm i -g @mcohenchen/pingcode-mcp
|
|
152
|
+
pingcode-mcp --version
|
|
153
|
+
pingcode-mcp init # 生成 ~/.config/pingcode-mcp/config.json 模板(已存在时拒绝覆盖,加 --force 强制)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
或从源码安装:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
git clone https://github.com/xl495/pingcode-mcp.git && cd pingcode-mcp
|
|
160
|
+
npm install
|
|
161
|
+
npm run build
|
|
162
|
+
npm i -g .
|
|
163
|
+
pingcode-mcp --version
|
|
164
|
+
pingcode-mcp init
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`pingcode-mcp init` 写入的位置是 `PINGCODE_CONFIG`(如设置)或默认配置路径;目录不存在会自动创建,文件权限为 600。
|
|
168
|
+
|
|
169
|
+
### 3. MCP 客户端只配 `command`
|
|
170
|
+
|
|
171
|
+
令牌和租户都在配置文件里,MCP 客户端配置**不需要 `env`**:
|
|
172
|
+
|
|
173
|
+
**Claude Code**
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
claude mcp add pingcode -- pingcode-mcp
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
或在项目根目录的 `.mcp.json` 中:
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
{
|
|
183
|
+
"mcpServers": {
|
|
184
|
+
"pingcode": {
|
|
185
|
+
"command": "pingcode-mcp"
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Claude Desktop / Cursor**
|
|
192
|
+
|
|
193
|
+
Claude Desktop 编辑 `claude_desktop_config.json`(macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`,Windows: `%APPDATA%\Claude\claude_desktop_config.json`);Cursor 编辑 `~/.cursor/mcp.json` 或项目内 `.cursor/mcp.json`:
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"mcpServers": {
|
|
198
|
+
"pingcode": {
|
|
199
|
+
"command": "pingcode-mcp"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
GUI 客户端的 `PATH` 可能不包含 npm 全局 bin 目录;如果找不到命令,把 `command` 改成 `which pingcode-mcp` 输出的绝对路径。未全局安装时,可用 `"command": "node", "args": ["/absolute/path/to/pingcode-mcp/dist/index.js"]`。修改配置文件或客户端配置后重启客户端。
|
|
206
|
+
|
|
207
|
+
启动时如果没有任何 profile,服务器会在 stderr 打印一次提示(配置文件路径 + 最小示例);调用工具时也会返回同样的引导。
|
|
208
|
+
|
|
209
|
+
## 配置
|
|
210
|
+
|
|
211
|
+
### 配置文件
|
|
212
|
+
|
|
213
|
+
位置:默认 `~/.config/pingcode-mcp/config.json`(设置了 `XDG_CONFIG_HOME` 时为 `$XDG_CONFIG_HOME/pingcode-mcp/config.json`;Windows 上是 `%USERPROFILE%\.config\pingcode-mcp\config.json`),可用环境变量 `PINGCODE_CONFIG` 指定其他路径。
|
|
214
|
+
|
|
215
|
+
每个 profile 的字段(也接受 snake_case 写法):
|
|
216
|
+
|
|
217
|
+
| 字段 | 别名 | 必填 | 说明 |
|
|
218
|
+
| --- | --- | --- | --- |
|
|
219
|
+
| `name` | | 多个 profile 时必填 | profile 名称;只有一个时默认为 `default` |
|
|
220
|
+
| `accessToken` | `access_token` | 是 | PingCode 开放平台访问令牌(以 Bearer 方式发送) |
|
|
221
|
+
| `apiBaseUrl` | `api_base_url` | 否 | 默认 `https://open.pingcode.com` |
|
|
222
|
+
| `webBaseUrl` | `web_base_url` | 否 | 租户 Web 地址,用于按 Wiki/PJM URL 主机名选择 profile |
|
|
223
|
+
| `defaultSpaceIdentifier` | `default_space_identifier` | 否 | 默认知识库空间标识,如 `DEMO` |
|
|
224
|
+
| `maxRetries` | `max_retries` | 否 | 该 profile 遇到 HTTP 429 时的最大重试次数(覆盖 `PINGCODE_MAX_RETRIES`) |
|
|
225
|
+
|
|
226
|
+
`profiles` 也可以写成以名称为键的对象:`{"profiles": {"demo": {...}, "other": {...}}}`。
|
|
227
|
+
|
|
228
|
+
默认 profile:`PINGCODE_DEFAULT_PROFILE` > 配置中的 `defaultProfile` > 名为 `default` 的 profile > 第一个 profile。
|
|
229
|
+
|
|
230
|
+
### 单令牌环境变量(兼容 / 本机开发)
|
|
231
|
+
|
|
232
|
+
不写配置文件时,也可以只设置一个令牌,自动生成名为 `default` 的 profile。**仅当没有任何其他 profile 时生效**,主要用于本机开发和 `npm run selftest`:
|
|
233
|
+
|
|
234
|
+
| 环境变量 | 必填 | 说明 |
|
|
235
|
+
| --- | --- | --- |
|
|
236
|
+
| `PINGCODE_ACCESS_TOKEN` | 是 | 访问令牌 |
|
|
237
|
+
| `PINGCODE_API_BASE_URL` | 否 | 默认 `https://open.pingcode.com` |
|
|
238
|
+
| `PINGCODE_WEB_BASE_URL` | 否 | 租户 Web 地址(匹配 Wiki/PJM URL) |
|
|
239
|
+
| `PINGCODE_DEFAULT_SPACE` | 否 | 默认知识库空间标识 |
|
|
240
|
+
|
|
241
|
+
可以复制 `.env.example` 为 `.env`。`.env` 会从项目根目录(以及当前工作目录)加载,已有的环境变量优先。
|
|
242
|
+
|
|
243
|
+
### 高级 / 兼容:`PINGCODE_PROFILES`
|
|
244
|
+
|
|
245
|
+
`PINGCODE_PROFILES` 环境变量可以用 JSON(数组,或与配置文件相同结构的对象)定义 profile,作为对配置文件的**覆盖**:同名 profile 按字段合并,环境变量一侧优先。它仍受支持,但**不推荐**——尤其不要把这段大 JSON 塞进 MCP 客户端配置的 `env` 里(难维护、令牌散落在多个客户端配置中)。多租户请写进配置文件。
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
# 仅在确有需要时(例如 CI 临时覆盖某个 profile 的令牌)
|
|
249
|
+
export PINGCODE_PROFILES='[{"name":"demo","accessToken":"pcpat_REPLACE_ME"}]'
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
完整的加载顺序(优先级从高到低):`PINGCODE_PROFILES` > 配置文件 > 单令牌环境变量(仅当前两者都没有 profile 时)。日常只需关心配置文件。
|
|
253
|
+
|
|
254
|
+
### URL 与 profile 的匹配
|
|
255
|
+
|
|
256
|
+
Wiki 与 PJM 工具在传入完整 URL 时共用同一套主机匹配(`selectProfile`):
|
|
257
|
+
|
|
258
|
+
- 传了 `profile` 参数:总是使用该 profile(仍从 URL 中解析资源 ID)
|
|
259
|
+
- 完整 URL(Wiki 或 `https://xxx.pingcode.com/pjm/projects/DEMO/...` 等)且未传 `profile`:按主机名匹配各 profile 的 `webBaseUrl`(忽略大小写和末尾斜杠)
|
|
260
|
+
- 恰好一个匹配:使用它
|
|
261
|
+
- 多个匹配:如果默认 profile 在其中则用默认 profile,否则报错
|
|
262
|
+
- 没有匹配:报错并列出所有 profile 及其 `webBaseUrl`;例外:只配置了一个 profile 且它没有设置 `webBaseUrl`(典型的旧版单令牌配置)时直接使用它
|
|
263
|
+
- 短 ID / 长 ID / 相对路径 / 裸 identifier(如 `DEMO-77`):使用默认 profile(多 profile 时 agent 应先问用户,见上文)
|
|
264
|
+
|
|
265
|
+
### 空间标识
|
|
266
|
+
|
|
267
|
+
`pingcode_wiki_list_pages` 可以直接用空间标识(如 `DEMO`)代替 24 位 `space_id`,服务器会分页列出空间并解析(大小写不敏感,也接受完全相同的空间名称),结果缓存 5 分钟。示例参数:
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{ "space_identifier": "DEMO", "page_size": 20 }
|
|
271
|
+
{ "space": "DEMO", "profile": "other" }
|
|
272
|
+
{}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
最后一种需要 profile 配置了 `defaultSpaceIdentifier`(或旧版的 `PINGCODE_DEFAULT_SPACE`)。
|
|
276
|
+
|
|
277
|
+
### 限流与重试
|
|
278
|
+
|
|
279
|
+
PingCode Open API 限流约为每个身份 200 次/分钟。所有请求都经过统一的请求封装:
|
|
280
|
+
|
|
281
|
+
- 收到 HTTP 429 时读取响应头 `x-pc-retry-after`(秒),等待后重试;没有该头(或无法解析)时按指数退避 1s / 2s / 4s / 8s…;单次等待最多 60 秒
|
|
282
|
+
- 最大重试次数默认 4,优先级:profile 的 `maxRetries` > 环境变量 `PINGCODE_MAX_RETRIES` > 4(设为 `0` 关闭重试)
|
|
283
|
+
- 每次重试会在 stderr 输出一行日志(第几次重试、等待秒数、等待来源);重试用尽后照常抛出 `PingCodeError`(`status` 为 429)
|
|
284
|
+
- 可选的客户端最小请求间隔:`PINGCODE_MIN_INTERVAL_MS`(毫秒,默认 `0` 不限制;按 profile 的客户端分别计算),例如设为 `300` 可将单个 profile 控制在约 200 次/分钟以内
|
|
285
|
+
|
|
286
|
+
非 429 的错误(401/403/404/5xx、网络错误)不会重试。
|
|
287
|
+
|
|
288
|
+
### 4. 给工作项写评论 → 查看 → 删除
|
|
289
|
+
|
|
290
|
+
以 Sprint2 中的 `DEMO-29`(View product details)为例。`work_item` 与 `pingcode_work_item_get` 一样支持 `DEMO-29`、短/长 id 或完整工作项 URL,profile 选择规则也一致。
|
|
291
|
+
|
|
292
|
+
写评论 `pingcode_work_item_comment_create`:
|
|
293
|
+
|
|
294
|
+
```json
|
|
295
|
+
{ "work_item": "DEMO-29", "content": "已确认详情页字段,待联调" }
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
返回 `{ profile, work_item: { id, identifier, title }, comment: { id, content, created_at, created_by } }`。
|
|
299
|
+
|
|
300
|
+
查看评论 `pingcode_work_item_comment_list`:
|
|
301
|
+
|
|
302
|
+
```json
|
|
303
|
+
{ "work_item": "DEMO-29" }
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
返回 `{ profile, work_item, total, comments: [...] }`,默认不含已删除评论。
|
|
307
|
+
|
|
308
|
+
删除评论 `pingcode_work_item_comment_delete`(`comment_id` 取自上一步):
|
|
309
|
+
|
|
310
|
+
```json
|
|
311
|
+
{ "work_item": "DEMO-29", "comment_id": "6aec49a1a23bd060a59436cc" }
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
删除后 PingCode 会保留一条 `is_deleted=1`、`content` 为空的记录;默认列表会过滤掉,传 `"include_deleted": true` 可看到(标记 `deleted: true`)。
|
|
315
|
+
|
|
316
|
+
**不支持修改评论**:Open API 没有评论更新接口,需要改内容时请先删除再重新创建。
|
|
317
|
+
|
|
318
|
+
## 开发与测试
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
npm run test:unit # 离线单元测试:profile、Wiki/PJM URL、sprint 选择、429 重试/限流、评论(mock fetch)
|
|
322
|
+
npm run selftest # 真实 API 冒烟测试(使用 .env / 配置的 profile;SKIP_LIVE=1 跳过)
|
|
323
|
+
npm test # 两者依次执行
|
|
324
|
+
npm start # 从源码以 stdio 方式启动(需先 npm run build)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
- `src/index.ts`:入口(`--version` / `--help` / `init` 参数、创建 MCP 服务器、`registerAllTools`、连接 stdio)
|
|
328
|
+
- `src/init.ts`:`pingcode-mcp init` 配置模板与无配置提示
|
|
329
|
+
- `src/config/index.ts`:profile 加载(环境变量 / 配置文件 / 旧版单令牌)、默认 profile、URL 主机匹配、令牌脱敏
|
|
330
|
+
- `src/client/`:`client.ts` API 客户端(429 重试、最小请求间隔);`profile.ts` 按 profile 缓存的客户端(`getClientFor` / `getClientForProfile`);`errors.ts` `PingCodeError`;`refs.ts` URL/id 解析共用正则
|
|
331
|
+
- `src/wiki/index.ts`:Wiki URL 解析、页面读取、空间/页面列表、空间标识解析
|
|
332
|
+
- `src/pjm/index.ts`:PJM URL 解析、项目/工作项/迭代、sprint 选择策略
|
|
333
|
+
- `src/comments/index.ts`:工作项评论 list/create/delete
|
|
334
|
+
- `src/tools/`:MCP 工具注册(`common.ts` 公共参数与结果封装;`profiles` / `wiki` / `pjm` / `comments` 各自的工具;`register.ts` 汇总注册)
|
|
335
|
+
- `scripts/unit/profiles.ts`:profile 加载/选择、Wiki URL 解析的离线单测
|
|
336
|
+
- `scripts/unit/ratelimit.ts`:429 重试 / 退避 / 最小间隔(mock `fetch`,注入 `sleep`)
|
|
337
|
+
- `scripts/unit/pjm.ts`:PJM URL 解析、未完成过滤、sprint 选择策略的离线单测
|
|
338
|
+
- `scripts/unit/comments.ts`:评论 list/create/delete 路径与参数、`include_deleted` 过滤、`post`/`delete` 方法(mock `fetch`)
|
|
339
|
+
- `scripts/selftest.ts`:真实 API 冒烟(Wiki + DEMO-77 + Sprint2 未完成 + DEMO-29 评论新增→列出→删除,会清理自己创建的评论;`SELFTEST_WORK_ITEM` 可换工作项;`SKIP_LIVE=1` 跳过)
|
|
340
|
+
|
|
341
|
+
注意:stdout 是 MCP 协议通道,日志请写到 stderr。
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { PingCodeError } from "./errors.js";
|
|
2
|
+
import { parseNonNegativeInt, resolveProfile } from "../config/index.js";
|
|
3
|
+
export const DEFAULT_MAX_RETRIES = 4;
|
|
4
|
+
/** Upper bound for a single 429 wait (x-pc-retry-after or backoff). */
|
|
5
|
+
export const MAX_RETRY_WAIT_MS = 60_000;
|
|
6
|
+
const BACKOFF_BASE_MS = 1_000;
|
|
7
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
/** Wait before retry `attempt` (1-based): x-pc-retry-after seconds if valid, else 1s/2s/4s…; capped. */
|
|
9
|
+
export function retryDelayMs(retryAfter, attempt) {
|
|
10
|
+
const secs = retryAfter !== null && retryAfter.trim() ? Number(retryAfter.trim()) : NaN;
|
|
11
|
+
if (Number.isFinite(secs) && secs >= 0)
|
|
12
|
+
return { ms: Math.min(secs * 1000, MAX_RETRY_WAIT_MS), fromHeader: true };
|
|
13
|
+
return { ms: Math.min(BACKOFF_BASE_MS * 2 ** (attempt - 1), MAX_RETRY_WAIT_MS), fromHeader: false };
|
|
14
|
+
}
|
|
15
|
+
export class PingCodeClient {
|
|
16
|
+
profile;
|
|
17
|
+
maxRetries;
|
|
18
|
+
minIntervalMs;
|
|
19
|
+
fetchImpl;
|
|
20
|
+
sleep;
|
|
21
|
+
log;
|
|
22
|
+
/** Earliest time the next request may start (min-interval throttle). */
|
|
23
|
+
nextSlotAt = 0;
|
|
24
|
+
constructor(profile = resolveProfile(), options = {}) {
|
|
25
|
+
this.profile = profile;
|
|
26
|
+
this.maxRetries =
|
|
27
|
+
options.maxRetries ?? profile.maxRetries ?? parseNonNegativeInt(process.env.PINGCODE_MAX_RETRIES) ?? DEFAULT_MAX_RETRIES;
|
|
28
|
+
this.minIntervalMs = options.minIntervalMs ?? parseNonNegativeInt(process.env.PINGCODE_MIN_INTERVAL_MS) ?? 0;
|
|
29
|
+
this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
|
|
30
|
+
this.sleep = options.sleep ?? defaultSleep;
|
|
31
|
+
this.log = options.log ?? ((msg) => console.error(msg));
|
|
32
|
+
}
|
|
33
|
+
/** Reserve the next request slot (synchronously, so concurrent callers queue up) and wait for it. */
|
|
34
|
+
async throttle() {
|
|
35
|
+
if (this.minIntervalMs <= 0)
|
|
36
|
+
return;
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const at = Math.max(now, this.nextSlotAt);
|
|
39
|
+
this.nextSlotAt = at + this.minIntervalMs;
|
|
40
|
+
if (at > now)
|
|
41
|
+
await this.sleep(at - now);
|
|
42
|
+
}
|
|
43
|
+
/** Single entry point for HTTP: throttling + retry on 429. */
|
|
44
|
+
async request(url, init) {
|
|
45
|
+
const method = init.method ?? "GET";
|
|
46
|
+
for (let attempt = 1;; attempt++) {
|
|
47
|
+
await this.throttle();
|
|
48
|
+
let res;
|
|
49
|
+
try {
|
|
50
|
+
res = await this.fetchImpl(url, { ...init, signal: AbortSignal.timeout(30_000) });
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
throw new PingCodeError(`请求 PingCode 失败 (profile ${this.profile.name}, ${method} ${url.pathname}): ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
if (res.status !== 429 || attempt > this.maxRetries)
|
|
56
|
+
return res;
|
|
57
|
+
const { ms, fromHeader } = retryDelayMs(res.headers.get("x-pc-retry-after"), attempt);
|
|
58
|
+
await res.body?.cancel().catch(() => { });
|
|
59
|
+
this.log(`pingcode-mcp: HTTP 429 [profile ${this.profile.name}] ${method} ${url.pathname}; ` +
|
|
60
|
+
`retry ${attempt}/${this.maxRetries} in ${ms / 1000}s (${fromHeader ? "x-pc-retry-after" : "backoff"})`);
|
|
61
|
+
await this.sleep(ms);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
get(apiPath, query = {}) {
|
|
65
|
+
return this.requestJson("GET", apiPath, query);
|
|
66
|
+
}
|
|
67
|
+
post(apiPath, body, query = {}) {
|
|
68
|
+
return this.requestJson("POST", apiPath, query, body);
|
|
69
|
+
}
|
|
70
|
+
delete(apiPath, query = {}) {
|
|
71
|
+
return this.requestJson("DELETE", apiPath, query);
|
|
72
|
+
}
|
|
73
|
+
/** JSON request with auth headers; non-2xx responses become PingCodeError. */
|
|
74
|
+
async requestJson(method, apiPath, query, body) {
|
|
75
|
+
const url = new URL(this.profile.apiBaseUrl + apiPath);
|
|
76
|
+
for (const [k, v] of Object.entries(query)) {
|
|
77
|
+
if (v !== undefined && v !== "")
|
|
78
|
+
url.searchParams.set(k, String(v));
|
|
79
|
+
}
|
|
80
|
+
const headers = {
|
|
81
|
+
Authorization: `Bearer ${this.profile.accessToken}`,
|
|
82
|
+
Accept: "application/json",
|
|
83
|
+
};
|
|
84
|
+
const init = { method, headers };
|
|
85
|
+
if (body !== undefined) {
|
|
86
|
+
headers["Content-Type"] = "application/json";
|
|
87
|
+
init.body = JSON.stringify(body);
|
|
88
|
+
}
|
|
89
|
+
const res = await this.request(url, init);
|
|
90
|
+
const text = await res.text();
|
|
91
|
+
let data = undefined;
|
|
92
|
+
if (text) {
|
|
93
|
+
try {
|
|
94
|
+
data = JSON.parse(text);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
data = text;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const code = typeof data === "object" && data ? data.code ?? data.error_code ?? data.error : undefined;
|
|
102
|
+
const msg = typeof data === "object" && data
|
|
103
|
+
? data.message ?? data.msg ?? data.error_description ?? JSON.stringify(data)
|
|
104
|
+
: (data || res.statusText);
|
|
105
|
+
const hint = res.status === 401
|
|
106
|
+
? `(令牌无效或已过期,请检查 profile "${this.profile.name}" 的 accessToken / PINGCODE_ACCESS_TOKEN)`
|
|
107
|
+
: res.status === 403
|
|
108
|
+
? "(权限不足)"
|
|
109
|
+
: res.status === 404
|
|
110
|
+
? "(资源不存在或无权访问)"
|
|
111
|
+
: res.status === 429
|
|
112
|
+
? `(触发限流,已重试 ${this.maxRetries} 次)`
|
|
113
|
+
: "";
|
|
114
|
+
throw new PingCodeError(`PingCode API 错误 [profile ${this.profile.name}]: ${method} ${url.pathname} -> HTTP ${res.status}` +
|
|
115
|
+
(code !== undefined ? `, code=${code}` : "") +
|
|
116
|
+
`: ${String(msg).slice(0, 500)}${hint}`, res.status, code);
|
|
117
|
+
}
|
|
118
|
+
return data;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function checkAuth(client) {
|
|
122
|
+
return client.get("/v1/myself");
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getProfiles, resolveProfile, selectProfile } from "../config/index.js";
|
|
2
|
+
import { PingCodeClient } from "./client.js";
|
|
3
|
+
const clients = new Map();
|
|
4
|
+
/** Cached client for a named profile (or the default profile). */
|
|
5
|
+
export function getClientForProfile(name) {
|
|
6
|
+
return clientFor(resolveProfile(name, getProfiles()));
|
|
7
|
+
}
|
|
8
|
+
/** Client for a request: explicit profile, else the profile matching the URL host, else the default. */
|
|
9
|
+
export function getClientFor(opts) {
|
|
10
|
+
return clientFor(selectProfile(opts, getProfiles()));
|
|
11
|
+
}
|
|
12
|
+
function clientFor(profile) {
|
|
13
|
+
let c = clients.get(profile.name);
|
|
14
|
+
if (!c)
|
|
15
|
+
clients.set(profile.name, (c = new PingCodeClient(profile)));
|
|
16
|
+
return c;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=profile.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Shared patterns for parsing PingCode URLs / ids (wiki, pjm, comments).
|
|
2
|
+
export const LONG_ID_RE = /^[0-9a-f]{24}$/i;
|
|
3
|
+
export const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
4
|
+
export const HOST_PREFIX_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?\//i;
|
|
5
|
+
export const decode = (s) => {
|
|
6
|
+
try {
|
|
7
|
+
return decodeURIComponent(s);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return s;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=refs.js.map
|