@autobest-ui/agent 1.0.8 → 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.
Files changed (32) hide show
  1. package/bin/sync-assets.test.mjs +2 -0
  2. package/mcp/code-mcp-pr/README.md +3 -2
  3. package/mcp/code-mcp-pr/azure-devops.js +81 -2
  4. package/mcp/code-mcp-pr/index.js +34 -1
  5. package/mcp/code-mcp-pr/index.test.js +161 -11
  6. package/package.json +1 -1
  7. package/plugins/autobest-delivery/.codex-plugin/plugin.json +1 -0
  8. package/plugins/autobest-delivery/README.md +52 -10
  9. package/plugins/autobest-delivery/mcp-server/package.json +1 -1
  10. package/plugins/autobest-delivery/mcp-server/src/fast-runner.mjs +470 -0
  11. package/plugins/autobest-delivery/mcp-server/src/paths.mjs +12 -7
  12. package/plugins/autobest-delivery/mcp-server/src/report.mjs +118 -16
  13. package/plugins/autobest-delivery/mcp-server/src/runner.mjs +163 -1
  14. package/plugins/autobest-delivery/mcp-server/src/server.mjs +173 -3
  15. package/plugins/autobest-delivery/mcp-server/tests/fast-runner.test.mjs +232 -0
  16. package/plugins/autobest-delivery/mcp-server/tests/fixture-server.mjs +5 -0
  17. package/plugins/autobest-delivery/mcp-server/tests/mcp-smoke.test.mjs +71 -0
  18. package/plugins/autobest-delivery/mcp-server/tests/report.test.mjs +107 -1
  19. package/plugins/autobest-delivery/mcp-server/tests/runner.test.mjs +279 -0
  20. package/plugins/autobest-delivery/skills/code-audit/SKILL.md +3 -3
  21. package/plugins/autobest-delivery/skills/delivery-loop/SKILL.md +10 -7
  22. package/plugins/autobest-delivery/skills/delivery-loop/references/delivery-contract.md +63 -6
  23. package/plugins/autobest-delivery/skills/delivery-loop-fast/SKILL.md +62 -0
  24. package/plugins/autobest-delivery/skills/delivery-loop-fast/agents/openai.yaml +7 -0
  25. package/plugins/autobest-delivery/skills/e2e-gen-spec/SKILL.md +7 -6
  26. package/plugins/autobest-delivery/skills/e2e-ui-checker/SKILL.md +5 -4
  27. package/plugins/autobest-delivery/skills/export-report/SKILL.md +6 -6
  28. package/plugins/autobest-delivery/skills/export-report/agents/openai.yaml +1 -2
  29. package/skills/README.md +1 -0
  30. package/skills/common/code-pr-submit/SKILL.md +25 -4
  31. package/skills/common/repo-setup/SKILL.md +91 -0
  32. package/skills/common/repo-setup/agents/openai.yaml +4 -0
@@ -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` 是所有工具访问 Azure DevOps 的必需配置。`DINGDING_ALERT_TOKEN` 仅在 `code-mcp-pr` 同时收到钉钉 markdown 标题和正文时使用。两个 token 都不会作为 MCP 工具参数接收。
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`。仅在 PR 创建成功且两个 markdown 字段都有值时发送钉钉通知。
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) return { ...baseResult, errorMsg: createResult.errorMsg };
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();
@@ -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, 2);
92
- assert.match(calls[0].url, new RegExp(`api-version=${API_VERSION.replaceAll('.', '\\.')}`));
93
- assert.deepEqual(JSON.parse(calls[0].options.body), result.adoApiPayload);
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[1].options.body).markdown.text,
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[0].options.headers.Authorization, /secret-pat/);
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, 1);
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 () => jsonResponse({ pullRequestId: 7 });
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 five tools over stdio', async () => {
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
- ['code-mcp-pr', 'get_repo_default_branch', 'guess_parent_branch', 'get_branch_diff', 'read_file_at_ref']
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobest-ui/agent",
3
- "version": "1.0.8",
3
+ "version": "1.0.11",
4
4
  "private": false,
5
5
  "description": "Autobest Agent skills/plugins/mcp assets + sync cli",
6
6
  "files": [
@@ -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 交付闭环 Skill 与插件自带的 Playwright MCP 运行器打包在一起。目标业务仓库只需提供应用源码、功能规格和启动命令,不需要安装 Playwright 依赖。
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`、配置或源码。每次执行前后都会校验冻结场景的 SHA-256。
9
+ 运行器不会在目标工作区调用包管理器,也不会修改目标仓库的清单、锁文件、`node_modules`、配置或源码。标准 `run_feature_e2e` 每次执行前后都会校验冻结场景的 SHA-256。
10
10
 
11
11
  ## 使用 npx 安装
12
12
 
@@ -65,10 +65,27 @@ npx --yes --package=@autobest-ui/agent@latest autobest-delivery-setup --help
65
65
  - `baseUrl`:可访问的应用 URL。
66
66
  - `outputDir`:工作区内的证据输出目录。
67
67
 
68
- 可选参数包括 `iteration`、`timeoutMs`、`headed`、`keepBrowserOpenOnBlock` 和 `blockedSessionTtlMs`。交付验收使用 headed 模式;发生 Blocked 时,工具保留可见浏览器并返回 session ID、过期时间、结构化检查、组件截图、Trace、控制台错误、网络失败和 SHA-256。
68
+ 新建或修订场景还必须传入用户确认的 `apiMode`(`real` 或 `mock`);Runner 会校验它与冻结场景一致。可选参数包括 `iteration`、`timeoutMs`、`headed`、`keepBrowserOpenOnBlock` 和 `blockedSessionTtlMs`。交付验收使用 headed 模式;发生 Blocked 时,工具保留可见浏览器并返回 session ID、过期时间、结构化检查、API 响应证据、组件截图、Trace、控制台错误、网络失败和 SHA-256。
69
+
70
+ `real` 模式禁止 `route()` 和 `page.route()` 请求拦截,并要求每个声明业务端点都产生响应证据;缺失时不能判为通过。`mock` 模式允许受控 fixture,但 Runner 和报告会明确标记为模拟。Delivery 命令未指定模式时会在 E2E 前询问;例如“执行真实API”会直接锁定 `real`,不会自动创建 mock。
69
71
 
70
72
  新版场景通过 `scene(id, callback)` 隔离页面和执行错误。一个 scene 的定位器、弹窗或准备步骤阻塞时,运行器会记录该 blocker 并继续执行后续独立 scene,全部 scene 尝试结束后再返回 `blocked`。旧版直接使用顶层 `page` 的场景仍可运行,但无法在未捕获异常后继续。
71
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
+
72
89
  ### `resolve_blocked_run`
73
90
 
74
91
  用户查看 Blocked 浏览器后,由创建该会话的验收代理调用此工具。工具将选择写入 `human-decision.json` 并关闭浏览器;`accept` 或 `skip` 会形成 waiver,而不是伪装成完整通过。
@@ -90,10 +107,29 @@ npx --yes --package=@autobest-ui/agent@latest autobest-delivery-setup --help
90
107
 
91
108
  ```js
92
109
  export const metadata = {
110
+ apiModeSchemaVersion: 1,
111
+ apiMode: 'real',
112
+ apiTargets: [{
113
+ id: 'product-detail',
114
+ method: 'GET',
115
+ pathname: '/api/product/detail'
116
+ }],
117
+ reportSchemaVersion: 1,
118
+ reportCaptureSchemaVersion: 1,
93
119
  traceabilitySchemaVersion: 1,
94
120
  requirementIds: ['REQ-PD-001'],
95
121
  scenes: ['initial'],
96
122
  viewports: [{ name: 'desktop', width: 1400, height: 1000 }],
123
+ reportCaptures: [{
124
+ requirementId: 'REQ-PD-001',
125
+ scene: 'initial',
126
+ capture: 'product-detail-desktop.png',
127
+ locator: '[data-testid="product-detail"]',
128
+ reportModule: '产品详情',
129
+ reportGroup: 'product-detail',
130
+ reportTitle: '产品详情',
131
+ reportDevice: 'desktop'
132
+ }],
97
133
  visualMappings: []
98
134
  };
99
135
 
@@ -113,7 +149,7 @@ export default async function run({
113
149
  }
114
150
  ```
115
151
 
116
- 每个 `metadata.scenes` 项必须由 `scene()` 恰好执行一次,并自行完成导航、API fixture 和状态准备。测试编写者在冻结 SHA-256 前使用同一运行器进行 preflight;定位器、弹窗、fixture 和等待条件等测试机械问题应在草稿阶段修正。preflight 只证明测试代码能够完整执行,不替代独立验收和视觉审查。
152
+ 每个 `metadata.scenes` 项必须由 `scene()` 恰好执行一次,并自行完成导航、数据和状态准备。`real` 模式使用真实环境数据且禁止请求拦截;`mock` 模式才允许 API fixture。每个报告功能组至少声明并生成一张代表性 UI 截图。测试编写者在冻结 SHA-256 前使用相同 API 模式和同一运行器进行 preflight;定位器、弹窗、fixture 和等待条件等测试机械问题应在草稿阶段修正。preflight 只证明测试代码能够完整执行、声明 API 已响应且报告截图已生成,不替代独立验收和视觉审查。
117
153
 
118
154
  视觉审查使用 `1400px` 桌面视口和与 Figma 一致的 `375px` 移动视口。截图必须定位到与基准相同的语义节点;比较组件结构、布局和视觉样式,不比较业务文字的字面值、长度或业务图片主体。文案及数据内容由独立功能检查验证。
119
155
 
@@ -123,19 +159,25 @@ export default async function run({
123
159
 
124
160
  Checker 和代码审计生成的 Markdown 使用简体中文,包括标题、摘要、表头、检查说明、期望、实际结果和 finding。JSON 字段名、机器状态、ID、路径及原始工具错误保持协议格式;英文原始错误旁提供中文解释。
125
161
 
126
- 手动调用 `$export-report` 时,Excel 只生成一个 `自测报告` 工作表。每行代表一个真实 UI 组件或完整业务功能;同一组件或功能的文案、样式、布局、响应式行为和相关检查合并到同一行,桌面端与移动端代表截图也放在该行。报告不展示 story ID、scene 名或原子检查明细。新证据通过 `reportModule`、`reportGroup`、`reportTitle`、`reportMethod` 和 `reportDevice` 提供通用分组;旧证据必须由导出 Skill 分析 `spec.md` 后生成经过哈希和完整覆盖校验的语义分组清单,禁止按 E2E scene 猜测。
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 猜测。
127
163
 
128
164
  ## 交付流程
129
165
 
130
- 功能目录存在 `spec-traceability.json` 时,Delivery 按 active REQ ID 运行。Maker 报告实现编号,E2E 检查和视觉映射携带 `requirementId`,Checker 与 Audit 逐项核对;运行器拒绝格式错误、未知编号和无验收覆盖的需求。没有追踪文件的历史交付仍按旧契约运行。
166
+ 功能目录存在 `spec-traceability.json` 时,Delivery 按 active REQ ID 运行。Maker 报告实现编号,E2E 检查、报告截图和视觉映射携带 `requirementId`,Checker 与 Audit 逐项核对;运行器拒绝格式错误、未知编号和无验收覆盖的需求。没有追踪文件的历史交付仍按旧契约运行。
131
167
 
132
- 1. `$delivery-loop` 校验可用的需求追踪文件,调用 `check_environment` 并记录固定审查点。
168
+ 1. `$delivery-loop` 校验需求追踪文件,确认 `real` 或 `mock` API 模式,调用 `check_environment` 并记录固定审查点。
133
169
  2. `$code-craft` 实现规格。
134
- 3. `$e2e-gen-spec` 创建无依赖场景,运行冻结前 preflight,确认所有 scene 和检查可达后冻结。
135
- 4. `$e2e-ui-checker` 启动声明的应用服务,调用 `run_feature_e2e`,并审查已映射的组件截图。
170
+ 3. `$e2e-gen-spec` 按已确认 API 模式创建无依赖场景,运行冻结前 preflight,确认 API、scene、检查和报告截图可达后冻结。
171
+ 4. `$e2e-ui-checker` 启动声明的应用服务,使用相同 API 模式调用 `run_feature_e2e`,核对 API 和报告截图证据,并审查有本地设计基准的视觉映射。
136
172
  5. Blocked 时进入人工决策;只有用户选择 `stop` 才终止。
137
173
  6. 验收通过或用户接受 waiver 后,`$code-audit` 审查代码规范和规格符合度。
138
174
 
175
+ ### 快速流程 `$delivery-loop-fast`
176
+
177
+ 快速流程由用户显式选择,不进行风险评分,也不会自动切换为完整流程。当前 Agent 根据 spec 实现代码、执行仓库定向检查,并使用上述交互式 Playwright 工具自测、截图和自查自纠;业务代码或测试步骤变化后开启新的完整自测 iteration。最新快速自测通过后,再由全新的独立 `$code-audit` 代理审查固定范围。审计发现阻断问题时,当前 Agent 修复后必须重新完成仓库检查、快速自测和独立审计。
178
+
179
+ 快速流程不生成持久化 E2E 场景、测试编写者结果或 Checker 结果。完成条件仍包括:最新交互式自测通过、每个报告功能组具备截图、独立代码审计通过,并且证据与状态一致。现有 `$delivery-loop` 的角色隔离、冻结 E2E 和可见验收流程保持不变。
180
+
139
181
  运行器或工具故障不消耗实现者修复回合。隔离 scene 会先继续执行其他场景,再进入 `awaiting-decision`;用户可查看可见浏览器和完整的其余场景证据后重试、确认、跳过、分类为实现/测试缺陷或停止。
140
182
 
141
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
  },