@youcloud/mantis-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.
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/cache.js +51 -0
- package/dist/client.js +142 -0
- package/dist/config.js +83 -0
- package/dist/connection-config.js +42 -0
- package/dist/index.js +66 -0
- package/dist/prompts/index.js +32 -0
- package/dist/resources/index.js +15 -0
- package/dist/services/files.js +95 -0
- package/dist/services/issues.js +37 -0
- package/dist/services/notes.js +7 -0
- package/dist/services/projects.js +91 -0
- package/dist/tools/admin.js +8 -0
- package/dist/tools/workflow.js +62 -0
- package/dist/workflow/adapter.js +8 -0
- package/dist/workflow/bindings.js +146 -0
- package/dist/workflow/context.js +221 -0
- package/dist/workflow/contracts.js +126 -0
- package/dist/workflow/executor.js +550 -0
- package/dist/workflow/operations.js +123 -0
- package/dist/workflow/planner.js +209 -0
- package/dist/workflow/query.js +98 -0
- package/dist/workflow/tokens.js +72 -0
- package/docs/clients.md +142 -0
- package/docs/configuration.md +57 -0
- package/docs/cookbook.md +116 -0
- package/docs/workflow-config.example.json +16 -0
- package/package.json +66 -4
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rename, unlink, chmod } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { WorkflowError } from './contracts.js';
|
|
5
|
+
import { digest } from './tokens.js';
|
|
6
|
+
const processStart = `${process.pid}:${Date.now() - Math.round(process.uptime() * 1000)}:${randomUUID()}`;
|
|
7
|
+
const retention = 7 * 86400_000;
|
|
8
|
+
export class Operations {
|
|
9
|
+
dir;
|
|
10
|
+
constructor(root) { this.dir = join(root, 'operations'); }
|
|
11
|
+
async initialize() {
|
|
12
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
13
|
+
await chmod(this.dir, 0o700);
|
|
14
|
+
}
|
|
15
|
+
async load(id) {
|
|
16
|
+
if (!/^[a-f0-9-]{36}$/i.test(id))
|
|
17
|
+
throw new WorkflowError('INVALID_INPUT', '操作编号无效');
|
|
18
|
+
try {
|
|
19
|
+
const record = JSON.parse(await readFile(join(this.dir, `${id}.json`), 'utf8'));
|
|
20
|
+
if (record.operation_id !== id || !Array.isArray(record.steps) || !record.plan || !record.request_hash
|
|
21
|
+
|| record.resolved_hash !== digest(record.plan))
|
|
22
|
+
throw new Error('invalid');
|
|
23
|
+
return record;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (e.code === 'ENOENT')
|
|
27
|
+
return null;
|
|
28
|
+
throw new WorkflowError('RECOVERY_UNAVAILABLE', '操作账本不可读取或已损坏,禁止重新发送');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
expired(record) { return Date.now() - (record.completed_at ?? record.created_at) > retention; }
|
|
32
|
+
async save(record) {
|
|
33
|
+
await this.initialize();
|
|
34
|
+
record.updated_at = Date.now();
|
|
35
|
+
const temp = join(this.dir, `${record.operation_id}.${randomUUID()}.tmp`);
|
|
36
|
+
const handle = await open(temp, 'wx', 0o600);
|
|
37
|
+
try {
|
|
38
|
+
await handle.writeFile(JSON.stringify(record));
|
|
39
|
+
await handle.sync();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await handle.close();
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
await rename(temp, join(this.dir, `${record.operation_id}.json`));
|
|
46
|
+
const directory = await open(this.dir, 'r');
|
|
47
|
+
try {
|
|
48
|
+
await directory.sync();
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await directory.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
await unlink(temp).catch(() => { });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async lock(key) {
|
|
59
|
+
await this.initialize();
|
|
60
|
+
if (!/^[a-z0-9-]+$/i.test(key))
|
|
61
|
+
throw new WorkflowError('INVALID_INPUT', '锁对象无效');
|
|
62
|
+
const path = join(this.dir, `${key}.lock`);
|
|
63
|
+
const owner = { pid: process.pid, process_start: processStart, token: randomUUID() };
|
|
64
|
+
const claim = async () => {
|
|
65
|
+
try {
|
|
66
|
+
const handle = await open(path, 'wx', 0o600);
|
|
67
|
+
try {
|
|
68
|
+
await handle.writeFile(JSON.stringify(owner));
|
|
69
|
+
await handle.sync();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await handle.close();
|
|
73
|
+
}
|
|
74
|
+
return async () => {
|
|
75
|
+
const current = JSON.parse(await readFile(path, 'utf8').catch(() => '{}'));
|
|
76
|
+
if (current.token === owner.token)
|
|
77
|
+
await unlink(path);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (e.code === 'EEXIST')
|
|
82
|
+
return null;
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const release = await claim();
|
|
87
|
+
if (release)
|
|
88
|
+
return release;
|
|
89
|
+
// 用独立回收锁串行化崩溃恢复。仍存活或 PID 已复用时保守地不抢锁。
|
|
90
|
+
let reaper;
|
|
91
|
+
try {
|
|
92
|
+
reaper = await open(`${path}.reaper`, 'wx', 0o600);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
let old;
|
|
99
|
+
try {
|
|
100
|
+
old = JSON.parse(await readFile(path, 'utf8'));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isSafeInteger(old.pid) || old.pid <= 0)
|
|
106
|
+
return null;
|
|
107
|
+
try {
|
|
108
|
+
process.kill(old.pid, 0);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
if (e.code !== 'ESRCH')
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
await unlink(path);
|
|
116
|
+
return await claim();
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await reaper.close();
|
|
120
|
+
await unlink(`${path}.reaper`).catch(() => { });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { enumValue, numberConfig } from './bindings.js';
|
|
2
|
+
import { WorkflowError } from './contracts.js';
|
|
3
|
+
import { digest } from './tokens.js';
|
|
4
|
+
import { operationMarker } from '../services/notes.js';
|
|
5
|
+
import { readUpload } from '../services/files.js';
|
|
6
|
+
import { facts, visibleCollection } from '../services/issues.js';
|
|
7
|
+
const labels = { accept_issue: '受理缺陷', assign_issue: '指派缺陷', submit_fix: '登记修复及研发自测(待测试验收)',
|
|
8
|
+
close_issue: '测试验收通过并关闭', reopen_issue: '重开缺陷', reject_issue: '拒绝本次缺陷处置', postpone_issue: '延期处理' };
|
|
9
|
+
export class Planner {
|
|
10
|
+
context;
|
|
11
|
+
uploadDir;
|
|
12
|
+
constructor(context, uploadDir) {
|
|
13
|
+
this.context = context;
|
|
14
|
+
this.uploadDir = uploadDir;
|
|
15
|
+
}
|
|
16
|
+
async version(project, id) {
|
|
17
|
+
const version = (await this.context.projects.versions(project, true)).find(v => v.id === id);
|
|
18
|
+
if (!version)
|
|
19
|
+
throw new WorkflowError('PROJECT_MISMATCH', '版本不属于当前项目的可用版本');
|
|
20
|
+
return version.name;
|
|
21
|
+
}
|
|
22
|
+
async prepare(action, input, session) {
|
|
23
|
+
if (action.endsWith('_version'))
|
|
24
|
+
return { plan: await this.versionPlan(action, input, session) };
|
|
25
|
+
const c = this.context;
|
|
26
|
+
const state = action === 'report_issue' ? undefined : await c.state(input.issue_id);
|
|
27
|
+
const issue = state?.issue;
|
|
28
|
+
const projectId = issue?.project.id ?? input.project_id;
|
|
29
|
+
const project = await c.projects.get(projectId);
|
|
30
|
+
const rules = state?.rules ?? (action === 'report_issue' ? await c.projects.rules(projectId) : undefined);
|
|
31
|
+
const binding = c.bindingRevision(rules);
|
|
32
|
+
if (input.expected_revision && issue)
|
|
33
|
+
session.tokens.check(input.expected_revision, `issue:${issue.id}`, binding, issue);
|
|
34
|
+
const plan = { project_id: projectId, issue_id: issue?.id, kind: 'issue', method: 'PATCH', path: `issues/${issue?.id}`,
|
|
35
|
+
primary: 'update_issue', expected: {}, body: {}, before_hash: digest(issue ?? null), before_facts: issue ? facts(issue, session.baseUrl) : {},
|
|
36
|
+
revision: issue ? c.revision(session, issue, binding, state?.etag) : {}, etag: state?.etag, binding };
|
|
37
|
+
if (action === 'upload_file') {
|
|
38
|
+
const file = await readUpload(input.file_path, input.filename, input.expected_file_sha256, this.uploadDir);
|
|
39
|
+
const latest = (await c.issues.get(issue.id)).issue;
|
|
40
|
+
if (digest(latest) !== plan.before_hash)
|
|
41
|
+
throw new WorkflowError('REVISION_CONFLICT', '读取附件期间缺陷发生变化', true);
|
|
42
|
+
plan.method = 'POST';
|
|
43
|
+
plan.path += '/files';
|
|
44
|
+
plan.primary = 'upload_file';
|
|
45
|
+
plan.body = undefined;
|
|
46
|
+
plan.file = file.info;
|
|
47
|
+
plan.attachment_ids = visibleCollection(issue, 'attachments', state.emptyArraysOmitted)?.map((a) => a.id);
|
|
48
|
+
if (!Array.isArray(plan.attachment_ids))
|
|
49
|
+
throw new WorkflowError('SERVICE_UNAVAILABLE', '无法取得附件比较基线');
|
|
50
|
+
return { plan, bytes: file.bytes };
|
|
51
|
+
}
|
|
52
|
+
if (['delete_issue', 'delete_note', 'remove_relationship', 'remove_monitor', 'detach_tag'].includes(action)) {
|
|
53
|
+
plan.method = 'DELETE';
|
|
54
|
+
plan.primary = action;
|
|
55
|
+
plan.body = undefined;
|
|
56
|
+
const target = action === 'delete_note' ? ['notes', 'note_id'] : action === 'remove_relationship' ? ['relationships', 'relationship_id']
|
|
57
|
+
: action === 'remove_monitor' ? ['monitors', 'username'] : action === 'detach_tag' ? ['tags', 'tag_id'] : undefined;
|
|
58
|
+
if (target) {
|
|
59
|
+
const [section, key] = target;
|
|
60
|
+
const entry = issue[section]?.find((v) => key === 'username' ? v.name === input[key] : v.id === input[key]);
|
|
61
|
+
if (!entry)
|
|
62
|
+
throw new WorkflowError('NOT_FOUND_OR_NOT_VISIBLE', '删除对象不属于该缺陷或当前不可见');
|
|
63
|
+
plan.path += `/${section}/${encodeURIComponent(String(input[key]))}`;
|
|
64
|
+
plan.expected = { removed_section: section, removed_id: input[key] };
|
|
65
|
+
if (action === 'remove_monitor') {
|
|
66
|
+
// 2.26.2 没有 DELETE monitors 路由;mc_issue_update 支持显式 monitors 集合。
|
|
67
|
+
// 只移除已核对的目标,保留其余 ID,仍受完整快照前置检查保护。
|
|
68
|
+
plan.method = 'PATCH';
|
|
69
|
+
plan.path = `issues/${issue.id}`;
|
|
70
|
+
plan.body = { monitors: issue.monitors.filter((v) => v.id !== entry.id).map((v) => ({ id: v.id })) };
|
|
71
|
+
plan.expected.remaining_monitor_ids = plan.body.monitors.map((v) => v.id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { plan };
|
|
75
|
+
}
|
|
76
|
+
if (!rules)
|
|
77
|
+
throw new WorkflowError('WORKFLOW_MAPPING_UNCONFIGURED', '该动作依赖的项目配置不可读取');
|
|
78
|
+
if (action === 'report_issue') {
|
|
79
|
+
if (!project.categories?.some((v) => v.id === input.category_id))
|
|
80
|
+
throw new WorkflowError('PROJECT_MISMATCH', '分类不属于当前项目');
|
|
81
|
+
plan.method = 'POST';
|
|
82
|
+
plan.path = 'issues';
|
|
83
|
+
plan.primary = 'create_issue';
|
|
84
|
+
plan.body = { project: { id: projectId }, category: { id: input.category_id }, summary: input.summary,
|
|
85
|
+
description: [input.description, `【预期行为】\n${input.expected_behavior}`, `【实际行为】\n${input.actual_behavior}`,
|
|
86
|
+
...(input.environment ? [`【环境】\n${input.environment}`] : [])].join('\n\n'),
|
|
87
|
+
additional_information: operationMarker(input.operation_id) };
|
|
88
|
+
if (input.steps_to_reproduce)
|
|
89
|
+
plan.body.steps_to_reproduce = input.steps_to_reproduce;
|
|
90
|
+
if (input.version_id)
|
|
91
|
+
plan.body.version = { name: await this.version(projectId, input.version_id) };
|
|
92
|
+
}
|
|
93
|
+
else if (action === 'add_note') {
|
|
94
|
+
plan.method = 'POST';
|
|
95
|
+
plan.path += '/notes';
|
|
96
|
+
plan.primary = 'append_note';
|
|
97
|
+
plan.body = { text: `${input.text}\n\n${operationMarker(input.operation_id)}`, view_state: { name: this.noteVisibility(projectId, rules, input) } };
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const target = action === 'assign_issue' ? {} : c.bindings.target(projectId, action, rules);
|
|
101
|
+
if (action === 'accept_issue') {
|
|
102
|
+
if (issue.status?.id >= numberConfig(rules, 'bug_resolved_status_threshold'))
|
|
103
|
+
throw new WorkflowError('INVALID_INPUT', '已解决缺陷须按实际原因重开后再受理');
|
|
104
|
+
plan.body.status = { id: input.assignee ? target.assigned_status_id : issue.status?.id === target.assigned_status_id ? target.assigned_status_id : target.status_id };
|
|
105
|
+
}
|
|
106
|
+
else if (action !== 'assign_issue') {
|
|
107
|
+
plan.body.status = { id: target.status_id };
|
|
108
|
+
if (target.resolution_id)
|
|
109
|
+
plan.body.resolution = { id: target.resolution_id };
|
|
110
|
+
}
|
|
111
|
+
if (action === 'reject_issue')
|
|
112
|
+
plan.body.resolution = { id: target.resolutions_by_reason[input.reason_code] };
|
|
113
|
+
if (action === 'close_issue') {
|
|
114
|
+
const fixed = c.bindings.target(projectId, 'submit_fix', rules);
|
|
115
|
+
if (issue.resolution?.id !== fixed.resolution_id)
|
|
116
|
+
throw new WorkflowError('INVALID_INPUT', '验收关闭仅用于修复结果,不得关闭拒绝或延期问题');
|
|
117
|
+
}
|
|
118
|
+
if (action === 'reopen_issue' && input.reason_code === 'resume_postponed') {
|
|
119
|
+
const postponed = c.bindings.target(projectId, 'postpone_issue', rules);
|
|
120
|
+
if (issue.status?.id !== postponed.status_id || issue.resolution?.id !== postponed.resolution_id)
|
|
121
|
+
throw new WorkflowError('INVALID_INPUT', '只有延期缺陷可使用恢复延期分支');
|
|
122
|
+
}
|
|
123
|
+
if (input.assignee) {
|
|
124
|
+
const userId = input.assignee === 'me' ? session.user.id : input.assignee;
|
|
125
|
+
if (!(await c.projects.members(projectId)).some(u => u.id === userId))
|
|
126
|
+
throw new WorkflowError('PROJECT_MISMATCH', '负责人不是当前可见项目成员');
|
|
127
|
+
plan.body.handler = { id: userId };
|
|
128
|
+
}
|
|
129
|
+
if (input.fixed_in_version_id)
|
|
130
|
+
plan.body.fixed_in_version = { name: await this.version(projectId, input.fixed_in_version_id) };
|
|
131
|
+
if (input.target_version_id)
|
|
132
|
+
plan.body.target_version = { name: await this.version(projectId, input.target_version_id) };
|
|
133
|
+
const verificationVersion = input.verification?.version_id ?? input.verification_context?.version_id;
|
|
134
|
+
if (verificationVersion)
|
|
135
|
+
await this.version(projectId, verificationVersion);
|
|
136
|
+
const sections = [`【${labels[action]}】`];
|
|
137
|
+
if (input.reason_code)
|
|
138
|
+
sections.push(`处置类型:${input.reason_code}`);
|
|
139
|
+
for (const [key, label] of Object.entries({ reason: '原因', fix_summary: '修复说明', root_cause: '根因', actual_behavior: '实际现象', review_on: '计划复查日期(不自动提醒)' })) {
|
|
140
|
+
if (input[key])
|
|
141
|
+
sections.push(`${label}:${input[key]}`);
|
|
142
|
+
}
|
|
143
|
+
if (input.self_test)
|
|
144
|
+
sections.push(`研发自测(调用者提供,MCP 未执行测试):${input.self_test.summary}\n结果:passed`, ...this.evidence(input.self_test.evidence));
|
|
145
|
+
if (input.verification)
|
|
146
|
+
sections.push(`测试验收:${input.verification.summary}\n结果:passed`, `验收环境/版本:${JSON.stringify(input.verification.environment ?? input.verification.version_id)}`, ...this.evidence(input.verification.evidence));
|
|
147
|
+
if (input.verification_context)
|
|
148
|
+
sections.push(`验证环境/版本:${JSON.stringify(input.verification_context)}`);
|
|
149
|
+
if (input.code_refs)
|
|
150
|
+
sections.push(`代码引用:${JSON.stringify(input.code_refs)}`);
|
|
151
|
+
sections.push(...this.evidence(input.evidence), operationMarker(input.operation_id));
|
|
152
|
+
plan.note = { text: sections.join('\n\n'), view_state: { name: this.noteVisibility(projectId, rules, input) } };
|
|
153
|
+
}
|
|
154
|
+
for (const key of ['priority', 'severity', 'reproducibility']) {
|
|
155
|
+
if (input[`${key}_id`]) {
|
|
156
|
+
enumValue(rules, key, input[`${key}_id`]);
|
|
157
|
+
plan.body[key] = { id: input[`${key}_id`] };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const registered = c.bindings.forProject(projectId).custom_fields_by_action;
|
|
161
|
+
const allowed = registered[action] ?? [];
|
|
162
|
+
const provided = input.custom_fields ?? [];
|
|
163
|
+
if (new Set(provided.map((f) => f.id)).size !== provided.length || provided.some((f) => !allowed.some(a => a.id === f.id))
|
|
164
|
+
|| allowed.some(f => f.required && !provided.some((p) => p.id === f.id)))
|
|
165
|
+
throw new WorkflowError('INVALID_INPUT', '自定义字段未登记、重复或缺少必填值');
|
|
166
|
+
if (provided.length)
|
|
167
|
+
plan.body.custom_fields = provided.map((f) => ({ field: { id: f.id }, value: f.value }));
|
|
168
|
+
if (action !== 'add_note')
|
|
169
|
+
plan.expected = { ...plan.body };
|
|
170
|
+
return { plan };
|
|
171
|
+
}
|
|
172
|
+
evidence(e) { return (e ?? []).map(v => `证据(${v.kind}):${v.value}`); }
|
|
173
|
+
noteVisibility(projectId, rules, input) {
|
|
174
|
+
if (input.note_view_state)
|
|
175
|
+
return input.note_view_state;
|
|
176
|
+
const policy = this.context.bindings.forProject(projectId).note_visibility;
|
|
177
|
+
if (policy !== 'instance_default')
|
|
178
|
+
return policy;
|
|
179
|
+
const value = numberConfig(rules, 'default_bugnote_view_status');
|
|
180
|
+
const name = enumValue(rules, 'view_state', value).name;
|
|
181
|
+
if (!['public', 'private'].includes(name))
|
|
182
|
+
throw new WorkflowError('WORKFLOW_MAPPING_INVALID', '项目默认备注可见性无法可靠解析');
|
|
183
|
+
return name;
|
|
184
|
+
}
|
|
185
|
+
async versionPlan(action, input, session) {
|
|
186
|
+
const c = this.context;
|
|
187
|
+
await c.projects.get(input.project_id);
|
|
188
|
+
const versions = await c.projects.versions(input.project_id);
|
|
189
|
+
if (action !== 'create_version' && !versions.some(v => v.id === input.version_id))
|
|
190
|
+
throw new WorkflowError('PROJECT_MISMATCH', '版本不属于该项目,继承版本请使用所属项目编号');
|
|
191
|
+
if (input.expected_revision)
|
|
192
|
+
session.tokens.check(input.expected_revision, `versions:${input.project_id}`, c.bindings.revision, versions);
|
|
193
|
+
const body = {};
|
|
194
|
+
for (const key of ['name', 'description', 'released', 'obsolete', 'timestamp'])
|
|
195
|
+
if (input[key] !== undefined)
|
|
196
|
+
body[key] = input[key];
|
|
197
|
+
if (action === 'release_version') {
|
|
198
|
+
body.released = true;
|
|
199
|
+
body.timestamp = input.timestamp ?? new Date().toISOString();
|
|
200
|
+
}
|
|
201
|
+
return { kind: 'version', project_id: input.project_id, version_id: input.version_id,
|
|
202
|
+
path: `projects/${input.project_id}/versions${action === 'create_version' ? '' : `/${input.version_id}`}`,
|
|
203
|
+
method: action === 'create_version' ? 'POST' : action === 'delete_version' ? 'DELETE' : 'PATCH', primary: action,
|
|
204
|
+
body: action === 'delete_version' ? undefined : body, expected: body, before_hash: digest(versions), before_facts: {},
|
|
205
|
+
unchanged_versions_hash: digest(versions.filter(v => v.id !== input.version_id)),
|
|
206
|
+
revision: session.tokens.revision(`versions:${input.project_id}`, c.bindings.revision, versions), binding: c.bindings.revision,
|
|
207
|
+
next_version: input.next_version };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { RestError } from '../client.js';
|
|
2
|
+
import { WorkflowError } from './contracts.js';
|
|
3
|
+
import { digest } from './tokens.js';
|
|
4
|
+
import { numberConfig } from './bindings.js';
|
|
5
|
+
import { facts } from '../services/issues.js';
|
|
6
|
+
export const conservativeCapabilities = { nativeAssigned: false, verifiedUpdatedSort: false, conditionalRequests: false };
|
|
7
|
+
export async function queryIssues(context, input, session, capabilities) {
|
|
8
|
+
await context.projects.get(input.project_id);
|
|
9
|
+
const rules = await context.projects.rules(input.project_id);
|
|
10
|
+
const binding = context.bindingRevision(rules);
|
|
11
|
+
const { cursor: _cursor, ...query } = input;
|
|
12
|
+
const queryHash = digest(query);
|
|
13
|
+
const c = input.cursor ? session.tokens.open(input.cursor) : undefined;
|
|
14
|
+
if (c && (c.kind !== 'query_cursor' || c.subject !== `project:${input.project_id}` || c.query !== queryHash || c.binding !== binding)) {
|
|
15
|
+
throw new WorkflowError('STALE_CURSOR', '列表游标与查询或项目配置不符');
|
|
16
|
+
}
|
|
17
|
+
const person = (value) => value === 'me' ? session.user.id : value;
|
|
18
|
+
const assignee = person(input.assignee), reporter = person(input.reporter);
|
|
19
|
+
const threshold = input.queue === 'unresolved' ? numberConfig(rules, 'bug_resolved_status_threshold') : undefined;
|
|
20
|
+
const target = input.queue === 'awaiting_verification' ? context.bindings.target(input.project_id, 'submit_fix', rules)
|
|
21
|
+
: input.queue === 'postponed' ? context.bindings.target(input.project_id, 'postpone_issue', rules) : undefined;
|
|
22
|
+
const wantsNative = input.assignee === 'me' && input.queue === 'unresolved' && !input.filter_id;
|
|
23
|
+
let native = c ? c.native === true : wantsNative && capabilities.nativeAssigned;
|
|
24
|
+
let page = Number(c?.page ?? 1), offset = Number(c?.offset ?? 0);
|
|
25
|
+
const warnings = ['DYNAMIC_SOURCE_NOT_SNAPSHOT'];
|
|
26
|
+
if (wantsNative && !native)
|
|
27
|
+
warnings.push('NATIVE_FILTER_UNAVAILABLE');
|
|
28
|
+
let scanned = 0, matched = 0, exhausted = false;
|
|
29
|
+
const items = [], seen = new Set();
|
|
30
|
+
while (scanned < 500 && items.length < input.limit) {
|
|
31
|
+
let source;
|
|
32
|
+
try {
|
|
33
|
+
source = await context.issues.page(input.project_id, page, input.filter_id ?? (native ? 'assigned' : undefined));
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (native && !c && scanned === 0 && e instanceof RestError && [400, 404, 422].includes(e.status ?? 0)) {
|
|
37
|
+
native = false;
|
|
38
|
+
page = 1;
|
|
39
|
+
offset = 0;
|
|
40
|
+
warnings.push('NATIVE_FILTER_UNAVAILABLE');
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!scanned)
|
|
44
|
+
throw e;
|
|
45
|
+
warnings.push('SOURCE_READ_FAILED');
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (native && source.some(i => i.project?.id !== input.project_id || i.handler?.id !== session.user.id || !(i.status?.id < threshold))) {
|
|
49
|
+
if (c || scanned)
|
|
50
|
+
throw new WorkflowError('NATIVE_FILTER_UNAVAILABLE', '原生筛选返回不符合契约,须从首页重新查询');
|
|
51
|
+
native = false;
|
|
52
|
+
warnings.push('NATIVE_FILTER_UNAVAILABLE');
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!source.length) {
|
|
56
|
+
exhausted = true;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
for (; offset < source.length && scanned < 500 && items.length < input.limit; offset++) {
|
|
60
|
+
const issue = source[offset];
|
|
61
|
+
scanned++;
|
|
62
|
+
if (!Number.isSafeInteger(issue.id) || !Number.isSafeInteger(issue.project?.id) || !Number.isSafeInteger(issue.status?.id)
|
|
63
|
+
|| !Number.isSafeInteger(issue.resolution?.id))
|
|
64
|
+
throw new WorkflowError('SERVICE_UNAVAILABLE', '源列表缺少必需过滤字段,不能报告完整查询');
|
|
65
|
+
if (issue.project.id !== input.project_id || assignee && issue.handler?.id !== assignee || reporter && issue.reporter?.id !== reporter)
|
|
66
|
+
continue;
|
|
67
|
+
if (threshold !== undefined && issue.status.id >= threshold)
|
|
68
|
+
continue;
|
|
69
|
+
if (target && (issue.status.id !== target.status_id || issue.resolution.id !== target.resolution_id))
|
|
70
|
+
continue;
|
|
71
|
+
const date = Date.parse(issue.updated_at);
|
|
72
|
+
if ((input.updated_after || input.updated_before) && !Number.isFinite(date))
|
|
73
|
+
throw new WorkflowError('SERVICE_UNAVAILABLE', '源列表缺少有效更新时间');
|
|
74
|
+
if (input.updated_after && date < Date.parse(input.updated_after) || input.updated_before && date > Date.parse(input.updated_before))
|
|
75
|
+
continue;
|
|
76
|
+
matched++;
|
|
77
|
+
if (!seen.has(issue.id)) {
|
|
78
|
+
items.push(facts(issue, session.baseUrl));
|
|
79
|
+
seen.add(issue.id);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (offset >= source.length) {
|
|
83
|
+
if (source.length < 100) {
|
|
84
|
+
exhausted = true;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
page++;
|
|
88
|
+
offset = 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { schema_version: 1, items, returned_count: items.length, scanned_count: scanned, matched_in_scan_count: matched,
|
|
92
|
+
scan_limit_reached: scanned >= 500 && !exhausted, source_exhausted: exhausted,
|
|
93
|
+
next_cursor: exhausted ? null : session.tokens.seal({ kind: 'query_cursor', subject: `project:${input.project_id}`, binding,
|
|
94
|
+
query: queryHash, page, offset, native }),
|
|
95
|
+
filter_execution: { server: input.filter_id ? 'saved_filter' : native ? 'assigned' : 'project', client: 'intersection', native_assigned_verified: native },
|
|
96
|
+
sort_execution: native && capabilities.verifiedUpdatedSort ? 'verified_updated_desc' : 'source_order_unverified',
|
|
97
|
+
warnings, total_count: null, snapshot_consistent: false };
|
|
98
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
2
|
+
import { WorkflowError } from './contracts.js';
|
|
3
|
+
export function canonical(value) {
|
|
4
|
+
if (Array.isArray(value))
|
|
5
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
6
|
+
if (value && typeof value === 'object')
|
|
7
|
+
return `{${Object.entries(value).filter(([, v]) => v !== undefined)
|
|
8
|
+
.sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`;
|
|
9
|
+
return JSON.stringify(value) ?? 'null';
|
|
10
|
+
}
|
|
11
|
+
export const digest = (value) => createHash('sha256').update(canonical(value)).digest('hex');
|
|
12
|
+
export const revisionLifetime = 30 * 60 * 1000;
|
|
13
|
+
/** 加密而非裸 JSON 游标,防止泄露快照和服务端 ETag;每次使用前必须重新认证。 */
|
|
14
|
+
export class Tokens {
|
|
15
|
+
key;
|
|
16
|
+
principal;
|
|
17
|
+
constructor(key, principal) {
|
|
18
|
+
this.key = key;
|
|
19
|
+
this.principal = principal;
|
|
20
|
+
}
|
|
21
|
+
seal(value) {
|
|
22
|
+
const nonce = randomBytes(12);
|
|
23
|
+
const cipher = createCipheriv('aes-256-gcm', this.key, nonce);
|
|
24
|
+
const data = Buffer.concat([cipher.update(JSON.stringify({ ...value, principal: this.principal, expires: value.expires ?? Date.now() + revisionLifetime })), cipher.final()]);
|
|
25
|
+
return Buffer.concat([nonce, cipher.getAuthTag(), data]).toString('base64url');
|
|
26
|
+
}
|
|
27
|
+
open(raw, allowExpired = false) {
|
|
28
|
+
try {
|
|
29
|
+
if (raw.length > 16000)
|
|
30
|
+
throw new Error();
|
|
31
|
+
const data = Buffer.from(raw, 'base64url');
|
|
32
|
+
const decipher = createDecipheriv('aes-256-gcm', this.key, data.subarray(0, 12));
|
|
33
|
+
decipher.setAuthTag(data.subarray(12, 28));
|
|
34
|
+
const value = JSON.parse(Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]).toString());
|
|
35
|
+
if (value.principal !== this.principal || !Number.isFinite(value.expires) || (!allowExpired && value.expires <= Date.now()))
|
|
36
|
+
throw new Error();
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new WorkflowError('STALE_CURSOR', '标识无效、已过期或不属于当前主体');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
revision(subject, binding, snapshot, etag) {
|
|
44
|
+
const expires = Date.now() + revisionLifetime;
|
|
45
|
+
return { expected_revision: this.seal({ kind: 'revision', subject, binding, snapshot: digest(snapshot), etag: etag ?? null, expires }),
|
|
46
|
+
revision_expires_at: new Date(expires).toISOString() };
|
|
47
|
+
}
|
|
48
|
+
check(raw, subject, binding, snapshot) {
|
|
49
|
+
try {
|
|
50
|
+
const t = this.open(raw);
|
|
51
|
+
if (t.kind !== 'revision' || t.subject !== subject || t.binding !== binding || t.snapshot !== digest(snapshot))
|
|
52
|
+
throw new Error();
|
|
53
|
+
return t;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new WorkflowError('REVISION_CONFLICT', '前置标识已过期、绑定变更或对象事实改变,请重新读取 context', true);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
compare(raw, subject, binding, snapshot) {
|
|
60
|
+
if (!raw)
|
|
61
|
+
return 'not_requested';
|
|
62
|
+
try {
|
|
63
|
+
const t = this.open(raw, true);
|
|
64
|
+
if (t.kind !== 'revision' || t.subject !== subject || t.binding !== binding)
|
|
65
|
+
return 'unavailable';
|
|
66
|
+
return t.snapshot === digest(snapshot) ? 'unchanged' : 'changed';
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return 'unavailable';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
package/docs/clients.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# MCP 客户端接入
|
|
2
|
+
|
|
3
|
+
[返回 README](../README.md) · [服务配置](configuration.md) · [调用手册](cookbook.md)
|
|
4
|
+
|
|
5
|
+
以下示例均启动本地 stdio 服务。使用变量引用的示例,需先在**启动客户端的环境**中设置并导出 `MANTIS_BASE_URL`、`MANTIS_API_KEY`,配置中的引用保持原样,不替换成真实 Key。Trae 示例通过 Node.js 加载本机私有环境文件,见对应小节。仅在编辑器的集成终端中设置变量,通常不能更新已经运行的编辑器进程;具体检查方法见[连接排障](#连接排障)。
|
|
6
|
+
|
|
7
|
+
已有配置文件时,只合并 `mantis` 条目,保留其他服务。各客户端的顶层字段和变量引用语法不同,请使用对应示例:
|
|
8
|
+
|
|
9
|
+
| 客户端 | 配置入口 | 凭据传入方式 |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| [Codex](#codex) | `~/.codex/config.toml` | `env_vars` 转发变量名 |
|
|
12
|
+
| [Claude Code](#claude-code) | 项目根目录 `.mcp.json` | `env` 中使用 `${变量名}` |
|
|
13
|
+
| [Cursor](#cursor) | `~/.cursor/mcp.json` 或项目 `.cursor/mcp.json` | `env` 中使用 `${env:变量名}` |
|
|
14
|
+
| [Trae](#trae) | 设置 > MCP > 手动添加,或项目 `.trae/mcp.json` | Node.js `--env-file` 加载本机私有环境文件 |
|
|
15
|
+
|
|
16
|
+
标准 Mantis 部署无需 `MANTIS_WORKFLOW_CONFIG`;需要自定义映射时,再按对应客户端的语法传入该变量,并使用配置文件的绝对路径。对于 `command` 为 `npx` 的示例,已全局安装本包时可以改为 `mantis-mcp` 并移除 `args`;Trae 示例保留 `node` 及其环境文件参数。
|
|
17
|
+
|
|
18
|
+
客户端运行环境须能找到 Node.js/npm;使用 nvm 或桌面启动器时,确认其 `PATH`,必要时使用可执行文件的绝对路径。SSH、WSL、容器等远程会话中,依赖、环境变量、配置路径及上传文件都应位于**实际运行 MCP 子进程的环境**。
|
|
19
|
+
|
|
20
|
+
## Codex
|
|
21
|
+
|
|
22
|
+
在 `~/.codex/config.toml` 中添加以下配置,适用于使用该配置的 Codex CLI、桌面客户端和 IDE 扩展。`env_vars` 填变量名称,实际值来自启动 Codex 的环境。参见 [OpenAI 官方 MCP 文档](https://learn.chatgpt.com/docs/extend/mcp#configure-with-configtoml)。
|
|
23
|
+
|
|
24
|
+
```toml
|
|
25
|
+
[mcp_servers.mantis]
|
|
26
|
+
command = "npx"
|
|
27
|
+
args = ["-y", "@youcloud/mantis-mcp@1.0.0"]
|
|
28
|
+
env_vars = ["MANTIS_BASE_URL", "MANTIS_API_KEY"]
|
|
29
|
+
startup_timeout_sec = 60
|
|
30
|
+
tool_timeout_sec = 180
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
使用自定义工作流文件时,将 `MANTIS_WORKFLOW_CONFIG` 加入 `env_vars`。重启 Codex 后,在会话中通过 `/mcp` 查看 `mantis` 的工具。
|
|
34
|
+
|
|
35
|
+
## Claude Code
|
|
36
|
+
|
|
37
|
+
在项目根目录的 `.mcp.json` 中添加以下配置。Claude Code 在加载时展开 `${变量名}`,因此配置文件可以保留变量引用。参见 [Claude Code 官方 MCP 文档](https://code.claude.com/docs/en/mcp#environment-variable-expansion-in-mcpjson)。
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"mcpServers": {
|
|
42
|
+
"mantis": {
|
|
43
|
+
"type": "stdio",
|
|
44
|
+
"command": "npx",
|
|
45
|
+
"args": ["-y", "@youcloud/mantis-mcp@1.0.0"],
|
|
46
|
+
"env": {
|
|
47
|
+
"MANTIS_BASE_URL": "${MANTIS_BASE_URL}",
|
|
48
|
+
"MANTIS_API_KEY": "${MANTIS_API_KEY}"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
从该项目目录启动 `claude`,首次使用按提示批准项目 MCP 服务,然后用 `/mcp` 查看连接状态。也可运行 `claude mcp list` 检查配置;如有缺失变量警告,先补齐启动环境再重启。
|
|
56
|
+
|
|
57
|
+
## Cursor
|
|
58
|
+
|
|
59
|
+
跨项目使用时编辑 `~/.cursor/mcp.json`,仅当前项目使用时编辑 `.cursor/mcp.json`。Cursor 使用 `${env:变量名}` 展开环境变量。参见 [Cursor 官方 MCP 文档](https://cursor.com/docs/mcp#config-interpolation)。
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"mantis": {
|
|
65
|
+
"type": "stdio",
|
|
66
|
+
"command": "npx",
|
|
67
|
+
"args": ["-y", "@youcloud/mantis-mcp@1.0.0"],
|
|
68
|
+
"env": {
|
|
69
|
+
"MANTIS_BASE_URL": "${env:MANTIS_BASE_URL}",
|
|
70
|
+
"MANTIS_API_KEY": "${env:MANTIS_API_KEY}"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
重启 Cursor 后,在设置中的 MCP 服务列表确认 `mantis` 已启用,再在 Agent 会话中使用其工具。若通过桌面图标启动,确认 Cursor 进程能够取得上述环境变量。
|
|
78
|
+
|
|
79
|
+
## Trae
|
|
80
|
+
|
|
81
|
+
在 Trae IDE 的 **设置 > MCP > 添加 > 手动添加** 中配置全局服务;也可通过 **原始配置(JSON)** 打开配置文件。若界面提供运行环境选项,选择**本地**。仅当前项目使用时,先在设置中启用项目级 MCP,再编辑项目根目录的 `.trae/mcp.json`。参见 [Trae 官方 MCP 文档](https://docs.trae.cn/ide_add-mcp-servers)。
|
|
82
|
+
|
|
83
|
+
Trae 官方文档仅列出 `${workspaceFolder}` 变量替换;本例用 Node.js 的 `--env-file` 读取凭据,MCP JSON 只保存本机路径。
|
|
84
|
+
|
|
85
|
+
先按 [README 安装说明](../README.md#安装)全局安装本包,运行 `npm root --global` 获取全局模块目录;在该目录后追加 `/@youcloud/mantis-mcp/dist/index.js`,即为下方所需的服务入口路径。然后在项目目录之外创建仅本人可读的 `mantis.env` 文件,在本机填写实际地址和个人 Key:
|
|
86
|
+
|
|
87
|
+
```dotenv
|
|
88
|
+
MANTIS_BASE_URL=https://mantis.example.org
|
|
89
|
+
MANTIS_API_KEY="替换为你的个人 API Key"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
在 Trae 中添加以下 JSON,将 `args` 中的两个路径替换为本机的**完整绝对路径**,不要使用 `~`;Windows 路径可写为 `C:/Users/你的用户名/...`:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"mcpServers": {
|
|
97
|
+
"mantis": {
|
|
98
|
+
"command": "node",
|
|
99
|
+
"args": [
|
|
100
|
+
"--env-file=/绝对路径/mantis.env",
|
|
101
|
+
"/npm全局模块目录/@youcloud/mantis-mcp/dist/index.js"
|
|
102
|
+
],
|
|
103
|
+
"env": {
|
|
104
|
+
"START_MCP_TIMEOUT_MS": "60000",
|
|
105
|
+
"RUN_MCP_TIMEOUT_MS": "180000"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
需要自定义映射时,将 `MANTIS_WORKFLOW_CONFIG` 及文件绝对路径加入 `mantis.env`。Node.js 会优先使用进程环境中已有的同名变量,修改凭据后需核对其来源并重启 MCP 服务;参见 [Node.js 环境文件说明](https://nodejs.org/download/release/v22.12.0/docs/api/cli.html#--env-fileconfig)。
|
|
113
|
+
|
|
114
|
+
保存后在 MCP 列表确认 `mantis` 已启用,并在使用的智能体中启用其工具;自定义智能体需在 **工具 > MCP** 中勾选 `mantis`,参见[官方智能体配置说明](https://docs.trae.cn/ide_use-mcp-servers-in-agents)。最后按下方[验证连接](#验证连接)执行一次只读调用。
|
|
115
|
+
|
|
116
|
+
## 验证连接
|
|
117
|
+
|
|
118
|
+
在所选客户端中发送:
|
|
119
|
+
|
|
120
|
+
> 请调用 mantis 的 get_project_context,参数为 {},列出当前账号可见的项目。
|
|
121
|
+
|
|
122
|
+
返回项目列表(允许为空)才表明本次只读访问成功。仅显示“已连接”或列出工具只证明本地服务已启动。
|
|
123
|
+
|
|
124
|
+
## 连接排障
|
|
125
|
+
|
|
126
|
+
| 错误码 | 含义与处理 |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `CONFIG_MISSING` | 缺少连接变量,消息只列出缺失名称;HTTP 请求尚未发送 |
|
|
129
|
+
| `CONFIG_INVALID` | 地址或 Key 格式无效;地址须为完整 HTTP(S) 基地址,可含部署子路径或 `/api/rest/` 后缀,不含用户凭据、查询参数或片段;HTTP 请求尚未发送 |
|
|
130
|
+
| `REQUEST_PREPARATION_FAILED` | 本地构造请求失败,HTTP 请求尚未发送;核对输入或提交脱敏错误报告 |
|
|
131
|
+
| `MANTIS_FORBIDDEN` | 服务端返回 HTTP 401/403,核对个人 Key 和账号权限 |
|
|
132
|
+
| `SERVICE_UNAVAILABLE` | 读取未取得可靠结果;网络错误消息按可识别的原因提示 DNS、超时、TLS 或连接类别 |
|
|
133
|
+
| `WRITE_OUTCOME_UNKNOWN` | 写入可能已发生,先按原 `operation_id` 核查,不能仅凭网络错误直接重试 |
|
|
134
|
+
|
|
135
|
+
Codex 的 `env_vars` 只允许并转发启动环境中已有的变量,不会加载 `.zshrc`。变量在交互终端中存在,不代表已启动的桌面客户端或其 MCP 子进程也能取得。确认使用 `export` 导出变量,并检查**启动客户端的环境**;更新后重启客户端及 MCP 子进程。具体转发语义见 [OpenAI 官方说明](https://learn.chatgpt.com/docs/extend/mcp#stdio-servers)。
|
|
136
|
+
|
|
137
|
+
可在用于启动客户端的终端中执行以下检查,只输出是否存在,不打印凭据;这不能证明另一个已运行进程的环境相同。
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
node -e 'for (const k of ["MANTIS_BASE_URL", "MANTIS_API_KEY"]) console.log(k + ": " + (process.env[k]?.trim() ? "已设置" : "缺失"))'
|
|
141
|
+
```
|
|
142
|
+
|