@autobest-ui/agent 1.0.10 → 1.0.12
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/README.md +20 -6
- package/bin/sync-assets.test.mjs +4 -0
- package/mcp/code-mcp-pr/README.md +3 -2
- package/mcp/code-mcp-pr/azure-devops.js +81 -2
- package/mcp/code-mcp-pr/index.js +34 -1
- package/mcp/code-mcp-pr/index.test.js +161 -11
- package/mcp/trace-mcp-recorder/.env.example +6 -0
- package/mcp/trace-mcp-recorder/README.md +58 -0
- package/mcp/trace-mcp-recorder/config.toml.example +11 -0
- package/mcp/trace-mcp-recorder/index.js +469 -0
- package/mcp/trace-mcp-recorder/index.test.js +179 -0
- package/mcp/trace-mcp-recorder/package.json +25 -0
- package/package.json +9 -2
- package/plugins/autobest-delivery/.codex-plugin/plugin.json +1 -0
- package/plugins/autobest-delivery/README.md +25 -4
- package/plugins/autobest-delivery/mcp-server/package.json +1 -1
- package/plugins/autobest-delivery/mcp-server/src/fast-runner.mjs +470 -0
- package/plugins/autobest-delivery/mcp-server/src/paths.mjs +12 -7
- package/plugins/autobest-delivery/mcp-server/src/report.mjs +57 -12
- package/plugins/autobest-delivery/mcp-server/src/server.mjs +167 -2
- package/plugins/autobest-delivery/mcp-server/tests/fast-runner.test.mjs +232 -0
- package/plugins/autobest-delivery/mcp-server/tests/mcp-smoke.test.mjs +71 -0
- package/plugins/autobest-delivery/mcp-server/tests/report.test.mjs +69 -1
- package/plugins/autobest-delivery/skills/code-audit/SKILL.md +3 -3
- package/plugins/autobest-delivery/skills/delivery-loop/references/delivery-contract.md +11 -0
- package/plugins/autobest-delivery/skills/delivery-loop-fast/SKILL.md +62 -0
- package/plugins/autobest-delivery/skills/delivery-loop-fast/agents/openai.yaml +7 -0
- package/plugins/autobest-delivery/skills/export-report/SKILL.md +3 -3
- package/plugins/autobest-delivery/skills/export-report/agents/openai.yaml +1 -2
- package/skills/README.md +2 -0
- package/skills/common/code-pr-submit/SKILL.md +25 -4
- package/skills/common/repo-setup/SKILL.md +91 -0
- package/skills/common/repo-setup/agents/openai.yaml +4 -0
- package/skills/common/trace-recorder/SKILL.md +25 -0
- package/skills/common/trace-recorder/agents/openai.yaml +11 -0
package/README.md
CHANGED
|
@@ -40,21 +40,35 @@ npx --yes \
|
|
|
40
40
|
autobest-azurepr-mcp
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
启动 Playwright Trace 人工录制 MCP:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx --yes --package=@autobest-ui/agent@latest playwright install chromium
|
|
47
|
+
|
|
48
|
+
BACKEND_BASE_URL="http://127.0.0.1:3001" \
|
|
49
|
+
MAX_RECORD_DURATION="1800000" \
|
|
50
|
+
npx --yes \
|
|
51
|
+
--package=@autobest-ui/agent@latest \
|
|
52
|
+
autobest-trace-mcp-recorder
|
|
53
|
+
```
|
|
54
|
+
|
|
43
55
|
启动成功后,MCP 会通过 stdio 持续等待客户端请求,终端没有输出属于正常状态,可按 `Ctrl+C` 停止。供 Codex 长期使用时,不要只执行上述临时命令,应将完整配置加入 `~/.codex/config.toml` 后重启 Codex。
|
|
44
56
|
|
|
45
|
-
同一个 npm
|
|
57
|
+
同一个 npm 包暴露多个独立命令:
|
|
46
58
|
|
|
47
|
-
| 命令
|
|
48
|
-
|
|
|
49
|
-
| `autobest-rag-mcp`
|
|
50
|
-
| `autobest-azurepr-mcp`
|
|
51
|
-
| `
|
|
59
|
+
| 命令 | 用途 | 必需配置 |
|
|
60
|
+
| ------------------------------- | ------------------------------ | ------------------ |
|
|
61
|
+
| `autobest-rag-mcp` | 连接 PRD Knowledge RAG API | `RAG_API_BASE_URL` |
|
|
62
|
+
| `autobest-azurepr-mcp` | 读取 Azure DevOps Pull Request | `AZURE_DEVOPS_PAT` |
|
|
63
|
+
| `autobest-trace-mcp-recorder` | 录制并上传 Playwright Trace | `BACKEND_BASE_URL` |
|
|
64
|
+
| `figma-mcp-bridge` | 连接本机 Figma Plugin | 无 |
|
|
52
65
|
|
|
53
66
|
Codex 配置示例分别位于:
|
|
54
67
|
|
|
55
68
|
- [RAG MCP 配置](mcp/rag-mcp-bridge/config.toml.example)
|
|
56
69
|
- [Azure PR MCP 配置](mcp/azurepr-mcp-bridge/config.toml.example)
|
|
57
70
|
- [Figma MCP 配置](mcp/figma-mcp-bridge/config.toml.example)
|
|
71
|
+
- [Trace Recorder MCP 配置](mcp/trace-mcp-recorder/config.toml.example)
|
|
58
72
|
|
|
59
73
|
生产环境可将 `@latest` 替换为明确版本,以固定 MCP 行为。
|
|
60
74
|
|
package/bin/sync-assets.test.mjs
CHANGED
|
@@ -23,7 +23,9 @@ test("installs common skills into the user-level skills directory", async () =>
|
|
|
23
23
|
"code-pr-submit",
|
|
24
24
|
"figma-ui-capture",
|
|
25
25
|
"make-spec",
|
|
26
|
+
"repo-setup",
|
|
26
27
|
"review-from-docs",
|
|
28
|
+
"trace-recorder",
|
|
27
29
|
"ui-prd-scope",
|
|
28
30
|
]);
|
|
29
31
|
await access(
|
|
@@ -33,6 +35,8 @@ test("installs common skills into the user-level skills directory", async () =>
|
|
|
33
35
|
await access(
|
|
34
36
|
path.join(result.targetRoot, "make-spec", "scripts", "validate-spec-traceability.mjs")
|
|
35
37
|
);
|
|
38
|
+
await access(path.join(result.targetRoot, "repo-setup", "SKILL.md"));
|
|
39
|
+
await access(path.join(result.targetRoot, "trace-recorder", "SKILL.md"));
|
|
36
40
|
await access(
|
|
37
41
|
path.join(result.targetRoot, "review-from-docs", "references", "review-result-schema.md")
|
|
38
42
|
);
|
|
@@ -9,7 +9,7 @@ export AZURE_DEVOPS_PAT="your-personal-access-token"
|
|
|
9
9
|
export DINGDING_ALERT_TOKEN="your-dingtalk-robot-access-token"
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
`AZURE_DEVOPS_PAT`
|
|
12
|
+
`AZURE_DEVOPS_PAT` 是访问 Azure DevOps 的工具所需配置。`DINGDING_ALERT_TOKEN` 在 `code-mcp-pr` 创建新 PR 后发送通知,或 `send_pr_dingtalk` 为已有 PR 补发通知时使用。两个 token 都不会作为 MCP 工具参数接收。
|
|
13
13
|
|
|
14
14
|
## 启动
|
|
15
15
|
|
|
@@ -30,7 +30,8 @@ Codex 配置示例见 [config.toml.example](config.toml.example)。
|
|
|
30
30
|
|
|
31
31
|
## 工具
|
|
32
32
|
|
|
33
|
-
- `code-mcp-pr`:`businessTargetBranch` 作为 ADO `sourceRefName`,`businessSourceBranch` 作为 `targetRefName
|
|
33
|
+
- `code-mcp-pr`:`businessTargetBranch` 作为 ADO `sourceRefName`,`businessSourceBranch` 作为 `targetRefName`。目标为 `main/master` 时要求显式传入 `targetBranchConfirmed=true`。创建前检查相同方向的 active PR;已存在时返回 `alreadyExists=true` 和 `prWebUrl`,不重复发送钉钉。仅在新 PR 创建成功且两个 markdown 字段都有值时发送钉钉通知。
|
|
34
|
+
- `send_pr_dingtalk`:在用户确认后,为一个已存在的 PR 链接补发钉钉 markdown 通知;不会创建或修改 PR。
|
|
34
35
|
- `get_repo_default_branch`:读取仓库默认分支。
|
|
35
36
|
- `guess_parent_branch`:以默认分支和 ADO merge-base 信息进行启发式推测。
|
|
36
37
|
- `get_branch_diff`:读取 source 相对 target 的文件列表和受限 diff 片段。
|
|
@@ -28,6 +28,7 @@ const DIFF_FILE_CONCURRENCY = 5;
|
|
|
28
28
|
* @property {string} prTitle
|
|
29
29
|
* @property {string=} prDescription
|
|
30
30
|
* @property {string[]=} reviewers
|
|
31
|
+
* @property {boolean=} targetBranchConfirmed
|
|
31
32
|
* @property {string=} dingtalkMarkdownTitle
|
|
32
33
|
* @property {string=} dingtalkMarkdownContent
|
|
33
34
|
*/
|
|
@@ -35,6 +36,9 @@ const DIFF_FILE_CONCURRENCY = 5;
|
|
|
35
36
|
/**
|
|
36
37
|
* @typedef {object} CreatePullRequestResult
|
|
37
38
|
* @property {boolean} success
|
|
39
|
+
* @property {boolean} created
|
|
40
|
+
* @property {boolean} alreadyExists
|
|
41
|
+
* @property {boolean} confirmationRequired
|
|
38
42
|
* @property {string|null} prId
|
|
39
43
|
* @property {string|null} prWebUrl
|
|
40
44
|
* @property {JsonObject} adoApiPayload
|
|
@@ -43,6 +47,13 @@ const DIFF_FILE_CONCURRENCY = 5;
|
|
|
43
47
|
* @property {string|null} errorMsg
|
|
44
48
|
*/
|
|
45
49
|
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {object} SendPullRequestDingtalkInput
|
|
52
|
+
* @property {string} prWebUrl
|
|
53
|
+
* @property {string} dingtalkMarkdownTitle
|
|
54
|
+
* @property {string} dingtalkMarkdownContent
|
|
55
|
+
*/
|
|
56
|
+
|
|
46
57
|
/**
|
|
47
58
|
* @typedef {object} DefaultBranchResult
|
|
48
59
|
* @property {boolean} success
|
|
@@ -203,6 +214,37 @@ function pullRequestWebUrl(input, responseBody, prId) {
|
|
|
203
214
|
return `https://dev.azure.com/${encodeURIComponent(input.orgName)}/${encodeURIComponent(input.projectName)}/_git/${encodeURIComponent(repositoryName)}/pullrequest/${encodeURIComponent(prId)}`;
|
|
204
215
|
}
|
|
205
216
|
|
|
217
|
+
function activePullRequestsUrl(input, sourceRefName, targetRefName) {
|
|
218
|
+
const query = new URLSearchParams({
|
|
219
|
+
'searchCriteria.status': 'active',
|
|
220
|
+
'searchCriteria.sourceRefName': sourceRefName,
|
|
221
|
+
'searchCriteria.targetRefName': targetRefName,
|
|
222
|
+
'$top': '1'
|
|
223
|
+
});
|
|
224
|
+
return `${repositoryRoot(input)}/pullrequests?${query.toString()}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function findActivePullRequest(input, sourceRefName, targetRefName, pat) {
|
|
228
|
+
const result = await adoRequest(activePullRequestsUrl(input, sourceRefName, targetRefName), pat);
|
|
229
|
+
if (!result.ok) return { pullRequest: null, errorMsg: result.errorMsg };
|
|
230
|
+
const body = asObject(result.data);
|
|
231
|
+
const pullRequests = Array.isArray(body.value) ? body.value : [];
|
|
232
|
+
return { pullRequest: asObject(pullRequests[0]), errorMsg: null };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function existingPullRequestResult(baseResult, input, pullRequest) {
|
|
236
|
+
const rawPrId = pullRequest.pullRequestId;
|
|
237
|
+
if (rawPrId === undefined || rawPrId === null) return null;
|
|
238
|
+
const prId = String(rawPrId);
|
|
239
|
+
return {
|
|
240
|
+
...baseResult,
|
|
241
|
+
success: true,
|
|
242
|
+
alreadyExists: true,
|
|
243
|
+
prId,
|
|
244
|
+
prWebUrl: pullRequestWebUrl(input, pullRequest, prId)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
206
248
|
async function sendDingtalkMarkdown(title, content) {
|
|
207
249
|
const token = process.env.DINGDING_ALERT_TOKEN?.trim();
|
|
208
250
|
if (!token) {
|
|
@@ -239,6 +281,9 @@ export async function createPullRequest(input) {
|
|
|
239
281
|
};
|
|
240
282
|
const baseResult = {
|
|
241
283
|
success: false,
|
|
284
|
+
created: false,
|
|
285
|
+
alreadyExists: false,
|
|
286
|
+
confirmationRequired: false,
|
|
242
287
|
prId: null,
|
|
243
288
|
prWebUrl: null,
|
|
244
289
|
adoApiPayload,
|
|
@@ -251,14 +296,35 @@ export async function createPullRequest(input) {
|
|
|
251
296
|
return { ...baseResult, errorMsg: 'businessTargetBranch 不能等于 businessSourceBranch' };
|
|
252
297
|
}
|
|
253
298
|
|
|
299
|
+
const targetBranch = branchVersion(targetRefName).toLowerCase();
|
|
300
|
+
if ((targetBranch === 'main' || targetBranch === 'master') && input.targetBranchConfirmed !== true) {
|
|
301
|
+
return {
|
|
302
|
+
...baseResult,
|
|
303
|
+
confirmationRequired: true,
|
|
304
|
+
errorMsg: `目标分支 ${targetBranch} 是主分支,需要用户明确确认后才能创建PR`
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
254
308
|
const pat = requirePat();
|
|
255
309
|
if (!pat) return { ...baseResult, errorMsg: missingPatError() };
|
|
256
310
|
|
|
311
|
+
const existingLookup = await findActivePullRequest(input, sourceRefName, targetRefName, pat);
|
|
312
|
+
if (existingLookup.errorMsg) return { ...baseResult, errorMsg: existingLookup.errorMsg };
|
|
313
|
+
const existingResult = existingPullRequestResult(baseResult, input, existingLookup.pullRequest);
|
|
314
|
+
if (existingResult) return existingResult;
|
|
315
|
+
|
|
257
316
|
const createResult = await adoRequest(`${repositoryRoot(input)}/pullrequests`, pat, {
|
|
258
317
|
method: 'POST',
|
|
259
318
|
body: JSON.stringify(adoApiPayload)
|
|
260
319
|
});
|
|
261
|
-
if (!createResult.ok)
|
|
320
|
+
if (!createResult.ok) {
|
|
321
|
+
if (createResult.status === 409) {
|
|
322
|
+
const raceLookup = await findActivePullRequest(input, sourceRefName, targetRefName, pat);
|
|
323
|
+
const racedExistingResult = existingPullRequestResult(baseResult, input, raceLookup.pullRequest);
|
|
324
|
+
if (racedExistingResult) return racedExistingResult;
|
|
325
|
+
}
|
|
326
|
+
return { ...baseResult, errorMsg: createResult.errorMsg };
|
|
327
|
+
}
|
|
262
328
|
|
|
263
329
|
const response = asObject(createResult.data);
|
|
264
330
|
const rawPrId = response.pullRequestId;
|
|
@@ -268,7 +334,7 @@ export async function createPullRequest(input) {
|
|
|
268
334
|
|
|
269
335
|
const prId = String(rawPrId);
|
|
270
336
|
const prWebUrl = pullRequestWebUrl(input, response, prId);
|
|
271
|
-
const successResult = { ...baseResult, success: true, prId, prWebUrl };
|
|
337
|
+
const successResult = { ...baseResult, success: true, created: true, prId, prWebUrl };
|
|
272
338
|
const title = input.dingtalkMarkdownTitle;
|
|
273
339
|
const content = input.dingtalkMarkdownContent;
|
|
274
340
|
if (!title?.trim() || !content?.trim()) return successResult;
|
|
@@ -282,6 +348,19 @@ export async function createPullRequest(input) {
|
|
|
282
348
|
};
|
|
283
349
|
}
|
|
284
350
|
|
|
351
|
+
/** @param {SendPullRequestDingtalkInput} input */
|
|
352
|
+
export async function sendPullRequestDingtalk(input) {
|
|
353
|
+
const renderedContent = input.dingtalkMarkdownContent.replaceAll('{{PR_WEB_URL}}', input.prWebUrl);
|
|
354
|
+
const dingtalk = await sendDingtalkMarkdown(input.dingtalkMarkdownTitle, renderedContent);
|
|
355
|
+
return {
|
|
356
|
+
success: dingtalk.success,
|
|
357
|
+
prWebUrl: input.prWebUrl,
|
|
358
|
+
dingtalkSendSuccess: dingtalk.success,
|
|
359
|
+
dingtalkResponse: dingtalk.response,
|
|
360
|
+
errorMsg: dingtalk.success ? null : boundedJson(dingtalk.response) || '钉钉通知发送失败'
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
285
364
|
/** @param {RepositoryInput} input @returns {Promise<DefaultBranchResult>} */
|
|
286
365
|
export async function getRepoDefaultBranch(input) {
|
|
287
366
|
const pat = requirePat();
|
package/mcp/code-mcp-pr/index.js
CHANGED
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
getBranchDiff,
|
|
9
9
|
getRepoDefaultBranch,
|
|
10
10
|
guessParentBranch,
|
|
11
|
-
readFileAtRef
|
|
11
|
+
readFileAtRef,
|
|
12
|
+
sendPullRequestDingtalk
|
|
12
13
|
} from './azure-devops.js';
|
|
13
14
|
|
|
14
15
|
const server = new McpServer(
|
|
@@ -56,6 +57,10 @@ registerJsonTool(
|
|
|
56
57
|
prTitle: z.string().min(1).describe('PR标题'),
|
|
57
58
|
prDescription: z.string().optional().describe('PR描述,可选,条目化变更详情'),
|
|
58
59
|
reviewers: z.array(z.string().min(1)).optional().describe('评审人ID列表,可选'),
|
|
60
|
+
targetBranchConfirmed: z
|
|
61
|
+
.boolean()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe('目标分支为main或master时,必须在用户明确二次确认后传true'),
|
|
59
64
|
dingtalkMarkdownTitle: z.string().optional().describe('钉钉通知markdown标题,可选;不传则不发送钉钉'),
|
|
60
65
|
dingtalkMarkdownContent: z
|
|
61
66
|
.string()
|
|
@@ -67,6 +72,9 @@ registerJsonTool(
|
|
|
67
72
|
createPullRequest,
|
|
68
73
|
errorMsg => ({
|
|
69
74
|
success: false,
|
|
75
|
+
created: false,
|
|
76
|
+
alreadyExists: false,
|
|
77
|
+
confirmationRequired: false,
|
|
70
78
|
prId: null,
|
|
71
79
|
prWebUrl: null,
|
|
72
80
|
adoApiPayload: {},
|
|
@@ -76,6 +84,31 @@ registerJsonTool(
|
|
|
76
84
|
})
|
|
77
85
|
);
|
|
78
86
|
|
|
87
|
+
registerJsonTool(
|
|
88
|
+
'send_pr_dingtalk',
|
|
89
|
+
{
|
|
90
|
+
title: '发送已有 PR 钉钉通知',
|
|
91
|
+
description: '为已确认的 PR 链接发送钉钉 markdown 通知;不会创建或修改 PR。',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
prWebUrl: z.string().url().describe('已存在 PR 的可访问 Web 链接'),
|
|
94
|
+
dingtalkMarkdownTitle: z.string().min(1).describe('钉钉通知 markdown 标题'),
|
|
95
|
+
dingtalkMarkdownContent: z
|
|
96
|
+
.string()
|
|
97
|
+
.min(1)
|
|
98
|
+
.describe('钉钉通知 markdown 正文;内部会替换{{PR_WEB_URL}}占位符为 PR 链接')
|
|
99
|
+
},
|
|
100
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
101
|
+
},
|
|
102
|
+
sendPullRequestDingtalk,
|
|
103
|
+
errorMsg => ({
|
|
104
|
+
success: false,
|
|
105
|
+
prWebUrl: null,
|
|
106
|
+
dingtalkSendSuccess: false,
|
|
107
|
+
dingtalkResponse: null,
|
|
108
|
+
errorMsg
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
|
|
79
112
|
registerJsonTool(
|
|
80
113
|
'get_repo_default_branch',
|
|
81
114
|
{
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
getRepoDefaultBranch,
|
|
13
13
|
guessParentBranch,
|
|
14
14
|
normalizeBranchRef,
|
|
15
|
-
readFileAtRef
|
|
15
|
+
readFileAtRef,
|
|
16
|
+
sendPullRequestDingtalk
|
|
16
17
|
} from './azure-devops.js';
|
|
17
18
|
|
|
18
19
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -57,6 +58,7 @@ test('creates the reverse PR with the required branch direction and sends render
|
|
|
57
58
|
globalThis.fetch = async (url, options) => {
|
|
58
59
|
calls.push({ url: String(url), options });
|
|
59
60
|
if (String(url).startsWith('https://dev.azure.com/')) {
|
|
61
|
+
if (!options?.method || options.method === 'GET') return jsonResponse({ value: [] });
|
|
60
62
|
return jsonResponse({
|
|
61
63
|
pullRequestId: 42,
|
|
62
64
|
repository: { name: 'web' },
|
|
@@ -75,12 +77,15 @@ test('creates the reverse PR with the required branch direction and sends render
|
|
|
75
77
|
businessTargetBranch: 'release/with-change',
|
|
76
78
|
businessSourceBranch: 'main',
|
|
77
79
|
prTitle: 'Reverse PR',
|
|
80
|
+
targetBranchConfirmed: true,
|
|
78
81
|
reviewers: ['reviewer-id'],
|
|
79
82
|
dingtalkMarkdownTitle: 'PR created',
|
|
80
83
|
dingtalkMarkdownContent: '[Open PR]({{PR_WEB_URL}})'
|
|
81
84
|
});
|
|
82
85
|
|
|
83
86
|
assert.equal(result.success, true);
|
|
87
|
+
assert.equal(result.created, true);
|
|
88
|
+
assert.equal(result.alreadyExists, false);
|
|
84
89
|
assert.equal(result.dingtalkSendSuccess, true);
|
|
85
90
|
assert.deepEqual(result.adoApiPayload, {
|
|
86
91
|
sourceRefName: 'refs/heads/release/with-change',
|
|
@@ -88,14 +93,14 @@ test('creates the reverse PR with the required branch direction and sends render
|
|
|
88
93
|
title: 'Reverse PR',
|
|
89
94
|
reviewers: [{ id: 'reviewer-id' }]
|
|
90
95
|
});
|
|
91
|
-
assert.equal(calls.length,
|
|
92
|
-
assert.match(calls[
|
|
93
|
-
assert.deepEqual(JSON.parse(calls[
|
|
96
|
+
assert.equal(calls.length, 3);
|
|
97
|
+
assert.match(calls[1].url, new RegExp(`api-version=${API_VERSION.replaceAll('.', '\\.')}`));
|
|
98
|
+
assert.deepEqual(JSON.parse(calls[1].options.body), result.adoApiPayload);
|
|
94
99
|
assert.equal(
|
|
95
|
-
JSON.parse(calls[
|
|
100
|
+
JSON.parse(calls[2].options.body).markdown.text,
|
|
96
101
|
'[Open PR](https://dev.azure.com/org/project/_git/web/pullrequest/42)'
|
|
97
102
|
);
|
|
98
|
-
assert.doesNotMatch(calls[
|
|
103
|
+
assert.doesNotMatch(calls[1].options.headers.Authorization, /secret-pat/);
|
|
99
104
|
});
|
|
100
105
|
} finally {
|
|
101
106
|
globalThis.fetch = originalFetch;
|
|
@@ -105,8 +110,9 @@ test('creates the reverse PR with the required branch direction and sends render
|
|
|
105
110
|
test('does not call DingTalk after an Azure DevOps create failure', async () => {
|
|
106
111
|
let callCount = 0;
|
|
107
112
|
const originalFetch = globalThis.fetch;
|
|
108
|
-
globalThis.fetch = async () => {
|
|
113
|
+
globalThis.fetch = async (_url, options) => {
|
|
109
114
|
callCount += 1;
|
|
115
|
+
if (!options?.method || options.method === 'GET') return jsonResponse({ value: [] });
|
|
110
116
|
return jsonResponse({ message: 'denied' }, 401);
|
|
111
117
|
};
|
|
112
118
|
|
|
@@ -119,12 +125,13 @@ test('does not call DingTalk after an Azure DevOps create failure', async () =>
|
|
|
119
125
|
businessTargetBranch: 'feature',
|
|
120
126
|
businessSourceBranch: 'main',
|
|
121
127
|
prTitle: 'PR',
|
|
128
|
+
targetBranchConfirmed: true,
|
|
122
129
|
dingtalkMarkdownTitle: 'title',
|
|
123
130
|
dingtalkMarkdownContent: 'content'
|
|
124
131
|
});
|
|
125
132
|
assert.equal(result.success, false);
|
|
126
133
|
assert.equal(result.dingtalkSendSuccess, null);
|
|
127
|
-
assert.equal(callCount,
|
|
134
|
+
assert.equal(callCount, 2);
|
|
128
135
|
});
|
|
129
136
|
} finally {
|
|
130
137
|
globalThis.fetch = originalFetch;
|
|
@@ -133,7 +140,10 @@ test('does not call DingTalk after an Azure DevOps create failure', async () =>
|
|
|
133
140
|
|
|
134
141
|
test('keeps PR success when DingTalk token is missing', async () => {
|
|
135
142
|
const originalFetch = globalThis.fetch;
|
|
136
|
-
globalThis.fetch = async () =>
|
|
143
|
+
globalThis.fetch = async (_url, options) =>
|
|
144
|
+
!options?.method || options.method === 'GET'
|
|
145
|
+
? jsonResponse({ value: [] })
|
|
146
|
+
: jsonResponse({ pullRequestId: 7 });
|
|
137
147
|
try {
|
|
138
148
|
await withEnvironment({ AZURE_DEVOPS_PAT: 'pat', DINGDING_ALERT_TOKEN: undefined }, async () => {
|
|
139
149
|
const result = await createPullRequest({
|
|
@@ -143,6 +153,7 @@ test('keeps PR success when DingTalk token is missing', async () => {
|
|
|
143
153
|
businessTargetBranch: 'feature',
|
|
144
154
|
businessSourceBranch: 'main',
|
|
145
155
|
prTitle: 'PR',
|
|
156
|
+
targetBranchConfirmed: true,
|
|
146
157
|
dingtalkMarkdownTitle: 'title',
|
|
147
158
|
dingtalkMarkdownContent: 'content'
|
|
148
159
|
});
|
|
@@ -155,6 +166,138 @@ test('keeps PR success when DingTalk token is missing', async () => {
|
|
|
155
166
|
}
|
|
156
167
|
});
|
|
157
168
|
|
|
169
|
+
test('requires explicit confirmation before targeting main or master', async () => {
|
|
170
|
+
const originalFetch = globalThis.fetch;
|
|
171
|
+
globalThis.fetch = async () => assert.fail('fetch must not be called before confirmation');
|
|
172
|
+
try {
|
|
173
|
+
for (const targetBranch of ['main', 'refs/heads/master', 'MAIN']) {
|
|
174
|
+
const result = await createPullRequest({
|
|
175
|
+
orgName: 'org',
|
|
176
|
+
projectName: 'project',
|
|
177
|
+
repoId: 'repo',
|
|
178
|
+
businessTargetBranch: 'feature',
|
|
179
|
+
businessSourceBranch: targetBranch,
|
|
180
|
+
prTitle: 'PR'
|
|
181
|
+
});
|
|
182
|
+
assert.equal(result.success, false);
|
|
183
|
+
assert.equal(result.confirmationRequired, true);
|
|
184
|
+
assert.match(result.errorMsg, /明确确认/);
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
globalThis.fetch = originalFetch;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('returns the existing active PR link without sending DingTalk again', async () => {
|
|
192
|
+
const calls = [];
|
|
193
|
+
const originalFetch = globalThis.fetch;
|
|
194
|
+
globalThis.fetch = async (url, options) => {
|
|
195
|
+
calls.push({ url: String(url), options });
|
|
196
|
+
return jsonResponse({
|
|
197
|
+
value: [
|
|
198
|
+
{
|
|
199
|
+
pullRequestId: 88,
|
|
200
|
+
repository: { name: 'web' },
|
|
201
|
+
_links: { web: { href: 'https://dev.azure.com/org/project/_git/web/pullrequest/88' } }
|
|
202
|
+
}
|
|
203
|
+
]
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
await withEnvironment({ AZURE_DEVOPS_PAT: 'pat', DINGDING_ALERT_TOKEN: 'ding-token' }, async () => {
|
|
209
|
+
const result = await createPullRequest({
|
|
210
|
+
orgName: 'org',
|
|
211
|
+
projectName: 'project',
|
|
212
|
+
repoId: 'repo',
|
|
213
|
+
businessTargetBranch: 'feature',
|
|
214
|
+
businessSourceBranch: 'develop',
|
|
215
|
+
prTitle: 'PR',
|
|
216
|
+
dingtalkMarkdownTitle: 'title',
|
|
217
|
+
dingtalkMarkdownContent: '[PR]({{PR_WEB_URL}})'
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
assert.equal(result.success, true);
|
|
221
|
+
assert.equal(result.created, false);
|
|
222
|
+
assert.equal(result.alreadyExists, true);
|
|
223
|
+
assert.equal(result.prId, '88');
|
|
224
|
+
assert.equal(result.prWebUrl, 'https://dev.azure.com/org/project/_git/web/pullrequest/88');
|
|
225
|
+
assert.equal(result.dingtalkSendSuccess, null);
|
|
226
|
+
assert.equal(calls.length, 1);
|
|
227
|
+
const query = new URL(calls[0].url).searchParams;
|
|
228
|
+
assert.equal(query.get('searchCriteria.status'), 'active');
|
|
229
|
+
assert.equal(query.get('searchCriteria.sourceRefName'), 'refs/heads/feature');
|
|
230
|
+
assert.equal(query.get('searchCriteria.targetRefName'), 'refs/heads/develop');
|
|
231
|
+
assert.equal(calls[0].options?.method, undefined);
|
|
232
|
+
});
|
|
233
|
+
} finally {
|
|
234
|
+
globalThis.fetch = originalFetch;
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('returns the existing PR link when creation races with another submitter', async () => {
|
|
239
|
+
let adoCall = 0;
|
|
240
|
+
const originalFetch = globalThis.fetch;
|
|
241
|
+
globalThis.fetch = async () => {
|
|
242
|
+
adoCall += 1;
|
|
243
|
+
if (adoCall === 1) return jsonResponse({ value: [] });
|
|
244
|
+
if (adoCall === 2) return jsonResponse({ message: 'active pull request already exists' }, 409);
|
|
245
|
+
return jsonResponse({
|
|
246
|
+
value: [
|
|
247
|
+
{
|
|
248
|
+
pullRequestId: 99,
|
|
249
|
+
repository: { name: 'web' },
|
|
250
|
+
_links: { web: { href: 'https://dev.azure.com/org/project/_git/web/pullrequest/99' } }
|
|
251
|
+
}
|
|
252
|
+
]
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await withEnvironment({ AZURE_DEVOPS_PAT: 'pat' }, async () => {
|
|
258
|
+
const result = await createPullRequest({
|
|
259
|
+
orgName: 'org',
|
|
260
|
+
projectName: 'project',
|
|
261
|
+
repoId: 'repo',
|
|
262
|
+
businessTargetBranch: 'feature',
|
|
263
|
+
businessSourceBranch: 'develop',
|
|
264
|
+
prTitle: 'PR'
|
|
265
|
+
});
|
|
266
|
+
assert.equal(result.success, true);
|
|
267
|
+
assert.equal(result.created, false);
|
|
268
|
+
assert.equal(result.alreadyExists, true);
|
|
269
|
+
assert.equal(result.prWebUrl, 'https://dev.azure.com/org/project/_git/web/pullrequest/99');
|
|
270
|
+
assert.equal(adoCall, 3);
|
|
271
|
+
});
|
|
272
|
+
} finally {
|
|
273
|
+
globalThis.fetch = originalFetch;
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('sends a confirmed DingTalk notification for an existing PR link', async () => {
|
|
278
|
+
const originalFetch = globalThis.fetch;
|
|
279
|
+
let markdownText;
|
|
280
|
+
globalThis.fetch = async (_url, options) => {
|
|
281
|
+
markdownText = JSON.parse(options.body).markdown.text;
|
|
282
|
+
return jsonResponse({ errcode: 0, errmsg: 'ok' });
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
await withEnvironment({ DINGDING_ALERT_TOKEN: 'ding-token' }, async () => {
|
|
287
|
+
const result = await sendPullRequestDingtalk({
|
|
288
|
+
prWebUrl: 'https://dev.azure.com/org/project/_git/web/pullrequest/88',
|
|
289
|
+
dingtalkMarkdownTitle: 'Existing PR',
|
|
290
|
+
dingtalkMarkdownContent: '[Open]({{PR_WEB_URL}})'
|
|
291
|
+
});
|
|
292
|
+
assert.equal(result.success, true);
|
|
293
|
+
assert.equal(result.dingtalkSendSuccess, true);
|
|
294
|
+
assert.equal(markdownText, '[Open](https://dev.azure.com/org/project/_git/web/pullrequest/88)');
|
|
295
|
+
});
|
|
296
|
+
} finally {
|
|
297
|
+
globalThis.fetch = originalFetch;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
158
301
|
test('rejects identical normalized PR branches without making an HTTP request', async () => {
|
|
159
302
|
const originalFetch = globalThis.fetch;
|
|
160
303
|
globalThis.fetch = async () => assert.fail('fetch must not be called');
|
|
@@ -266,7 +409,7 @@ test('returns a structured missing-PAT error', async () => {
|
|
|
266
409
|
});
|
|
267
410
|
});
|
|
268
411
|
|
|
269
|
-
test('exposes all
|
|
412
|
+
test('exposes all six tools over stdio', async () => {
|
|
270
413
|
const client = new Client({ name: 'code-mcp-pr-test', version: '1.0.0' });
|
|
271
414
|
const transport = new StdioClientTransport({
|
|
272
415
|
command: process.execPath,
|
|
@@ -281,7 +424,14 @@ test('exposes all five tools over stdio', async () => {
|
|
|
281
424
|
const { tools } = await client.listTools();
|
|
282
425
|
assert.deepEqual(
|
|
283
426
|
tools.map(tool => tool.name),
|
|
284
|
-
[
|
|
427
|
+
[
|
|
428
|
+
'code-mcp-pr',
|
|
429
|
+
'send_pr_dingtalk',
|
|
430
|
+
'get_repo_default_branch',
|
|
431
|
+
'guess_parent_branch',
|
|
432
|
+
'get_branch_diff',
|
|
433
|
+
'read_file_at_ref'
|
|
434
|
+
]
|
|
285
435
|
);
|
|
286
436
|
} finally {
|
|
287
437
|
await client.close();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# trace-mcp-recorder
|
|
2
|
+
|
|
3
|
+
`trace-mcp-recorder` 是基于 `@modelcontextprotocol/sdk` 和 `StdioServerTransport` 的 Node.js MCP Server。它打开非无头 Chromium 供测试人员手动操作,停止后上传 `trace.zip` 和浏览器元数据,并返回私有后端的 Trace Viewer 链接。同一进程同一时间只允许一个录制任务。
|
|
4
|
+
|
|
5
|
+
## 安装与启动
|
|
6
|
+
|
|
7
|
+
要求 Node.js 20 或更高版本。在本目录安装依赖及 Chromium:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npx playwright install chromium
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
复制 `.env.example` 中的变量到 MCP 客户端环境。服务不会自动读取 `.env` 文件,也不会在代码中提供后端默认地址。直接启动:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
BACKEND_BASE_URL="http://127.0.0.1:3001" \
|
|
18
|
+
MAX_RECORD_DURATION="1800000" \
|
|
19
|
+
npm start
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
服务使用 stdio 通信,启动后持续等待请求且不会向标准输出写日志。
|
|
23
|
+
|
|
24
|
+
## Codex 接入
|
|
25
|
+
|
|
26
|
+
将 [config.toml.example](config.toml.example) 的内容加入 `~/.codex/config.toml` 并按实际部署修改 `BACKEND_BASE_URL`,然后重启 Codex。发布包暴露的命令是 `autobest-trace-mcp-recorder`。
|
|
27
|
+
|
|
28
|
+
配套 Skill 位于 `../../skills/common/trace-recorder/`,由 `autobest-agent-sync common` 安装,并作为 `skill://trace-recorder/SKILL.md` MCP 资源提供。Skill 的 `agents/openai.yaml` 声明了对 `trace-mcp-recorder` 的工具依赖。
|
|
29
|
+
|
|
30
|
+
## 环境变量
|
|
31
|
+
|
|
32
|
+
| 变量 | 默认值 | 说明 |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `BACKEND_BASE_URL` | 无,必填 | 私有后端基础地址,必须为 HTTP(S) URL |
|
|
35
|
+
| `MAX_RECORD_DURATION` | `1800000` | 最大录制时长,单位毫秒;超时后自动停止并上传 |
|
|
36
|
+
|
|
37
|
+
回放链接按 `${BACKEND_BASE_URL}/trace-viewer/?trace=${fileUrl}` 生成。服务地址、域名和端口均来自 `BACKEND_BASE_URL`。
|
|
38
|
+
|
|
39
|
+
## MCP 工具
|
|
40
|
+
|
|
41
|
+
- `start_trace_recording`:参数为必填 `url` 和可选 `version`、`title`、`operator`。`url` 独立确定被测页面;`title` 是录制标题或功能描述,也可以直接填写 `bug3452` 这类 Bug 编号。系统不额外传递独立的 Bug 字段。MCP 认证上下文中的 `operator`、`name` 或 `preferred_username` 优先于参数。
|
|
42
|
+
- `stop_trace_recording`:首次调用不传参数,只停止 trace 并询问是否上传;用户确认后传 `confirm=upload`,取消时传 `confirm=cancel`。上传成功后返回回放链接及版本、标题、操作人、浏览器版本。
|
|
43
|
+
|
|
44
|
+
录制超时后服务自动停止并自动上传(无需人工确认),随后第一次人工调用 `stop_trace_recording` 会取回自动停止的结果;再调用则返回没有活动会话。
|
|
45
|
+
|
|
46
|
+
## 后端接口契约
|
|
47
|
+
|
|
48
|
+
服务期望后端提供以下接口:
|
|
49
|
+
|
|
50
|
+
- `POST /api/trace-sessions`:接收 `multipart/form-data`,必填字段为 `trace`、`target_url`,可选字段为 `version`、`title`、`operator`、`browser_name`、`browser_version`、`user_agent`、`os_name`、`viewport`;后端生成 UUID,创建并上传成功后返回 `{ "uuid": "...", "fileUrl": "...", "item": { "status": "completed" } }`。
|
|
51
|
+
|
|
52
|
+
`metadata` 包含浏览器版本、User-Agent、viewport、MCP 所在操作系统信息、目标 URL 和录制业务字段。上传或状态更新失败时,本地 trace 仍会被清理,会话会尽力回写为 `failed`。
|
|
53
|
+
|
|
54
|
+
## 验证
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm run verify
|
|
58
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[mcp_servers.trace-mcp-recorder]
|
|
2
|
+
command = "npx"
|
|
3
|
+
args = ["--yes", "--package=@autobest-ui/agent@latest", "autobest-trace-mcp-recorder"]
|
|
4
|
+
startup_timeout_sec = 30
|
|
5
|
+
tool_timeout_sec = 180
|
|
6
|
+
enabled = true
|
|
7
|
+
|
|
8
|
+
[mcp_servers.trace-mcp-recorder.env]
|
|
9
|
+
BACKEND_BASE_URL = "http://127.0.0.1:3001"
|
|
10
|
+
MAX_RECORD_DURATION = "1800000"
|
|
11
|
+
|