@youcloud/metersphere-mcp 0.0.0-stage → 1.0.0

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.
@@ -0,0 +1,250 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { ensure } from '../errors.js';
3
+ import { array, executionDefinition, executionSnapshot, object, parseRecord, pick, segment, fieldsMatch, resultBody } from '../adapters/v1-20.js';
4
+ import { executionSchema } from '../workflow/contracts.js';
5
+ import { digest, Tokens } from '../workflow/tokens.js';
6
+ export class MeterSphere {
7
+ client;
8
+ config;
9
+ constructor(client, config) {
10
+ this.client = client;
11
+ this.config = config;
12
+ }
13
+ async session() {
14
+ const version = await this.client.read('/system/version');
15
+ ensure(typeof version === 'string' && /^v1\.20\.23-lts(?:-|$)/.test(version), 'UNSUPPORTED_VERSION', '当前仅验证 v1.20.23-lts 适配;请先核对其他部署版本');
16
+ const user = object(await this.client.read('/currentUser'), '当前身份');
17
+ ensure(typeof user.id === 'string' && user.id.length > 0, 'AUTHENTICATION_FAILED', '未取得真实用户 ID');
18
+ const namespace = digest([this.config.baseUrl, user.id]);
19
+ const credentials = this.config.credentials();
20
+ const key = createHmac('sha256', credentials.secretKey).update(`metersphere-mcp:${credentials.accessKey}:${namespace}`).digest();
21
+ return { user: { id: user.id, name: typeof user.name === 'string' ? user.name : user.id }, namespace,
22
+ tokens: new Tokens(key, digest([namespace, this.config.workflow])) };
23
+ }
24
+ async project(id) {
25
+ const value = object(await this.client.read(`/project/get/${segment(id)}`), '项目');
26
+ ensure(value.id === id && typeof value.workspaceId === 'string', 'NOT_FOUND_OR_NOT_VISIBLE', '项目不存在、不可见或缺少工作空间');
27
+ return value;
28
+ }
29
+ async plan(projectId, id) {
30
+ const value = object(await this.client.read(`/test/plan/get/${segment(id)}`, {}), '计划');
31
+ ensure(value.id === id && value.projectId === projectId, 'TARGET_MISMATCH', '计划与指定项目不匹配');
32
+ return value;
33
+ }
34
+ async review(projectId, id) {
35
+ const value = object(await this.client.read(`/test/case/review/get/${segment(id)}`), '评审');
36
+ ensure(value.id === id && value.projectId === projectId, 'TARGET_MISMATCH', '评审与指定项目不匹配');
37
+ return value;
38
+ }
39
+ async principals(planId) { return array(await this.client.read(`/test/plan/principal/${segment(planId)}`), '计划负责人').map(x => pick(x, ['id', 'name'])); }
40
+ async reviewers(reviewId) { return array(await this.client.read('/test/case/review/reviewer', { id: reviewId }), '评审人员').map(x => pick(x, ['id', 'name'])); }
41
+ async case(projectId, id) {
42
+ const value = object(await this.client.read(`/test/case/get/${segment(id)}`), '用例');
43
+ ensure(value.id === id && value.projectId === projectId && value.type === 'functional', 'TARGET_MISMATCH', '用例不属于指定项目或不是功能用例');
44
+ return value;
45
+ }
46
+ async reviewCase(projectId, reviewId, row) {
47
+ ensure(typeof row.id === 'string' && row.id.length > 0 && row.reviewId === reviewId
48
+ && typeof row.caseId === 'string' && row.caseId.length > 0 && (row.projectId == null || row.projectId === projectId), 'TARGET_MISMATCH', '评审用例关联与指定评审或项目不匹配');
49
+ // v1.20 的评审列表不选择 project_id,也不按请求 projectId 过滤;通过用例详情核验归属。
50
+ const item = await this.case(projectId, row.caseId);
51
+ ensure(row.versionId == null || row.versionId === item.versionId, 'INCOMPLETE_SCOPE', '读取期间评审用例版本已改变,请重新读取');
52
+ return { ...row, projectId: item.projectId, refId: item.refId };
53
+ }
54
+ async planCase(projectId, planId, id) {
55
+ await this.plan(projectId, planId);
56
+ return this.planCaseDetail(projectId, planId, id);
57
+ }
58
+ async planCaseDetail(projectId, planId, id) {
59
+ const value = object(await this.client.read(`/test/plan/case/get/${segment(id)}`), '计划用例');
60
+ ensure(value.id === id && value.projectId === projectId && value.planId === planId && typeof value.caseId === 'string' && value.type === 'functional', 'TARGET_MISMATCH', '计划用例与指定项目/计划不匹配');
61
+ return value;
62
+ }
63
+ async nodes(projectId) { return array(await this.client.read(`/case/node/list/${segment(projectId)}`), '模块树'); }
64
+ async nodeIndex(projectId) {
65
+ const seen = new Set();
66
+ const visit = (nodes, parentId = null, prefix = '', level = 1) => nodes.flatMap(n => {
67
+ const parent = n.parentId === '0' || n.parentId === '' ? null : n.parentId ?? null;
68
+ ensure(typeof n.id === 'string' && !seen.has(n.id) && typeof n.name === 'string' && n.projectId === projectId
69
+ && n.level === level && level <= 8 && parent === parentId, 'SCHEMA_DRIFT', '模块树的项目、父子关系或层级不符合预期');
70
+ seen.add(n.id);
71
+ const node = { id: n.id, projectId, name: n.name, parentId: parent, level, path: `${prefix}/${n.name}` };
72
+ return [node, ...visit(array(n.children ?? [], '子模块'), n.id, node.path, level + 1)];
73
+ });
74
+ return visit(await this.nodes(projectId));
75
+ }
76
+ async node(projectId, nodeId) {
77
+ const node = (await this.nodeIndex(projectId)).find(x => x.id === nodeId);
78
+ ensure(node, 'TARGET_MISMATCH', '模块不属于指定项目');
79
+ return node;
80
+ }
81
+ async moduleLocation(projectId, name, parentId = null) {
82
+ const nodes = await this.nodeIndex(projectId), parent = parentId === null ? null : nodes.find(x => x.id === parentId);
83
+ ensure(parentId === null || parent, 'TARGET_MISMATCH', '父模块不存在或不属于指定项目;一级模块请省略 parent_id');
84
+ const level = parent ? parent.level + 1 : 1;
85
+ ensure(level <= 8, 'MODULE_DEPTH_LIMIT', '功能用例模块最多支持 8 层,不能在第 8 层下创建子模块');
86
+ const existing = nodes.find(x => x.parentId === parentId && x.name === name);
87
+ ensure(!existing, 'MODULE_ALREADY_EXISTS', `同一父模块下已存在同名模块(ID:${existing?.id}),不会自动复用或重复创建`);
88
+ return { parent: parent ?? null, parent_id: parentId, level, path: `${parent?.path ?? ''}/${name}` };
89
+ }
90
+ async members(projectId) { return array(await this.client.read(`/user/project/member/${segment(projectId)}`), '项目人员').map(x => pick(x, ['id', 'name', 'status'])); }
91
+ async checkMembers(projectId, ids) {
92
+ const members = await this.members(projectId);
93
+ ensure(ids.every(id => members.some(x => x.id === id)), 'TARGET_MISMATCH', '指定人员不在项目成员中');
94
+ }
95
+ async page(path, body, page = 1, size = 100) {
96
+ const data = object(await this.client.read(`${path}/${page}/${size}`, body), '分页');
97
+ const items = array(data.listObject, '分页条目');
98
+ ensure(Number.isSafeInteger(data.itemCount) && data.itemCount >= 0 && items.length <= size && (!items.length || (page - 1) * size + items.length <= data.itemCount), 'SCHEMA_DRIFT', '分页数量结构无效');
99
+ return { items, total: data.itemCount };
100
+ }
101
+ async all(path, body, cap = this.config.workflow.limits.max_plan_items_for_completion) {
102
+ const result = [], seen = new Set();
103
+ let previousTotal;
104
+ for (let page = 1;; page++) {
105
+ const data = await this.page(path, body, page, 100);
106
+ ensure(previousTotal === undefined || previousTotal === data.total, 'INCOMPLETE_SCOPE', '扫描期间总数发生变化,请重新读取');
107
+ previousTotal = data.total;
108
+ ensure(data.total <= cap, 'INCOMPLETE_SCOPE', '完整扫描超过配置上限');
109
+ for (const x of data.items) {
110
+ ensure(typeof x.id === 'string' && !seen.has(x.id), 'INCOMPLETE_SCOPE', '分页存在重复或缺少 ID,不能当成完整快照');
111
+ seen.add(x.id);
112
+ result.push(x);
113
+ }
114
+ if (result.length === data.total)
115
+ return result;
116
+ ensure(result.length < data.total && data.items.length > 0 && result.length < cap, 'INCOMPLETE_SCOPE', '分页提前结束或数量不一致');
117
+ }
118
+ }
119
+ async query(path, body, input, session, predicate = () => true) {
120
+ const identity = digest([path, body, { ...input, cursor: undefined }]);
121
+ const position = input.cursor ? session.tokens.open(input.cursor, 'cursor') : { query: identity, page: 1, offset: 0 };
122
+ ensure(position.query === identity && Number.isSafeInteger(position.page) && position.page >= 1 && Number.isSafeInteger(position.offset) && position.offset >= 0 && position.offset < 100, 'STALE_CURSOR', '游标与当前查询不一致');
123
+ const items = [], seen = new Set();
124
+ let scanned = 0, exhausted = false, sourceTotal = null;
125
+ let page = position.page, offset = position.offset;
126
+ const limit = Math.min(input.limit ?? this.config.workflow.limits.default_page_size, this.config.workflow.limits.max_page_size), budget = this.config.workflow.limits.max_scan_items_per_call;
127
+ outer: while (scanned < budget && items.length < limit) {
128
+ const data = await this.page(path, body, page, 100);
129
+ sourceTotal = data.total;
130
+ if (!data.items.length) {
131
+ ensure((page - 1) * 100 >= data.total, 'INCOMPLETE_SCOPE', '服务端提前返回空页');
132
+ exhausted = true;
133
+ break;
134
+ }
135
+ for (; offset < data.items.length; offset++) {
136
+ if (scanned >= budget || items.length >= limit)
137
+ break outer;
138
+ const value = data.items[offset];
139
+ scanned++;
140
+ ensure(typeof value.id === 'string', 'SCHEMA_DRIFT', '列表条目缺少真实 ID');
141
+ if (await predicate(value) && !seen.has(value.id)) {
142
+ items.push(value);
143
+ seen.add(value.id);
144
+ }
145
+ }
146
+ if ((page - 1) * 100 + offset >= data.total) {
147
+ exhausted = true;
148
+ break;
149
+ }
150
+ ensure(data.items.length === 100, 'INCOMPLETE_SCOPE', '非末页条目数量不足,未跳过未知条目');
151
+ page++;
152
+ offset = 0;
153
+ }
154
+ return { items, returned_count: items.length, scanned_count: scanned, source_exhausted: exhausted,
155
+ next_cursor: exhausted ? null : session.tokens.seal({ kind: 'cursor', query: identity, page, offset }), source_total_count: sourceTotal,
156
+ snapshot_consistent: false, filter_execution: 'client_verified', warnings: exhausted ? [] : ['本次结果不是全量;续查游标不保证下一页有匹配项'] };
157
+ }
158
+ async comments(caseId) {
159
+ const comments = array(await this.client.read(`/test/case/comment/list/${segment(caseId)}`), '用例评论');
160
+ ensure(comments.length <= 2000 && JSON.stringify(comments).length <= 2 * 1024 * 1024, 'INCOMPLETE_HISTORY', '评论超过读取上限,未将截断内容当成完整记录');
161
+ return comments;
162
+ }
163
+ async records(caseId) { return (await this.comments(caseId)).map(parseRecord).filter((x) => !!x); }
164
+ async caseEditState(projectId, caseId) {
165
+ const item = await this.case(projectId, caseId);
166
+ const follows = await this.client.read(`/test/case/follow/${segment(caseId)}`);
167
+ ensure(Array.isArray(follows) && follows.every(x => typeof x === 'string'), 'SCHEMA_DRIFT', '关注人列表结构无法可靠保留');
168
+ const files = array(await this.client.read(`/test/case/file/metadata/${segment(caseId)}`), '附件');
169
+ return { item, follows, files };
170
+ }
171
+ async inventory(projectId, planId) {
172
+ const plan = await this.plan(projectId, planId);
173
+ const listed = await this.all('/test/plan/case/list', { projectId, planId });
174
+ ensure(listed.every(x => x.projectId === projectId && x.planId === planId && typeof x.caseId === 'string' && x.type === 'functional'), 'TARGET_MISMATCH', '计划包含其他项目或未知类型的用例');
175
+ // v1.20 列表不含完整定义和逐步结果;快照与执行评论必须使用同一详情来源。
176
+ const cases = [];
177
+ for (const item of listed) {
178
+ const detail = await this.planCaseDetail(projectId, planId, item.id);
179
+ ensure(detail.caseId === item.caseId && detail.versionId === item.versionId, 'INCOMPLETE_SCOPE', '读取期间计划用例或版本已改变,请重新读取');
180
+ cases.push(detail);
181
+ }
182
+ const counts = await Promise.all([
183
+ this.page('/test/plan/api/case/list', { projectId, planId }, 1, 1),
184
+ this.page('/test/plan/scenario/case/list', { projectId, planId }, 1, 1),
185
+ this.page('/test/plan/load/case/list', { projectId, testPlanId: planId }, 1, 1),
186
+ ]);
187
+ return { plan, cases, other_counts: { api: counts[0].total, scenario: counts[1].total, load: counts[2].total } };
188
+ }
189
+ membership(inventory) { return { plan: pick(inventory.plan, ['id', 'projectId', 'status']), members: inventory.cases.map((x) => pick(x, ['id', 'caseId', 'refId', 'versionId'])).sort((a, b) => a.id.localeCompare(b.id)), other_counts: inventory.other_counts }; }
190
+ evidenceSnapshot(summary) {
191
+ const sorted = (items) => [...items].sort((a, b) => digest(a).localeCompare(digest(b)));
192
+ return { counts: summary.counts, attempts: sorted(summary.attempts), references: sorted(summary.external_defect_references), problems: sorted(summary.problems) };
193
+ }
194
+ completionSnapshot(inventory, runId, summary) { return { run_id: runId, membership: this.membership(inventory), cases: inventory.cases.map(executionSnapshot).sort((a, b) => a.execution.id.localeCompare(b.execution.id)), evidence: this.evidenceSnapshot(summary) }; }
195
+ async executionSummary(inventory, runId) {
196
+ const counts = { passed: 0, failed: 0, blocked: 0, skipped: 0, not_run: 0 }, problems = [], references = [], attempts = [];
197
+ for (const item of inventory.cases) {
198
+ const records = (await this.records(item.caseId)).filter(x => x.plan_id === inventory.plan.id && x.plan_case_id === item.id && x.project_id === item.projectId);
199
+ const ordered = records.filter(x => x.kind === 'execution').sort((a, b) => (b.recorded_at || 0) - (a.recorded_at || 0));
200
+ const last = ordered[0];
201
+ const parsed = last && executionSchema.safeParse(last.result);
202
+ if (!last || last.run_id !== runId || !parsed?.success || last.definition_hash !== digest(executionDefinition(item))) {
203
+ counts.not_run++;
204
+ problems.push({ plan_case_id: item.id, code: 'MISSING_CURRENT_ATTEMPT' });
205
+ continue;
206
+ }
207
+ if (ordered[1]?.recorded_at === last.recorded_at) {
208
+ counts.not_run++;
209
+ problems.push({ plan_case_id: item.id, code: 'AMBIGUOUS_ATTEMPT' });
210
+ continue;
211
+ }
212
+ const expected = resultBody(item, parsed.data);
213
+ if (!fieldsMatch(item, expected)) {
214
+ counts.not_run++;
215
+ problems.push({ plan_case_id: item.id, code: 'RESULT_MISMATCH' });
216
+ continue;
217
+ }
218
+ const binding = this.config.workflow.project_bindings.find(x => x.metersphere_project_id === item.projectId);
219
+ const links = records.filter(x => x.kind === 'mantis_reference' && x.run_id === runId && x.attempt_id === last.attempt_id && x.definition_hash === last.definition_hash && binding?.mantis_project_id === x.mantis_project_id && Number.isSafeInteger(x.issue_id) && x.issue_id > 0);
220
+ references.push(...links.map(x => ({ ...pick(x, ['issue_id', 'mantis_project_id', 'url', 'relation', 'attempt_id', 'plan_case_id']), record_hash: digest(x) })));
221
+ if (parsed.data.status === 'failed' && !links.length && !parsed.data.failure_disposition)
222
+ problems.push({ plan_case_id: item.id, code: 'UNTRIAGED_FAILURE' });
223
+ counts[parsed.data.status]++;
224
+ attempts.push({ plan_case_id: item.id, attempt_id: last.attempt_id, comment_id: last.comment_id, actor_id: last.actor_id, record_hash: digest(last) });
225
+ }
226
+ const mixed = Object.values(inventory.other_counts).some(x => Number(x) > 0);
227
+ if (mixed)
228
+ problems.push({ code: 'MIXED_PLAN' });
229
+ if (!inventory.cases.length)
230
+ problems.push({ code: 'EMPTY_PLAN' });
231
+ return { run_id: runId, counts, attempts, external_defect_references: references, external_reference_count: references.length,
232
+ all_passed: inventory.cases.length > 0 && counts.passed === inventory.cases.length && !mixed,
233
+ completion_ready: problems.length === 0, problems, snapshot_consistent: false, proof_of_execution: false };
234
+ }
235
+ async report(projectId, reportId) {
236
+ const reports = await this.all('/test/plan/report/list', { projectId });
237
+ const meta = reports.find(x => x.id === reportId);
238
+ ensure(meta && typeof meta.testPlanId === 'string', 'NOT_FOUND_OR_NOT_VISIBLE', '指定项目的报告列表中未找到报告');
239
+ await this.plan(projectId, meta.testPlanId);
240
+ const content = object(await this.client.read(`/test/plan/report/db/${segment(reportId)}`), '报告详情');
241
+ ensure(!content.testPlanReportId || content.testPlanReportId === reportId, 'TARGET_MISMATCH', '报告内容 ID 与目标不符');
242
+ return { meta, content };
243
+ }
244
+ reportView(report) {
245
+ return { report: pick(report.meta, ['id', 'testPlanId', 'name', 'status', 'creator', 'createTime', 'triggerMode']),
246
+ content: pick(report.content, ['testPlanReportId', 'name', 'caseCount', 'executeCount', 'passCount', 'summary', 'functionResult']),
247
+ functional_cases: Array.isArray(report.content.functionAllCases) ? report.content.functionAllCases.map((x) => pick(x, ['id', 'caseId', 'name', 'status', 'results', 'actualResult'])) : null,
248
+ native_issue_count: Array.isArray(report.content.issueList) ? report.content.issueList.length : null, content_verification: 'partial' };
249
+ }
250
+ }
@@ -0,0 +1,3 @@
1
+ import { readFileSync } from 'node:fs';
2
+ // 源码与安装包均从根目录清单读取版本,npm version 后无需手改协议或日志。
3
+ export const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -0,0 +1,145 @@
1
+ import { array, definitionView, executionSnapshot, pick, segment } from '../adapters/v1-20.js';
2
+ import { ensure } from '../errors.js';
3
+ import { Operations } from './operations.js';
4
+ import { digest } from './tokens.js';
5
+ import { resolveEnabledTools } from './tool-access.js';
6
+ export class Context {
7
+ ms;
8
+ enabledTools;
9
+ constructor(ms) {
10
+ this.ms = ms;
11
+ this.enabledTools = resolveEnabledTools(ms.config);
12
+ }
13
+ assertToolEnabled(name) { ensure(this.enabledTools.has(name), 'TOOL_DISABLED', '当前工具未启用或已被只读模式禁用'); }
14
+ ledger(session) { return new Operations(this.ms.config.stateDir, session.namespace, digest(this.ms.config.baseUrl)); }
15
+ async visible(args) {
16
+ if (args.project_id)
17
+ await this.ms.project(args.project_id);
18
+ if (args.plan_id)
19
+ await this.ms.plan(args.project_id, args.plan_id);
20
+ if (args.plan_case_id)
21
+ await this.ms.planCase(args.project_id, args.plan_id, args.plan_case_id);
22
+ if (args.case_id)
23
+ await this.ms.case(args.project_id, args.case_id);
24
+ if (args.review_id)
25
+ await this.ms.review(args.project_id, args.review_id);
26
+ if (args.report_id)
27
+ await this.ms.report(args.project_id, args.report_id);
28
+ if (args.node_id)
29
+ await this.ms.node(args.project_id, args.node_id);
30
+ }
31
+ async read(name, input, session) {
32
+ this.assertToolEnabled(name);
33
+ const ms = this.ms;
34
+ if (name === 'get_project_context') {
35
+ if (!input.project_id) {
36
+ const spaces = array(await ms.client.read('/workspace/list/userworkspace'), '工作空间');
37
+ if (!input.workspace_id)
38
+ return { user: session.user, workspaces: spaces.map(x => pick(x, ['id', 'name'])), warnings: ['选择 workspace_id 继续发现项目'] };
39
+ ensure(spaces.some(x => x.id === input.workspace_id), 'NOT_FOUND_OR_NOT_VISIBLE', '当前用户不可见指定工作空间');
40
+ const result = await ms.query('/project/list', { workspaceId: input.workspace_id }, input, session, x => x.workspaceId === input.workspace_id);
41
+ return { ...result, items: result.items.map((x) => pick(x, ['id', 'name', 'workspaceId'])), user: session.user };
42
+ }
43
+ const project = await ms.project(input.project_id), out = { user: session.user, project: pick(project, ['id', 'name', 'workspaceId']) };
44
+ if (input.sections.includes('nodes')) {
45
+ const clean = (nodes) => nodes.map(x => ({ ...pick(x, ['id', 'name', 'level', 'parentId']), children: clean(Array.isArray(x.children) ? x.children : []) }));
46
+ out.nodes = clean(await ms.nodes(project.id));
47
+ }
48
+ if (input.sections.includes('members'))
49
+ out.members = await ms.members(project.id);
50
+ if (input.sections.includes('stages'))
51
+ out.stages = await ms.client.read(`/test/plan/get/stage/option/${segment(project.id)}`);
52
+ if (input.sections.includes('issue_platform'))
53
+ out.issue_platform = project.platform ?? null;
54
+ if (input.sections.includes('bindings'))
55
+ out.mantis_binding = ms.config.workflow.project_bindings.find(x => x.metersphere_project_id === project.id) ?? null;
56
+ out.supported_actions_are_not_permissions = true;
57
+ return out;
58
+ }
59
+ await this.visible(input);
60
+ if (name === 'list_test_cases') {
61
+ const result = await ms.query('/test/case/list', { projectId: input.project_id, ...(input.node_ids ? { nodeIds: input.node_ids } : {}), ...(input.query ? { name: input.query } : {}) }, input, session, x => x.projectId === input.project_id && x.type === 'functional' && (!input.query || x.name?.includes(input.query)) && (!input.node_ids || input.node_ids.includes(x.nodeId)));
62
+ return { ...result, items: result.items.map((x) => pick(x, ['id', 'name', 'projectId', 'nodeId', 'priority', 'refId', 'versionId', 'reviewStatus'])) };
63
+ }
64
+ if (name === 'get_test_case_context') {
65
+ const isPlan = !!input.plan_case_id;
66
+ const state = isPlan ? await ms.planCase(input.project_id, input.plan_id, input.plan_case_id) : await ms.caseEditState(input.project_id, input.case_id);
67
+ const item = isPlan ? state : state.item, scope = isPlan ? 'case_execution' : 'case_definition';
68
+ const subject = isPlan ? input.plan_case_id : input.case_id, snapshot = isPlan ? executionSnapshot(item) : state;
69
+ return { definition: definitionView({ ...item, id: isPlan ? item.caseId : item.id }),
70
+ ...(isPlan ? { execution: pick(item, ['id', 'caseId', 'planId', 'status', 'executor', 'results', 'actualResult']) } : {}),
71
+ expected_revision: session.tokens.revision(scope, subject, snapshot), revision_scope: scope,
72
+ revision_comparison: input.previous_revision ? session.tokens.compare(input.previous_revision, scope, subject, snapshot) : 'not_requested',
73
+ ...(input.sections.includes('execution_records') ? { execution_records: await ms.records(isPlan ? item.caseId : item.id) } : {}) };
74
+ }
75
+ if (name === 'list_test_plans') {
76
+ const principal = input.executor === 'me' ? session.user.id : input.executor;
77
+ const result = await ms.query('/test/plan/list', { projectId: input.project_id, ...(input.query ? { name: input.query } : {}) }, input, session, async (x) => {
78
+ if (x.projectId !== input.project_id || input.query && !x.name?.includes(input.query) || input.statuses && !input.statuses.includes(x.status))
79
+ return false;
80
+ if (!principal)
81
+ return true;
82
+ // 部署的计划列表可能返回 principals=null,不能因此漏掉当前人的计划。
83
+ x.principals = await ms.principals(x.id);
84
+ return x.principals.some((p) => p.id === principal);
85
+ });
86
+ for (const item of result.items)
87
+ if (Array.isArray(item.principals))
88
+ item.principals = item.principals.map((x) => typeof x === 'string' ? x : pick(x, ['id', 'name']));
89
+ return { ...result, items: result.items.map((x) => pick(x, ['id', 'projectId', 'name', 'status', 'stage', 'principals', 'testPlanTestCaseCount', 'testPlanApiCaseCount', 'testPlanApiScenarioCount', 'testPlanLoadCaseCount'])),
90
+ executor_filter_scope: principal ? 'plan_principal_only' : null };
91
+ }
92
+ if (name === 'get_case_review_context') {
93
+ const review = await ms.review(input.project_id, input.review_id);
94
+ const result = await ms.query('/test/review/case/list', { projectId: input.project_id, reviewId: input.review_id }, input, session, async (x) => {
95
+ Object.assign(x, await ms.reviewCase(input.project_id, input.review_id, x));
96
+ return true;
97
+ });
98
+ const counts = { passed: 0, failed: 0, pending: 0 };
99
+ for (const c of result.items) {
100
+ if (c.reviewStatus === 'Pass')
101
+ counts.passed++;
102
+ else if (c.reviewStatus === 'UnPass')
103
+ counts.failed++;
104
+ else
105
+ counts.pending++;
106
+ }
107
+ return { review: pick(review, ['id', 'name', 'status', 'projectId']), reviewers: await ms.reviewers(input.review_id), ...result,
108
+ items: result.items.map((x) => pick(x, ['id', 'caseId', 'name', 'reviewStatus', 'refId', 'versionId'])), counts_in_page: counts,
109
+ all_passed: !input.cursor && result.source_exhausted && result.items.length > 0 && counts.passed === result.items.length };
110
+ }
111
+ if (name === 'get_test_plan_context') {
112
+ const inventory = await ms.inventory(input.project_id, input.plan_id);
113
+ const out = { plan: pick(inventory.plan, ['id', 'projectId', 'name', 'status', 'stage', 'description', 'plannedStartTime', 'plannedEndTime']),
114
+ functional_case_count: inventory.cases.length, other_case_counts: inventory.other_counts,
115
+ expected_revision: session.tokens.revision('plan_membership', input.plan_id, ms.membership(inventory)), revision_scope: 'plan_membership' };
116
+ if (input.sections.includes('cases')) {
117
+ const result = await ms.query('/test/plan/case/list', { projectId: input.project_id, planId: input.plan_id }, input, session, x => x.projectId === input.project_id && x.planId === input.plan_id);
118
+ out.cases = { ...result, items: result.items.map((x) => pick(x, ['id', 'caseId', 'planId', 'name', 'status', 'priority', 'executor', 'versionId', 'refId'])) };
119
+ }
120
+ if (input.sections.includes('reports'))
121
+ out.reports = (await ms.all('/test/plan/report/list', { projectId: input.project_id })).filter(x => x.testPlanId === input.plan_id).map(x => pick(x, ['id', 'name', 'status', 'createTime', 'creator']));
122
+ if (input.sections.includes('execution_summary')) {
123
+ out.execution_summary = await ms.executionSummary(inventory, input.run_id);
124
+ out.completion_revision = session.tokens.revision('plan_completion', input.plan_id, ms.completionSnapshot(inventory, input.run_id, out.execution_summary));
125
+ out.execution_summary.pending_operations = await this.ledger(session).pending(input.plan_id);
126
+ if (out.execution_summary.pending_operations.length)
127
+ out.execution_summary.completion_ready = false;
128
+ }
129
+ return out;
130
+ }
131
+ if (name === 'get_test_report') {
132
+ const report = await ms.report(input.project_id, input.report_id), out = ms.reportView(report);
133
+ out.supplement = null;
134
+ out.supplement_unavailable = true;
135
+ if (input.operation_id) {
136
+ const operation = await this.ledger(session).load(input.operation_id);
137
+ ensure(operation && operation.actor_id === session.user.id && operation.action === 'generate_test_report' && operation.args.project_id === input.project_id && operation.result?.report?.id === input.report_id, 'TARGET_MISMATCH', '操作记录不能与该报告关联');
138
+ out.supplement = operation.result.execution_summary;
139
+ out.supplement_unavailable = !out.supplement;
140
+ }
141
+ return out;
142
+ }
143
+ throw new Error('读取入口不支持该工具');
144
+ }
145
+ }
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ const id = z.string().trim().min(1).max(200).regex(/^[^\s/\\?#\u0000-\u001f]+$/).describe('从真实上下文获得的字符串 ID');
3
+ const text = z.string().trim().min(1).max(20000).describe('非空正文');
4
+ const name = z.string().trim().min(1).max(255).describe('对象名称,不超过 255 字符');
5
+ const iso = z.iso.datetime({ offset: true }).describe('带时区的 ISO8601 时间');
6
+ const page = { limit: z.number().int().min(1).max(100).optional().describe('本次最多返回条数;省略使用配置默认值'), cursor: text.optional().describe('同一查询返回的续读游标') };
7
+ const common = { operation_id: z.uuid().optional().describe('真实写入必填;重试与恢复保持原 UUID'),
8
+ dry_run: z.boolean().default(false).describe('只读预览,不验证写入权限'), resume: z.boolean().default(false).describe('恢复原操作,不得改变业务参数'),
9
+ expected_revision: text.optional().describe('上下文返回的并发前置标识') };
10
+ const project = { project_id: id.describe('MeterSphere 项目 ID') };
11
+ const plan = { ...project, plan_id: id.describe('所属测试计划 ID') };
12
+ const target = { ...plan, plan_case_id: id.describe('计划内用例关联 ID,与用例库 case_id 不同'), run_id: z.uuid().describe('同一执行轮次复用的 UUID'), attempt_id: z.uuid().describe('本次实际执行的 UUID;真正复测才使用新值') };
13
+ const steps = z.array(z.strictObject({ action: text.describe('操作步骤'), expected: text.describe('预期结果') })).min(1).max(100);
14
+ const status = z.enum(['passed', 'failed', 'blocked', 'skipped']);
15
+ export const executionSchema = z.strictObject({ status: status.describe('真实执行结论'), environment: text.describe('实际被测环境'), tested_version: text.optional().describe('实际被测构建或版本'), started_at: iso, finished_at: iso,
16
+ summary: text.describe('本次执行摘要'), observed: text.describe('实际观察'), reason: text.optional().describe('阻塞或跳过的真实原因'),
17
+ failure_disposition: z.strictObject({ kind: z.enum(['not_a_product_defect', 'deferred']), reason: text }).optional(),
18
+ step_results: z.array(z.strictObject({ step_index: z.number().int().positive(), status: z.enum(['passed', 'failed', 'blocked', 'skipped', 'not_run']), observed: text })).max(100).optional(),
19
+ evidence: z.array(z.strictObject({ kind: z.enum(['text', 'url']), value: text }).refine(x => x.kind !== 'url' || /^https:\/\//.test(x.value), '证据 URL 须使用 HTTPS')).min(1).max(20),
20
+ }).refine(x => Date.parse(x.finished_at) >= Date.parse(x.started_at), '结束时间不得早于开始时间')
21
+ .refine(x => !['blocked', 'skipped'].includes(x.status) || !!x.reason, '阻塞或跳过须填写原因');
22
+ const changes = z.strictObject({ name: name.optional(), node_id: id.optional(), priority: z.enum(['P0', 'P1', 'P2', 'P3']).optional(),
23
+ prerequisite: z.string().max(20000).optional(), steps: steps.optional(), step_description: text.optional(), expected_result: text.optional(),
24
+ tags: z.array(z.string().max(100)).max(50).optional(), remark: z.string().max(20000).optional() })
25
+ .refine(x => Object.keys(x).length > 0, '至少修改一个字段').refine(x => !x.steps || !x.step_description && !x.expected_result, 'STEP 与 TEXT 字段不能混用');
26
+ export const schemas = {
27
+ get_project_context: z.strictObject({ workspace_id: id.optional(), project_id: id.optional(), sections: z.array(z.enum(['nodes', 'members', 'stages', 'issue_platform', 'bindings'])).default([]), ...page }),
28
+ list_test_cases: z.strictObject({ ...project, node_ids: z.array(id).max(50).optional(), query: text.optional(), ...page }),
29
+ create_case_module: z.strictObject({ ...common, ...project,
30
+ name: z.string().trim().min(1).max(100).regex(/^[^/\\\u0000-\u001f]+$/).describe('功能用例模块名称,不超过 100 字符,不含路径分隔符'),
31
+ parent_id: id.nullable().optional().describe('父模块真实 ID;省略或 null 创建一级模块,传入 ID 创建子模块,最多 8 层') }),
32
+ get_test_case_context: z.strictObject({ ...project, case_id: id.optional(), plan_id: id.optional(), plan_case_id: id.optional(),
33
+ sections: z.array(z.enum(['execution_records'])).default([]), previous_revision: text.optional() })
34
+ .refine(x => !!x.case_id !== !!x.plan_case_id && (!!x.plan_case_id === !!x.plan_id), '传 case_id 或同时传 plan_id、plan_case_id'),
35
+ create_test_case: z.strictObject({ ...common, ...project, node_id: id, name, priority: z.enum(['P0', 'P1', 'P2', 'P3']).default('P3'), prerequisite: z.string().max(20000).default(''), steps,
36
+ tags: z.array(z.string().max(100)).max(50).default([]).describe('自定义标签;自动补充并去重 AI 标签,合并后最多 50 个'),
37
+ remark: z.string().max(20000).default('').describe('用户备注;不自动追加操作标记') }),
38
+ update_test_case: z.strictObject({ ...common, ...project, case_id: id, changes }),
39
+ create_case_review: z.strictObject({ ...common, ...project, name, case_ids: z.array(id).min(1).max(50), reviewer_ids: z.array(id).min(1).max(50), description: z.string().max(20000).default(''), ends_at: iso.optional() }),
40
+ get_case_review_context: z.strictObject({ ...project, review_id: id, ...page }),
41
+ list_test_plans: z.strictObject({ ...project, query: text.optional(), executor: id.optional(), statuses: z.array(z.enum(['Prepare', 'Underway', 'Completed', 'Finished', 'Archived'])).optional(), ...page }),
42
+ get_test_plan_context: z.strictObject({ ...plan, sections: z.array(z.enum(['cases', 'reports', 'execution_summary'])).default(['cases']), run_id: z.uuid().optional(), ...page })
43
+ .refine(x => !x.sections.includes('execution_summary') || !!x.run_id, '执行汇总须指定 run_id'),
44
+ create_test_plan: z.strictObject({ ...common, ...project, name, stage: text, principal_ids: z.array(id).min(1).max(50), starts_at: iso, ends_at: iso, description: z.string().max(20000).default('') })
45
+ .refine(x => Date.parse(x.ends_at) >= Date.parse(x.starts_at), '计划结束时间不得早于开始时间'),
46
+ add_cases_to_plan: z.strictObject({ ...common, ...plan, case_ids: z.array(id).min(1).max(50) }),
47
+ record_case_result: z.strictObject({ ...common, ...target, result: executionSchema }),
48
+ link_mantis_issue: z.strictObject({ ...common, ...target, mantis_project_id: z.number().int().positive(), issue_id: z.number().int().positive(), relation: z.enum(['found_in', 'verified_in']), reason: text }),
49
+ complete_test_plan: z.strictObject({ ...common, ...plan, run_id: z.uuid(), summary: text }),
50
+ generate_test_report: z.strictObject({ ...common, ...plan, run_id: z.uuid() }),
51
+ get_test_report: z.strictObject({ ...project, report_id: id, operation_id: z.uuid().optional() }),
52
+ get_workflow_operation: z.strictObject({ operation_id: z.uuid() }),
53
+ };
54
+ export const toolNames = Object.keys(schemas);
55
+ export const readTools = new Set(['get_project_context', 'list_test_cases', 'get_test_case_context', 'get_case_review_context', 'list_test_plans', 'get_test_plan_context', 'get_test_report', 'get_workflow_operation']);
56
+ export const resultSchema = z.object({ schema_version: z.literal(1), error: z.strictObject({ code: z.string(), message: z.string(), system_code: z.string().optional() }).optional() }).catchall(z.unknown());
57
+ const dataObject = z.record(z.string(), z.unknown());
58
+ export const writeResultSchema = z.object({
59
+ schema_version: z.literal(1), action: z.string(), operation_id: z.uuid().nullable(),
60
+ outcome: z.enum(['preview', 'succeeded', 'partial_success', 'unknown', 'conflict', 'rejected', 'in_progress']),
61
+ subject: dataObject, result: dataObject.nullable(), steps: z.array(z.object({ name: z.string(), state: z.enum(['not_started', 'in_flight', 'succeeded', 'rejected', 'unknown']), error: dataObject.optional() })),
62
+ verification: dataObject, recovery: dataObject, warnings: z.array(z.string()), error: resultSchema.shape.error,
63
+ });