@fitlab-ai/agent-infra 0.5.4 → 0.5.6

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 (39) hide show
  1. package/README.md +94 -1
  2. package/README.zh-CN.md +94 -1
  3. package/lib/defaults.json +1 -0
  4. package/lib/sandbox/commands/create.js +7 -3
  5. package/lib/sandbox/shell.js +47 -7
  6. package/lib/sandbox/tools.js +18 -14
  7. package/package.json +1 -1
  8. package/templates/.agents/README.en.md +52 -0
  9. package/templates/.agents/README.zh-CN.md +52 -0
  10. package/templates/.agents/rules/issue-pr-commands.github.en.md +10 -1
  11. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +10 -1
  12. package/templates/.agents/rules/issue-sync.github.en.md +12 -10
  13. package/templates/.agents/rules/issue-sync.github.zh-CN.md +12 -10
  14. package/templates/.agents/rules/milestone-inference.github.en.md +6 -5
  15. package/templates/.agents/rules/milestone-inference.github.zh-CN.md +6 -5
  16. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +87 -14
  17. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
  18. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +1 -1
  19. package/templates/.agents/skills/create-pr/config/verify.json +2 -0
  20. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +6 -7
  21. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +6 -7
  22. package/templates/.agents/skills/create-release-note/SKILL.en.md +27 -2
  23. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +27 -2
  24. package/templates/.agents/skills/implement-task/SKILL.en.md +1 -1
  25. package/templates/.agents/skills/implement-task/SKILL.zh-CN.md +1 -1
  26. package/templates/.agents/skills/import-issue/SKILL.en.md +10 -2
  27. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +10 -2
  28. package/templates/.agents/skills/import-issue/config/verify.json +2 -1
  29. package/templates/.agents/skills/plan-task/SKILL.en.md +1 -1
  30. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -1
  31. package/templates/.agents/skills/refine-task/SKILL.en.md +1 -1
  32. package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +1 -1
  33. package/templates/.agents/skills/review-task/SKILL.en.md +1 -1
  34. package/templates/.agents/skills/review-task/SKILL.zh-CN.md +1 -1
  35. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +316 -1
  36. package/templates/.github/scripts/sync-labels-to-set.sh +110 -0
  37. package/templates/.github/workflows/metadata-sync.yml +11 -20
  38. package/templates/.github/workflows/pr-label.yml +10 -19
  39. package/templates/.github/workflows/status-label.yml +20 -34
@@ -62,8 +62,8 @@ date "+%Y-%m-%d %H:%M:%S%:z"
62
62
  如果 task.md 中存在有效的 `issue_number`,执行以下同步操作(任一失败则跳过并继续):
63
63
  - 执行前先读取 `.agents/rules/issue-sync.md`,完成 upstream 仓库检测和权限检测
64
64
  - 按 issue-sync.md 设置 `status: in-progress`
65
- - 发布 `{refinement-artifact}` 评论
66
65
  - 创建或更新 `<!-- sync-issue:{task-id}:task -->` 评论(按 issue-sync.md 的 task.md 评论同步规则)
66
+ - 发布 `{refinement-artifact}` 评论
67
67
 
68
68
  ### 7. 完成校验
69
69
 
@@ -56,8 +56,8 @@ Update task.md and append:
56
56
  If task.md contains a valid `issue_number`, perform these sync actions (skip and continue on any failure):
57
57
  - Read `.agents/rules/issue-sync.md` before syncing, and complete upstream repository detection plus permission detection
58
58
  - Set `status: in-progress` by following issue-sync.md
59
- - Publish the `{review-artifact}` comment
60
59
  - Create or update the `<!-- sync-issue:{task-id}:task -->` comment (follow the task.md comment sync rule in issue-sync.md)
60
+ - Publish the `{review-artifact}` comment
61
61
 
62
62
  ### 7. Verification Gate
63
63
 
@@ -56,8 +56,8 @@ date "+%Y-%m-%d %H:%M:%S%:z"
56
56
  如果 task.md 中存在有效的 `issue_number`,执行以下同步操作(任一失败则跳过并继续):
57
57
  - 执行前先读取 `.agents/rules/issue-sync.md`,完成 upstream 仓库检测和权限检测
58
58
  - 按 issue-sync.md 设置 `status: in-progress`
59
- - 发布 `{review-artifact}` 评论
60
59
  - 创建或更新 `<!-- sync-issue:{task-id}:task -->` 评论(按 issue-sync.md 的 task.md 评论同步规则)
60
+ - 发布 `{review-artifact}` 评论
61
61
 
62
62
  ### 7. 完成校验
63
63
 
@@ -15,6 +15,7 @@
15
15
 
16
16
  import childProcess from 'node:child_process';
17
17
  import fs from 'node:fs';
18
+ import os from 'node:os';
18
19
  import path from 'node:path';
19
20
  import { fileURLToPath } from 'node:url';
20
21
 
@@ -56,6 +57,7 @@ const DEFAULTS = {
56
57
  ".claude/hooks/",
57
58
  ".gemini/commands/",
58
59
  ".github/hooks/check-version-format.sh",
60
+ ".github/scripts/",
59
61
  ".opencode/commands/"
60
62
  ],
61
63
  "merged": [
@@ -76,7 +78,7 @@ const DEFAULTS = {
76
78
  }
77
79
  };
78
80
 
79
- const INSTALLER_VERSION = "v0.5.4";
81
+ const INSTALLER_VERSION = "v0.5.6";
80
82
  const PACKAGE_NAME = '@fitlab-ai/agent-infra';
81
83
  // Add a new identifier here only after shipping matching .{platform}. template variants.
82
84
  const KNOWN_PLATFORMS = new Set(['github']);
@@ -123,6 +125,292 @@ function removeEmptyDirs(dir) {
123
125
  }
124
126
  }
125
127
 
128
+ function parseSkillFrontmatter(filePath) {
129
+ const content = fs.readFileSync(filePath, 'utf8');
130
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
131
+ if (!match) return {};
132
+
133
+ const result = {};
134
+ const lines = match[1].split(/\r?\n/);
135
+ const normalizeValue = (value) => value.replace(/^["']|["']$/g, '').trim();
136
+
137
+ for (let index = 0; index < lines.length; index += 1) {
138
+ const line = lines[index];
139
+ const pair = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
140
+ if (!pair) continue;
141
+
142
+ const [, key, rawValue] = pair;
143
+ if (rawValue === '>') {
144
+ const block = [];
145
+ for (let offset = index + 1; offset < lines.length; offset += 1) {
146
+ const nextLine = lines[offset];
147
+ if (!/^\s+/.test(nextLine)) break;
148
+
149
+ block.push(nextLine.trim());
150
+ index = offset;
151
+ }
152
+ result[key] = block.join(' ').trim();
153
+ continue;
154
+ }
155
+
156
+ result[key] = normalizeValue(rawValue);
157
+ }
158
+
159
+ return result;
160
+ }
161
+
162
+ function listTemplateSkillNames(templateRoot) {
163
+ const templateSkillsDir = path.join(templateRoot, '.agents/skills');
164
+ if (!fs.existsSync(templateSkillsDir)) return new Set();
165
+
166
+ return new Set(
167
+ fs.readdirSync(templateSkillsDir, { withFileTypes: true })
168
+ .filter((entry) => entry.isDirectory())
169
+ .map((entry) => entry.name)
170
+ );
171
+ }
172
+
173
+ function detectCustomSkills(projectRoot, templateSkillNames) {
174
+ const skillsDir = path.join(projectRoot, '.agents/skills');
175
+ if (!fs.existsSync(skillsDir)) return [];
176
+
177
+ return fs.readdirSync(skillsDir, { withFileTypes: true })
178
+ .filter((entry) => entry.isDirectory() && !templateSkillNames.has(entry.name))
179
+ .map((entry) => {
180
+ const skillMd = path.join(skillsDir, entry.name, 'SKILL.md');
181
+ if (!fs.existsSync(skillMd)) return null;
182
+
183
+ const meta = parseSkillFrontmatter(skillMd);
184
+ return {
185
+ dirName: entry.name,
186
+ name: meta.name || entry.name,
187
+ description: meta.description || '',
188
+ args: meta.args || null
189
+ };
190
+ })
191
+ .filter(Boolean)
192
+ .sort((left, right) => left.dirName.localeCompare(right.dirName));
193
+ }
194
+
195
+ function isCustomProtected(targetPath, customSkills, project) {
196
+ const normalized = norm(targetPath);
197
+
198
+ return customSkills.some(({ dirName }) => (
199
+ normalized.startsWith(`.agents/skills/${dirName}/`) ||
200
+ normalized === `.claude/commands/${dirName}.md` ||
201
+ normalized === `.opencode/commands/${dirName}.md` ||
202
+ normalized === '.gemini/commands/' + project + '/' + dirName + '.toml'
203
+ ));
204
+ }
205
+
206
+ function expandHome(inputPath) {
207
+ if (inputPath === '~') return os.homedir();
208
+ if (inputPath.startsWith('~/')) {
209
+ return path.join(os.homedir(), inputPath.slice(2));
210
+ }
211
+
212
+ return path.resolve(inputPath);
213
+ }
214
+
215
+ function writeIfChanged(projectRoot, targetPath, content, reportBucket) {
216
+ const fullPath = path.join(projectRoot, targetPath);
217
+ const exists = fs.existsSync(fullPath);
218
+
219
+ if (exists && fs.readFileSync(fullPath, 'utf8') === content) {
220
+ reportBucket.unchanged.push(targetPath);
221
+ return;
222
+ }
223
+
224
+ const dir = path.dirname(fullPath);
225
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
226
+ fs.writeFileSync(fullPath, content, 'utf8');
227
+
228
+ (exists ? reportBucket.updated : reportBucket.generated).push(targetPath);
229
+ }
230
+
231
+ function syncCustomSkillSources(projectRoot, sources, report, templateSkillNames) {
232
+ const skillsDir = path.join(projectRoot, '.agents/skills');
233
+ const syncedSkills = new Map();
234
+
235
+ for (const source of sources) {
236
+ if (source?.type !== 'local') continue;
237
+ if (typeof source.path !== 'string' || source.path.trim() === '') {
238
+ report.custom.sourceErrors.push({ source: String(source?.path || ''), reason: 'invalid path' });
239
+ continue;
240
+ }
241
+
242
+ const srcDir = expandHome(source.path);
243
+ if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) {
244
+ report.custom.sourceErrors.push({ source: source.path, reason: 'directory not found' });
245
+ continue;
246
+ }
247
+
248
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
249
+ if (!entry.isDirectory()) continue;
250
+ if (templateSkillNames.has(entry.name)) {
251
+ report.custom.sourceErrors.push({
252
+ source: source.path,
253
+ reason: `skill ${entry.name} conflicts with built-in skill`
254
+ });
255
+ continue;
256
+ }
257
+
258
+ const skillSrcDir = path.join(srcDir, entry.name);
259
+ const skillMd = path.join(skillSrcDir, 'SKILL.md');
260
+ if (!fs.existsSync(skillMd)) continue;
261
+
262
+ const skillDstDir = path.join(skillsDir, entry.name);
263
+ const trackedFiles = syncedSkills.get(entry.name) || new Set();
264
+ syncedSkills.set(entry.name, trackedFiles);
265
+
266
+ for (const srcFile of walkDir(skillSrcDir)) {
267
+ const relPath = norm(path.relative(skillSrcDir, srcFile));
268
+ const dstFile = path.join(skillDstDir, relPath);
269
+ const projectPath = norm(path.relative(projectRoot, dstFile));
270
+ const srcContent = fs.readFileSync(srcFile);
271
+ const existed = fs.existsSync(dstFile);
272
+
273
+ trackedFiles.add(relPath);
274
+
275
+ if (existed) {
276
+ const dstContent = fs.readFileSync(dstFile);
277
+ if (srcContent.equals(dstContent)) {
278
+ report.custom.unchanged.push(projectPath);
279
+ continue;
280
+ }
281
+ }
282
+
283
+ const dir = path.dirname(dstFile);
284
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
285
+ fs.writeFileSync(dstFile, srcContent);
286
+
287
+ (existed ? report.custom.updated : report.custom.generated).push(projectPath);
288
+ }
289
+ }
290
+ }
291
+
292
+ return syncedSkills;
293
+ }
294
+
295
+ function cleanStaleSyncedFiles(projectRoot, syncedSkills, report) {
296
+ const skillsDir = path.join(projectRoot, '.agents/skills');
297
+
298
+ for (const [skillName, expectedFiles] of syncedSkills) {
299
+ const skillDir = path.join(skillsDir, skillName);
300
+ if (!fs.existsSync(skillDir)) continue;
301
+
302
+ const actualFiles = walkDir(skillDir).map((filePath) => norm(path.relative(skillDir, filePath)));
303
+ const removedBefore = report.custom.removed.length;
304
+
305
+ for (const actualFile of actualFiles) {
306
+ if (expectedFiles.has(actualFile)) continue;
307
+
308
+ const staleFile = path.join(skillDir, actualFile);
309
+ fs.unlinkSync(staleFile);
310
+ report.custom.removed.push(norm(path.relative(projectRoot, staleFile)));
311
+ }
312
+
313
+ if (report.custom.removed.length > removedBefore) {
314
+ removeEmptyDirs(skillDir);
315
+ }
316
+ }
317
+ }
318
+
319
+ function generateClaudeCommand(skill, lang) {
320
+ const isZhCN = lang === 'zh-CN';
321
+ const lines = ['---', `description: ${JSON.stringify(skill.description)}`];
322
+
323
+ if (skill.args) {
324
+ lines.push(`usage: ${JSON.stringify(`/${skill.dirName} ${skill.args}`)}`);
325
+ }
326
+
327
+ lines.push('---', '');
328
+ lines.push(
329
+ isZhCN
330
+ ? `读取并执行 \`.agents/skills/${skill.dirName}/SKILL.md\` 中的 ${skill.dirName} 技能。`
331
+ : `Read and execute the ${skill.dirName} skill from \`.agents/skills/${skill.dirName}/SKILL.md\`.`
332
+ );
333
+ lines.push('');
334
+ lines.push(isZhCN ? '严格按照技能中定义的所有步骤执行。' : 'Follow all steps defined in the skill exactly.');
335
+
336
+ return `${lines.join('\n')}\n`;
337
+ }
338
+
339
+ function generateGeminiCommand(skill, lang) {
340
+ const isZhCN = lang === 'zh-CN';
341
+ const promptLines = [];
342
+
343
+ if (skill.args) {
344
+ promptLines.push(isZhCN ? '参数:{{args}}' : 'Arguments: {{args}}');
345
+ promptLines.push('');
346
+ }
347
+
348
+ promptLines.push(
349
+ isZhCN
350
+ ? `读取并执行 \`.agents/skills/${skill.dirName}/SKILL.md\` 中的 ${skill.dirName} 技能。`
351
+ : `Read and execute the ${skill.dirName} skill from \`.agents/skills/${skill.dirName}/SKILL.md\`.`
352
+ );
353
+ promptLines.push('');
354
+ promptLines.push(isZhCN ? '严格按照技能中定义的所有步骤执行。' : 'Follow all steps defined in the skill exactly.');
355
+
356
+ return [
357
+ `description = ${JSON.stringify(skill.description)}`,
358
+ 'prompt = """',
359
+ ...promptLines,
360
+ '"""'
361
+ ].join('\n') + '\n';
362
+ }
363
+
364
+ function generateOpenCodeCommand(skill, lang) {
365
+ const isZhCN = lang === 'zh-CN';
366
+ const lines = [
367
+ '---',
368
+ `description: ${JSON.stringify(skill.description)}`,
369
+ 'agent: general',
370
+ 'subtask: false',
371
+ '---',
372
+ ''
373
+ ];
374
+
375
+ if (skill.args) {
376
+ lines.push(isZhCN ? '参数:$ARGUMENTS' : 'Arguments: $ARGUMENTS');
377
+ lines.push('');
378
+ }
379
+
380
+ lines.push(
381
+ isZhCN
382
+ ? `读取并执行 \`.agents/skills/${skill.dirName}/SKILL.md\` 中的 ${skill.dirName} 技能。`
383
+ : `Read and execute the ${skill.dirName} skill from \`.agents/skills/${skill.dirName}/SKILL.md\`.`
384
+ );
385
+ lines.push('');
386
+ lines.push(isZhCN ? '严格按照技能中定义的所有步骤执行。' : 'Follow all steps defined in the skill exactly.');
387
+
388
+ return `${lines.join('\n')}\n`;
389
+ }
390
+
391
+ function generateCustomCommands(projectRoot, customSkills, project, lang, report) {
392
+ for (const skill of customSkills) {
393
+ writeIfChanged(
394
+ projectRoot,
395
+ `.claude/commands/${skill.dirName}.md`,
396
+ generateClaudeCommand(skill, lang),
397
+ report.custom.commands
398
+ );
399
+ writeIfChanged(
400
+ projectRoot,
401
+ '.gemini/commands/' + project + '/' + skill.dirName + '.toml',
402
+ generateGeminiCommand(skill, lang),
403
+ report.custom.commands
404
+ );
405
+ writeIfChanged(
406
+ projectRoot,
407
+ `.opencode/commands/${skill.dirName}.md`,
408
+ generateOpenCodeCommand(skill, lang),
409
+ report.custom.commands
410
+ );
411
+ }
412
+ }
413
+
126
414
  function matchesAny(rel, patterns) {
127
415
  const n = norm(rel);
128
416
  return patterns.some(p => norm(p) === n || globMatch(p, n));
@@ -411,6 +699,8 @@ function syncTemplates(projectRoot, templateRootOverride) {
411
699
  const { project, org, language: lang = 'en' } = cfg;
412
700
  const platformType = cfg.platform?.type || DEFAULTS.platform.type;
413
701
  const vars = { project, org };
702
+ const templateSkillNames = listTemplateSkillNames(templateRoot);
703
+ const protectedCustomSkills = detectCustomSkills(projectRoot, templateSkillNames);
414
704
 
415
705
  const managed = [...(cfg.files.managed || [])];
416
706
  const merged = [...(cfg.files.merged || [])];
@@ -421,6 +711,15 @@ function syncTemplates(projectRoot, templateRootOverride) {
421
711
  templateRoot: norm(templateRoot),
422
712
  registryAdded: [],
423
713
  managed: { written: [], created: [], unchanged: [], skippedMerged: [], removed: [] },
714
+ custom: {
715
+ detected: [],
716
+ generated: [],
717
+ updated: [],
718
+ unchanged: [],
719
+ removed: [],
720
+ sourceErrors: [],
721
+ commands: { generated: [], updated: [], unchanged: [] }
722
+ },
424
723
  ejected: { created: [], skipped: [] },
425
724
  merged: { pending: [] },
426
725
  configUpdated: false,
@@ -496,6 +795,7 @@ function syncTemplates(projectRoot, templateRootOverride) {
496
795
  for (const projFile of projFiles) {
497
796
  if (expectedTargets.has(projFile)) continue;
498
797
  if (projFile === configPathRel) continue;
798
+ if (isCustomProtected(projFile, protectedCustomSkills, project)) continue;
499
799
  if (matchesAny(projFile, merged) || matchesAny(projFile, ejected)) continue;
500
800
 
501
801
  fs.unlinkSync(path.join(projectRoot, projFile));
@@ -508,6 +808,16 @@ function syncTemplates(projectRoot, templateRootOverride) {
508
808
  }
509
809
  }
510
810
 
811
+ const sources = Array.isArray(cfg.skills?.sources) ? cfg.skills.sources : [];
812
+ if (sources.length > 0) {
813
+ const syncedSkills = syncCustomSkillSources(projectRoot, sources, report, templateSkillNames);
814
+ cleanStaleSyncedFiles(projectRoot, syncedSkills, report);
815
+ }
816
+
817
+ const customSkills = detectCustomSkills(projectRoot, templateSkillNames);
818
+ report.custom.detected = customSkills.map((skill) => skill.dirName);
819
+ generateCustomCommands(projectRoot, customSkills, project, lang, report);
820
+
511
821
  for (const entry of ejected) {
512
822
  const dstFull = path.join(projectRoot, entry);
513
823
  if (fs.existsSync(dstFull)) {
@@ -556,6 +866,11 @@ function syncTemplates(projectRoot, templateRootOverride) {
556
866
  report.managed.written.length +
557
867
  report.managed.created.length +
558
868
  report.managed.removed.length +
869
+ report.custom.generated.length +
870
+ report.custom.updated.length +
871
+ report.custom.removed.length +
872
+ report.custom.commands.generated.length +
873
+ report.custom.commands.updated.length +
559
874
  report.ejected.created.length +
560
875
  report.registryAdded.length
561
876
  ) > 0;
@@ -0,0 +1,110 @@
1
+ #!/bin/sh
2
+ # Ensure the labels matching --prefix on an issue or PR equal the set passed via
3
+ # repeated --target flags (0, 1, or N labels).
4
+ # Algorithm must stay in sync with .agents/rules/issue-sync.md.
5
+
6
+ set -e
7
+
8
+ usage() {
9
+ printf 'Usage: %s --repo <owner/repo> (--issue <number> | --pr <number>) --prefix <prefix> [--target <label> ...]\n' "$0" >&2
10
+ exit 1
11
+ }
12
+
13
+ append_target() {
14
+ if [ -n "$targets" ]; then
15
+ targets=$(printf '%s\n%s' "$targets" "$1")
16
+ else
17
+ targets=$1
18
+ fi
19
+ }
20
+
21
+ repo=""
22
+ number=""
23
+ kind=""
24
+ prefix=""
25
+ targets=""
26
+
27
+ while [ $# -gt 0 ]; do
28
+ case "$1" in
29
+ --repo)
30
+ [ $# -ge 2 ] || usage
31
+ repo=$2
32
+ shift 2
33
+ ;;
34
+ --issue)
35
+ [ $# -ge 2 ] || usage
36
+ [ -z "$kind" ] || usage
37
+ kind="issue"
38
+ number=$2
39
+ shift 2
40
+ ;;
41
+ --pr)
42
+ [ $# -ge 2 ] || usage
43
+ [ -z "$kind" ] || usage
44
+ kind="pr"
45
+ number=$2
46
+ shift 2
47
+ ;;
48
+ --prefix)
49
+ [ $# -ge 2 ] || usage
50
+ prefix=$2
51
+ shift 2
52
+ ;;
53
+ --target)
54
+ [ $# -ge 2 ] || usage
55
+ append_target "$2"
56
+ shift 2
57
+ ;;
58
+ *)
59
+ printf 'Unknown argument: %s\n' "$1" >&2
60
+ usage
61
+ ;;
62
+ esac
63
+ done
64
+
65
+ [ -n "$repo" ] || usage
66
+ [ -n "$number" ] || usage
67
+ [ -n "$kind" ] || usage
68
+ [ -n "$prefix" ] || usage
69
+
70
+ while IFS= read -r label; do
71
+ [ -z "$label" ] && continue
72
+ case "$label" in
73
+ "$prefix"*) ;;
74
+ *)
75
+ printf 'Target "%s" must start with prefix "%s"\n' "$label" "$prefix" >&2
76
+ exit 1
77
+ ;;
78
+ esac
79
+ done <<EOF
80
+ $targets
81
+ EOF
82
+
83
+ current_labels=$(gh "$kind" view "$number" \
84
+ --repo "$repo" \
85
+ --json labels --jq ".labels[].name | select(startswith(\"$prefix\"))" \
86
+ 2>/dev/null || true)
87
+
88
+ while IFS= read -r label; do
89
+ [ -z "$label" ] && continue
90
+ if ! printf '%s\n' "$targets" | grep -qxF "$label"; then
91
+ gh "$kind" edit "$number" \
92
+ --repo "$repo" \
93
+ --remove-label "$label" \
94
+ 2>/dev/null || true
95
+ fi
96
+ done <<EOF
97
+ $current_labels
98
+ EOF
99
+
100
+ while IFS= read -r label; do
101
+ [ -z "$label" ] && continue
102
+ if ! printf '%s\n' "$current_labels" | grep -qxF "$label"; then
103
+ gh "$kind" edit "$number" \
104
+ --repo "$repo" \
105
+ --add-label "$label" \
106
+ 2>/dev/null || true
107
+ fi
108
+ done <<EOF
109
+ $targets
110
+ EOF
@@ -54,6 +54,13 @@ jobs:
54
54
  printf 'type=%s\n' "$type" >> "$GITHUB_OUTPUT"
55
55
  printf 'milestone=%s\n' "$milestone" >> "$GITHUB_OUTPUT"
56
56
 
57
+ - name: Checkout shared scripts
58
+ if: steps.metadata.outputs.is_task_comment == 'true' && steps.metadata.outputs.type != ''
59
+ uses: actions/checkout@v6
60
+ with:
61
+ sparse-checkout: .github/scripts
62
+ sparse-checkout-cone-mode: false
63
+
57
64
  - name: Sync type label
58
65
  if: steps.metadata.outputs.is_task_comment == 'true' && steps.metadata.outputs.type != ''
59
66
  env:
@@ -72,27 +79,11 @@ jobs:
72
79
  esac
73
80
 
74
81
  if [ -n "$TYPE_LABEL" ]; then
75
- current_type_labels=$(gh issue view "$ISSUE_NUMBER" \
82
+ .github/scripts/sync-labels-to-set.sh \
76
83
  --repo "$GITHUB_REPOSITORY" \
77
- --json labels --jq '.labels[].name | select(startswith("type:"))' \
78
- 2>/dev/null || true)
79
-
80
- printf '%s\n' "$current_type_labels" | while IFS= read -r label; do
81
- [ -z "$label" ] && continue
82
- if [ "$label" != "$TYPE_LABEL" ]; then
83
- gh issue edit "$ISSUE_NUMBER" \
84
- --repo "$GITHUB_REPOSITORY" \
85
- --remove-label "$label" \
86
- 2>/dev/null || true
87
- fi
88
- done || true
89
-
90
- if ! printf '%s\n' "$current_type_labels" | grep -qxF "$TYPE_LABEL"; then
91
- gh issue edit "$ISSUE_NUMBER" \
92
- --repo "$GITHUB_REPOSITORY" \
93
- --add-label "$TYPE_LABEL" \
94
- 2>/dev/null || true
95
- fi
84
+ --issue "$ISSUE_NUMBER" \
85
+ --prefix "type:" \
86
+ --target "$TYPE_LABEL"
96
87
  fi
97
88
 
98
89
  - name: Sync milestone
@@ -18,7 +18,7 @@ jobs:
18
18
  runs-on: ubuntu-latest
19
19
  steps:
20
20
  - name: Checkout base branch
21
- uses: actions/checkout@v4
21
+ uses: actions/checkout@v6
22
22
 
23
23
  - name: Sync in-labels
24
24
  env:
@@ -37,28 +37,19 @@ jobs:
37
37
  | map("in: " + .key)
38
38
  | .[]?')
39
39
 
40
- current_labels=$(gh pr view "$PR_NUMBER" \
40
+ set -- \
41
41
  --repo "$GITHUB_REPOSITORY" \
42
- --json labels --jq '.labels[].name | select(startswith("in: "))' \
43
- 2>/dev/null || true)
42
+ --pr "$PR_NUMBER" \
43
+ --prefix "in: "
44
44
 
45
- printf '%s\n' "$should_labels" | while IFS= read -r label; do
45
+ while IFS= read -r label; do
46
46
  [ -z "$label" ] && continue
47
- if ! printf '%s\n' "$current_labels" | grep -qxF "$label"; then
48
- gh pr edit "$PR_NUMBER" \
49
- --repo "$GITHUB_REPOSITORY" \
50
- --add-label "$label" 2>/dev/null || true
51
- fi
52
- done
47
+ set -- "$@" --target "$label"
48
+ done <<EOF
49
+ $should_labels
50
+ EOF
53
51
 
54
- printf '%s\n' "$current_labels" | while IFS= read -r label; do
55
- [ -z "$label" ] && continue
56
- if ! printf '%s\n' "$should_labels" | grep -qxF "$label"; then
57
- gh pr edit "$PR_NUMBER" \
58
- --repo "$GITHUB_REPOSITORY" \
59
- --remove-label "$label" 2>/dev/null || true
60
- fi
61
- done
52
+ .github/scripts/sync-labels-to-set.sh "$@"
62
53
 
63
54
  - name: Assign PR creator if unassigned
64
55
  env: