@fitlab-ai/agent-infra 0.5.2 → 0.5.4
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 +3 -3
- package/README.zh-CN.md +3 -3
- package/lib/merge.js +22 -7
- package/lib/sandbox/commands/rm.js +1 -1
- package/lib/sandbox/runtimes/base.dockerfile +17 -1
- package/package.json +1 -1
- package/templates/.agents/rules/issue-pr-commands.github.en.md +25 -9
- package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +25 -9
- package/templates/.agents/rules/issue-sync.github.en.md +111 -23
- package/templates/.agents/rules/issue-sync.github.zh-CN.md +105 -17
- package/templates/.agents/rules/milestone-inference.github.en.md +13 -6
- package/templates/.agents/rules/milestone-inference.github.zh-CN.md +13 -6
- package/templates/.agents/rules/pr-sync.github.en.md +3 -1
- package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -1
- package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +1080 -0
- package/templates/.agents/scripts/validate-artifact.js +54 -805
- package/templates/.agents/skills/analyze-task/SKILL.en.md +4 -4
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/analyze-task/config/verify.json +1 -1
- package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +1 -1
- package/templates/.agents/skills/block-task/SKILL.en.md +4 -4
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/block-task/config/verify.json +1 -1
- package/templates/.agents/skills/cancel-task/SKILL.en.md +18 -18
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +18 -18
- package/templates/.agents/skills/cancel-task/config/verify.json +1 -1
- package/templates/.agents/skills/close-codescan/SKILL.en.md +2 -2
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +2 -2
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/commit/SKILL.en.md +15 -3
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +15 -3
- package/templates/.agents/skills/commit/config/verify.json +2 -1
- package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +23 -0
- package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +23 -0
- package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -2
- package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -2
- package/templates/.agents/skills/complete-task/SKILL.en.md +13 -13
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +13 -13
- package/templates/.agents/skills/complete-task/config/verify.json +1 -1
- package/templates/.agents/skills/create-issue/SKILL.en.md +4 -2
- package/templates/.agents/skills/create-issue/SKILL.zh-CN.md +4 -2
- package/templates/.agents/skills/create-issue/config/verify.json +1 -1
- package/templates/.agents/skills/create-issue/reference/label-and-type.en.md +6 -1
- package/templates/.agents/skills/create-issue/reference/label-and-type.zh-CN.md +6 -1
- package/templates/.agents/skills/create-pr/SKILL.en.md +5 -5
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +5 -5
- package/templates/.agents/skills/create-pr/config/verify.json +1 -1
- package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +9 -5
- package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +9 -5
- package/templates/.agents/skills/create-task/SKILL.en.md +4 -4
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/create-task/config/verify.json +1 -1
- package/templates/.agents/skills/implement-task/SKILL.en.md +6 -6
- package/templates/.agents/skills/implement-task/SKILL.zh-CN.md +6 -6
- package/templates/.agents/skills/implement-task/config/verify.json +1 -2
- package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -2
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/import-codescan/config/verify.json +1 -1
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -2
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/import-dependabot/config/verify.json +1 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +5 -5
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +5 -5
- package/templates/.agents/skills/import-issue/config/verify.json +1 -1
- package/templates/.agents/skills/plan-task/SKILL.en.md +4 -4
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/plan-task/config/verify.json +1 -1
- package/templates/.agents/skills/refine-task/SKILL.en.md +4 -6
- package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +4 -6
- package/templates/.agents/skills/refine-task/config/verify.json +1 -2
- package/templates/.agents/skills/refine-title/SKILL.en.md +5 -1
- package/templates/.agents/skills/refine-title/SKILL.zh-CN.md +5 -1
- package/templates/.agents/skills/restore-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/restore-task/config/verify.json +1 -1
- package/templates/.agents/skills/review-task/SKILL.en.md +4 -4
- package/templates/.agents/skills/review-task/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/review-task/config/verify.json +1 -1
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +1 -1
- package/templates/.agents/templates/task.en.md +3 -3
- package/templates/.agents/templates/task.zh-CN.md +3 -3
- package/templates/.github/workflows/metadata-sync.yml +127 -0
- package/templates/.github/workflows/pr-label.yml +75 -0
- package/templates/.github/workflows/status-label.yml +12 -8
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const CHECK_TYPE = "platform-sync";
|
|
7
|
+
const DEFAULT_RETRY_DELAYS_MS = [3000, 10000];
|
|
8
|
+
|
|
9
|
+
let activeShared = null;
|
|
10
|
+
let repoRoot = "";
|
|
11
|
+
|
|
12
|
+
function getShared() {
|
|
13
|
+
if (!activeShared) {
|
|
14
|
+
throw new Error("platform-sync adapter shared utilities are unavailable");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return activeShared;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadTask(...args) {
|
|
21
|
+
return getShared().loadTask(...args);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getCheckedRequirements(...args) {
|
|
25
|
+
return getShared().getCheckedRequirements(...args);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeContent(...args) {
|
|
29
|
+
return getShared().normalizeContent(...args);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isBlank(...args) {
|
|
33
|
+
return getShared().isBlank(...args);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function escapeRegExp(...args) {
|
|
37
|
+
return getShared().escapeRegExp(...args);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function passResult(...args) {
|
|
41
|
+
return getShared().passResult(...args);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function failResult(...args) {
|
|
45
|
+
return getShared().failResult(...args);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function blockedResult(...args) {
|
|
49
|
+
return getShared().blockedResult(...args);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeStat(...args) {
|
|
53
|
+
return getShared().safeStat(...args);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseIssueNumber(...args) {
|
|
57
|
+
return getShared().parseIssueNumber(...args);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parsePrNumber(...args) {
|
|
61
|
+
return getShared().parsePrNumber(...args);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function check({ taskDir, config, artifactFile }, shared) {
|
|
65
|
+
activeShared = shared;
|
|
66
|
+
repoRoot = shared.repoRoot;
|
|
67
|
+
const context = buildSyncContext({ taskDir, config, artifactFile });
|
|
68
|
+
if (context.earlyReturn) {
|
|
69
|
+
return context.earlyReturn;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const remoteData = fetchRemoteData(context);
|
|
73
|
+
if (remoteData.earlyReturn) {
|
|
74
|
+
return remoteData.earlyReturn;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const subChecks = [
|
|
78
|
+
checkStatusLabel,
|
|
79
|
+
checkCommentMarker,
|
|
80
|
+
checkPrCommentMarker,
|
|
81
|
+
checkPrCommentLastCommit,
|
|
82
|
+
checkCommentContent,
|
|
83
|
+
checkTaskCommentContent,
|
|
84
|
+
checkInLabelsComputed,
|
|
85
|
+
checkInLabelsMatchPr,
|
|
86
|
+
checkPrAssignee,
|
|
87
|
+
checkSyncedRequirements,
|
|
88
|
+
checkIssueType,
|
|
89
|
+
checkMilestone
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
for (const subCheck of subChecks) {
|
|
93
|
+
const result = subCheck(context, remoteData);
|
|
94
|
+
if (result) {
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return passResult(CHECK_TYPE, `GitHub sync checks passed for Issue #${context.issueNumber}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildSyncContext({ taskDir, config, artifactFile }) {
|
|
103
|
+
const task = loadTask(taskDir);
|
|
104
|
+
if (!task.ok) {
|
|
105
|
+
return { earlyReturn: failResult(CHECK_TYPE, task.message) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const issueNumber = parseIssueNumber(task.metadata.issue_number);
|
|
109
|
+
const prNumber = parsePrNumber(task.metadata.pr_number);
|
|
110
|
+
if (config.when === "issue_number_exists" && !issueNumber) {
|
|
111
|
+
return { earlyReturn: passResult(CHECK_TYPE, "Skipped: task has no issue_number") };
|
|
112
|
+
}
|
|
113
|
+
if (config.when === "pr_number_exists" && !prNumber) {
|
|
114
|
+
return { earlyReturn: passResult(CHECK_TYPE, "Skipped: task has no pr_number") };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!issueNumber) {
|
|
118
|
+
return { earlyReturn: passResult(CHECK_TYPE, "Skipped: platform-sync not required for this task") };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const upstreamRepo = resolveUpstreamRepo(taskDir);
|
|
122
|
+
if (!upstreamRepo.ok) {
|
|
123
|
+
return { earlyReturn: blockedResult(CHECK_TYPE, upstreamRepo.message, "network_error") };
|
|
124
|
+
}
|
|
125
|
+
const permissions = detectPermissions(upstreamRepo.value, taskDir);
|
|
126
|
+
|
|
127
|
+
const marker = config.expected_comment_marker
|
|
128
|
+
? interpolate(config.expected_comment_marker, taskDir, artifactFile)
|
|
129
|
+
: null;
|
|
130
|
+
const prMarker = config.expected_pr_comment_marker
|
|
131
|
+
? interpolate(config.expected_pr_comment_marker, taskDir, artifactFile)
|
|
132
|
+
: null;
|
|
133
|
+
const artifactPath = artifactFile ? path.join(taskDir, artifactFile) : null;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
task,
|
|
137
|
+
taskDir,
|
|
138
|
+
config,
|
|
139
|
+
artifactFile,
|
|
140
|
+
artifactPath,
|
|
141
|
+
issueNumber,
|
|
142
|
+
prNumber,
|
|
143
|
+
upstreamRepo: upstreamRepo.value,
|
|
144
|
+
hasTriage: permissions.hasTriage,
|
|
145
|
+
hasPush: permissions.hasPush,
|
|
146
|
+
marker,
|
|
147
|
+
prMarker
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fetchRemoteData(context) {
|
|
152
|
+
let issueResult = withRetry(() => ghJson([
|
|
153
|
+
"issue",
|
|
154
|
+
"view",
|
|
155
|
+
String(context.issueNumber),
|
|
156
|
+
"-R",
|
|
157
|
+
context.upstreamRepo,
|
|
158
|
+
"--json",
|
|
159
|
+
"state,labels,body,milestone"
|
|
160
|
+
], context.taskDir));
|
|
161
|
+
if (!issueResult.ok && issueResult.type !== "check_failed") {
|
|
162
|
+
const fallbackIssueResult = withRetry(() => ghJson([
|
|
163
|
+
"api",
|
|
164
|
+
`repos/${context.upstreamRepo}/issues/${context.issueNumber}`
|
|
165
|
+
], context.taskDir));
|
|
166
|
+
if (fallbackIssueResult.ok) {
|
|
167
|
+
issueResult = {
|
|
168
|
+
ok: true,
|
|
169
|
+
value: normalizeIssuePayload(fallbackIssueResult.value)
|
|
170
|
+
};
|
|
171
|
+
} else {
|
|
172
|
+
issueResult = fallbackIssueResult;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!issueResult.ok) {
|
|
176
|
+
return {
|
|
177
|
+
earlyReturn: issueResult.type === "check_failed"
|
|
178
|
+
? failResult(CHECK_TYPE, issueResult.message, issueResult.type)
|
|
179
|
+
: blockedResult(CHECK_TYPE, issueResult.message, issueResult.type)
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const issue = issueResult.value;
|
|
184
|
+
if (context.config.issue_must_exist !== false && !issue) {
|
|
185
|
+
return {
|
|
186
|
+
earlyReturn: failResult(CHECK_TYPE, `Issue #${context.issueNumber} not found`, "check_failed")
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let comments = null;
|
|
191
|
+
if (shouldFetchComments(context.config)) {
|
|
192
|
+
const commentsResult = withRetry(() => ghPaginatedJson([
|
|
193
|
+
"api",
|
|
194
|
+
"--paginate",
|
|
195
|
+
"--slurp",
|
|
196
|
+
`repos/${context.upstreamRepo}/issues/${context.issueNumber}/comments?per_page=100`
|
|
197
|
+
], context.taskDir));
|
|
198
|
+
|
|
199
|
+
if (!commentsResult.ok) {
|
|
200
|
+
return {
|
|
201
|
+
earlyReturn: commentsResult.type === "check_failed"
|
|
202
|
+
? failResult(CHECK_TYPE, commentsResult.message, commentsResult.type)
|
|
203
|
+
: blockedResult(CHECK_TYPE, commentsResult.message, commentsResult.type)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
comments = flattenComments(commentsResult.value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let prComments = null;
|
|
211
|
+
if (context.config.expected_pr_comment_marker) {
|
|
212
|
+
if (!context.prNumber) {
|
|
213
|
+
return {
|
|
214
|
+
earlyReturn: failResult(CHECK_TYPE, "Expected a valid pr_number for PR comment verification", "check_failed")
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const prCommentsResult = withRetry(() => ghPaginatedJson([
|
|
219
|
+
"api",
|
|
220
|
+
"--paginate",
|
|
221
|
+
"--slurp",
|
|
222
|
+
`repos/${context.upstreamRepo}/issues/${context.prNumber}/comments?per_page=100`
|
|
223
|
+
], context.taskDir));
|
|
224
|
+
|
|
225
|
+
if (!prCommentsResult.ok) {
|
|
226
|
+
return {
|
|
227
|
+
earlyReturn: prCommentsResult.type === "check_failed"
|
|
228
|
+
? failResult(CHECK_TYPE, prCommentsResult.message, prCommentsResult.type)
|
|
229
|
+
: blockedResult(CHECK_TYPE, prCommentsResult.message, prCommentsResult.type)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
prComments = flattenComments(prCommentsResult.value);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let issueType;
|
|
237
|
+
if (context.config.verify_issue_type && context.hasPush) {
|
|
238
|
+
const issueTypeResult = withRetry(() => ghText([
|
|
239
|
+
"api",
|
|
240
|
+
`repos/${context.upstreamRepo}/issues/${context.issueNumber}`,
|
|
241
|
+
"--jq",
|
|
242
|
+
".type.name // empty"
|
|
243
|
+
], context.taskDir));
|
|
244
|
+
|
|
245
|
+
if (issueTypeResult.ok) {
|
|
246
|
+
issueType = issueTypeResult.value || null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let prLabels = null;
|
|
251
|
+
let prMilestone;
|
|
252
|
+
let prAssignees;
|
|
253
|
+
if (((context.config.verify_in_labels_match_pr && context.hasTriage)
|
|
254
|
+
|| (context.config.verify_milestone && context.hasTriage)
|
|
255
|
+
|| (context.config.verify_pr_assignee && context.hasPush)) && context.prNumber) {
|
|
256
|
+
const prFields = [];
|
|
257
|
+
if (context.config.verify_in_labels_match_pr) {
|
|
258
|
+
prFields.push("labels");
|
|
259
|
+
}
|
|
260
|
+
if (context.config.verify_milestone) {
|
|
261
|
+
prFields.push("milestone");
|
|
262
|
+
}
|
|
263
|
+
if (context.config.verify_pr_assignee) {
|
|
264
|
+
prFields.push("assignees");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const prResult = withRetry(() => ghJson([
|
|
268
|
+
"pr",
|
|
269
|
+
"view",
|
|
270
|
+
String(context.prNumber),
|
|
271
|
+
"--json",
|
|
272
|
+
prFields.join(",")
|
|
273
|
+
], context.taskDir));
|
|
274
|
+
|
|
275
|
+
if (!prResult.ok) {
|
|
276
|
+
return {
|
|
277
|
+
earlyReturn: prResult.type === "check_failed"
|
|
278
|
+
? failResult(CHECK_TYPE, prResult.message, prResult.type)
|
|
279
|
+
: blockedResult(CHECK_TYPE, prResult.message, prResult.type)
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
prLabels = context.config.verify_in_labels_match_pr
|
|
284
|
+
? extractLabelNames(prResult.value?.labels)
|
|
285
|
+
: null;
|
|
286
|
+
prMilestone = context.config.verify_milestone
|
|
287
|
+
? prResult.value?.milestone ?? null
|
|
288
|
+
: undefined;
|
|
289
|
+
prAssignees = context.config.verify_pr_assignee
|
|
290
|
+
? (prResult.value?.assignees || []).map((a) => a.login).filter(Boolean)
|
|
291
|
+
: undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
issue,
|
|
296
|
+
comments,
|
|
297
|
+
prComments,
|
|
298
|
+
prLabels,
|
|
299
|
+
issueType,
|
|
300
|
+
prMilestone,
|
|
301
|
+
prAssignees
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function shouldFetchComments(config) {
|
|
306
|
+
return Boolean(
|
|
307
|
+
config.expected_comment_marker
|
|
308
|
+
|| config.expected_pr_comment_marker
|
|
309
|
+
|| config.verify_pr_comment_last_commit_matches_head
|
|
310
|
+
|| config.verify_comment_content
|
|
311
|
+
|| config.verify_task_comment_content
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function flattenComments(value) {
|
|
316
|
+
if (!Array.isArray(value)) {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return value.flatMap((page) => Array.isArray(page) ? page : []);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function checkStatusLabel(context, remoteData) {
|
|
324
|
+
if (!context.config.expected_status_label || !context.hasTriage) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (String(remoteData.issue.state || "").toUpperCase() !== "OPEN") {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const labels = extractLabelNames(remoteData.issue.labels);
|
|
333
|
+
if (labels.includes(context.config.expected_status_label)) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return failResult(CHECK_TYPE,
|
|
338
|
+
`Expected label '${context.config.expected_status_label}' not found on Issue #${context.issueNumber}`,
|
|
339
|
+
"check_failed"
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function checkCommentMarker(context, remoteData) {
|
|
344
|
+
if (!context.marker) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const comment = findCommentByMarker(remoteData.comments, context.marker);
|
|
349
|
+
if (comment) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return failResult(CHECK_TYPE,
|
|
354
|
+
`Expected comment marker '${context.marker}' not found on Issue #${context.issueNumber}`,
|
|
355
|
+
"check_failed"
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function checkPrCommentMarker(context, remoteData) {
|
|
360
|
+
if (!context.prMarker) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const comment = findCommentByMarker(remoteData.prComments, context.prMarker);
|
|
365
|
+
if (comment) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return failResult(CHECK_TYPE,
|
|
370
|
+
`Expected PR comment marker '${context.prMarker}' not found on PR #${context.prNumber}`,
|
|
371
|
+
"check_failed"
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function checkPrCommentLastCommit(context, remoteData) {
|
|
376
|
+
if (!context.config.verify_pr_comment_last_commit_matches_head) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!context.prMarker) {
|
|
381
|
+
return failResult(CHECK_TYPE,
|
|
382
|
+
"verify_pr_comment_last_commit_matches_head requires expected_pr_comment_marker",
|
|
383
|
+
"check_failed"
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const comment = findCommentByMarker(remoteData.prComments, context.prMarker);
|
|
388
|
+
if (!comment) {
|
|
389
|
+
return failResult(CHECK_TYPE,
|
|
390
|
+
`Expected PR comment marker '${context.prMarker}' not found on PR #${context.prNumber}`,
|
|
391
|
+
"check_failed"
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const match = String(comment.body || "").match(/<!--\s*last-commit:\s*([0-9a-f]{7,40})\s*-->/i);
|
|
396
|
+
if (!match) {
|
|
397
|
+
return failResult(CHECK_TYPE,
|
|
398
|
+
`PR #${context.prNumber} summary comment is missing '<!-- last-commit: <sha> -->' metadata`,
|
|
399
|
+
"check_failed"
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const headResult = withRetry(() => gitText(["rev-parse", "HEAD"], context.taskDir));
|
|
404
|
+
if (!headResult.ok) {
|
|
405
|
+
return headResult.type === "check_failed"
|
|
406
|
+
? failResult(CHECK_TYPE, headResult.message, headResult.type)
|
|
407
|
+
: blockedResult(CHECK_TYPE, headResult.message, headResult.type);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const expectedHead = String(headResult.value || "").trim();
|
|
411
|
+
const actualHead = match[1].trim();
|
|
412
|
+
if (expectedHead === actualHead) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return failResult(CHECK_TYPE,
|
|
417
|
+
`PR #${context.prNumber} summary comment last-commit metadata mismatch: expected ${expectedHead}, got ${actualHead}`,
|
|
418
|
+
"check_failed"
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function checkCommentContent(context, remoteData) {
|
|
423
|
+
if (!context.config.verify_comment_content) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!context.marker) {
|
|
428
|
+
return failResult(CHECK_TYPE, "verify_comment_content requires expected_comment_marker", "check_failed");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!context.artifactPath || !safeStat(context.artifactPath)) {
|
|
432
|
+
return failResult(CHECK_TYPE,
|
|
433
|
+
`Artifact not found for comment verification: ${context.artifactFile || "(missing artifactFile)"}`,
|
|
434
|
+
"check_failed"
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const comment = findCommentByMarker(remoteData.comments, context.marker);
|
|
439
|
+
const localContent = normalizeContent(fs.readFileSync(context.artifactPath, "utf8"));
|
|
440
|
+
const commentContent = normalizeContent(extractCommentBody(comment?.body || ""));
|
|
441
|
+
|
|
442
|
+
if (localContent === commentContent) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return failResult(CHECK_TYPE,
|
|
447
|
+
buildCommentContentMismatchMessage(
|
|
448
|
+
path.basename(context.artifactPath, path.extname(context.artifactPath)),
|
|
449
|
+
context.issueNumber,
|
|
450
|
+
localContent,
|
|
451
|
+
commentContent
|
|
452
|
+
),
|
|
453
|
+
"check_failed"
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function checkTaskCommentContent(context, remoteData) {
|
|
458
|
+
if (!context.config.verify_task_comment_content) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const taskMarker = `<!-- sync-issue:${context.task.metadata.id}:task -->`;
|
|
463
|
+
const comment = findCommentByMarker(remoteData.comments, taskMarker);
|
|
464
|
+
if (!comment) {
|
|
465
|
+
return failResult(CHECK_TYPE,
|
|
466
|
+
`Expected comment marker '${taskMarker}' not found on Issue #${context.issueNumber}`,
|
|
467
|
+
"check_failed"
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const expectedBody = normalizeContent(buildExpectedTaskBody(context.task.content));
|
|
472
|
+
const commentBody = normalizeContent(extractCommentBody(comment.body || ""));
|
|
473
|
+
|
|
474
|
+
if (expectedBody === commentBody) {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return failResult(CHECK_TYPE,
|
|
479
|
+
buildCommentContentMismatchMessage("task", context.issueNumber, expectedBody, commentBody),
|
|
480
|
+
"check_failed"
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function checkInLabelsMatchPr(context, remoteData) {
|
|
485
|
+
if (!context.config.verify_in_labels_match_pr || !context.hasTriage || !context.prNumber || !remoteData.prLabels) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const issueInLabels = extractLabelNames(remoteData.issue.labels)
|
|
490
|
+
.filter((label) => label.startsWith("in:"))
|
|
491
|
+
.sort();
|
|
492
|
+
const prInLabels = remoteData.prLabels
|
|
493
|
+
.filter((label) => label.startsWith("in:"))
|
|
494
|
+
.sort();
|
|
495
|
+
|
|
496
|
+
if (arraysEqual(issueInLabels, prInLabels)) {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return failResult(CHECK_TYPE,
|
|
501
|
+
`in: labels mismatch — PR #${context.prNumber} has [${formatLabelList(prInLabels)}], Issue #${context.issueNumber} has [${formatLabelList(issueInLabels)}]`,
|
|
502
|
+
"check_failed"
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function checkInLabelsComputed(context, remoteData) {
|
|
507
|
+
if (!context.config.verify_in_labels_computed || !context.hasTriage) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const expectedInLabels = computeExpectedInLabels(context.taskDir);
|
|
512
|
+
if (!expectedInLabels.ok) {
|
|
513
|
+
return expectedInLabels.type === "check_failed"
|
|
514
|
+
? failResult(CHECK_TYPE, expectedInLabels.message, expectedInLabels.type)
|
|
515
|
+
: blockedResult(CHECK_TYPE, expectedInLabels.message, expectedInLabels.type);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (expectedInLabels.mode === "skipped") {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const actualInLabels = extractLabelNames(remoteData.issue.labels)
|
|
523
|
+
.filter((label) => label.startsWith("in:"))
|
|
524
|
+
.sort();
|
|
525
|
+
|
|
526
|
+
if (arraysEqual(expectedInLabels.labels, actualInLabels)) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return failResult(
|
|
531
|
+
CHECK_TYPE,
|
|
532
|
+
`Issue #${context.issueNumber} in: labels do not match committed changes: expected [${formatLabelList(expectedInLabels.labels)}], got [${formatLabelList(actualInLabels)}]`,
|
|
533
|
+
"check_failed"
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function checkSyncedRequirements(context, remoteData) {
|
|
538
|
+
if (!context.config.sync_checked_requirements || !context.hasTriage) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const checkedRequirements = getCheckedRequirements(context.task.content);
|
|
543
|
+
if (checkedRequirements.length === 0) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const issueBody = remoteData.issue.body || "";
|
|
548
|
+
const missingRequirements = checkedRequirements.filter(
|
|
549
|
+
(item) => !new RegExp(`^- \\[x\\] ${escapeRegExp(item)}$`, "m").test(issueBody)
|
|
550
|
+
);
|
|
551
|
+
if (missingRequirements.length === 0) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return failResult(CHECK_TYPE,
|
|
556
|
+
`Issue body is missing checked requirements: ${missingRequirements.join(", ")}`,
|
|
557
|
+
"check_failed"
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function checkIssueType(context, remoteData) {
|
|
562
|
+
if (!context.config.verify_issue_type || !context.hasPush) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (remoteData.issueType === undefined) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (!remoteData.issueType) {
|
|
571
|
+
return failResult(CHECK_TYPE,
|
|
572
|
+
`Issue #${context.issueNumber} has no Issue Type set`,
|
|
573
|
+
"check_failed"
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const expectedType = mapTaskTypeToIssueType(context.task.metadata.type);
|
|
578
|
+
if (expectedType && remoteData.issueType !== expectedType) {
|
|
579
|
+
return failResult(CHECK_TYPE,
|
|
580
|
+
`Issue #${context.issueNumber} has type '${remoteData.issueType}', expected '${expectedType}' (from task type '${context.task.metadata.type}')`,
|
|
581
|
+
"check_failed"
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function checkPrAssignee(context, remoteData) {
|
|
589
|
+
if (!context.config.verify_pr_assignee || !context.hasPush || !context.prNumber) {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (!remoteData.prAssignees || remoteData.prAssignees.length === 0) {
|
|
594
|
+
return failResult(CHECK_TYPE,
|
|
595
|
+
`PR #${context.prNumber} has no assignee`,
|
|
596
|
+
"check_failed"
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function checkMilestone(context, remoteData) {
|
|
604
|
+
if (!context.config.verify_milestone || !context.hasTriage) {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (!remoteData.issue?.milestone?.title) {
|
|
609
|
+
return failResult(CHECK_TYPE,
|
|
610
|
+
`Issue #${context.issueNumber} has no milestone set`,
|
|
611
|
+
"check_failed"
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (context.prNumber && remoteData.prMilestone !== undefined && !remoteData.prMilestone?.title) {
|
|
616
|
+
return failResult(CHECK_TYPE,
|
|
617
|
+
`PR #${context.prNumber} has no milestone set`,
|
|
618
|
+
"check_failed"
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function findCommentByMarker(comments, marker) {
|
|
626
|
+
return (comments || []).find((comment) => typeof comment.body === "string" && comment.body.includes(marker)) || null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function extractCommentBody(commentBody) {
|
|
630
|
+
const lines = String(commentBody || "").split(/\r?\n/);
|
|
631
|
+
|
|
632
|
+
let start = 0;
|
|
633
|
+
while (start < lines.length && (lines[start].trim() === "" || /^<!--.*-->$/.test(lines[start].trim()))) {
|
|
634
|
+
start += 1;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (start < lines.length && lines[start].startsWith("## ")) {
|
|
638
|
+
start += 1;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
while (start < lines.length && lines[start].trim() === "") {
|
|
642
|
+
start += 1;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (start < lines.length && /^> \*\*.+\*\* · .+$/.test(lines[start].trim())) {
|
|
646
|
+
start += 1;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
while (start < lines.length && lines[start].trim() === "") {
|
|
650
|
+
start += 1;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
let end = lines.length;
|
|
654
|
+
for (let index = lines.length - 1; index >= start; index -= 1) {
|
|
655
|
+
const trimmed = lines[index].trim();
|
|
656
|
+
if (trimmed === "") {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (/^\*.*\*$/.test(trimmed)) {
|
|
661
|
+
end = index;
|
|
662
|
+
if (end > start && lines[end - 1].trim() === "---") {
|
|
663
|
+
end -= 1;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return lines.slice(start, end).join("\n");
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function buildExpectedTaskBody(taskContent) {
|
|
673
|
+
const frontmatterMatch = taskContent.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
674
|
+
if (!frontmatterMatch) {
|
|
675
|
+
return taskContent.trim();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const body = taskContent.slice(frontmatterMatch[0].length).trim();
|
|
679
|
+
return [
|
|
680
|
+
buildTaskFrontmatterSummary(),
|
|
681
|
+
"",
|
|
682
|
+
"```yaml",
|
|
683
|
+
frontmatterMatch[0].trim(),
|
|
684
|
+
"```",
|
|
685
|
+
"",
|
|
686
|
+
"</details>",
|
|
687
|
+
"",
|
|
688
|
+
body
|
|
689
|
+
].join("\n").trim();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function buildTaskFrontmatterSummary() {
|
|
693
|
+
const language = loadProjectLanguage();
|
|
694
|
+
if (language === "en" || language === "en-US") {
|
|
695
|
+
return "<details><summary>Metadata (frontmatter)</summary>";
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return "<details><summary>元数据 (frontmatter)</summary>";
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function loadProjectLanguage() {
|
|
702
|
+
const override = process.env.VALIDATE_ARTIFACT_LANGUAGE;
|
|
703
|
+
if (!isBlank(override)) {
|
|
704
|
+
return String(override).trim();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const configPath = path.join(repoRoot, ".agents", ".airc.json");
|
|
708
|
+
if (!fs.existsSync(configPath)) {
|
|
709
|
+
return "";
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
try {
|
|
713
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
714
|
+
return String(config.language || "").trim();
|
|
715
|
+
} catch {
|
|
716
|
+
return "";
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function buildCommentContentMismatchMessage(fileStem, issueNumber, localContent, commentContent) {
|
|
721
|
+
const diffIndex = firstDifferenceIndex(localContent, commentContent);
|
|
722
|
+
const position = indexToLineColumn(localContent, diffIndex);
|
|
723
|
+
|
|
724
|
+
return `Comment content mismatch for '${fileStem}' on Issue #${issueNumber}: local file has ${localContent.length} chars, comment body has ${commentContent.length} chars (first difference near char ${diffIndex + 1}, line ${position.line}, column ${position.column})`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function firstDifferenceIndex(left, right) {
|
|
728
|
+
const limit = Math.max(left.length, right.length);
|
|
729
|
+
for (let index = 0; index < limit; index += 1) {
|
|
730
|
+
if (left[index] !== right[index]) {
|
|
731
|
+
return index;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return limit;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function indexToLineColumn(text, index) {
|
|
739
|
+
const prefix = text.slice(0, Math.min(index, text.length));
|
|
740
|
+
const lines = prefix.split("\n");
|
|
741
|
+
return {
|
|
742
|
+
line: lines.length,
|
|
743
|
+
column: (lines.at(-1) || "").length + 1
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function extractLabelNames(labels) {
|
|
748
|
+
return (labels || [])
|
|
749
|
+
.map((label) => typeof label === "string" ? label : label?.name)
|
|
750
|
+
.filter((label) => typeof label === "string" && label.length > 0);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function mapTaskTypeToIssueType(taskType) {
|
|
754
|
+
const mapping = {
|
|
755
|
+
bug: "Bug",
|
|
756
|
+
bugfix: "Bug",
|
|
757
|
+
enhancement: "Feature",
|
|
758
|
+
feature: "Feature",
|
|
759
|
+
task: "Task",
|
|
760
|
+
documentation: "Task",
|
|
761
|
+
"dependency-upgrade": "Task",
|
|
762
|
+
chore: "Task",
|
|
763
|
+
docs: "Task",
|
|
764
|
+
refactor: "Task",
|
|
765
|
+
refactoring: "Task"
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
return mapping[taskType] || "Task";
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function arraysEqual(left, right) {
|
|
772
|
+
if (left.length !== right.length) {
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return left.every((value, index) => value === right[index]);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function formatLabelList(labels) {
|
|
780
|
+
return labels.length > 0 ? labels.join(", ") : "none";
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function computeExpectedInLabels(taskDir) {
|
|
784
|
+
const changedFilesResult = gitText(["diff", "main...HEAD", "--name-only"], taskDir);
|
|
785
|
+
if (!changedFilesResult.ok) {
|
|
786
|
+
return changedFilesResult;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const changedFiles = String(changedFilesResult.value || "")
|
|
790
|
+
.split(/\r?\n/)
|
|
791
|
+
.map((value) => value.trim())
|
|
792
|
+
.filter(Boolean);
|
|
793
|
+
|
|
794
|
+
const mapping = loadInLabelMapping();
|
|
795
|
+
if (!mapping.ok) {
|
|
796
|
+
return mapping;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (Object.keys(mapping.value).length > 0) {
|
|
800
|
+
const labels = new Set();
|
|
801
|
+
|
|
802
|
+
for (const file of changedFiles) {
|
|
803
|
+
for (const [label, prefixes] of Object.entries(mapping.value)) {
|
|
804
|
+
if (prefixes.some((prefix) => file.startsWith(prefix))) {
|
|
805
|
+
labels.add(`in: ${label}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return { ok: true, labels: Array.from(labels).sort(), mode: "mapped" };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const repoLabelsResult = ghJson([
|
|
814
|
+
"label",
|
|
815
|
+
"list",
|
|
816
|
+
"--limit",
|
|
817
|
+
"200",
|
|
818
|
+
"--json",
|
|
819
|
+
"name"
|
|
820
|
+
], taskDir);
|
|
821
|
+
if (!repoLabelsResult.ok) {
|
|
822
|
+
return repoLabelsResult;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const repoInLabels = new Set(
|
|
826
|
+
extractLabelNames(repoLabelsResult.value)
|
|
827
|
+
.filter((label) => label.startsWith("in:"))
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
if (repoInLabels.size === 0) {
|
|
831
|
+
return { ok: true, labels: [], mode: "fallback" };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const labels = new Set();
|
|
835
|
+
for (const file of changedFiles) {
|
|
836
|
+
const topLevel = file.split("/")[0];
|
|
837
|
+
if (!topLevel) {
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const candidate = `in: ${topLevel}`;
|
|
842
|
+
if (repoInLabels.has(candidate)) {
|
|
843
|
+
labels.add(candidate);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return { ok: true, labels: Array.from(labels).sort(), mode: "fallback" };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function loadInLabelMapping() {
|
|
851
|
+
const configPath = path.join(repoRoot, ".agents", ".airc.json");
|
|
852
|
+
if (!fs.existsSync(configPath)) {
|
|
853
|
+
return { ok: true, value: {} };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
try {
|
|
857
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
858
|
+
const mapping = config?.labels?.in;
|
|
859
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) {
|
|
860
|
+
return { ok: true, value: {} };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const normalized = {};
|
|
864
|
+
for (const [label, prefixes] of Object.entries(mapping)) {
|
|
865
|
+
if (!Array.isArray(prefixes)) {
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const cleaned = prefixes
|
|
870
|
+
.map((value) => String(value || "").trim())
|
|
871
|
+
.filter(Boolean);
|
|
872
|
+
if (cleaned.length > 0) {
|
|
873
|
+
normalized[label] = cleaned;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return { ok: true, value: normalized };
|
|
878
|
+
} catch (error) {
|
|
879
|
+
return { ok: false, type: "check_failed", message: `Unable to parse .agents/.airc.json: ${error.message}` };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// === GitHub API ===
|
|
884
|
+
|
|
885
|
+
function normalizeIssuePayload(payload) {
|
|
886
|
+
return {
|
|
887
|
+
state: String(payload?.state || "").toUpperCase(),
|
|
888
|
+
labels: Array.isArray(payload?.labels) ? payload.labels : [],
|
|
889
|
+
body: typeof payload?.body === "string" ? payload.body : "",
|
|
890
|
+
milestone: payload?.milestone ?? null
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function resolveUpstreamRepo(taskDir) {
|
|
895
|
+
const ownerRepo = resolveOwnerRepo(taskDir);
|
|
896
|
+
if (!ownerRepo.ok) {
|
|
897
|
+
return ownerRepo;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const upstreamResult = ghText([
|
|
901
|
+
"api",
|
|
902
|
+
`repos/${ownerRepo.value}`,
|
|
903
|
+
"--jq",
|
|
904
|
+
"if .fork then .parent.full_name else .full_name end"
|
|
905
|
+
], taskDir);
|
|
906
|
+
|
|
907
|
+
if (!upstreamResult.ok) {
|
|
908
|
+
return upstreamResult;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (isBlank(upstreamResult.value)) {
|
|
912
|
+
return { ok: false, message: "Unable to resolve upstream repository" };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return { ok: true, value: upstreamResult.value };
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function resolveOwnerRepo(taskDir) {
|
|
919
|
+
const gitResult = spawnSync("git", ["remote", "get-url", "origin"], {
|
|
920
|
+
cwd: taskDir,
|
|
921
|
+
encoding: "utf8"
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
if (gitResult.status !== 0) {
|
|
925
|
+
return { ok: false, message: `Unable to resolve git remote: ${gitResult.stderr.trim() || gitResult.stdout.trim()}` };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const remote = gitResult.stdout.trim();
|
|
929
|
+
const sshMatch = remote.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
930
|
+
if (!sshMatch) {
|
|
931
|
+
return { ok: false, message: `Unable to parse owner/repo from remote '${remote}'` };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return { ok: true, value: sshMatch[1] };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function detectPermissions(upstreamRepo, taskDir) {
|
|
938
|
+
const permissionsResult = ghJson([
|
|
939
|
+
"api",
|
|
940
|
+
`repos/${upstreamRepo}`,
|
|
941
|
+
"--jq",
|
|
942
|
+
".permissions"
|
|
943
|
+
], taskDir);
|
|
944
|
+
|
|
945
|
+
if (!permissionsResult.ok) {
|
|
946
|
+
return { hasTriage: false, hasPush: false };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const permissions = permissionsResult.value && typeof permissionsResult.value === "object"
|
|
950
|
+
? permissionsResult.value
|
|
951
|
+
: {};
|
|
952
|
+
|
|
953
|
+
return {
|
|
954
|
+
hasTriage: permissions.triage === true,
|
|
955
|
+
hasPush: permissions.push === true
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function ghJson(args, cwd) {
|
|
960
|
+
const result = ghCommand(args, cwd);
|
|
961
|
+
if (!result.ok) {
|
|
962
|
+
return result;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
try {
|
|
966
|
+
return { ok: true, value: JSON.parse(result.value || "null") };
|
|
967
|
+
} catch (error) {
|
|
968
|
+
return { ok: false, type: "network_error", message: `Invalid JSON from gh: ${error.message}` };
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function ghText(args, cwd) {
|
|
973
|
+
const result = ghCommand(args, cwd);
|
|
974
|
+
if (!result.ok) {
|
|
975
|
+
return result;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return { ok: true, value: String(result.value || "").trim() };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function ghCommand(args, cwd) {
|
|
982
|
+
const result = spawnSync("gh", args, {
|
|
983
|
+
cwd,
|
|
984
|
+
encoding: "utf8",
|
|
985
|
+
env: process.env
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
if (result.status !== 0) {
|
|
989
|
+
const stderr = `${result.stderr || ""}${result.stdout || ""}`.trim();
|
|
990
|
+
const classified = classifyGhFailure(stderr, args);
|
|
991
|
+
return { ok: false, type: classified.type, message: classified.message };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
return { ok: true, value: result.stdout };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function ghPaginatedJson(args, cwd) {
|
|
998
|
+
return ghJson(args, cwd);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function gitText(args, cwd) {
|
|
1002
|
+
const result = spawnSync("git", args, {
|
|
1003
|
+
cwd,
|
|
1004
|
+
encoding: "utf8",
|
|
1005
|
+
env: process.env
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
if (result.status !== 0) {
|
|
1009
|
+
const stderr = `${result.stderr || ""}${result.stdout || ""}`.trim();
|
|
1010
|
+
return {
|
|
1011
|
+
ok: false,
|
|
1012
|
+
type: "check_failed",
|
|
1013
|
+
message: stderr || `git ${args.join(" ")} failed`
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return { ok: true, value: String(result.stdout || "").trim() };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function withRetry(operation) {
|
|
1021
|
+
const delays = getRetryDelays();
|
|
1022
|
+
let lastFailure = null;
|
|
1023
|
+
|
|
1024
|
+
for (let attempt = 0; attempt <= delays.length; attempt += 1) {
|
|
1025
|
+
const result = operation();
|
|
1026
|
+
if (result.ok) {
|
|
1027
|
+
return result;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
lastFailure = result;
|
|
1031
|
+
if (result.type === "check_failed") {
|
|
1032
|
+
return result;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (attempt < delays.length) {
|
|
1036
|
+
sleep(delays[attempt]);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return lastFailure || { ok: false, type: "network_error", message: "Unknown GitHub sync failure" };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function classifyGhFailure(stderr, args) {
|
|
1044
|
+
const message = stderr || `gh ${args.join(" ")} failed`;
|
|
1045
|
+
|
|
1046
|
+
if (/not found|could not resolve to an issue|http 404/i.test(message)) {
|
|
1047
|
+
return { type: "check_failed", message };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return { type: "network_error", message };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function getRetryDelays() {
|
|
1054
|
+
const override = process.env.VALIDATE_ARTIFACT_RETRY_DELAYS_MS;
|
|
1055
|
+
if (!override) {
|
|
1056
|
+
return DEFAULT_RETRY_DELAYS_MS;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const parsed = override
|
|
1060
|
+
.split(",")
|
|
1061
|
+
.map((value) => Number(value.trim()))
|
|
1062
|
+
.filter((value) => Number.isFinite(value) && value >= 0);
|
|
1063
|
+
|
|
1064
|
+
return parsed.length > 0 ? parsed : DEFAULT_RETRY_DELAYS_MS;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function sleep(delayMs) {
|
|
1068
|
+
if (delayMs <= 0) {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function interpolate(template, taskDir, artifactFile) {
|
|
1076
|
+
const artifactStem = artifactFile ? path.basename(artifactFile, path.extname(artifactFile)) : "";
|
|
1077
|
+
return template
|
|
1078
|
+
.replace(/\{task-id\}/g, path.basename(taskDir))
|
|
1079
|
+
.replace(/\{artifact-stem\}/g, artifactStem);
|
|
1080
|
+
}
|