@xurenxiang1219/pingcode-mcp 0.3.1
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 +115 -0
- package/.env.stdio.local.example +13 -0
- package/README.md +222 -0
- package/dist/approve-write.d.ts +3 -0
- package/dist/approve-write.d.ts.map +1 -0
- package/dist/approve-write.js +81 -0
- package/dist/approve-write.js.map +1 -0
- package/dist/auth/cimd-client-resolver.d.ts +35 -0
- package/dist/auth/cimd-client-resolver.d.ts.map +1 -0
- package/dist/auth/cimd-client-resolver.js +293 -0
- package/dist/auth/cimd-client-resolver.js.map +1 -0
- package/dist/auth/memory-store.d.ts +15 -0
- package/dist/auth/memory-store.d.ts.map +1 -0
- package/dist/auth/memory-store.js +46 -0
- package/dist/auth/memory-store.js.map +1 -0
- package/dist/auth/pingcode-oauth-client.d.ts +23 -0
- package/dist/auth/pingcode-oauth-client.d.ts.map +1 -0
- package/dist/auth/pingcode-oauth-client.js +103 -0
- package/dist/auth/pingcode-oauth-client.js.map +1 -0
- package/dist/auth/pingcode-oauth-provider.d.ts +70 -0
- package/dist/auth/pingcode-oauth-provider.d.ts.map +1 -0
- package/dist/auth/pingcode-oauth-provider.js +414 -0
- package/dist/auth/pingcode-oauth-provider.js.map +1 -0
- package/dist/auth/redis-store.d.ts +16 -0
- package/dist/auth/redis-store.d.ts.map +1 -0
- package/dist/auth/redis-store.js +54 -0
- package/dist/auth/redis-store.js.map +1 -0
- package/dist/auth/runtime.d.ts +10 -0
- package/dist/auth/runtime.d.ts.map +1 -0
- package/dist/auth/runtime.js +57 -0
- package/dist/auth/runtime.js.map +1 -0
- package/dist/auth/store.d.ts +35 -0
- package/dist/auth/store.d.ts.map +1 -0
- package/dist/auth/store.js +55 -0
- package/dist/auth/store.js.map +1 -0
- package/dist/auth/token-cipher.d.ts +7 -0
- package/dist/auth/token-cipher.d.ts.map +1 -0
- package/dist/auth/token-cipher.js +36 -0
- package/dist/auth/token-cipher.js.map +1 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +270 -0
- package/dist/config.js.map +1 -0
- package/dist/domain/content-hash.d.ts +5 -0
- package/dist/domain/content-hash.d.ts.map +1 -0
- package/dist/domain/content-hash.js +17 -0
- package/dist/domain/content-hash.js.map +1 -0
- package/dist/domain/requirement-context.d.ts +29 -0
- package/dist/domain/requirement-context.d.ts.map +1 -0
- package/dist/domain/requirement-context.js +2 -0
- package/dist/domain/requirement-context.js.map +1 -0
- package/dist/domain/test-case-context.d.ts +56 -0
- package/dist/domain/test-case-context.d.ts.map +1 -0
- package/dist/domain/test-case-context.js +2 -0
- package/dist/domain/test-case-context.js.map +1 -0
- package/dist/domain/work-item-context.d.ts +47 -0
- package/dist/domain/work-item-context.d.ts.map +1 -0
- package/dist/domain/work-item-context.js +2 -0
- package/dist/domain/work-item-context.js.map +1 -0
- package/dist/environment.d.ts +2 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +21 -0
- package/dist/environment.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect-wiki.d.ts +3 -0
- package/dist/inspect-wiki.d.ts.map +1 -0
- package/dist/inspect-wiki.js +49 -0
- package/dist/inspect-wiki.js.map +1 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +40 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp/create-server.d.ts +10 -0
- package/dist/mcp/create-server.d.ts.map +1 -0
- package/dist/mcp/create-server.js +40 -0
- package/dist/mcp/create-server.js.map +1 -0
- package/dist/mcp/pagination-guidance.d.ts +19 -0
- package/dist/mcp/pagination-guidance.d.ts.map +1 -0
- package/dist/mcp/pagination-guidance.js +36 -0
- package/dist/mcp/pagination-guidance.js.map +1 -0
- package/dist/mcp/reference-resolution.d.ts +19 -0
- package/dist/mcp/reference-resolution.d.ts.map +1 -0
- package/dist/mcp/reference-resolution.js +160 -0
- package/dist/mcp/reference-resolution.js.map +1 -0
- package/dist/mcp/tool-registry.d.ts +2 -0
- package/dist/mcp/tool-registry.d.ts.map +1 -0
- package/dist/mcp/tool-registry.js +55 -0
- package/dist/mcp/tool-registry.js.map +1 -0
- package/dist/mcp/tool-runtime.d.ts +22 -0
- package/dist/mcp/tool-runtime.d.ts.map +1 -0
- package/dist/mcp/tool-runtime.js +214 -0
- package/dist/mcp/tool-runtime.js.map +1 -0
- package/dist/mcp/tools/member-role.d.ts +8 -0
- package/dist/mcp/tools/member-role.d.ts.map +1 -0
- package/dist/mcp/tools/member-role.js +451 -0
- package/dist/mcp/tools/member-role.js.map +1 -0
- package/dist/mcp/tools/metadata.d.ts +5 -0
- package/dist/mcp/tools/metadata.d.ts.map +1 -0
- package/dist/mcp/tools/metadata.js +606 -0
- package/dist/mcp/tools/metadata.js.map +1 -0
- package/dist/mcp/tools/project-context.d.ts +4 -0
- package/dist/mcp/tools/project-context.d.ts.map +1 -0
- package/dist/mcp/tools/project-context.js +111 -0
- package/dist/mcp/tools/project-context.js.map +1 -0
- package/dist/mcp/tools/project.d.ts +7 -0
- package/dist/mcp/tools/project.d.ts.map +1 -0
- package/dist/mcp/tools/project.js +370 -0
- package/dist/mcp/tools/project.js.map +1 -0
- package/dist/mcp/tools/relation.d.ts +7 -0
- package/dist/mcp/tools/relation.d.ts.map +1 -0
- package/dist/mcp/tools/relation.js +592 -0
- package/dist/mcp/tools/relation.js.map +1 -0
- package/dist/mcp/tools/runtime-info.d.ts +4 -0
- package/dist/mcp/tools/runtime-info.d.ts.map +1 -0
- package/dist/mcp/tools/runtime-info.js +49 -0
- package/dist/mcp/tools/runtime-info.js.map +1 -0
- package/dist/mcp/tools/ship-suite.d.ts +7 -0
- package/dist/mcp/tools/ship-suite.d.ts.map +1 -0
- package/dist/mcp/tools/ship-suite.js +636 -0
- package/dist/mcp/tools/ship-suite.js.map +1 -0
- package/dist/mcp/tools/ship.d.ts +7 -0
- package/dist/mcp/tools/ship.d.ts.map +1 -0
- package/dist/mcp/tools/ship.js +617 -0
- package/dist/mcp/tools/ship.js.map +1 -0
- package/dist/mcp/tools/sync.d.ts +10 -0
- package/dist/mcp/tools/sync.d.ts.map +1 -0
- package/dist/mcp/tools/sync.js +1110 -0
- package/dist/mcp/tools/sync.js.map +1 -0
- package/dist/mcp/tools/task.d.ts +5 -0
- package/dist/mcp/tools/task.d.ts.map +1 -0
- package/dist/mcp/tools/task.js +236 -0
- package/dist/mcp/tools/task.js.map +1 -0
- package/dist/mcp/tools/test-case-bulk.d.ts +7 -0
- package/dist/mcp/tools/test-case-bulk.d.ts.map +1 -0
- package/dist/mcp/tools/test-case-bulk.js +297 -0
- package/dist/mcp/tools/test-case-bulk.js.map +1 -0
- package/dist/mcp/tools/test-case-delete.d.ts +7 -0
- package/dist/mcp/tools/test-case-delete.d.ts.map +1 -0
- package/dist/mcp/tools/test-case-delete.js +247 -0
- package/dist/mcp/tools/test-case-delete.js.map +1 -0
- package/dist/mcp/tools/test-case.d.ts +5 -0
- package/dist/mcp/tools/test-case.d.ts.map +1 -0
- package/dist/mcp/tools/test-case.js +155 -0
- package/dist/mcp/tools/test-case.js.map +1 -0
- package/dist/mcp/tools/test-library.d.ts +7 -0
- package/dist/mcp/tools/test-library.d.ts.map +1 -0
- package/dist/mcp/tools/test-library.js +302 -0
- package/dist/mcp/tools/test-library.js.map +1 -0
- package/dist/mcp/tools/wiki.d.ts +5 -0
- package/dist/mcp/tools/wiki.d.ts.map +1 -0
- package/dist/mcp/tools/wiki.js +266 -0
- package/dist/mcp/tools/wiki.js.map +1 -0
- package/dist/mcp/write-approval.d.ts +48 -0
- package/dist/mcp/write-approval.d.ts.map +1 -0
- package/dist/mcp/write-approval.js +321 -0
- package/dist/mcp/write-approval.js.map +1 -0
- package/dist/observability/metrics.d.ts +46 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +79 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/pingcode/access-token-provider.d.ts +5 -0
- package/dist/pingcode/access-token-provider.d.ts.map +1 -0
- package/dist/pingcode/access-token-provider.js +2 -0
- package/dist/pingcode/access-token-provider.js.map +1 -0
- package/dist/pingcode/client.d.ts +43 -0
- package/dist/pingcode/client.d.ts.map +1 -0
- package/dist/pingcode/client.js +300 -0
- package/dist/pingcode/client.js.map +1 -0
- package/dist/pingcode/errors.d.ts +20 -0
- package/dist/pingcode/errors.d.ts.map +1 -0
- package/dist/pingcode/errors.js +14 -0
- package/dist/pingcode/errors.js.map +1 -0
- package/dist/pingcode/schemas.d.ts +465 -0
- package/dist/pingcode/schemas.d.ts.map +1 -0
- package/dist/pingcode/schemas.js +450 -0
- package/dist/pingcode/schemas.js.map +1 -0
- package/dist/pingcode/token-provider.d.ts +20 -0
- package/dist/pingcode/token-provider.d.ts.map +1 -0
- package/dist/pingcode/token-provider.js +97 -0
- package/dist/pingcode/token-provider.js.map +1 -0
- package/dist/pingcode/write-response.d.ts +9 -0
- package/dist/pingcode/write-response.d.ts.map +1 -0
- package/dist/pingcode/write-response.js +39 -0
- package/dist/pingcode/write-response.js.map +1 -0
- package/dist/services.d.ts +22 -0
- package/dist/services.d.ts.map +1 -0
- package/dist/services.js +84 -0
- package/dist/services.js.map +1 -0
- package/dist/sources/metadata-source.d.ts +445 -0
- package/dist/sources/metadata-source.d.ts.map +1 -0
- package/dist/sources/metadata-source.js +167 -0
- package/dist/sources/metadata-source.js.map +1 -0
- package/dist/sources/project-context-source.d.ts +61 -0
- package/dist/sources/project-context-source.d.ts.map +1 -0
- package/dist/sources/project-context-source.js +224 -0
- package/dist/sources/project-context-source.js.map +1 -0
- package/dist/sources/relation-source.d.ts +83 -0
- package/dist/sources/relation-source.d.ts.map +1 -0
- package/dist/sources/relation-source.js +129 -0
- package/dist/sources/relation-source.js.map +1 -0
- package/dist/sources/requirement-source.d.ts +5 -0
- package/dist/sources/requirement-source.d.ts.map +1 -0
- package/dist/sources/requirement-source.js +2 -0
- package/dist/sources/requirement-source.js.map +1 -0
- package/dist/sources/ship-source.d.ts +67 -0
- package/dist/sources/ship-source.d.ts.map +1 -0
- package/dist/sources/ship-source.js +135 -0
- package/dist/sources/ship-source.js.map +1 -0
- package/dist/sources/test-case-source.d.ts +30 -0
- package/dist/sources/test-case-source.d.ts.map +1 -0
- package/dist/sources/test-case-source.js +206 -0
- package/dist/sources/test-case-source.js.map +1 -0
- package/dist/sources/wiki-source.d.ts +34 -0
- package/dist/sources/wiki-source.d.ts.map +1 -0
- package/dist/sources/wiki-source.js +136 -0
- package/dist/sources/wiki-source.js.map +1 -0
- package/dist/sources/work-item-source.d.ts +33 -0
- package/dist/sources/work-item-source.d.ts.map +1 -0
- package/dist/sources/work-item-source.js +159 -0
- package/dist/sources/work-item-source.js.map +1 -0
- package/dist/sources/write-source.d.ts +461 -0
- package/dist/sources/write-source.d.ts.map +1 -0
- package/dist/sources/write-source.js +174 -0
- package/dist/sources/write-source.js.map +1 -0
- package/dist/transports/bearer-auth.d.ts +20 -0
- package/dist/transports/bearer-auth.d.ts.map +1 -0
- package/dist/transports/bearer-auth.js +79 -0
- package/dist/transports/bearer-auth.js.map +1 -0
- package/dist/transports/http.d.ts +11 -0
- package/dist/transports/http.d.ts.map +1 -0
- package/dist/transports/http.js +203 -0
- package/dist/transports/http.js.map +1 -0
- package/dist/transports/metrics-auth.d.ts +3 -0
- package/dist/transports/metrics-auth.d.ts.map +1 -0
- package/dist/transports/metrics-auth.js +22 -0
- package/dist/transports/metrics-auth.js.map +1 -0
- package/dist/transports/stdio.d.ts +5 -0
- package/dist/transports/stdio.d.ts.map +1 -0
- package/dist/transports/stdio.js +11 -0
- package/dist/transports/stdio.js.map +1 -0
- package/dist/uat/run-label.d.ts +2 -0
- package/dist/uat/run-label.d.ts.map +1 -0
- package/dist/uat/run-label.js +12 -0
- package/dist/uat/run-label.js.map +1 -0
- package/dist/verify.d.ts +3 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +59 -0
- package/dist/verify.js.map +1 -0
- package/dist/write/approval.d.ts +35 -0
- package/dist/write/approval.d.ts.map +1 -0
- package/dist/write/approval.js +66 -0
- package/dist/write/approval.js.map +1 -0
- package/dist/write/plan.d.ts +2 -0
- package/dist/write/plan.d.ts.map +1 -0
- package/dist/write/plan.js +17 -0
- package/dist/write/plan.js.map +1 -0
- package/dist/write/runtime.d.ts +90 -0
- package/dist/write/runtime.d.ts.map +1 -0
- package/dist/write/runtime.js +244 -0
- package/dist/write/runtime.js.map +1 -0
- package/dist/write/store.d.ts +101 -0
- package/dist/write/store.d.ts.map +1 -0
- package/dist/write/store.js +201 -0
- package/dist/write/store.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { PingCodeError } from "../../pingcode/errors.js";
|
|
3
|
+
import { contentHashSchema, normalizeContentHash } from "../../domain/content-hash.js";
|
|
4
|
+
import { requestHash as calculateRequestHash } from "../../write/plan.js";
|
|
5
|
+
import { executeTool, requestIdForLog } from "../tool-runtime.js";
|
|
6
|
+
import { resolveCandidate, resolveMetadataReference, resolveProjectStageReference, resolveUserReference, resolveWorkItemReference } from "../reference-resolution.js";
|
|
7
|
+
import { approvalTokenSchema, commitWithApproval, formatPreviewToolResult, formatWriteConfirmationMessage, formatWriteToolResult, requestClientWriteConfirmation, resolveCommitVerification, } from "../write-approval.js";
|
|
8
|
+
const previewAnnotations = {
|
|
9
|
+
readOnlyHint: true,
|
|
10
|
+
destructiveHint: false,
|
|
11
|
+
idempotentHint: false,
|
|
12
|
+
openWorldHint: true,
|
|
13
|
+
};
|
|
14
|
+
const commitAnnotations = {
|
|
15
|
+
readOnlyHint: false,
|
|
16
|
+
destructiveHint: true,
|
|
17
|
+
idempotentHint: true,
|
|
18
|
+
openWorldHint: true,
|
|
19
|
+
};
|
|
20
|
+
const identifier = z.string().trim().min(1).max(200);
|
|
21
|
+
const contentHash = contentHashSchema;
|
|
22
|
+
const agentSessionId = z
|
|
23
|
+
.string()
|
|
24
|
+
.trim()
|
|
25
|
+
.min(8)
|
|
26
|
+
.max(200)
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("当前 Agent/IDE 会话的稳定不透明 ID;能取得时由 Agent 在 Preview 与 Commit 间原样复用,不要要求用户手填。客户端无法提供时可省略。");
|
|
29
|
+
const approvalContextId = z.string().uuid();
|
|
30
|
+
const sourceReferenceSchema = z.object({
|
|
31
|
+
type: z
|
|
32
|
+
.enum(["wiki", "task", "test_case", "spec", "plan", "code"])
|
|
33
|
+
.describe("来源资源类型"),
|
|
34
|
+
id: identifier.describe("来源资源的稳定 ID 或仓库内规范 ID"),
|
|
35
|
+
url: z.url().optional().describe("来源可访问 URL;没有稳定 URL 时省略"),
|
|
36
|
+
content_hash: contentHash.optional().describe("读取来源时取得的 sha256 内容 Hash,用于追溯版本"),
|
|
37
|
+
});
|
|
38
|
+
const propertiesSchema = z
|
|
39
|
+
.record(z.string().trim().min(1).max(100), z.unknown())
|
|
40
|
+
.refine((value) => Object.keys(value).length <= 50, "properties 最多包含 50 个字段")
|
|
41
|
+
.refine((value) => Buffer.byteLength(JSON.stringify(value), "utf8") <= 32 * 1024, "properties 不得超过 32 KiB")
|
|
42
|
+
.describe("工作项属性键值。只能使用元数据 Tool 返回 writeField 为 properties.<id> 的字段,并按 valueShape/allowedValues 传值;不得猜字段 ID 或选项值。");
|
|
43
|
+
// Test Case 仍保留旧客户端使用的 custom_properties 入口;工作项只使用官方
|
|
44
|
+
// properties 字段,二者在各自的 endpoint 映射中保持明确边界。
|
|
45
|
+
const customPropertiesSchema = propertiesSchema.describe("兼容旧客户端的自定义属性键值;Test Case 请求会映射为官方 properties。");
|
|
46
|
+
const taskCommonFields = {
|
|
47
|
+
title: z.string().trim().min(1).max(500).optional().describe("工作项标题"),
|
|
48
|
+
description: z.string().max(50_000).optional().describe("工作项 Markdown 描述"),
|
|
49
|
+
start_at: z.number().int().min(0).optional().describe("开始时间,Unix 秒时间戳"),
|
|
50
|
+
end_at: z.number().int().min(0).optional().describe("结束时间,Unix 秒时间戳"),
|
|
51
|
+
priority_id: identifier.optional().describe("优先级 ID;从工作项元数据 priorities 获取"),
|
|
52
|
+
priority_name: identifier.optional().describe("优先级名称;Tool 会从工作项元数据解析 ID,名称不存在时返回候选"),
|
|
53
|
+
state_id: identifier.optional().describe("状态 ID;从指定项目和工作项类型的 states 获取"),
|
|
54
|
+
state_name: identifier.optional().describe("状态名称;Tool 会从工作项元数据解析 ID,名称不存在时返回候选"),
|
|
55
|
+
assignee_id: identifier.optional().describe("负责人用户 ID;通过 pingcode_user_search 查询"),
|
|
56
|
+
assignee_name: identifier.optional().describe("负责人姓名;Tool 会通过用户搜索解析 ID,搜索不到时返回用户候选"),
|
|
57
|
+
parent_id: identifier.optional().describe("父工作项内部 ID;不得填写显示编号"),
|
|
58
|
+
parent_name: identifier.optional().describe("父工作项名称或编号;Tool 会通过工作项列表解析 ID"),
|
|
59
|
+
participant_ids: z.array(identifier).max(50).optional().describe("关注人用户 ID 列表;每个 ID 均通过 pingcode_user_search 查询"),
|
|
60
|
+
participant_names: z.array(identifier).max(50).optional().describe("关注人姓名列表;Tool 会通过用户目录解析 ID"),
|
|
61
|
+
phase_id: identifier.optional().describe("发布阶段 ID;仅 waterfall/hybrid 项目有效,由发布阶段目录验证"),
|
|
62
|
+
phase_name: identifier.optional().describe("发布阶段名称;仅 waterfall/hybrid 项目有效,由发布阶段目录解析"),
|
|
63
|
+
version_ids: z
|
|
64
|
+
.array(identifier)
|
|
65
|
+
.optional()
|
|
66
|
+
.describe("官方版本 ID 列表;从工作项元数据 versions 获取"),
|
|
67
|
+
version_id: identifier
|
|
68
|
+
.optional()
|
|
69
|
+
.describe("兼容旧客户端的单个版本 ID;新调用必须使用官方字段 version_ids"),
|
|
70
|
+
board_id: identifier.optional().describe("看板 ID;从工作项元数据 boards 获取"),
|
|
71
|
+
board_name: identifier.optional().describe("看板名称;Tool 会从工作项元数据解析 ID"),
|
|
72
|
+
entry_id: identifier.optional().describe("看板栏 ID;提供 board_id 后从 entries 获取"),
|
|
73
|
+
entry_name: identifier.optional().describe("看板栏名称;Tool 会从工作项元数据解析 ID"),
|
|
74
|
+
swimlane_id: identifier.optional().describe("泳道 ID;提供 board_id 后从 swimlanes 获取"),
|
|
75
|
+
swimlane_name: identifier.optional().describe("泳道名称;Tool 会从工作项元数据解析 ID"),
|
|
76
|
+
properties: propertiesSchema.optional(),
|
|
77
|
+
story_points: z.number().min(0).max(100_000).refine((value) => Number.isInteger(value * 10), "story_points 必须是整数或最多包含一位小数").optional().describe("故事点数;必须是整数或最多一位小数"),
|
|
78
|
+
estimated_workload: z.number().min(0).max(1_000_000).optional().describe("预估工时"),
|
|
79
|
+
remaining_workload: z.number().min(0).max(1_000_000).optional().describe("剩余工时"),
|
|
80
|
+
};
|
|
81
|
+
const taskCreateFieldsSchema = z
|
|
82
|
+
.object({
|
|
83
|
+
project_id: identifier.optional().describe("可直接提供的项目内部 ID;必须来自 pingcode_project_list,Tool 会再次校验"),
|
|
84
|
+
project_name: identifier.optional().describe("项目名称或标识;正常流程先调用 pingcode_project_list 展示候选,Tool 会解析为 project_id"),
|
|
85
|
+
type_id: identifier.optional().describe("工作项类型内部 ID;必须来自 pingcode_work_item_metadata_get 的 types 目录"),
|
|
86
|
+
type_name: identifier.optional().describe("工作项类型名称或标识;Tool 会从项目 types 目录解析 ID"),
|
|
87
|
+
...taskCommonFields,
|
|
88
|
+
title: z.string().trim().min(1).max(500).describe("工作项标题"),
|
|
89
|
+
sprint_id: identifier.optional().describe("迭代 ID;从工作项元数据 sprints 获取"),
|
|
90
|
+
sprint_name: identifier.optional().describe("迭代名称;Tool 会从工作项元数据解析 ID"),
|
|
91
|
+
})
|
|
92
|
+
.strict()
|
|
93
|
+
.refine((value) => value.version_id === undefined || value.version_ids === undefined, {
|
|
94
|
+
message: "version_ids 与兼容字段 version_id 不得同时提供",
|
|
95
|
+
})
|
|
96
|
+
.superRefine((value, context) => {
|
|
97
|
+
if (value.project_id === undefined && value.project_name === undefined)
|
|
98
|
+
context.addIssue({ code: "custom", path: ["project_id"], message: "project_id 或 project_name 至少提供一个" });
|
|
99
|
+
if (value.project_id !== undefined && value.project_name !== undefined)
|
|
100
|
+
context.addIssue({ code: "custom", path: ["project_name"], message: "project_id 与 project_name 不得同时提供" });
|
|
101
|
+
if (value.type_id !== undefined && value.type_name !== undefined)
|
|
102
|
+
context.addIssue({ code: "custom", path: ["type_name"], message: "type_id 与 type_name 不得同时提供" });
|
|
103
|
+
for (const [idKey, nameKey] of [["priority_id", "priority_name"], ["state_id", "state_name"], ["assignee_id", "assignee_name"], ["parent_id", "parent_name"], ["sprint_id", "sprint_name"], ["board_id", "board_name"], ["entry_id", "entry_name"], ["swimlane_id", "swimlane_name"]]) {
|
|
104
|
+
if (value[idKey] !== undefined && value[nameKey] !== undefined) {
|
|
105
|
+
context.addIssue({ code: "custom", path: [nameKey], message: `${idKey} 与 ${nameKey} 不得同时提供` });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (value.phase_id !== undefined && value.phase_name !== undefined)
|
|
109
|
+
context.addIssue({ code: "custom", path: ["phase_name"], message: "phase_id 与 phase_name 不得同时提供" });
|
|
110
|
+
if (value.participant_ids !== undefined && value.participant_names !== undefined)
|
|
111
|
+
context.addIssue({ code: "custom", path: ["participant_names"], message: "participant_ids 与 participant_names 不得同时提供" });
|
|
112
|
+
});
|
|
113
|
+
const taskUpdateFieldsSchema = z
|
|
114
|
+
.object({
|
|
115
|
+
...taskCommonFields,
|
|
116
|
+
sprint_id: identifier.optional().describe("迭代 ID;从工作项元数据 sprints 获取"),
|
|
117
|
+
sprint_name: identifier.optional().describe("迭代名称;Tool 会从工作项元数据解析 ID"),
|
|
118
|
+
})
|
|
119
|
+
.strict()
|
|
120
|
+
.refine((value) => Object.values(value).some((item) => item !== undefined), "至少提供一個更新欄位")
|
|
121
|
+
.refine((value) => value.version_id === undefined || value.version_ids === undefined, {
|
|
122
|
+
message: "version_ids 与兼容字段 version_id 不得同时提供",
|
|
123
|
+
})
|
|
124
|
+
.superRefine((value, context) => {
|
|
125
|
+
for (const [idKey, nameKey] of [["priority_id", "priority_name"], ["state_id", "state_name"], ["assignee_id", "assignee_name"], ["parent_id", "parent_name"], ["sprint_id", "sprint_name"], ["board_id", "board_name"], ["entry_id", "entry_name"], ["swimlane_id", "swimlane_name"]]) {
|
|
126
|
+
if (value[idKey] !== undefined && value[nameKey] !== undefined) {
|
|
127
|
+
context.addIssue({ code: "custom", path: [nameKey], message: `${idKey} 与 ${nameKey} 不得同时提供` });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (value.phase_id !== undefined && value.phase_name !== undefined)
|
|
131
|
+
context.addIssue({ code: "custom", path: ["phase_name"], message: "phase_id 与 phase_name 不得同时提供" });
|
|
132
|
+
if (value.participant_ids !== undefined && value.participant_names !== undefined)
|
|
133
|
+
context.addIssue({ code: "custom", path: ["participant_names"], message: "participant_ids 与 participant_names 不得同时提供" });
|
|
134
|
+
});
|
|
135
|
+
const taskCreateOperationSchema = z
|
|
136
|
+
.object({
|
|
137
|
+
operation_id: identifier.describe("计划内唯一操作 ID,由 Agent 生成并在 Preview/Commit 间保持不变"),
|
|
138
|
+
action: z.literal("create").describe("创建工作项"),
|
|
139
|
+
fields: taskCreateFieldsSchema,
|
|
140
|
+
parent_operation_id: identifier
|
|
141
|
+
.optional()
|
|
142
|
+
.describe("引用本计划前面一个 create 操作的 operation_id,用其创建结果作为 parent_id"),
|
|
143
|
+
source_references: z
|
|
144
|
+
.array(sourceReferenceSchema)
|
|
145
|
+
.min(1)
|
|
146
|
+
.max(20)
|
|
147
|
+
.describe("支持本次创建的需求、规范或代码来源,至少一个"),
|
|
148
|
+
})
|
|
149
|
+
.refine((value) => value.parent_operation_id === undefined
|
|
150
|
+
|| (value.fields.parent_id === undefined && value.fields.parent_name === undefined), "parent_id/parent_name 與 parent_operation_id 不得同時提供");
|
|
151
|
+
const taskUpdateOperationSchema = z.object({
|
|
152
|
+
operation_id: identifier.describe("计划内唯一操作 ID,由 Agent 生成并在 Preview/Commit 间保持不变"),
|
|
153
|
+
action: z.literal("update").describe("更新既有工作项"),
|
|
154
|
+
target_id: identifier.describe("由 pingcode_task_get 返回的工作项内部 ID"),
|
|
155
|
+
expected_content_hash: contentHash.describe("最近一次 pingcode_task_get 返回的 source.contentHash"),
|
|
156
|
+
fields: taskUpdateFieldsSchema,
|
|
157
|
+
source_references: z
|
|
158
|
+
.array(sourceReferenceSchema)
|
|
159
|
+
.min(1)
|
|
160
|
+
.max(20)
|
|
161
|
+
.describe("支持本次更新的需求、规范或代码来源,至少一个"),
|
|
162
|
+
});
|
|
163
|
+
const taskOperationSchema = z.discriminatedUnion("action", [
|
|
164
|
+
taskCreateOperationSchema,
|
|
165
|
+
taskUpdateOperationSchema,
|
|
166
|
+
]);
|
|
167
|
+
const taskPlanSchema = z.object({
|
|
168
|
+
plan_id: identifier.describe("同步计划稳定 ID;Preview 与 Commit 必须完全相同"),
|
|
169
|
+
operations: z
|
|
170
|
+
.array(taskOperationSchema)
|
|
171
|
+
.min(1)
|
|
172
|
+
.max(50)
|
|
173
|
+
.describe("输入计划操作数组;使用 snake_case 字段。Preview 返回的 operations 是 camelCase 展示差异,不能复制回 Commit"),
|
|
174
|
+
});
|
|
175
|
+
const testCaseStepSchema = z.object({
|
|
176
|
+
step_id: identifier.optional().describe("更新既有步骤时使用的步骤 ID;创建时省略"),
|
|
177
|
+
description: z.string().trim().min(1).max(5_000).describe("测试步骤"),
|
|
178
|
+
expected_value: z.string().max(5_000).optional().describe("预期结果"),
|
|
179
|
+
is_group: z.boolean().optional().describe("是否为步骤分组;普通步骤省略"),
|
|
180
|
+
group_id: identifier.optional().describe("所属步骤分组 ID;普通步骤省略"),
|
|
181
|
+
}).strict();
|
|
182
|
+
const testCaseCommonFields = {
|
|
183
|
+
title: z.string().trim().min(1).max(500).optional().describe("测试用例标题"),
|
|
184
|
+
suite_id: identifier.optional().describe("模块 ID;从 Test Case 元数据 suites 获取"),
|
|
185
|
+
suite_name: identifier.optional().describe("模块名称;Tool 会从 Test Case 元数据 suites 解析 ID"),
|
|
186
|
+
type_id: identifier.optional().describe("用例类型 ID;从 Test Case 元数据 types 获取"),
|
|
187
|
+
type_name: identifier.optional().describe("用例类型显示名称或目录标识;Tool 会从 Test Case 元数据 types 解析 ID"),
|
|
188
|
+
important_level_id: identifier
|
|
189
|
+
.optional()
|
|
190
|
+
.describe("官方重要程度 ID;从 Test Case 元数据 importantLevels 获取"),
|
|
191
|
+
important_level_name: identifier.optional().describe("重要程度名称;Tool 会从 Test Case 元数据 important_levels 解析 ID"),
|
|
192
|
+
important_level: z
|
|
193
|
+
.enum(["P0", "P1", "P2", "P3", "P4"])
|
|
194
|
+
.optional()
|
|
195
|
+
.describe("兼容旧客户端的名称值;新调用必须使用官方字段 important_level_id"),
|
|
196
|
+
maintenance_id: identifier.optional().describe("维护人用户 ID;通过 pingcode_user_search 查询"),
|
|
197
|
+
maintenance_name: identifier.optional().describe("维护人姓名;Tool 会通过用户搜索解析 ID"),
|
|
198
|
+
participant_ids: z.array(identifier).max(50).optional().describe("关注人用户 ID 列表;每个 ID 均通过 pingcode_user_search 查询"),
|
|
199
|
+
participant_names: z.array(identifier).max(50).optional().describe("关注人姓名列表;Tool 会通过用户目录解析 ID"),
|
|
200
|
+
custom_properties: customPropertiesSchema.optional(),
|
|
201
|
+
description: z.string().max(50_000).optional().describe("测试用例说明"),
|
|
202
|
+
precondition: z.string().max(50_000).optional().describe("测试前置条件"),
|
|
203
|
+
steps: z.array(testCaseStepSchema).max(100).optional().describe("测试步骤,最多 100 条"),
|
|
204
|
+
};
|
|
205
|
+
const testCaseCreateFieldsSchema = z
|
|
206
|
+
.object({
|
|
207
|
+
test_library_id: identifier.optional().describe("兼容字段;由 Tool 从测试库列表验证"),
|
|
208
|
+
test_library_name: identifier.optional().describe("测试库名称或标识;Tool 会从测试库列表解析 ID"),
|
|
209
|
+
...testCaseCommonFields,
|
|
210
|
+
title: z.string().trim().min(1).max(500).describe("测试用例标题"),
|
|
211
|
+
})
|
|
212
|
+
.strict()
|
|
213
|
+
.refine((value) => value.test_library_id !== undefined || value.test_library_name !== undefined, "test_library_id 或 test_library_name 至少提供一个")
|
|
214
|
+
.refine((value) => value.test_library_id === undefined || value.test_library_name === undefined, "test_library_id 与 test_library_name 不得同时提供")
|
|
215
|
+
.refine((value) => value.important_level_id === undefined || value.important_level === undefined, { message: "important_level_id 与兼容字段 important_level 不得同时提供" })
|
|
216
|
+
.superRefine((value, context) => {
|
|
217
|
+
for (const [idKey, nameKey] of [["suite_id", "suite_name"], ["type_id", "type_name"], ["important_level_id", "important_level_name"], ["maintenance_id", "maintenance_name"]]) {
|
|
218
|
+
if (value[idKey] !== undefined && value[nameKey] !== undefined)
|
|
219
|
+
context.addIssue({ code: "custom", path: [nameKey], message: `${idKey} 与 ${nameKey} 不得同时提供` });
|
|
220
|
+
}
|
|
221
|
+
if (value.participant_ids !== undefined && value.participant_names !== undefined)
|
|
222
|
+
context.addIssue({ code: "custom", path: ["participant_names"], message: "participant_ids 与 participant_names 不得同时提供" });
|
|
223
|
+
});
|
|
224
|
+
const testCaseUpdateFieldsSchema = z
|
|
225
|
+
.object({
|
|
226
|
+
...testCaseCommonFields,
|
|
227
|
+
state_id: identifier.optional().describe("用例状态 ID;从 Test Case 元数据 states 获取"),
|
|
228
|
+
state_name: identifier.optional().describe("用例状态名称;Tool 会从 Test Case 元数据 states 解析 ID"),
|
|
229
|
+
})
|
|
230
|
+
.strict()
|
|
231
|
+
.refine((value) => Object.values(value).some((item) => item !== undefined), "至少提供一個更新欄位")
|
|
232
|
+
.refine((value) => value.important_level_id === undefined || value.important_level === undefined, { message: "important_level_id 与兼容字段 important_level 不得同时提供" })
|
|
233
|
+
.superRefine((value, context) => {
|
|
234
|
+
for (const [idKey, nameKey] of [["suite_id", "suite_name"], ["type_id", "type_name"], ["important_level_id", "important_level_name"], ["maintenance_id", "maintenance_name"], ["state_id", "state_name"]]) {
|
|
235
|
+
if (value[idKey] !== undefined && value[nameKey] !== undefined)
|
|
236
|
+
context.addIssue({ code: "custom", path: [nameKey], message: `${idKey} 与 ${nameKey} 不得同时提供` });
|
|
237
|
+
}
|
|
238
|
+
if (value.participant_ids !== undefined && value.participant_names !== undefined)
|
|
239
|
+
context.addIssue({ code: "custom", path: ["participant_names"], message: "participant_ids 与 participant_names 不得同时提供" });
|
|
240
|
+
});
|
|
241
|
+
const testCaseCreateOperationSchema = z.object({
|
|
242
|
+
operation_id: identifier.describe("计划内唯一操作 ID,由 Agent 生成并在 Preview/Commit 间保持不变"),
|
|
243
|
+
action: z.literal("create").describe("创建 Test Case"),
|
|
244
|
+
fields: testCaseCreateFieldsSchema,
|
|
245
|
+
related_work_item_ids: z
|
|
246
|
+
.array(identifier)
|
|
247
|
+
.max(50)
|
|
248
|
+
.default([])
|
|
249
|
+
.describe("本次要新增关系的 story 或 bug 内部 ID;不要填写已经存在的关系,也不得填写 task ID"),
|
|
250
|
+
related_work_item_names: z.array(identifier).max(50).optional().describe("要关联的 story/bug 名称或编号;Tool 会通过工作项目录解析 ID"),
|
|
251
|
+
source_references: z
|
|
252
|
+
.array(sourceReferenceSchema)
|
|
253
|
+
.min(1)
|
|
254
|
+
.max(20)
|
|
255
|
+
.describe("支持本次创建的需求或测试规范来源,至少一个"),
|
|
256
|
+
}).superRefine((value, context) => {
|
|
257
|
+
if (value.related_work_item_ids.length > 0 && value.related_work_item_names !== undefined)
|
|
258
|
+
context.addIssue({ code: "custom", path: ["related_work_item_names"], message: "related_work_item_ids 与 related_work_item_names 不得同时提供" });
|
|
259
|
+
});
|
|
260
|
+
const testCaseUpdateOperationSchema = z.object({
|
|
261
|
+
operation_id: identifier.describe("计划内唯一操作 ID,由 Agent 生成并在 Preview/Commit 间保持不变"),
|
|
262
|
+
action: z.literal("update").describe("更新既有 Test Case"),
|
|
263
|
+
target_id: identifier.describe("由 pingcode_test_case_get 返回的 Test Case 内部 ID"),
|
|
264
|
+
expected_content_hash: contentHash.describe("最近一次 pingcode_test_case_get 返回的 source.contentHash"),
|
|
265
|
+
fields: testCaseUpdateFieldsSchema,
|
|
266
|
+
related_work_item_ids: z
|
|
267
|
+
.array(identifier)
|
|
268
|
+
.max(50)
|
|
269
|
+
.default([])
|
|
270
|
+
.describe("要建立关系的 story 或 bug 内部 ID;不得填写 task ID"),
|
|
271
|
+
related_work_item_names: z.array(identifier).max(50).optional().describe("要关联的 story/bug 名称或编号;Tool 会通过工作项目录解析 ID"),
|
|
272
|
+
source_references: z
|
|
273
|
+
.array(sourceReferenceSchema)
|
|
274
|
+
.min(1)
|
|
275
|
+
.max(20)
|
|
276
|
+
.describe("支持本次更新的需求或测试规范来源,至少一个"),
|
|
277
|
+
}).superRefine((value, context) => {
|
|
278
|
+
if (value.related_work_item_ids.length > 0 && value.related_work_item_names !== undefined)
|
|
279
|
+
context.addIssue({ code: "custom", path: ["related_work_item_names"], message: "related_work_item_ids 与 related_work_item_names 不得同时提供" });
|
|
280
|
+
});
|
|
281
|
+
const testCaseOperationSchema = z.discriminatedUnion("action", [
|
|
282
|
+
testCaseCreateOperationSchema,
|
|
283
|
+
testCaseUpdateOperationSchema,
|
|
284
|
+
]);
|
|
285
|
+
const testCasePlanSchema = z.object({
|
|
286
|
+
plan_id: identifier.describe("同步计划稳定 ID;Preview 与 Commit 必须完全相同"),
|
|
287
|
+
operations: z.array(testCaseOperationSchema).min(1).max(50).describe("按执行顺序排列的操作,最多 50 条"),
|
|
288
|
+
});
|
|
289
|
+
export function registerSyncTools(server, services, logger, writeRuntime, writeApprovalMode) {
|
|
290
|
+
server.registerTool("pingcode_task_sync_preview", {
|
|
291
|
+
title: "預覽 PingCode Task 同步",
|
|
292
|
+
description: "验证 Agent 生成的工作项创建/更新计划并显示差异;不写入 PingCode,但会建立短效 Approval Context。空参数或缺少字段时,按以下最小创建结构补齐:{plan_id, operations:[{operation_id, action:'create', fields:{project_name:'用户选择的项目', title:'工作项标题'}, source_references:[{type:'spec', id:'来源编号'}]}]}。create 的 fields 必须提供且只能提供一个 project_name 或 project_id:正常流程先调用 pingcode_project_list 展示项目名称、identifier 和 ID,等待用户选择后可传 project_name/identifier;也支持用户直接传 project_id,Server 会在项目目录中验证,不接受猜测 ID。类型可省略,MCP 会从该项目 types 目录选择系统 task 作为默认值;只有目录没有唯一默认 task 时,才展示 types 候选并要求选择 type_name。若传类型,type_name 与 type_id 也只能二选一,type_id 只允许使用目录返回的 ID。source_references 是 operation 内与 fields 同级的必填数组。其他负责人、状态、优先级、迭代、看板、属性等引用字段应优先使用名称或元数据目录候选,不能同时传 *_id 与 *_name。每个 operations 元素必须是完整 JSON 对象,fields 必须是 JSON 对象而不是字符串。Child Task 与 Story 同一 Preview 时使用 parent_operation_id;如果 Story 已在另一 Preview 中创建并已提交,则在新的 Preview 中使用 parent_name(或由目录返回的 parent_id)引用该 Story,不要把另一计划的 operation_id 当作当前计划的父操作。update 需要 operation_id、action=update、target_id、expected_content_hash、fields 和 source_references。不要把 Preview 返回的 camelCase 展示对象复制回 Commit。正常用户回复只展示操作、目标项目、字段差异、关系和警告;Commit 是否显示 MCP Client 确认由 PINGCODE_WRITE_CONFIRMATION 决定,不得先在聊天中重复询问。requestHash、approvalContextId、sessionBinding、evidenceHash、agent_session_id 和 idempotency_key 仅供 Agent 内部传递,不得在正常用户回复中展示或要求用户复制。调用前必须解析所有 ID,无法确定时才询问用户,禁止猜测。",
|
|
293
|
+
inputSchema: {
|
|
294
|
+
...taskPlanSchema.shape,
|
|
295
|
+
plan_id: taskPlanSchema.shape.plan_id.optional().describe("业务上必填;省略仅用于返回缺参引导"),
|
|
296
|
+
operations: z.array(taskOperationSchema).optional().describe("业务上必填且至少包含一个 operation;省略或传空数组仅用于返回缺参引导"),
|
|
297
|
+
agent_session_id: agentSessionId,
|
|
298
|
+
},
|
|
299
|
+
annotations: previewAnnotations,
|
|
300
|
+
}, async (input, extra) => executeTool(logger, "pingcode_task_sync_preview", requestIdForLog(extra.requestInfo?.headers["x-request-id"], extra.requestId), { planId: input.plan_id ?? "", operationCount: input.operations?.length ?? 0 }, () => {
|
|
301
|
+
if (input.plan_id === undefined || input.operations === undefined || input.operations.length === 0) {
|
|
302
|
+
throw new PingCodeError("缺少 plan_id 或 operations;请补充工作项同步计划后重试 Preview", "BAD_REQUEST");
|
|
303
|
+
}
|
|
304
|
+
return previewTaskPlan({ plan_id: input.plan_id, operations: input.operations }, services, writeRuntime, input.agent_session_id, extra.authInfo);
|
|
305
|
+
}, { formatResult: formatPreviewToolResult }));
|
|
306
|
+
server.registerTool("pingcode_task_sync_commit", {
|
|
307
|
+
title: "提交 PingCode Task 同步",
|
|
308
|
+
description: "依已核准的 Preview 建立或更新 PingCode Task。Commit 必须原样复用 Preview 请求中的 plan_id 和 operations;不要复制 Preview 响应里的 camelCase operations 展示对象(operationId、targetId、changes、sourceReferences)。是否要求当前 MCP Client 确认由 PINGCODE_WRITE_CONFIRMATION 决定,并原样使用验证信息和唯一幂等键。",
|
|
309
|
+
inputSchema: {
|
|
310
|
+
...taskPlanSchema.shape,
|
|
311
|
+
agent_session_id: agentSessionId,
|
|
312
|
+
preview_id: approvalContextId.optional().describe("可选。省略时 Server 依相同 plan_id 与 operations 自动匹配刚才的 Preview,并据此恢复 request hash、evidence hash、session binding 和幂等边界;仅当客户端确实读到 Preview 的 approvalContextId 时才需要传入"),
|
|
313
|
+
approval_context_id: approvalContextId.optional().describe("Preview 返回的短效 Approval Context ID;由 Agent 原样转交,不要要求用户手填"),
|
|
314
|
+
approval_token: approvalTokenSchema(writeApprovalMode),
|
|
315
|
+
idempotency_key: z
|
|
316
|
+
.string()
|
|
317
|
+
.trim()
|
|
318
|
+
.min(16)
|
|
319
|
+
.max(200)
|
|
320
|
+
.optional().describe("本次业务写入的唯一幂等键;未提供时由 Server 绑定到 Preview Context"),
|
|
321
|
+
},
|
|
322
|
+
annotations: commitAnnotations,
|
|
323
|
+
}, async (input, extra) => executeTool(logger, "pingcode_task_sync_commit", requestIdForLog(extra.requestInfo?.headers["x-request-id"], extra.requestId), { planId: input.plan_id, operationCount: input.operations.length }, async () => {
|
|
324
|
+
writeRuntime.assertMcpWriteScope(extra.authInfo?.scopes);
|
|
325
|
+
const verification = await resolveCommitVerification(writeRuntime, {
|
|
326
|
+
previewId: input.preview_id,
|
|
327
|
+
approvalContextId: input.approval_context_id,
|
|
328
|
+
idempotencyKey: input.idempotency_key,
|
|
329
|
+
requestHash: calculateRequestHash({ plan_id: input.plan_id, operations: input.operations }),
|
|
330
|
+
agentSessionId: input.agent_session_id,
|
|
331
|
+
authInfo: extra.authInfo,
|
|
332
|
+
}, "task");
|
|
333
|
+
const resolvedInput = { ...input, request_hash: verification.requestHash, approval_context_id: verification.approvalContextId, session_binding: verification.sessionBinding, evidence_hash: verification.evidenceHash, idempotency_key: verification.idempotencyKey };
|
|
334
|
+
await requestClientWriteConfirmation(server, logger, writeApprovalMode, input.approval_token, taskCommitConfirmationMessage(resolvedInput), writeRuntime.requiresConfirmation(input.operations.map((operation) => operation.action)));
|
|
335
|
+
return commitTaskPlan(resolvedInput, services, writeRuntime, writeApprovalMode, extra.authInfo);
|
|
336
|
+
}, { formatResult: formatWriteToolResult }));
|
|
337
|
+
server.registerTool("pingcode_test_case_sync_preview", {
|
|
338
|
+
title: "預覽 PingCode Test Case 同步",
|
|
339
|
+
description: "验证 Agent 根据 Acceptance Scenario 生成的 Test Case 创建/更新及工作项关联计划;创建的最小输入是 plan_id、operations[action=create]、fields.title、fields.test_library_id 或 test_library_name(二选一)以及 operation 内的 source_references。用户未主动指定测试库时,先调用 pingcode_project_context_get;如果已有已确认的 Test Library,只能把它作为候选展示并询问用户是否复用,用户同意后才传入 test_library_name 或 test_library_id;没有上下文或用户拒绝时调用 pingcode_test_library_list。每个 operations 元素必须是完整 JSON 对象,fields 必须是 JSON 对象而不是字符串,source_references 是 operation 内与 fields 同级的必填数组;suite_id/suite_name 只接受目标 Test Library 的 suites 目录,不接受 Ship Product 需求模块;若要关联 Product 需求模块,请使用 Relation Tool。不要复制 Preview 返回的 camelCase 展示对象回传。默认关联对应 story,不关联其子 task。此操作不写入 PingCode,但会建立短效 Approval Context。正常用户回复只展示操作、目标测试库、字段差异、关系和警告;Commit 是否显示 MCP Client 确认由 PINGCODE_WRITE_CONFIRMATION 决定,不得先在聊天中重复询问。requestHash、approvalContextId、sessionBinding、evidenceHash、agent_session_id 和 idempotency_key 仅供 Agent 内部传递,不得在正常用户回复中展示或要求用户复制。调用前必须用测试库和 Test Case 元数据 Tool 解析所有 ID/枚举;无法唯一确定时先询问用户。",
|
|
340
|
+
inputSchema: {
|
|
341
|
+
...testCasePlanSchema.shape,
|
|
342
|
+
plan_id: testCasePlanSchema.shape.plan_id.optional().describe("业务上必填;省略仅用于返回缺参引导"),
|
|
343
|
+
operations: z.array(testCaseOperationSchema).optional().describe("业务上必填且至少包含一个 operation;省略或传空数组仅用于返回缺参引导"),
|
|
344
|
+
agent_session_id: agentSessionId,
|
|
345
|
+
},
|
|
346
|
+
annotations: previewAnnotations,
|
|
347
|
+
}, async (input, extra) => executeTool(logger, "pingcode_test_case_sync_preview", requestIdForLog(extra.requestInfo?.headers["x-request-id"], extra.requestId), { planId: input.plan_id ?? "", operationCount: input.operations?.length ?? 0 }, () => {
|
|
348
|
+
if (input.plan_id === undefined || input.operations === undefined || input.operations.length === 0) {
|
|
349
|
+
throw new PingCodeError("缺少 plan_id 或 operations;请补充 Test Case 同步计划后重试 Preview", "BAD_REQUEST");
|
|
350
|
+
}
|
|
351
|
+
return previewTestCasePlan({ plan_id: input.plan_id, operations: input.operations }, services, writeRuntime, input.agent_session_id, extra.authInfo);
|
|
352
|
+
}, { formatResult: formatPreviewToolResult }));
|
|
353
|
+
server.registerTool("pingcode_test_case_sync_commit", {
|
|
354
|
+
title: "提交 PingCode Test Case 同步",
|
|
355
|
+
description: "依已核准的 Preview 建立或更新 Test Case,并按计划关联工作项。Commit 必须原样复用 Preview 请求中的 plan_id 和 operations;不要复制 Preview 响应里的 camelCase operations 展示对象。是否要求当前 MCP Client 确认由 PINGCODE_WRITE_CONFIRMATION 决定,并原样使用验证信息和唯一幂等键。",
|
|
356
|
+
inputSchema: {
|
|
357
|
+
...testCasePlanSchema.shape,
|
|
358
|
+
agent_session_id: agentSessionId,
|
|
359
|
+
preview_id: approvalContextId.optional().describe("可选。省略时 Server 依相同 plan_id 与 operations 自动匹配刚才的 Preview,并据此恢复 request hash、evidence hash、session binding 和幂等边界;仅当客户端确实读到 Preview 的 approvalContextId 时才需要传入"),
|
|
360
|
+
approval_context_id: approvalContextId.optional().describe("Preview 返回的短效 Approval Context ID;由 Agent 原样转交,不要要求用户手填"),
|
|
361
|
+
approval_token: approvalTokenSchema(writeApprovalMode),
|
|
362
|
+
idempotency_key: z
|
|
363
|
+
.string()
|
|
364
|
+
.trim()
|
|
365
|
+
.min(16)
|
|
366
|
+
.max(200)
|
|
367
|
+
.optional().describe("本次业务写入的唯一幂等键;未提供时由 Server 绑定到 Preview Context"),
|
|
368
|
+
},
|
|
369
|
+
annotations: commitAnnotations,
|
|
370
|
+
}, async (input, extra) => executeTool(logger, "pingcode_test_case_sync_commit", requestIdForLog(extra.requestInfo?.headers["x-request-id"], extra.requestId), { planId: input.plan_id, operationCount: input.operations.length }, async () => {
|
|
371
|
+
writeRuntime.assertMcpWriteScope(extra.authInfo?.scopes);
|
|
372
|
+
const verification = await resolveCommitVerification(writeRuntime, {
|
|
373
|
+
previewId: input.preview_id,
|
|
374
|
+
approvalContextId: input.approval_context_id,
|
|
375
|
+
idempotencyKey: input.idempotency_key,
|
|
376
|
+
requestHash: calculateRequestHash({ plan_id: input.plan_id, operations: input.operations }),
|
|
377
|
+
agentSessionId: input.agent_session_id,
|
|
378
|
+
authInfo: extra.authInfo,
|
|
379
|
+
}, "test_case");
|
|
380
|
+
const resolvedInput = { ...input, request_hash: verification.requestHash, approval_context_id: verification.approvalContextId, session_binding: verification.sessionBinding, evidence_hash: verification.evidenceHash, idempotency_key: verification.idempotencyKey };
|
|
381
|
+
await requestClientWriteConfirmation(server, logger, writeApprovalMode, input.approval_token, await testCaseCommitConfirmationMessage(resolvedInput, services), writeRuntime.requiresConfirmation(input.operations.map((operation) => operation.action)));
|
|
382
|
+
return commitTestCasePlan(resolvedInput, services, writeRuntime, writeApprovalMode, extra.authInfo);
|
|
383
|
+
}, { formatResult: formatWriteToolResult }));
|
|
384
|
+
}
|
|
385
|
+
async function previewTaskPlan(plan, services, runtime, agentSessionIdValue, authInfo) {
|
|
386
|
+
validateOperations(plan.operations);
|
|
387
|
+
const resolvedPlan = await resolveTaskPlanReferences(plan, services);
|
|
388
|
+
const operations = [];
|
|
389
|
+
for (const operation of resolvedPlan.operations) {
|
|
390
|
+
if (operation.action === "create") {
|
|
391
|
+
operations.push({
|
|
392
|
+
operationId: operation.operation_id,
|
|
393
|
+
action: operation.action,
|
|
394
|
+
targetId: null,
|
|
395
|
+
changes: {
|
|
396
|
+
...changesFromCreate(operation.fields),
|
|
397
|
+
...(operation.parent_operation_id === undefined
|
|
398
|
+
? {}
|
|
399
|
+
: { parent_operation_id: { before: null, after: operation.parent_operation_id } }),
|
|
400
|
+
},
|
|
401
|
+
sourceReferences: operation.source_references,
|
|
402
|
+
});
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const current = await services.getTaskSource().getWorkItemContext(operation.target_id);
|
|
406
|
+
assertExpectedHash(operation.expected_content_hash, current.source.contentHash);
|
|
407
|
+
operations.push({
|
|
408
|
+
operationId: operation.operation_id,
|
|
409
|
+
action: operation.action,
|
|
410
|
+
targetId: current.source.id,
|
|
411
|
+
changes: taskChanges(current, operation.fields),
|
|
412
|
+
sourceReferences: operation.source_references,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return previewResult("task", plan, operations, runtime, 0, agentSessionIdValue, authInfo);
|
|
416
|
+
}
|
|
417
|
+
async function previewTestCasePlan(plan, services, runtime, agentSessionIdValue, authInfo) {
|
|
418
|
+
validateOperations(plan.operations);
|
|
419
|
+
const resolvedPlan = await resolveTestCasePlanReferences(plan, services);
|
|
420
|
+
await assertSupportedTestCaseRelationTargets(resolvedPlan, services);
|
|
421
|
+
const operations = [];
|
|
422
|
+
for (const operation of resolvedPlan.operations) {
|
|
423
|
+
if (operation.action === "create") {
|
|
424
|
+
operations.push({
|
|
425
|
+
operationId: operation.operation_id,
|
|
426
|
+
action: operation.action,
|
|
427
|
+
targetId: null,
|
|
428
|
+
changes: changesFromCreate(operation.fields),
|
|
429
|
+
sourceReferences: operation.source_references,
|
|
430
|
+
relatedWorkItemIds: operation.related_work_item_ids,
|
|
431
|
+
});
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const current = await services.getTestCaseSource().getTestCaseContext(operation.target_id);
|
|
435
|
+
assertExpectedHash(operation.expected_content_hash, current.source.contentHash);
|
|
436
|
+
operations.push({
|
|
437
|
+
operationId: operation.operation_id,
|
|
438
|
+
action: operation.action,
|
|
439
|
+
targetId: current.source.id,
|
|
440
|
+
changes: testCaseChanges(current, operation.fields),
|
|
441
|
+
sourceReferences: operation.source_references,
|
|
442
|
+
relatedWorkItemIds: operation.related_work_item_ids,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const relations = plan.operations.reduce((total, operation) => total + operation.related_work_item_ids.length, 0);
|
|
446
|
+
return previewResult("test_case", plan, operations, runtime, relations, agentSessionIdValue, authInfo);
|
|
447
|
+
}
|
|
448
|
+
async function resolveTestCasePlanReferences(plan, services) {
|
|
449
|
+
let libraries;
|
|
450
|
+
const operations = [];
|
|
451
|
+
for (const operation of plan.operations) {
|
|
452
|
+
const current = operation.action === "update"
|
|
453
|
+
? await services.getTestCaseSource().getTestCaseContext(operation.target_id)
|
|
454
|
+
: undefined;
|
|
455
|
+
const rawFields = { ...operation.fields };
|
|
456
|
+
if (rawFields.test_library_id !== undefined || rawFields.test_library_name !== undefined) {
|
|
457
|
+
libraries ??= [];
|
|
458
|
+
if (libraries.length === 0) {
|
|
459
|
+
for (let pageIndex = 0; pageIndex < 100; pageIndex += 1) {
|
|
460
|
+
const page = await services.getMetadataSource().listTestLibraries({ pageIndex, pageSize: 100 });
|
|
461
|
+
libraries.push(...page.values.map((library) => ({ id: library.id, name: library.name, identifier: library.identifier })));
|
|
462
|
+
if (libraries.length >= page.total || page.values.length === 0)
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
rawFields.test_library_id = resolveCandidate("测试库", typeof rawFields.test_library_id === "string" ? rawFields.test_library_id : undefined, typeof rawFields.test_library_name === "string" ? rawFields.test_library_name : undefined, libraries);
|
|
467
|
+
const selectedLibrary = libraries.find((library) => library.id === rawFields.test_library_id);
|
|
468
|
+
if (selectedLibrary)
|
|
469
|
+
rawFields.test_library_name = `${selectedLibrary.name} (${selectedLibrary.identifier ?? selectedLibrary.id})`;
|
|
470
|
+
}
|
|
471
|
+
else if (current !== undefined) {
|
|
472
|
+
rawFields.test_library_id = current.source.libraryId;
|
|
473
|
+
}
|
|
474
|
+
const libraryId = typeof rawFields.test_library_id === "string" ? rawFields.test_library_id : undefined;
|
|
475
|
+
const metadata = libraryId === undefined ? null : await services.getMetadataSource().getTestCaseMetadata(libraryId);
|
|
476
|
+
const resolveCatalog = async (idKey, nameKey, catalog) => {
|
|
477
|
+
const value = rawFields[idKey];
|
|
478
|
+
const name = rawFields[nameKey];
|
|
479
|
+
if (typeof value !== "string" && typeof name !== "string")
|
|
480
|
+
return;
|
|
481
|
+
const result = metadata?.[catalog];
|
|
482
|
+
if (!result || !result.available)
|
|
483
|
+
throw new PingCodeError(`${nameKey} 无法解析:测试用例元数据未返回目录`, "NOT_CONFIGURED");
|
|
484
|
+
if (catalog === "suites" && typeof value === "string" && !result.values.some((candidate) => candidate.id === value)) {
|
|
485
|
+
throw new PingCodeError("Test Case 的 suite_id 必须来自目标 Test Library 的 suites 目录;Ship Product 需求模块 ID 不能作为 Test Case 模块,请改用 Test Library 模块或通过 Relation 关联 Product 需求模块", "NOT_FOUND");
|
|
486
|
+
}
|
|
487
|
+
rawFields[idKey] = await resolveMetadataReference(nameKey, typeof value === "string" ? value : undefined, typeof name === "string" ? name : undefined, result.values);
|
|
488
|
+
// 保留可读名称用于 Preview 展示;写入 Adapter 只消费解析后的 *_id。
|
|
489
|
+
};
|
|
490
|
+
await resolveCatalog("suite_id", "suite_name", "suites");
|
|
491
|
+
await resolveCatalog("type_id", "type_name", "types");
|
|
492
|
+
await resolveCatalog("state_id", "state_name", "states");
|
|
493
|
+
await resolveCatalog("important_level_id", "important_level_name", "importantLevels");
|
|
494
|
+
if (typeof rawFields.maintenance_id === "string" || typeof rawFields.maintenance_name === "string") {
|
|
495
|
+
rawFields.maintenance_id = await resolveUserReference("维护人", typeof rawFields.maintenance_id === "string" ? rawFields.maintenance_id : undefined, typeof rawFields.maintenance_name === "string" ? rawFields.maintenance_name : undefined, services);
|
|
496
|
+
delete rawFields.maintenance_name;
|
|
497
|
+
}
|
|
498
|
+
if (typeof rawFields.participant_names === "object" && Array.isArray(rawFields.participant_names)) {
|
|
499
|
+
rawFields.participant_ids = await Promise.all(rawFields.participant_names.map(async (name) => {
|
|
500
|
+
const id = await resolveUserReference("关注人", undefined, typeof name === "string" ? name : undefined, services);
|
|
501
|
+
if (id === undefined)
|
|
502
|
+
throw new PingCodeError("关注人尚未解析为有效用户", "BAD_REQUEST");
|
|
503
|
+
return id;
|
|
504
|
+
}));
|
|
505
|
+
delete rawFields.participant_names;
|
|
506
|
+
}
|
|
507
|
+
else if (Array.isArray(rawFields.participant_ids)) {
|
|
508
|
+
rawFields.participant_ids = await Promise.all(rawFields.participant_ids.map(async (value) => {
|
|
509
|
+
const id = await resolveUserReference("关注人", typeof value === "string" ? value : undefined, undefined, services);
|
|
510
|
+
if (id === undefined)
|
|
511
|
+
throw new PingCodeError("关注人尚未解析为有效用户", "BAD_REQUEST");
|
|
512
|
+
return id;
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
const relatedWorkItemIds = operation.related_work_item_names === undefined
|
|
516
|
+
? operation.related_work_item_ids
|
|
517
|
+
: await Promise.all(operation.related_work_item_names.map(async (name) => {
|
|
518
|
+
const id = await resolveWorkItemReference("关联工作项", undefined, name, services);
|
|
519
|
+
if (id === undefined)
|
|
520
|
+
throw new PingCodeError("关联工作项尚未解析为有效资源", "BAD_REQUEST");
|
|
521
|
+
return id;
|
|
522
|
+
}));
|
|
523
|
+
operations.push({ ...operation, fields: rawFields, related_work_item_ids: relatedWorkItemIds, related_work_item_names: undefined });
|
|
524
|
+
}
|
|
525
|
+
return { ...plan, operations };
|
|
526
|
+
}
|
|
527
|
+
async function resolveTaskPlanReferences(plan, services) {
|
|
528
|
+
const operations = [];
|
|
529
|
+
const createOperationTypes = new Map();
|
|
530
|
+
for (const originalOperation of plan.operations) {
|
|
531
|
+
let operation = originalOperation;
|
|
532
|
+
if (operation.action === "create" && (operation.fields.project_id !== undefined || operation.fields.project_name !== undefined)) {
|
|
533
|
+
const projects = [];
|
|
534
|
+
for (let pageIndex = 0; pageIndex < 100; pageIndex += 1) {
|
|
535
|
+
const page = await services.getMetadataSource().listProjects({ pageIndex, pageSize: 100 });
|
|
536
|
+
projects.push(...page.values.map((project) => ({ id: project.id, name: project.name, identifier: project.identifier })));
|
|
537
|
+
if (projects.length >= page.total || page.values.length === 0)
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
const projectId = resolveCandidate("目标项目", operation.fields.project_id, operation.fields.project_name, projects);
|
|
541
|
+
if (projectId === undefined)
|
|
542
|
+
throw new PingCodeError("目标项目尚未解析为有效资源", "BAD_REQUEST");
|
|
543
|
+
const selectedProject = projects.find((project) => project.id === projectId);
|
|
544
|
+
operation = { ...operation, fields: { ...operation.fields, project_id: projectId, ...(selectedProject?.name === undefined ? {} : { project_name: selectedProject.name }) } };
|
|
545
|
+
}
|
|
546
|
+
const base = operation.action === "create"
|
|
547
|
+
? { projectId: operation.fields.project_id, typeId: operation.fields.type_id, typeName: operation.fields.type_name }
|
|
548
|
+
: {};
|
|
549
|
+
const current = operation.action === "update"
|
|
550
|
+
? await services.getTaskSource().getWorkItemContext(operation.target_id)
|
|
551
|
+
: undefined;
|
|
552
|
+
const projectId = base.projectId ?? current?.source.projectId;
|
|
553
|
+
let typeId = base.typeId;
|
|
554
|
+
let metadata = projectId === undefined
|
|
555
|
+
? null
|
|
556
|
+
: await services.getMetadataSource().getWorkItemMetadata({
|
|
557
|
+
projectId,
|
|
558
|
+
...(typeId === undefined ? {} : { workItemTypeId: typeId }),
|
|
559
|
+
include: typeId === undefined ? ["types", "priorities", "sprints", "boards"] : undefined,
|
|
560
|
+
});
|
|
561
|
+
if (typeId !== undefined || base.typeName !== undefined) {
|
|
562
|
+
if (!metadata?.types.available) {
|
|
563
|
+
throw new PingCodeError("工作项类型无法解析:工作项元数据未返回 types 目录", "NOT_CONFIGURED");
|
|
564
|
+
}
|
|
565
|
+
typeId = await resolveMetadataReference("工作项类型", typeId, base.typeName, metadata.types.values);
|
|
566
|
+
if (operation.action === "create") {
|
|
567
|
+
const selectedType = metadata.types.values.find((value) => String(value.id) === String(typeId));
|
|
568
|
+
operation = { ...operation, fields: { ...operation.fields, type_id: typeId, ...(typeof selectedType?.name === "string" ? { type_name: selectedType.name } : {}) } };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
else if (operation.action === "create") {
|
|
572
|
+
if (!metadata?.types.available) {
|
|
573
|
+
throw new PingCodeError("工作项类型无法解析:工作项元数据未返回 types 目录", "NOT_CONFIGURED");
|
|
574
|
+
}
|
|
575
|
+
typeId = defaultWorkItemTypeId(metadata.types.values);
|
|
576
|
+
if (typeId === undefined) {
|
|
577
|
+
throw new PingCodeError("未指定工作项类型,且当前项目没有唯一默认类型;请从 types 候选中选择 type_name", "BAD_REQUEST");
|
|
578
|
+
}
|
|
579
|
+
const selectedType = metadata.types.values.find((value) => String(value.id) === String(typeId));
|
|
580
|
+
operation = { ...operation, fields: { ...operation.fields, type_id: typeId, ...(typeof selectedType?.name === "string" ? { type_name: selectedType.name } : {}) } };
|
|
581
|
+
}
|
|
582
|
+
if (operation.action === "create" && typeId !== undefined && !metadata?.states.available) {
|
|
583
|
+
metadata = await services.getMetadataSource().getWorkItemMetadata({ projectId: projectId, workItemTypeId: typeId });
|
|
584
|
+
}
|
|
585
|
+
const needsTypedMetadata = operation.action === "create"
|
|
586
|
+
|| fieldsNeedWorkItemStateMetadata(operation.fields);
|
|
587
|
+
if (typeId === undefined && operation.action === "update" && needsTypedMetadata && current?.type !== undefined && metadata?.types.available) {
|
|
588
|
+
typeId = resolveCurrentWorkItemTypeId(current.type, metadata.types.values);
|
|
589
|
+
if (typeId !== undefined) {
|
|
590
|
+
metadata = projectId === undefined
|
|
591
|
+
? null
|
|
592
|
+
: await services.getMetadataSource().getWorkItemMetadata({ projectId, workItemTypeId: typeId });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (operation.action === "update" && fieldsNeedWorkItemStateMetadata(operation.fields) && typeId === undefined) {
|
|
596
|
+
throw new PingCodeError(`工作项更新需要按当前类型读取 states 目录,但回读类型 '${current?.type ?? "未知"}' 无法匹配项目类型目录`, "NOT_FOUND");
|
|
597
|
+
}
|
|
598
|
+
if (operation.action === "create" && typeId !== undefined) {
|
|
599
|
+
const typeEntry = metadata?.types.available
|
|
600
|
+
? metadata.types.values.find((value) => String(value.id) === String(typeId))
|
|
601
|
+
: undefined;
|
|
602
|
+
createOperationTypes.set(operation.operation_id, classifyWorkItemType(typeEntry, typeId));
|
|
603
|
+
}
|
|
604
|
+
const fields = { ...operation.fields };
|
|
605
|
+
const resolveCatalog = async (idKey, nameKey, catalog, source = metadata) => {
|
|
606
|
+
const value = fields[idKey];
|
|
607
|
+
const name = fields[nameKey];
|
|
608
|
+
if (typeof value !== "string" && typeof name !== "string")
|
|
609
|
+
return;
|
|
610
|
+
const result = source?.[catalog];
|
|
611
|
+
if (!result || !result.available) {
|
|
612
|
+
throw new PingCodeError(`${nameKey} 无法解析:工作项元数据未返回 ${String(catalog)} 目录`, "NOT_CONFIGURED");
|
|
613
|
+
}
|
|
614
|
+
fields[idKey] = await resolveMetadataReference(nameKey, typeof value === "string" ? value : undefined, typeof name === "string" ? name : undefined, result.values);
|
|
615
|
+
// 保留可读名称用于 Preview 展示;写入 Adapter 只消费解析后的 *_id。
|
|
616
|
+
};
|
|
617
|
+
await resolveCatalog("priority_id", "priority_name", "priorities");
|
|
618
|
+
await resolveCatalog("state_id", "state_name", "states");
|
|
619
|
+
await resolveCatalog("sprint_id", "sprint_name", "sprints");
|
|
620
|
+
await resolveCatalog("board_id", "board_name", "boards");
|
|
621
|
+
if ((typeof fields.entry_id === "string" || typeof fields.entry_name === "string"
|
|
622
|
+
|| typeof fields.swimlane_id === "string" || typeof fields.swimlane_name === "string")
|
|
623
|
+
&& typeof fields.board_id === "string" && projectId !== undefined) {
|
|
624
|
+
metadata = await services.getMetadataSource().getWorkItemMetadata({
|
|
625
|
+
projectId,
|
|
626
|
+
...(typeId === undefined ? {} : { workItemTypeId: typeId }),
|
|
627
|
+
boardId: fields.board_id,
|
|
628
|
+
include: ["entries", "swimlanes"],
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
await resolveCatalog("entry_id", "entry_name", "entries");
|
|
632
|
+
await resolveCatalog("swimlane_id", "swimlane_name", "swimlanes");
|
|
633
|
+
if (typeof fields.assignee_id === "string" || typeof fields.assignee_name === "string") {
|
|
634
|
+
fields.assignee_id = await resolveUserReference("负责人", typeof fields.assignee_id === "string" ? fields.assignee_id : undefined, typeof fields.assignee_name === "string" ? fields.assignee_name : undefined, services);
|
|
635
|
+
delete fields.assignee_name;
|
|
636
|
+
}
|
|
637
|
+
if (typeof fields.parent_id === "string" || typeof fields.parent_name === "string") {
|
|
638
|
+
fields.parent_id = await resolveWorkItemReference("父工作项", typeof fields.parent_id === "string" ? fields.parent_id : undefined, typeof fields.parent_name === "string" ? fields.parent_name : undefined, services, projectId);
|
|
639
|
+
delete fields.parent_name;
|
|
640
|
+
const parent = await services.getTaskSource().getWorkItemContext(String(fields.parent_id));
|
|
641
|
+
assertSupportedTaskParent(parent);
|
|
642
|
+
}
|
|
643
|
+
if (operation.action === "create" && operation.parent_operation_id !== undefined) {
|
|
644
|
+
const parentType = createOperationTypes.get(operation.parent_operation_id);
|
|
645
|
+
if (parentType === undefined) {
|
|
646
|
+
throw new PingCodeError(`parent_operation_id '${operation.parent_operation_id}' 的父工作项类型无法在 Preview 阶段解析`, "BAD_REQUEST");
|
|
647
|
+
}
|
|
648
|
+
if (parentType !== "story") {
|
|
649
|
+
throw new PingCodeError(`Child Task 的父工作项必须是 story;计划中的 '${operation.parent_operation_id}' 类型是 '${parentType}'`, "BAD_REQUEST");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (typeof fields.phase_id === "string" || typeof fields.phase_name === "string") {
|
|
653
|
+
if (projectId === undefined)
|
|
654
|
+
throw new PingCodeError("发布阶段解析缺少项目", "BAD_REQUEST");
|
|
655
|
+
const project = await services.getMetadataSource().getProject(projectId);
|
|
656
|
+
if (project.type !== "waterfall" && project.type !== "hybrid") {
|
|
657
|
+
throw new PingCodeError(`项目类型 ${project.type} 不支持 phase_id;scrum/kanban 不会请求发布阶段目录`, "BAD_REQUEST");
|
|
658
|
+
}
|
|
659
|
+
fields.phase_id = await resolveProjectStageReference("发布阶段", typeof fields.phase_id === "string" ? fields.phase_id : undefined, typeof fields.phase_name === "string" ? fields.phase_name : undefined, services);
|
|
660
|
+
delete fields.phase_name;
|
|
661
|
+
}
|
|
662
|
+
if (Array.isArray(fields.participant_names)) {
|
|
663
|
+
fields.participant_ids = await Promise.all(fields.participant_names.map(async (name) => {
|
|
664
|
+
const id = await resolveUserReference("关注人", undefined, typeof name === "string" ? name : undefined, services);
|
|
665
|
+
if (id === undefined)
|
|
666
|
+
throw new PingCodeError("关注人尚未解析为有效用户", "BAD_REQUEST");
|
|
667
|
+
return id;
|
|
668
|
+
}));
|
|
669
|
+
delete fields.participant_names;
|
|
670
|
+
}
|
|
671
|
+
else if (Array.isArray(fields.participant_ids)) {
|
|
672
|
+
fields.participant_ids = await Promise.all(fields.participant_ids.map(async (value) => {
|
|
673
|
+
const id = await resolveUserReference("关注人", typeof value === "string" ? value : undefined, undefined, services);
|
|
674
|
+
if (id === undefined)
|
|
675
|
+
throw new PingCodeError("关注人尚未解析为有效用户", "BAD_REQUEST");
|
|
676
|
+
return id;
|
|
677
|
+
}));
|
|
678
|
+
}
|
|
679
|
+
operations.push({ ...operation, fields });
|
|
680
|
+
}
|
|
681
|
+
return { ...plan, operations };
|
|
682
|
+
}
|
|
683
|
+
function classifyWorkItemType(value, fallback) {
|
|
684
|
+
const candidates = [value?.type, value?.name, value?.id, fallback]
|
|
685
|
+
.filter((item) => typeof item === "string")
|
|
686
|
+
.map((item) => item.trim().toLowerCase());
|
|
687
|
+
if (candidates.some((item) => item === "story" || item.includes("story") || item.includes("用户故事"))) {
|
|
688
|
+
return "story";
|
|
689
|
+
}
|
|
690
|
+
if (candidates.some((item) => item === "bug" || item.includes("bug") || item.includes("缺陷"))) {
|
|
691
|
+
return "bug";
|
|
692
|
+
}
|
|
693
|
+
if (candidates.some((item) => item === "task" || item.includes("task") || item.includes("任务"))) {
|
|
694
|
+
return "task";
|
|
695
|
+
}
|
|
696
|
+
return candidates[0] ?? fallback;
|
|
697
|
+
}
|
|
698
|
+
export function defaultWorkItemTypeId(values) {
|
|
699
|
+
const exactTask = values.find((value) => value.id === "task");
|
|
700
|
+
if (exactTask && typeof exactTask.id === "string")
|
|
701
|
+
return exactTask.id;
|
|
702
|
+
const taskGroup = values.filter((value) => value.group === "task");
|
|
703
|
+
if (taskGroup.length === 1 && typeof taskGroup[0]?.id === "string")
|
|
704
|
+
return taskGroup[0].id;
|
|
705
|
+
if (values.length === 1 && typeof values[0]?.id === "string")
|
|
706
|
+
return values[0].id;
|
|
707
|
+
return undefined;
|
|
708
|
+
}
|
|
709
|
+
function fieldsNeedWorkItemStateMetadata(fields) {
|
|
710
|
+
return typeof fields.state_id === "string" || typeof fields.state_name === "string";
|
|
711
|
+
}
|
|
712
|
+
export function resolveCurrentWorkItemTypeId(type, values) {
|
|
713
|
+
const normalized = type.trim().toLocaleLowerCase();
|
|
714
|
+
const match = values.find((value) => [value.id, value.name, value.type, value.key, value.code]
|
|
715
|
+
.some((candidate) => typeof candidate === "string" && candidate.trim().toLocaleLowerCase() === normalized));
|
|
716
|
+
return typeof match?.id === "string" ? match.id : undefined;
|
|
717
|
+
}
|
|
718
|
+
function assertSupportedTaskParent(parent) {
|
|
719
|
+
if (parent.type === "story") {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
throw new PingCodeError(`Child Task 的父工作项必须是 story;'${parent.source.identifier}' 的类型是 '${parent.type}'`, "BAD_REQUEST");
|
|
723
|
+
}
|
|
724
|
+
async function commitTaskPlan(input, services, runtime, writeApprovalMode, authInfo) {
|
|
725
|
+
const rawPlan = { plan_id: input.plan_id, operations: input.operations };
|
|
726
|
+
assertRequestHash(rawPlan, input.request_hash);
|
|
727
|
+
const plan = await resolveTaskPlanReferences(rawPlan, services);
|
|
728
|
+
validateOperations(plan.operations);
|
|
729
|
+
const committed = await commitWithApproval(runtime, writeApprovalMode, {
|
|
730
|
+
resourceType: "task",
|
|
731
|
+
requestHash: input.request_hash,
|
|
732
|
+
evidenceHash: input.evidence_hash,
|
|
733
|
+
approvalContextId: input.approval_context_id,
|
|
734
|
+
sessionBinding: input.session_binding,
|
|
735
|
+
...(input.agent_session_id === undefined ? {} : { agentSessionId: input.agent_session_id }),
|
|
736
|
+
approvalToken: input.approval_token,
|
|
737
|
+
idempotencyKey: input.idempotency_key,
|
|
738
|
+
...(authInfo === undefined ? {} : { authInfo }),
|
|
739
|
+
actions: plan.operations.map((operation) => operation.action),
|
|
740
|
+
action: async () => {
|
|
741
|
+
const resources = [];
|
|
742
|
+
const createdIds = new Map();
|
|
743
|
+
for (const operation of plan.operations) {
|
|
744
|
+
if (operation.action === "create") {
|
|
745
|
+
const fields = taskFields(operation.fields);
|
|
746
|
+
if (operation.parent_operation_id) {
|
|
747
|
+
fields.parentId = createdIds.get(operation.parent_operation_id);
|
|
748
|
+
}
|
|
749
|
+
const resource = await services.getWriteSource().createTask(fields);
|
|
750
|
+
createdIds.set(operation.operation_id, resource.id);
|
|
751
|
+
resources.push(resourceResult(operation, resource, []));
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
const current = await services.getTaskSource().getWorkItemContext(operation.target_id);
|
|
755
|
+
assertExpectedHash(operation.expected_content_hash, current.source.contentHash);
|
|
756
|
+
const resource = await services
|
|
757
|
+
.getWriteSource()
|
|
758
|
+
.updateTask(current.source.id, taskFields(operation.fields));
|
|
759
|
+
resources.push(resourceResult(operation, resource, []));
|
|
760
|
+
}
|
|
761
|
+
return { planId: plan.plan_id, requestHash: input.request_hash, resources };
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
return {
|
|
765
|
+
planId: committed.result.planId,
|
|
766
|
+
resources: committed.result.resources,
|
|
767
|
+
replayed: committed.replayed,
|
|
768
|
+
approvedBy: committed.approval.approvedBy,
|
|
769
|
+
auditId: committed.auditId,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
async function commitTestCasePlan(input, services, runtime, writeApprovalMode, authInfo) {
|
|
773
|
+
const rawPlan = { plan_id: input.plan_id, operations: input.operations };
|
|
774
|
+
assertRequestHash(rawPlan, input.request_hash);
|
|
775
|
+
const plan = await resolveTestCasePlanReferences(rawPlan, services);
|
|
776
|
+
validateOperations(plan.operations);
|
|
777
|
+
const committed = await commitWithApproval(runtime, writeApprovalMode, {
|
|
778
|
+
resourceType: "test_case",
|
|
779
|
+
requestHash: input.request_hash,
|
|
780
|
+
evidenceHash: input.evidence_hash,
|
|
781
|
+
approvalContextId: input.approval_context_id,
|
|
782
|
+
sessionBinding: input.session_binding,
|
|
783
|
+
...(input.agent_session_id === undefined ? {} : { agentSessionId: input.agent_session_id }),
|
|
784
|
+
approvalToken: input.approval_token,
|
|
785
|
+
idempotencyKey: input.idempotency_key,
|
|
786
|
+
...(authInfo === undefined ? {} : { authInfo }),
|
|
787
|
+
actions: plan.operations.map((operation) => operation.action),
|
|
788
|
+
action: async () => {
|
|
789
|
+
// Validate every target before the first mutation so an invalid relation cannot cause a partial plan.
|
|
790
|
+
await assertSupportedTestCaseRelationTargets(plan, services);
|
|
791
|
+
const resources = [];
|
|
792
|
+
for (const operation of plan.operations) {
|
|
793
|
+
let resource;
|
|
794
|
+
if (operation.action === "create") {
|
|
795
|
+
resource = await services.getWriteSource().createTestCase(testCaseFields(operation.fields));
|
|
796
|
+
const verifiedId = await verifyCreatedTestCase(resource.id, services);
|
|
797
|
+
if (verifiedId !== undefined)
|
|
798
|
+
resource = { ...resource, id: verifiedId };
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
const current = await services.getTestCaseSource().getTestCaseContext(operation.target_id);
|
|
802
|
+
assertExpectedHash(operation.expected_content_hash, current.source.contentHash);
|
|
803
|
+
resource = await services
|
|
804
|
+
.getWriteSource()
|
|
805
|
+
.updateTestCase(current.source.id, testCaseFields(operation.fields));
|
|
806
|
+
}
|
|
807
|
+
const relationIds = [];
|
|
808
|
+
for (const workItemId of operation.related_work_item_ids) {
|
|
809
|
+
const relation = await services
|
|
810
|
+
.getWriteSource()
|
|
811
|
+
.relateTestCaseToWorkItem(resource.id, workItemId);
|
|
812
|
+
relationIds.push(relation.id);
|
|
813
|
+
}
|
|
814
|
+
resources.push(resourceResult(operation, resource, relationIds));
|
|
815
|
+
}
|
|
816
|
+
return { planId: plan.plan_id, requestHash: input.request_hash, resources };
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
return {
|
|
820
|
+
planId: committed.result.planId,
|
|
821
|
+
resources: committed.result.resources,
|
|
822
|
+
replayed: committed.replayed,
|
|
823
|
+
approvedBy: committed.approval.approvedBy,
|
|
824
|
+
auditId: committed.auditId,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
async function assertSupportedTestCaseRelationTargets(plan, services) {
|
|
828
|
+
for (const operation of plan.operations) {
|
|
829
|
+
if (operation.action !== "update" || operation.related_work_item_ids.length === 0) {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
const current = await services.getTestCaseSource().getTestCaseContext(operation.target_id);
|
|
833
|
+
if (!current.relationsAvailable) {
|
|
834
|
+
throw new PingCodeError(`Test Case '${current.source.identifier}' 的现有关系无法读取(${current.relationErrorCode ?? "UNKNOWN"});无法安全判断新增关系是否重复,请省略 related_work_item_ids 或传空数组`, "BAD_REQUEST");
|
|
835
|
+
}
|
|
836
|
+
const existingTargetIds = new Set(current.relatedWorkItemIds);
|
|
837
|
+
const duplicateTargetIds = operation.related_work_item_ids.filter((id) => existingTargetIds.has(id));
|
|
838
|
+
if (duplicateTargetIds.length > 0) {
|
|
839
|
+
throw new PingCodeError(`Test Case '${current.source.identifier}' 已存在 ${duplicateTargetIds.length} 个计划中的工作项关系;related_work_item_ids 只用于新增关系,保留已有关系时请省略或传空数组`, "BAD_REQUEST");
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
const targetIds = [
|
|
843
|
+
...new Set(plan.operations.flatMap((operation) => operation.related_work_item_ids)),
|
|
844
|
+
];
|
|
845
|
+
for (const targetId of targetIds) {
|
|
846
|
+
const target = await services.getTaskSource().getWorkItemContext(targetId);
|
|
847
|
+
if (target.type !== "story" && target.type !== "bug") {
|
|
848
|
+
throw new PingCodeError(`測試用例只能關聯 story 或 bug;'${target.source.identifier}' 的類型是 '${target.type}'`, "BAD_REQUEST");
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async function previewResult(resourceType, plan, operations, runtime, relations, agentSessionIdValue, authInfo) {
|
|
853
|
+
const requestHash = calculateRequestHash(plan);
|
|
854
|
+
const evidenceHash = calculateRequestHash({
|
|
855
|
+
planId: plan.plan_id,
|
|
856
|
+
requestHash,
|
|
857
|
+
operations,
|
|
858
|
+
});
|
|
859
|
+
const context = await runtime.createApprovalContext({
|
|
860
|
+
resourceType,
|
|
861
|
+
requestHash,
|
|
862
|
+
evidenceHash,
|
|
863
|
+
...(agentSessionIdValue === undefined ? {} : { agentSessionId: agentSessionIdValue }),
|
|
864
|
+
...(authInfo === undefined ? {} : { authInfo }),
|
|
865
|
+
});
|
|
866
|
+
return {
|
|
867
|
+
planId: plan.plan_id,
|
|
868
|
+
requestHash,
|
|
869
|
+
approvalContextId: context.approvalContextId,
|
|
870
|
+
sessionBinding: context.sessionBinding,
|
|
871
|
+
evidenceHash,
|
|
872
|
+
approvalExpiresAt: context.expiresAt,
|
|
873
|
+
counts: {
|
|
874
|
+
creates: plan.operations.filter((operation) => operation.action === "create").length,
|
|
875
|
+
updates: plan.operations.filter((operation) => operation.action === "update").length,
|
|
876
|
+
relations,
|
|
877
|
+
},
|
|
878
|
+
operations,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function validateOperations(operations) {
|
|
882
|
+
const seen = new Set();
|
|
883
|
+
const actions = new Map();
|
|
884
|
+
for (const operation of operations) {
|
|
885
|
+
if (seen.has(operation.operation_id)) {
|
|
886
|
+
throw new PingCodeError(`operation_id '${operation.operation_id}' 重複`, "BAD_REQUEST");
|
|
887
|
+
}
|
|
888
|
+
if (operation.parent_operation_id) {
|
|
889
|
+
if (!seen.has(operation.parent_operation_id) || actions.get(operation.parent_operation_id) !== "create") {
|
|
890
|
+
throw new PingCodeError(`parent_operation_id '${operation.parent_operation_id}' 必須指向前面已定義的建立操作`, "BAD_REQUEST");
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
seen.add(operation.operation_id);
|
|
894
|
+
if (operation.action) {
|
|
895
|
+
actions.set(operation.operation_id, operation.action);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function assertRequestHash(plan, expected) {
|
|
900
|
+
if (calculateRequestHash(plan) !== expected) {
|
|
901
|
+
throw new PingCodeError("Commit 內容與 Preview request hash 不一致", "CONFLICT");
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function assertExpectedHash(expected, actual) {
|
|
905
|
+
if (normalizeContentHash(expected) !== normalizeContentHash(actual)) {
|
|
906
|
+
throw new PingCodeError("目標資源自 Preview 後已變更,請重新取得內容並產生 Preview", "CONFLICT");
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
function changesFromCreate(fields) {
|
|
910
|
+
return Object.fromEntries(Object.entries(fields)
|
|
911
|
+
.filter(([, value]) => value !== undefined)
|
|
912
|
+
.map(([field, value]) => [field, { before: null, after: value }]));
|
|
913
|
+
}
|
|
914
|
+
function taskChanges(current, fields) {
|
|
915
|
+
const before = {
|
|
916
|
+
title: current.title,
|
|
917
|
+
description: current.description,
|
|
918
|
+
start_at: current.startAt,
|
|
919
|
+
end_at: current.endAt,
|
|
920
|
+
priority_id: current.priority?.id ?? null,
|
|
921
|
+
state_id: current.state?.id ?? null,
|
|
922
|
+
assignee_id: current.assignee?.id ?? null,
|
|
923
|
+
parent_id: current.parent?.id ?? null,
|
|
924
|
+
properties: current.properties,
|
|
925
|
+
};
|
|
926
|
+
return selectedChanges(before, fields);
|
|
927
|
+
}
|
|
928
|
+
function testCaseChanges(current, fields) {
|
|
929
|
+
const before = {
|
|
930
|
+
title: current.title,
|
|
931
|
+
description: current.description,
|
|
932
|
+
precondition: current.precondition,
|
|
933
|
+
suite_id: current.suite?.id ?? null,
|
|
934
|
+
state_id: current.state?.id ?? null,
|
|
935
|
+
type_id: current.type?.id ?? null,
|
|
936
|
+
important_level_id: current.importantLevel?.id ?? null,
|
|
937
|
+
important_level: current.importantLevel?.name ?? null,
|
|
938
|
+
maintenance_id: current.maintenance?.id ?? null,
|
|
939
|
+
custom_properties: current.properties,
|
|
940
|
+
steps: current.steps,
|
|
941
|
+
};
|
|
942
|
+
return selectedChanges(before, fields);
|
|
943
|
+
}
|
|
944
|
+
function selectedChanges(before, fields) {
|
|
945
|
+
return Object.fromEntries(Object.entries(fields)
|
|
946
|
+
.filter(([, value]) => value !== undefined)
|
|
947
|
+
.map(([field, after]) => [field, { before: before[field] ?? null, after }]));
|
|
948
|
+
}
|
|
949
|
+
function taskFields(fields) {
|
|
950
|
+
return compact({
|
|
951
|
+
projectId: fields.project_id,
|
|
952
|
+
typeId: fields.type_id,
|
|
953
|
+
title: fields.title,
|
|
954
|
+
description: fields.description,
|
|
955
|
+
startAt: fields.start_at,
|
|
956
|
+
endAt: fields.end_at,
|
|
957
|
+
priorityId: fields.priority_id,
|
|
958
|
+
stateId: fields.state_id,
|
|
959
|
+
assigneeId: fields.assignee_id,
|
|
960
|
+
parentId: fields.parent_id,
|
|
961
|
+
sprintId: fields.sprint_id,
|
|
962
|
+
versionIds: fields.version_ids,
|
|
963
|
+
versionId: fields.version_id,
|
|
964
|
+
boardId: fields.board_id,
|
|
965
|
+
entryId: fields.entry_id,
|
|
966
|
+
swimlaneId: fields.swimlane_id,
|
|
967
|
+
phaseId: fields.phase_id,
|
|
968
|
+
storyPoints: fields.story_points,
|
|
969
|
+
estimatedWorkload: fields.estimated_workload,
|
|
970
|
+
remainingWorkload: fields.remaining_workload,
|
|
971
|
+
properties: fields.properties,
|
|
972
|
+
participantIds: fields.participant_ids,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
function testCaseFields(fields) {
|
|
976
|
+
const steps = fields.steps;
|
|
977
|
+
return compact({
|
|
978
|
+
testLibraryId: fields.test_library_id,
|
|
979
|
+
title: fields.title,
|
|
980
|
+
suiteId: fields.suite_id,
|
|
981
|
+
stateId: fields.state_id,
|
|
982
|
+
typeId: fields.type_id,
|
|
983
|
+
importantLevel: fields.important_level,
|
|
984
|
+
importantLevelId: fields.important_level_id,
|
|
985
|
+
maintenanceId: fields.maintenance_id,
|
|
986
|
+
participantIds: fields.participant_ids,
|
|
987
|
+
properties: fields.custom_properties,
|
|
988
|
+
description: fields.description,
|
|
989
|
+
precondition: fields.precondition,
|
|
990
|
+
steps: steps?.map((step) => compact({
|
|
991
|
+
stepId: step.step_id,
|
|
992
|
+
description: step.description,
|
|
993
|
+
expectedValue: step.expected_value,
|
|
994
|
+
isGroup: step.is_group,
|
|
995
|
+
groupId: step.group_id,
|
|
996
|
+
})),
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
function compact(value) {
|
|
1000
|
+
return Object.fromEntries(Object.entries(value).filter(([, nested]) => nested !== undefined));
|
|
1001
|
+
}
|
|
1002
|
+
function taskCommitConfirmationMessage(input) {
|
|
1003
|
+
const projectIds = [
|
|
1004
|
+
...new Set(input.operations.flatMap((operation) => operation.action === "create" ? [operation.fields.project_id] : [])),
|
|
1005
|
+
];
|
|
1006
|
+
const targets = projectIds.map((id) => {
|
|
1007
|
+
const namedOperation = input.operations.find((operation) => operation.action === "create" && operation.fields.project_id === id);
|
|
1008
|
+
return namedOperation && "project_name" in namedOperation.fields && typeof namedOperation.fields.project_name === "string"
|
|
1009
|
+
? namedOperation.fields.project_name
|
|
1010
|
+
: "已确认项目";
|
|
1011
|
+
});
|
|
1012
|
+
const projectNames = [
|
|
1013
|
+
...new Set(input.operations.flatMap((operation) => operation.action === "create" && operation.fields.project_id === undefined
|
|
1014
|
+
? [operation.fields.project_name]
|
|
1015
|
+
: [])),
|
|
1016
|
+
];
|
|
1017
|
+
const projectLabels = [...targets, ...projectNames.map((name) => `项目 ${name}`)];
|
|
1018
|
+
const creates = input.operations.filter((operation) => operation.action === "create").length;
|
|
1019
|
+
const updates = input.operations.length - creates;
|
|
1020
|
+
const resources = input.operations.map((operation) => operation.fields.title ?? (operation.action === "update" ? "已确认工作项" : "已确认工作项"));
|
|
1021
|
+
return formatWriteConfirmationMessage("即将写入 PingCode 工作项", [
|
|
1022
|
+
`目标项目:${projectLabels.length > 0 ? projectLabels.join("、") : "由现有工作项决定"}`,
|
|
1023
|
+
`操作:创建 ${creates} 个,更新 ${updates} 个`,
|
|
1024
|
+
`资源:${summarizeLabels(resources)}`,
|
|
1025
|
+
]);
|
|
1026
|
+
}
|
|
1027
|
+
async function testCaseCommitConfirmationMessage(input, services) {
|
|
1028
|
+
const libraryIds = [
|
|
1029
|
+
...new Set(input.operations.flatMap((operation) => operation.action === "create" && operation.fields.test_library_id !== undefined ? [operation.fields.test_library_id] : [])),
|
|
1030
|
+
];
|
|
1031
|
+
const targets = await resolveTestLibraryLabels(input, libraryIds, services);
|
|
1032
|
+
const creates = input.operations.filter((operation) => operation.action === "create").length;
|
|
1033
|
+
const updates = input.operations.length - creates;
|
|
1034
|
+
const relations = input.operations.reduce((total, operation) => total + operation.related_work_item_ids.length, 0);
|
|
1035
|
+
const resources = input.operations.map((operation) => operation.fields.title ?? "已确认测试用例");
|
|
1036
|
+
return formatWriteConfirmationMessage("即将写入 PingCode Test Case", [
|
|
1037
|
+
`目标测试库:${targets.length > 0 ? targets.join("、") : "由现有 Test Case 决定"}`,
|
|
1038
|
+
`操作:创建 ${creates} 个,更新 ${updates} 个,建立关系 ${relations} 个`,
|
|
1039
|
+
`资源:${summarizeLabels(resources)}`,
|
|
1040
|
+
]);
|
|
1041
|
+
}
|
|
1042
|
+
async function resolveTestLibraryLabels(input, libraryIds, services) {
|
|
1043
|
+
const labels = new Map();
|
|
1044
|
+
await Promise.all(libraryIds.map(async (id) => {
|
|
1045
|
+
try {
|
|
1046
|
+
const library = await services.getMetadataSource().getTestLibrary(id);
|
|
1047
|
+
labels.set(id, `${library.name} (${library.identifier})`);
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
labels.set(id, "已确认测试库");
|
|
1051
|
+
}
|
|
1052
|
+
}));
|
|
1053
|
+
await Promise.all(input.operations.filter((operation) => operation.action === "update").map(async (operation) => {
|
|
1054
|
+
try {
|
|
1055
|
+
const context = await services.getTestCaseSource().getTestCaseContext(operation.target_id);
|
|
1056
|
+
labels.set(context.source.libraryId, `${context.source.libraryName} (${context.source.libraryIdentifier})`);
|
|
1057
|
+
}
|
|
1058
|
+
catch {
|
|
1059
|
+
// The update can still be confirmed using the Test Case target if enrichment is unavailable.
|
|
1060
|
+
}
|
|
1061
|
+
}));
|
|
1062
|
+
const updateOnly = input.operations.some((operation) => operation.action === "update") && libraryIds.length === 0;
|
|
1063
|
+
return updateOnly && labels.size === 0
|
|
1064
|
+
? ["由现有 Test Case 决定(库信息未能在确认前读取)"]
|
|
1065
|
+
: [...labels.values()];
|
|
1066
|
+
}
|
|
1067
|
+
function summarizeLabels(values) {
|
|
1068
|
+
const visible = values.slice(0, 5);
|
|
1069
|
+
const remaining = values.length - visible.length;
|
|
1070
|
+
return `${visible.join("、")}${remaining > 0 ? `,另有 ${remaining} 个` : ""}`;
|
|
1071
|
+
}
|
|
1072
|
+
function resourceResult(operation, resource, relationIds) {
|
|
1073
|
+
return {
|
|
1074
|
+
operationId: operation.operation_id,
|
|
1075
|
+
action: operation.action,
|
|
1076
|
+
id: resource.id,
|
|
1077
|
+
identifier: resource.identifier,
|
|
1078
|
+
title: resource.title,
|
|
1079
|
+
url: resource.html_url ?? resource.url,
|
|
1080
|
+
relationIds,
|
|
1081
|
+
verified: true,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
export async function verifyCreatedTestCase(caseId, services, sleep = waitForReadConsistency) {
|
|
1085
|
+
let lastError;
|
|
1086
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
1087
|
+
try {
|
|
1088
|
+
const context = await services.getTestCaseSource().getTestCaseContext(caseId);
|
|
1089
|
+
return context.source.id === caseId ? undefined : context.source.id;
|
|
1090
|
+
}
|
|
1091
|
+
catch (error) {
|
|
1092
|
+
lastError = error;
|
|
1093
|
+
const retryableVisibilityError = isTestCaseReadConsistencyError(error);
|
|
1094
|
+
if (!retryableVisibilityError || attempt === 3) {
|
|
1095
|
+
break;
|
|
1096
|
+
}
|
|
1097
|
+
await sleep(250 * 2 ** attempt);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
throw new PingCodeError(`Test Case ${caseId} 已创建,但写后读仍未可见;PingCode 可能存在索引或资源同步延迟。请使用 pingcode_test_case_get 按返回 ID 回读,确认成功前禁止更换幂等键重试`, "WRITE_OUTCOME_UNKNOWN", { cause: lastError });
|
|
1101
|
+
}
|
|
1102
|
+
function isTestCaseReadConsistencyError(error) {
|
|
1103
|
+
if (!(error instanceof PingCodeError))
|
|
1104
|
+
return false;
|
|
1105
|
+
return error.code === "NOT_FOUND" || /100601|尚未可见|尚未可見/i.test(error.message);
|
|
1106
|
+
}
|
|
1107
|
+
async function waitForReadConsistency(milliseconds) {
|
|
1108
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
1109
|
+
}
|
|
1110
|
+
//# sourceMappingURL=sync.js.map
|