@autobest-ui/agent 1.0.10 → 1.0.11
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/bin/sync-assets.test.mjs +2 -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/package.json +1 -1
- 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 +1 -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/bin/sync-assets.test.mjs
CHANGED
|
@@ -23,6 +23,7 @@ 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",
|
|
27
28
|
"ui-prd-scope",
|
|
28
29
|
]);
|
|
@@ -33,6 +34,7 @@ test("installs common skills into the user-level skills directory", async () =>
|
|
|
33
34
|
await access(
|
|
34
35
|
path.join(result.targetRoot, "make-spec", "scripts", "validate-spec-traceability.mjs")
|
|
35
36
|
);
|
|
37
|
+
await access(path.join(result.targetRoot, "repo-setup", "SKILL.md"));
|
|
36
38
|
await access(
|
|
37
39
|
path.join(result.targetRoot, "review-from-docs", "references", "review-result-schema.md")
|
|
38
40
|
);
|
|
@@ -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();
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"defaultPrompt": [
|
|
20
20
|
"\u9488\u5bf9\u5f53\u524d\u529f\u80fd\u89c4\u683c\u8fd0\u884c\u5b8c\u6574\u4ea4\u4ed8\u95ed\u73af\u3002",
|
|
21
|
+
"\u4f7f\u7528\u5feb\u901f\u4ea4\u4ed8\u95ed\u73af\u5b9e\u73b0\u5f53\u524d\u529f\u80fd\uff0c\u5b8c\u6210\u4ea4\u4e92\u5f0f Playwright \u81ea\u6d4b\u3001\u622a\u56fe\u548c\u72ec\u7acb\u4ee3\u7801\u5ba1\u8ba1\u3002",
|
|
21
22
|
"\u68c0\u67e5\u9694\u79bb E2E \u8fd0\u884c\u5668\u662f\u5426\u5df2\u7ecf\u5c31\u7eea\u3002"
|
|
22
23
|
]
|
|
23
24
|
},
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Autobest 交付插件
|
|
2
2
|
|
|
3
|
-
`autobest-delivery` 将 Autobest
|
|
3
|
+
`autobest-delivery` 将 Autobest 完整及快速交付闭环 Skill 与插件自带的 Playwright MCP 运行器打包在一起。目标业务仓库只需提供应用源码、功能规格和启动命令,不需要安装 Playwright 依赖。
|
|
4
4
|
|
|
5
5
|
## 隔离机制
|
|
6
6
|
|
|
7
|
-
运行器的 Node 依赖位于 `mcp-server/node_modules/`,Chromium 位于 `mcp-server/.runtime/ms-playwright
|
|
7
|
+
运行器的 Node 依赖位于 `mcp-server/node_modules/`,Chromium 位于 `mcp-server/.runtime/ms-playwright/`,这两个路径均被插件忽略。工具只接受绝对路径形式的目标工作区,并且仅允许读取所需输入、向工作区内指定功能输出目录写入证据。标准运行器会额外校验冻结场景在执行前后的 SHA-256。
|
|
8
8
|
|
|
9
|
-
运行器不会在目标工作区调用包管理器,也不会修改目标仓库的清单、锁文件、`node_modules
|
|
9
|
+
运行器不会在目标工作区调用包管理器,也不会修改目标仓库的清单、锁文件、`node_modules`、配置或源码。标准 `run_feature_e2e` 每次执行前后都会校验冻结场景的 SHA-256。
|
|
10
10
|
|
|
11
11
|
## 使用 npx 安装
|
|
12
12
|
|
|
@@ -71,6 +71,21 @@ npx --yes --package=@autobest-ui/agent@latest autobest-delivery-setup --help
|
|
|
71
71
|
|
|
72
72
|
新版场景通过 `scene(id, callback)` 隔离页面和执行错误。一个 scene 的定位器、弹窗或准备步骤阻塞时,运行器会记录该 blocker 并继续执行后续独立 scene,全部 scene 尝试结束后再返回 `blocked`。旧版直接使用顶层 `page` 的场景仍可运行,但无法在未捕获异常后继续。
|
|
73
73
|
|
|
74
|
+
### 快速交互式 Playwright 工具
|
|
75
|
+
|
|
76
|
+
`$delivery-loop-fast` 使用以下工具直接操作浏览器,不创建或执行 `e2e/e2e.feature.mjs`:
|
|
77
|
+
|
|
78
|
+
- `start_fast_check`:启动会话并导航到应用;API 模式支持 `real`、`mock` 和无需业务 API 证明的 `none`。
|
|
79
|
+
- `fast_browser_snapshot`:读取当前页面的可访问性快照。
|
|
80
|
+
- `fast_browser_action`:执行导航、点击、填写、选择、按键、等待和视口切换。
|
|
81
|
+
- `mock_fast_check_route`:仅在 `mock` 模式安装受控 JSON 响应。
|
|
82
|
+
- `fast_browser_check`:执行并记录结构化页面检查。
|
|
83
|
+
- `capture_fast_check`:保存按报告功能组映射的桌面端或移动端截图。
|
|
84
|
+
- `finish_fast_check`:写入结果 JSON、中文 Markdown、Trace 和截图索引后关闭会话。
|
|
85
|
+
- `abort_fast_check`:放弃未完成的会话并关闭浏览器。
|
|
86
|
+
|
|
87
|
+
每轮产物位于 `fast-check/runs/iteration-NN/`,并以 `evidenceSource: interactive-playwright` 明确标识为实现者交互式自测,不属于独立 Checker 证据。后续 `$export-report` 可直接读取最新快速结果和截图。
|
|
88
|
+
|
|
74
89
|
### `resolve_blocked_run`
|
|
75
90
|
|
|
76
91
|
用户查看 Blocked 浏览器后,由创建该会话的验收代理调用此工具。工具将选择写入 `human-decision.json` 并关闭浏览器;`accept` 或 `skip` 会形成 waiver,而不是伪装成完整通过。
|
|
@@ -144,7 +159,7 @@ export default async function run({
|
|
|
144
159
|
|
|
145
160
|
Checker 和代码审计生成的 Markdown 使用简体中文,包括标题、摘要、表头、检查说明、期望、实际结果和 finding。JSON 字段名、机器状态、ID、路径及原始工具错误保持协议格式;英文原始错误旁提供中文解释。
|
|
146
161
|
|
|
147
|
-
手动调用 `$export-report` 时,Excel
|
|
162
|
+
手动调用 `$export-report` 时,Excel 可读取标准 Runner/Checker 证据或快速交互式自测证据,并且只生成一个 `自测报告` 工作表。每行代表一个真实 UI 组件或完整业务功能;同一组件或功能的文案、样式、布局、响应式行为和相关检查合并到同一行,桌面端与移动端代表截图也放在该行。报告不展示 REQ/story ID、scene 名或原子检查明细。新证据通过 `reportModule`、`reportGroup`、`reportTitle`、`reportMethod` 和 `reportDevice` 提供通用分组,并通过 `reportCaptures` 留下运行时 UI 截图。`visualMappings` 只负责有 Figma 基准时的视觉一致性验收;普通报告截图不表示视觉比对通过。旧证据必须由导出 Skill 分析 `spec.md` 后生成经过哈希和完整覆盖校验的语义分组清单,禁止按 E2E scene 猜测。
|
|
148
163
|
|
|
149
164
|
## 交付流程
|
|
150
165
|
|
|
@@ -157,6 +172,12 @@ Checker 和代码审计生成的 Markdown 使用简体中文,包括标题、
|
|
|
157
172
|
5. Blocked 时进入人工决策;只有用户选择 `stop` 才终止。
|
|
158
173
|
6. 验收通过或用户接受 waiver 后,`$code-audit` 审查代码规范和规格符合度。
|
|
159
174
|
|
|
175
|
+
### 快速流程 `$delivery-loop-fast`
|
|
176
|
+
|
|
177
|
+
快速流程由用户显式选择,不进行风险评分,也不会自动切换为完整流程。当前 Agent 根据 spec 实现代码、执行仓库定向检查,并使用上述交互式 Playwright 工具自测、截图和自查自纠;业务代码或测试步骤变化后开启新的完整自测 iteration。最新快速自测通过后,再由全新的独立 `$code-audit` 代理审查固定范围。审计发现阻断问题时,当前 Agent 修复后必须重新完成仓库检查、快速自测和独立审计。
|
|
178
|
+
|
|
179
|
+
快速流程不生成持久化 E2E 场景、测试编写者结果或 Checker 结果。完成条件仍包括:最新交互式自测通过、每个报告功能组具备截图、独立代码审计通过,并且证据与状态一致。现有 `$delivery-loop` 的角色隔离、冻结 E2E 和可见验收流程保持不变。
|
|
180
|
+
|
|
160
181
|
运行器或工具故障不消耗实现者修复回合。隔离 scene 会先继续执行其他场景,再进入 `awaiting-decision`;用户可查看可见浏览器和完整的其余场景证据后重试、确认、跳过、分类为实现/测试缺陷或停止。
|
|
161
182
|
|
|
162
183
|
## 源码开发与验证
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"node": ">=20"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"lint": "node --check ../scripts/setup.mjs && node --check ../scripts/start-mcp.mjs && node --check ../scripts/export-delivery-report.mjs && node --check src/paths.mjs && node --check src/report.mjs && node --check src/runner.mjs && node --check src/server.mjs && node --check tests/fixture-server.mjs && node --check tests/report.test.mjs && node --check tests/runner.test.mjs && node --check tests/mcp-smoke.test.mjs && node --check tests/fixtures/basic.feature.mjs",
|
|
10
|
+
"lint": "node --check ../scripts/setup.mjs && node --check ../scripts/start-mcp.mjs && node --check ../scripts/export-delivery-report.mjs && node --check src/paths.mjs && node --check src/report.mjs && node --check src/runner.mjs && node --check src/fast-runner.mjs && node --check src/server.mjs && node --check tests/fixture-server.mjs && node --check tests/report.test.mjs && node --check tests/runner.test.mjs && node --check tests/fast-runner.test.mjs && node --check tests/mcp-smoke.test.mjs && node --check tests/fixtures/basic.feature.mjs",
|
|
11
11
|
"test": "node --test --test-concurrency=1 tests/*.test.mjs",
|
|
12
12
|
"verify": "npm run lint && npm test"
|
|
13
13
|
},
|