@fitlab-ai/agent-infra 0.9.0 → 0.9.1

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 (82) hide show
  1. package/README.md +6 -1
  2. package/README.zh-CN.md +5 -1
  3. package/dist/bin/internal-cli.js +5 -0
  4. package/dist/lib/internal/platform-release-notes.js +53 -0
  5. package/dist/lib/internal/release-workflow.js +105 -2
  6. package/dist/lib/platform/github-release-notes.js +96 -0
  7. package/dist/lib/platform/issues.js +4 -2
  8. package/dist/lib/platform/merged-pr-equivalence.js +54 -0
  9. package/dist/lib/platform/pr-checks.js +6 -6
  10. package/dist/lib/platform/pull-requests.js +3 -1
  11. package/dist/lib/platform/release-notes.js +143 -0
  12. package/dist/lib/platform/verification-required.js +29 -5
  13. package/dist/lib/sandbox/build-proxy.js +63 -0
  14. package/dist/lib/sandbox/commands/create.js +57 -9
  15. package/dist/lib/sandbox/commands/rebuild.js +24 -7
  16. package/dist/lib/sandbox/image-build.js +2 -1
  17. package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +24 -3
  18. package/dist/lib/sandbox/runtimes/base.dockerfile +8 -1
  19. package/dist/lib/sandbox/runtimes/java17.dockerfile +8 -1
  20. package/dist/lib/sandbox/runtimes/java21.dockerfile +8 -1
  21. package/dist/lib/sandbox/runtimes/node20.dockerfile +8 -1
  22. package/dist/lib/sandbox/runtimes/node22.dockerfile +8 -1
  23. package/dist/lib/sandbox/runtimes/python3.dockerfile +8 -1
  24. package/dist/lib/sandbox/shell.js +2 -1
  25. package/dist/lib/task/commands/issue-body.js +10 -5
  26. package/dist/lib/task/events.js +1 -1
  27. package/dist/lib/task/issue-form.js +2 -0
  28. package/dist/lib/task/review-fingerprint.js +9 -0
  29. package/dist/lib/task/verification-engine.js +34 -17
  30. package/dist/lib/task/verification-review.js +1 -1
  31. package/dist/lib/task/verification.js +1 -1
  32. package/lib/internal/platform-release-notes.ts +55 -0
  33. package/lib/internal/release-workflow.ts +105 -3
  34. package/lib/platform/github-release-notes.ts +132 -0
  35. package/lib/platform/issues.ts +6 -4
  36. package/lib/platform/merged-pr-equivalence.ts +70 -0
  37. package/lib/platform/pr-checks.ts +8 -7
  38. package/lib/platform/pull-requests.ts +9 -3
  39. package/lib/platform/release-notes.ts +167 -0
  40. package/lib/platform/verification-required.ts +31 -5
  41. package/lib/sandbox/build-proxy.ts +94 -0
  42. package/lib/sandbox/commands/create.ts +77 -11
  43. package/lib/sandbox/commands/rebuild.ts +29 -7
  44. package/lib/sandbox/image-build.ts +4 -1
  45. package/lib/sandbox/runtimes/ai-tools.dockerfile +24 -3
  46. package/lib/sandbox/runtimes/base.dockerfile +8 -1
  47. package/lib/sandbox/runtimes/java17.dockerfile +8 -1
  48. package/lib/sandbox/runtimes/java21.dockerfile +8 -1
  49. package/lib/sandbox/runtimes/node20.dockerfile +8 -1
  50. package/lib/sandbox/runtimes/node22.dockerfile +8 -1
  51. package/lib/sandbox/runtimes/python3.dockerfile +8 -1
  52. package/lib/sandbox/shell.ts +3 -1
  53. package/lib/task/commands/issue-body.ts +10 -5
  54. package/lib/task/events.ts +1 -1
  55. package/lib/task/issue-form.ts +4 -0
  56. package/lib/task/review-fingerprint.ts +10 -1
  57. package/lib/task/verification-engine.ts +33 -17
  58. package/lib/task/verification-review.ts +1 -0
  59. package/lib/task/verification.ts +1 -1
  60. package/package.json +1 -1
  61. package/templates/.agents/rules/release-commands.en.md +2 -2
  62. package/templates/.agents/rules/release-commands.github.en.md +10 -40
  63. package/templates/.agents/rules/release-commands.github.zh-CN.md +9 -39
  64. package/templates/.agents/rules/release-commands.zh-CN.md +2 -2
  65. package/templates/.agents/rules/review-handshake.en.md +2 -1
  66. package/templates/.agents/rules/review-handshake.zh-CN.md +2 -1
  67. package/templates/.agents/rules/testing-discipline.en.md +20 -2
  68. package/templates/.agents/rules/testing-discipline.zh-CN.md +20 -2
  69. package/templates/.agents/skills/analyze-task/SKILL.en.md +4 -1
  70. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +4 -1
  71. package/templates/.agents/skills/complete-task/SKILL.en.md +3 -3
  72. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +3 -3
  73. package/templates/.agents/skills/complete-task/config/verify.en.json +0 -1
  74. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +0 -1
  75. package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -38
  76. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +16 -37
  77. package/templates/.agents/skills/create-task/SKILL.en.md +4 -0
  78. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -0
  79. package/templates/.agents/skills/create-task/reference/context-capture.en.md +29 -0
  80. package/templates/.agents/skills/create-task/reference/context-capture.zh-CN.md +29 -0
  81. package/templates/.agents/templates/task.en.md +18 -0
  82. package/templates/.agents/templates/task.zh-CN.md +18 -0
package/README.md CHANGED
@@ -35,9 +35,14 @@ agent-infra standardizes that shared infrastructure. It gives every supported AI
35
35
  ## See it in Action
36
36
 
37
37
  <p align="center">
38
- <img src="./assets/demo-init.gif" alt="CLI install and initialize demo" width="100%" style="max-width: 720px;">
38
+ <img src="./assets/demo-init.gif" alt="CLI install and initialize demo" width="720">
39
39
  </p>
40
40
 
41
+ > The demo is stored with Git LFS. Install Git LFS and run `git lfs install`
42
+ > before cloning, or run `git lfs pull` in an existing checkout. If the image
43
+ > is a small text pointer or is missing, verify `git lfs ls-files`, then retry
44
+ > `git lfs pull`.
45
+
41
46
  Once initialized, open the project in your AI TUI and install the latest skills:
42
47
 
43
48
  ```bash
package/README.zh-CN.md CHANGED
@@ -35,9 +35,13 @@ agent-infra 的目标就是把这层共享基础设施标准化。它为所有
35
35
  ## 实战演示
36
36
 
37
37
  <p align="center">
38
- <img src="./assets/demo-init.gif" alt="CLI 安装初始化演示" width="100%" style="max-width: 720px;">
38
+ <img src="./assets/demo-init.gif" alt="CLI 安装初始化演示" width="720">
39
39
  </p>
40
40
 
41
+ > 演示动画通过 Git LFS 存储。克隆前请安装 Git LFS 并运行 `git lfs install`;
42
+ > 已有检出可运行 `git lfs pull`。如果图片变成很小的文本指针或缺失,请先用
43
+ > `git lfs ls-files` 核对跟踪状态,再重试 `git lfs pull`。
44
+
41
45
  完成初始化后,在你的 AI TUI 中打开项目并安装最新 skills:
42
46
 
43
47
  ```bash
@@ -16,6 +16,11 @@ switch (command) {
16
16
  await releaseWorkflow(process.argv.slice(3));
17
17
  break;
18
18
  }
19
+ case 'platform-release-notes': {
20
+ const { platformReleaseNotes } = await import('../lib/internal/platform-release-notes.js');
21
+ platformReleaseNotes(process.argv.slice(3));
22
+ break;
23
+ }
19
24
  case 'platform-context': {
20
25
  const { platformContext } = await import('../lib/internal/platform-context.js');
21
26
  platformContext(process.argv.slice(3));
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { publishReleaseNotes, releaseNoteContext } from '../platform/release-notes.js';
4
+ function option(args, name) {
5
+ const index = args.indexOf(name);
6
+ return index >= 0 && args[index + 1] ? args[index + 1] : null;
7
+ }
8
+ function finish(result) {
9
+ process.stdout.write(`${JSON.stringify(result)}\n`);
10
+ process.exitCode = result.status === 'blocked' ? 2 : result.status === 'failed' ? 1 : 0;
11
+ }
12
+ function platformReleaseNotes(args = []) {
13
+ const [action, ...rest] = args;
14
+ const cwd = path.resolve(option(rest, '--cwd') || process.cwd());
15
+ if (action === 'context') {
16
+ const historyRaw = option(rest, '--history-limit');
17
+ finish(releaseNoteContext({
18
+ fromTag: option(rest, '--from-tag') || '',
19
+ toTag: option(rest, '--to-tag') || '',
20
+ branch: option(rest, '--branch') || '',
21
+ historyLimit: historyRaw === null ? undefined : Number(historyRaw)
22
+ }, { cwd }));
23
+ return;
24
+ }
25
+ if (action === 'publish') {
26
+ const notesPath = option(rest, '--notes-file');
27
+ if (!notesPath) {
28
+ finish({ status: 'failed', changed: false, error: { code: 'RELEASE_NOTES_INPUT_INVALID', message: 'notes-file is required', retryable: false } });
29
+ return;
30
+ }
31
+ let resolvedNotesPath = notesPath;
32
+ if (notesPath === '-') {
33
+ resolvedNotesPath = path.join(process.env.TMPDIR || '/tmp', `agent-infra-release-notes-${process.pid}.md`);
34
+ fs.writeFileSync(resolvedNotesPath, fs.readFileSync(0));
35
+ }
36
+ try {
37
+ finish(publishReleaseNotes({
38
+ tag: option(rest, '--tag') || '',
39
+ title: option(rest, '--title') || option(rest, '--tag') || '',
40
+ notesFile: resolvedNotesPath,
41
+ dryRun: rest.includes('--dry-run')
42
+ }, { cwd }));
43
+ }
44
+ finally {
45
+ if (notesPath === '-')
46
+ fs.rmSync(resolvedNotesPath, { force: true });
47
+ }
48
+ return;
49
+ }
50
+ finish({ status: 'failed', changed: false, error: { code: 'RELEASE_NOTES_INPUT_INVALID', message: 'Usage: platform-release-notes context|publish', retryable: false } });
51
+ }
52
+ export { platformReleaseNotes };
53
+ //# sourceMappingURL=platform-release-notes.js.map
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import crypto from 'node:crypto';
3
4
  import { spawnSync } from 'node:child_process';
4
5
  import semver from 'semver';
5
6
  import { commitExplicitPaths, inspectGitWorkflow, pushGitRefs } from '../git/workflow.js';
@@ -9,7 +10,97 @@ import { releaseSnapshot } from '../release/workflow.js';
9
10
  function command(cwd, executable, args) {
10
11
  return spawnSync(executable, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
11
12
  }
13
+ const DEMO_INPUT_PATHS = [
14
+ 'assets/demo-init.tape',
15
+ 'scripts/demo-regen.sh',
16
+ 'scripts/normalize-gif-duration.py',
17
+ 'bin/cli.ts',
18
+ 'lib/init.ts',
19
+ 'lib/log.ts',
20
+ 'lib/prompt.ts',
21
+ 'lib/paths.ts',
22
+ 'lib/render.ts',
23
+ 'lib/builtin-tuis.ts',
24
+ 'lib/sandbox/engines/',
25
+ 'src/sync-templates.js',
26
+ 'templates/'
27
+ ];
28
+ const DEMO_DIGEST_PATH = 'assets/demo-init.inputs.sha256';
29
+ const DEMO_OUTPUT_PATH = 'assets/demo-init.gif';
30
+ const DEMO_MAX_BYTES = 4 * 1024 * 1024;
31
+ function demoInputFiles(cwd) {
32
+ const files = new Set();
33
+ for (const input of DEMO_INPUT_PATHS) {
34
+ if (input.endsWith('/')) {
35
+ const listed = command(cwd, 'git', ['ls-files', '--', input]);
36
+ if (listed.status !== 0)
37
+ throw new Error(String(listed.stderr || `Unable to list ${input}`));
38
+ const directoryFiles = String(listed.stdout).split('\n').filter(Boolean);
39
+ if (!directoryFiles.length)
40
+ throw new Error(`Canonical demo input directory is empty: ${input}`);
41
+ for (const file of directoryFiles)
42
+ files.add(file.replaceAll('\\', '/'));
43
+ }
44
+ else {
45
+ files.add(input);
46
+ }
47
+ }
48
+ const sorted = [...files].sort((left, right) => Buffer.from(left).compare(Buffer.from(right)));
49
+ if (!sorted.length)
50
+ throw new Error('Canonical demo input set is empty');
51
+ for (const file of sorted) {
52
+ const absolute = path.join(cwd, file);
53
+ if (!fs.statSync(absolute).isFile())
54
+ throw new Error(`Canonical demo input is not a file: ${file}`);
55
+ }
56
+ return sorted;
57
+ }
58
+ function computeDemoInputDigest(cwd) {
59
+ const hash = crypto.createHash('sha256');
60
+ for (const file of demoInputFiles(cwd)) {
61
+ const bytes = fs.readFileSync(path.join(cwd, file));
62
+ hash.update(file);
63
+ hash.update('\0');
64
+ hash.update(String(bytes.byteLength));
65
+ hash.update('\0');
66
+ hash.update(bytes);
67
+ hash.update('\0');
68
+ }
69
+ return hash.digest('hex');
70
+ }
71
+ function validGif(filePath) {
72
+ if (!fs.existsSync(filePath))
73
+ return false;
74
+ const header = fs.readFileSync(filePath).subarray(0, 6).toString('ascii');
75
+ return header === 'GIF87a' || header === 'GIF89a';
76
+ }
77
+ function writeDigestAtomically(cwd, digest) {
78
+ const target = path.join(cwd, DEMO_DIGEST_PATH);
79
+ const temporary = `${target}.tmp-${process.pid}`;
80
+ fs.writeFileSync(temporary, `${digest}\n`);
81
+ fs.renameSync(temporary, target);
82
+ }
12
83
  function runOptionalDemo(cwd, run = command) {
84
+ let digest;
85
+ try {
86
+ digest = computeDemoInputDigest(cwd);
87
+ }
88
+ catch (error) {
89
+ return { status: 'failed', reasonCode: 'DEMO_DIGEST_FAILED', message: String(error), outputPath: null };
90
+ }
91
+ const digestPath = path.join(cwd, DEMO_DIGEST_PATH);
92
+ const priorDigest = fs.existsSync(digestPath) ? fs.readFileSync(digestPath, 'utf8').trim() : '';
93
+ if (/^[0-9a-f]{64}$/.test(priorDigest) && priorDigest === digest) {
94
+ return { status: 'skipped', reasonCode: 'DEMO_INPUTS_UNCHANGED', message: null, outputPath: null };
95
+ }
96
+ const lfs = run(cwd, 'git', ['lfs', 'version']);
97
+ if (lfs.status !== 0) {
98
+ return { status: 'failed', reasonCode: 'GIT_LFS_MISSING', message: String(lfs.stderr || lfs.stdout), outputPath: null };
99
+ }
100
+ const lfsAttribute = run(cwd, 'git', ['check-attr', 'filter', '--', DEMO_OUTPUT_PATH]);
101
+ if (lfsAttribute.status !== 0 || !String(lfsAttribute.stdout).trim().endsWith(': lfs')) {
102
+ return { status: 'failed', reasonCode: 'GIT_LFS_MISSING', message: `${DEMO_OUTPUT_PATH} is not tracked by Git LFS`, outputPath: null };
103
+ }
13
104
  const vhs = run(cwd, 'vhs', ['--version']);
14
105
  if (vhs.status !== 0)
15
106
  return { status: 'skipped', reasonCode: 'VHS_MISSING', message: null, outputPath: null };
@@ -25,7 +116,7 @@ function runOptionalDemo(cwd, run = command) {
25
116
  outputPath: null
26
117
  };
27
118
  }
28
- const outputPath = 'assets/demo-init.gif';
119
+ const outputPath = DEMO_OUTPUT_PATH;
29
120
  if (!fs.existsSync(path.join(cwd, outputPath))) {
30
121
  return {
31
122
  status: 'failed',
@@ -34,6 +125,18 @@ function runOptionalDemo(cwd, run = command) {
34
125
  outputPath: null
35
126
  };
36
127
  }
128
+ const absoluteOutput = path.join(cwd, outputPath);
129
+ if (!validGif(absoluteOutput)) {
130
+ return { status: 'failed', reasonCode: 'DEMO_OUTPUT_INVALID', message: `${outputPath} is not a GIF`, outputPath: null };
131
+ }
132
+ if (fs.statSync(absoluteOutput).size > DEMO_MAX_BYTES) {
133
+ return { status: 'failed', reasonCode: 'DEMO_OUTPUT_TOO_LARGE', message: `${outputPath} exceeds 4 MiB`, outputPath: null };
134
+ }
135
+ const pointer = run(cwd, 'git', ['lfs', 'pointer', `--file=${outputPath}`]);
136
+ if (pointer.status !== 0 || !String(pointer.stdout).includes(`size ${fs.statSync(absoluteOutput).size}`)) {
137
+ return { status: 'failed', reasonCode: 'DEMO_OUTPUT_INVALID', message: 'Git LFS could not produce a matching pointer', outputPath: null };
138
+ }
139
+ writeDigestAtomically(cwd, digest);
37
140
  return { status: 'recorded', reasonCode: null, message: null, outputPath };
38
141
  }
39
142
  function git(cwd, args) {
@@ -213,5 +316,5 @@ async function releaseWorkflow(args = []) {
213
316
  process.stdout.write(`${JSON.stringify({ ...pushed, demo, snapshot: releaseSnapshot(version, await inspectFacts(cwd, version)) })}\n`);
214
317
  process.exitCode = pushed.status === 'failed' ? 1 : pushed.status === 'degraded' ? 2 : 0;
215
318
  }
216
- export { inspectFacts, releaseWorkflow, runOptionalDemo };
319
+ export { computeDemoInputDigest, inspectFacts, releaseWorkflow, runOptionalDemo };
217
320
  //# sourceMappingURL=release-workflow.js.map
@@ -0,0 +1,96 @@
1
+ import { createGitHubClient } from './github-client.js';
2
+ function normalizeGitHubActor(actor) {
3
+ const name = String(actor.name || '').trim();
4
+ const email = actor.email ? String(actor.email).trim() : null;
5
+ const platformLogin = actor.user?.login?.trim().toLowerCase() || null;
6
+ const noReplyLogin = email
7
+ ? /^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/i.exec(email)?.[1]?.toLowerCase() || null
8
+ : null;
9
+ const login = platformLogin || noReplyLogin;
10
+ return {
11
+ name,
12
+ email,
13
+ login,
14
+ bot: Boolean(login?.endsWith('[bot]')),
15
+ resolution: platformLogin ? 'platform-user' : noReplyLogin ? 'platform-noreply' : 'unresolved'
16
+ };
17
+ }
18
+ function failure(error) {
19
+ return {
20
+ status: error.retryable ? 'blocked' : 'failed',
21
+ changed: false,
22
+ operation: null,
23
+ url: null,
24
+ error
25
+ };
26
+ }
27
+ function publishGitHubReleaseNotes(input, options = {}) {
28
+ const client = options.client ?? createGitHubClient();
29
+ const inspected = client.json(['release', 'view', input.tag, '--repo', input.repository, '--json', 'url'], { cwd: options.cwd });
30
+ if (!inspected.ok && inspected.error.code !== 'RESOURCE_NOT_FOUND')
31
+ return failure(inspected.error);
32
+ const exists = inspected.ok;
33
+ const operation = exists ? 'release:update-notes' : 'release:create';
34
+ if (input.dryRun) {
35
+ return {
36
+ status: 'planned',
37
+ changed: false,
38
+ operation,
39
+ url: inspected.ok ? inspected.value.url || null : null,
40
+ error: null
41
+ };
42
+ }
43
+ const args = exists
44
+ ? ['release', 'edit', input.tag, '--repo', input.repository, '--notes-file', input.notesFile]
45
+ : ['release', 'create', input.tag, '--repo', input.repository, '--title', input.title, '--notes-file', input.notesFile];
46
+ const written = client.text(args, { cwd: options.cwd, method: exists ? 'PATCH' : 'POST' });
47
+ if (!written.ok)
48
+ return failure(written.error);
49
+ return {
50
+ status: 'applied',
51
+ changed: true,
52
+ operation,
53
+ url: written.value || (inspected.ok ? inspected.value.url || null : null),
54
+ error: null
55
+ };
56
+ }
57
+ function fetchGitHubReleaseNoteData(input, options = {}) {
58
+ const client = options.client ?? createGitHubClient();
59
+ const releases = client.json(['release', 'list', '--repo', input.repository, '--limit', String(input.historyLimit + 10), '--json', 'tagName,isDraft,isPrerelease'], { cwd: options.cwd });
60
+ if (!releases.ok)
61
+ return failure(releases.error);
62
+ const history = [];
63
+ for (const item of releases.value.filter((entry) => !entry.isDraft && !entry.isPrerelease).slice(0, input.historyLimit)) {
64
+ const viewed = client.json(['release', 'view', String(item.tagName), '--repo', input.repository, '--json', 'body,url'], { cwd: options.cwd });
65
+ if (!viewed.ok)
66
+ return failure(viewed.error);
67
+ history.push({ tag: String(item.tagName), body: String(viewed.value.body || ''), url: viewed.value.url || null });
68
+ }
69
+ const prs = client.json(['pr', 'list', '--repo', input.repository, '--state', 'merged', '--base', input.branch, '--limit', '1000', '--json', 'number,title,body,url,mergedAt,labels,author,closingIssuesReferences'], { cwd: options.cwd });
70
+ if (!prs.ok)
71
+ return failure(prs.error);
72
+ const authors = new Map();
73
+ for (const oid of input.commitOids) {
74
+ const query = `query($owner:String!,$name:String!,$oid:GitObjectID!){repository(owner:$owner,name:$name){object(oid:$oid){... on Commit{authors(first:100){nodes{name email user{login}} pageInfo{hasNextPage}}}}}}`;
75
+ const [owner, name] = input.repository.split('/');
76
+ const result = client.json([
77
+ 'api', 'graphql', '-f', `query=${query}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `oid=${oid}`
78
+ ], { cwd: options.cwd });
79
+ if (!result.ok)
80
+ return failure(result.error);
81
+ const connection = result.value.data?.repository?.object?.authors;
82
+ if (connection?.pageInfo?.hasNextPage) {
83
+ return failure({ code: 'RELEASE_NOTES_AUTHORS_TRUNCATED', message: `Commit authors exceeded the supported page size for ${oid}`, retryable: false });
84
+ }
85
+ authors.set(oid, (connection?.nodes || []).map(normalizeGitHubActor));
86
+ }
87
+ const fromTime = Date.parse(input.fromTime);
88
+ const toTime = Date.parse(input.toTime);
89
+ const pullRequests = prs.value.filter((item) => {
90
+ const mergedAt = Date.parse(String(item.mergedAt || ''));
91
+ return Number.isFinite(mergedAt) && mergedAt > fromTime && mergedAt <= toTime;
92
+ });
93
+ return { status: 'no-op', changed: false, history, pullRequests, authors, error: null };
94
+ }
95
+ export { fetchGitHubReleaseNoteData, normalizeGitHubActor, publishGitHubReleaseNotes };
96
+ //# sourceMappingURL=github-release-notes.js.map
@@ -5,7 +5,7 @@ import { buildDefaultBody } from '../task/commands/issue-body.js';
5
5
  import { extractTitle, parseTaskFrontmatter } from '../task/frontmatter.js';
6
6
  import { requirementFieldLabels, renderTemplateBody } from '../task/issue-form.js';
7
7
  import { resolveTaskRef } from '../task/resolve-ref.js';
8
- import { extractSection } from '../task/sections.js';
8
+ import { extractSection, findSectionHeading } from '../task/sections.js';
9
9
  import { writeTask } from '../task/write.js';
10
10
  import { resolvePlatformContext } from './context.js';
11
11
  import { createGitHubClient } from './github-client.js';
@@ -228,7 +228,9 @@ function deterministicIssueBody(repoRoot, content, taskType) {
228
228
  return renderTemplateBody(fs.readFileSync(selected, 'utf8'), {
229
229
  title: taskTitle(content),
230
230
  description: extractSection(content, ['描述', 'Description']),
231
- requirements: extractSection(content, ['需求', 'Requirements'])
231
+ requirements: extractSection(content, ['需求', 'Requirements']),
232
+ taskInput: extractSection(content, ['任务输入', 'Task Input']),
233
+ taskInputHeading: findSectionHeading(content, ['任务输入', 'Task Input'])
232
234
  });
233
235
  }
234
236
  catch {
@@ -0,0 +1,54 @@
1
+ import { execFileSync } from "node:child_process";
2
+ const SHA_PATTERN = /^[0-9a-f]{40}$/i;
3
+ function git(cwd, args) {
4
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
5
+ }
6
+ function commitExists(gitRoot, sha) {
7
+ try {
8
+ git(gitRoot, ["cat-file", "-e", `${sha}^{commit}`]);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ export function resolveReviewedHeadRelation(input) {
16
+ const { gitRoot, localHead, lastReviewedCommit, pullRequest, pathspecs } = input;
17
+ if (localHead === lastReviewedCommit && lastReviewedCommit === pullRequest.head.sha) {
18
+ return { status: "strict", reviewedHead: lastReviewedCommit };
19
+ }
20
+ if (pullRequest.state !== "closed" || !pullRequest.mergedAt || !pullRequest.mergeCommitSha ||
21
+ lastReviewedCommit !== pullRequest.head.sha) {
22
+ return { status: "failed", code: "PR_MERGE_IDENTITY_INVALID", message: "PR merge identity does not match the reviewed head" };
23
+ }
24
+ const shas = [pullRequest.base.sha, pullRequest.head.sha, pullRequest.mergeCommitSha, localHead];
25
+ if (shas.some((sha) => !SHA_PATTERN.test(sha) || !commitExists(gitRoot, sha))) {
26
+ return { status: "blocked", code: "PR_MERGE_OBJECT_MISSING", message: "Required PR merge Git objects are unavailable" };
27
+ }
28
+ try {
29
+ git(gitRoot, ["merge-base", "--is-ancestor", pullRequest.mergeCommitSha, localHead]);
30
+ const parents = git(gitRoot, ["rev-list", "--parents", "-n", "1", pullRequest.mergeCommitSha]).trim().split(/\s+/);
31
+ if (parents.length !== 2) {
32
+ return { status: "failed", code: "PR_MERGE_TOPOLOGY_INVALID", message: "PR merge commit is not a single-parent squash commit" };
33
+ }
34
+ const diffArgs = ["diff", "--no-renames", "--binary", "--full-index"];
35
+ const reviewedDiff = git(gitRoot, [...diffArgs, pullRequest.base.sha, pullRequest.head.sha]);
36
+ const mergedDiff = git(gitRoot, [...diffArgs, `${pullRequest.mergeCommitSha}^`, pullRequest.mergeCommitSha]);
37
+ if (reviewedDiff !== mergedDiff) {
38
+ return { status: "failed", code: "PR_MERGE_CONTENT_MISMATCH", message: "Squash merge content differs from the reviewed PR changes" };
39
+ }
40
+ const postMerge = git(gitRoot, ["rev-list", `${pullRequest.mergeCommitSha}..${localHead}`, "--", ...pathspecs]).trim();
41
+ if (postMerge) {
42
+ return { status: "failed", code: "POST_MERGE_CHANGES", message: "Protected paths changed after the squash merge" };
43
+ }
44
+ }
45
+ catch {
46
+ return { status: "blocked", code: "PR_MERGE_GIT_EVIDENCE_UNAVAILABLE", message: "Unable to verify PR merge topology or content" };
47
+ }
48
+ return {
49
+ status: "merged-equivalent",
50
+ reviewedHead: lastReviewedCommit,
51
+ mergeCommit: pullRequest.mergeCommitSha
52
+ };
53
+ }
54
+ //# sourceMappingURL=merged-pr-equivalence.js.map
@@ -118,7 +118,7 @@ function inspectRequiredChecks(taskRef, options = {}) {
118
118
  return checksResult(inspected.error.retryable ? 'blocked' : 'failed', {
119
119
  platform: base.context.platform, capabilities: base.context.capabilities,
120
120
  resource: { kind: 'pull-request', number: base.prNumber },
121
- pullRequest: { number: base.prNumber, headSha: base.pullRequest.head.sha }, error: inspected.error
121
+ pullRequest: base.pullRequest, error: inspected.error
122
122
  });
123
123
  }
124
124
  const classified = classifyRequiredChecks(normalizeChecks(inspected.value));
@@ -128,7 +128,7 @@ function inspectRequiredChecks(taskRef, options = {}) {
128
128
  return checksResult(status, {
129
129
  platform: base.context.platform, capabilities: base.context.capabilities,
130
130
  resource: { kind: 'pull-request', number: base.prNumber },
131
- pullRequest: { number: base.prNumber, headSha: base.pullRequest.head.sha }, checks: classified,
131
+ pullRequest: base.pullRequest, checks: classified,
132
132
  error: status === 'no-op' ? null : {
133
133
  code: classified.state === 'pending' ? 'REQUIRED_CHECKS_PENDING' : `REQUIRED_CHECKS_${classified.state.toUpperCase()}`,
134
134
  message: `Required checks are ${classified.state}`,
@@ -172,10 +172,10 @@ function resolvePlatformCheckRun(taskRef, options) {
172
172
  if (direct) {
173
173
  const run = base.client.json(['api', `repos/${base.context.platform.repository}/actions/runs/${direct.runId}`], { cwd: base.resolved.repoRoot });
174
174
  if (!run.ok)
175
- return checksResult(run.error.retryable ? 'blocked' : 'failed', { platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: { number: base.prNumber, headSha: base.pullRequest.head.sha }, error: run.error });
175
+ return checksResult(run.error.retryable ? 'blocked' : 'failed', { platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: base.pullRequest, error: run.error });
176
176
  if (run.value.head_sha !== base.pullRequest.head.sha)
177
177
  return checksResult('failed', { error: { code: 'CHECK_RUN_HEAD_MISMATCH', message: 'Resolved run does not belong to the PR head SHA', retryable: false } });
178
- return checksResult('no-op', { platform: base.context.platform, capabilities: base.context.capabilities, resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: { number: base.prNumber, headSha: base.pullRequest.head.sha }, resolution: { status: 'resolved', ...direct }, error: null });
178
+ return checksResult('no-op', { platform: base.context.platform, capabilities: base.context.capabilities, resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: base.pullRequest, resolution: { status: 'resolved', ...direct }, error: null });
179
179
  }
180
180
  const listed = base.client.json(['api', `repos/${base.context.platform.repository}/commits/${base.pullRequest.head.sha}/check-runs`], { cwd: base.resolved.repoRoot });
181
181
  if (!listed.ok)
@@ -189,7 +189,7 @@ function resolvePlatformCheckRun(taskRef, options) {
189
189
  const resolution = resolveRunCandidate(candidates, base.pullRequest.head.sha, options.checkName);
190
190
  return checksResult(resolution.status === 'resolved' ? 'no-op' : 'failed', {
191
191
  platform: base.context.platform, capabilities: base.context.capabilities,
192
- resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: { number: base.prNumber, headSha: base.pullRequest.head.sha },
192
+ resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: base.pullRequest,
193
193
  resolution,
194
194
  error: resolution.status === 'resolved' ? null : { code: resolution.status === 'ambiguous' ? 'CHECK_RUN_AMBIGUOUS' : 'CHECK_RUN_NOT_FOUND', message: `Check run is ${resolution.status}`, retryable: false }
195
195
  });
@@ -208,7 +208,7 @@ function fetchPlatformCheckLogs(taskRef, options) {
208
208
  return checksResult('failed', { error: { code: 'CHECK_LOGS_MISSING', message: 'No failed logs are available', retryable: false } });
209
209
  return checksResult('no-op', {
210
210
  platform: base.context.platform, capabilities: base.context.capabilities,
211
- resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: { number: base.prNumber, headSha: base.pullRequest.head.sha },
211
+ resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: base.pullRequest,
212
212
  logs: { runId: options.run, ...(options.job ? { jobId: options.job } : {}), text: fetched.value }, error: null
213
213
  });
214
214
  }
@@ -33,7 +33,9 @@ function normalizePullRequest(remote, repository) {
33
33
  body: remote.body || '',
34
34
  draft: Boolean(remote.draft),
35
35
  head: { repository: headRepository, ref: remote.head.ref, sha: remote.head.sha },
36
- base: { repository: baseRepository, ref: remote.base.ref },
36
+ base: { repository: baseRepository, ref: remote.base.ref, sha: remote.base.sha || '' },
37
+ mergedAt: remote.merged_at || null,
38
+ mergeCommitSha: remote.merge_commit_sha || null,
37
39
  labels: (remote.labels || []).map((label) => typeof label === 'string' ? label : label.name || '').filter(Boolean).sort(),
38
40
  assignees: (remote.assignees || []).map((assignee) => assignee.login || '').filter(Boolean).sort(),
39
41
  milestone: remote.milestone?.title || null
@@ -0,0 +1,143 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { resolvePlatformContext } from './context.js';
5
+ import { fetchGitHubReleaseNoteData, publishGitHubReleaseNotes } from './github-release-notes.js';
6
+ const unsupportedError = {
7
+ code: 'PLATFORM_RELEASE_NOTES_UNSUPPORTED',
8
+ message: 'Release notes are not supported for the configured platform',
9
+ retryable: false
10
+ };
11
+ function baseResult(status) {
12
+ return {
13
+ status,
14
+ changed: false,
15
+ range: null,
16
+ history: [],
17
+ pullRequests: [],
18
+ commits: [],
19
+ error: status === 'no-op' ? unsupportedError : null
20
+ };
21
+ }
22
+ function git(cwd, args) {
23
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
24
+ }
25
+ function configuredPlatform(cwd) {
26
+ try {
27
+ const value = JSON.parse(fs.readFileSync(path.join(cwd, '.agents', '.airc.json'), 'utf8'));
28
+ return typeof value?.platform?.type === 'string' ? value.platform.type : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function releaseNoteContext(input, options = {}) {
35
+ const historyLimit = input.historyLimit ?? 3;
36
+ if (!input.fromTag || !input.toTag || !input.branch || !Number.isInteger(historyLimit) || historyLimit < 1 || historyLimit > 20) {
37
+ return { ...baseResult('failed'), error: { code: 'RELEASE_NOTES_INPUT_INVALID', message: 'from-tag, to-tag, branch, and a history limit from 1 to 20 are required', retryable: false } };
38
+ }
39
+ const cwd = path.resolve(options.cwd || process.cwd());
40
+ const platformType = options.platformType ?? configuredPlatform(cwd);
41
+ if (platformType !== 'github')
42
+ return baseResult('no-op');
43
+ let fromOid;
44
+ let toOid;
45
+ let fromTime;
46
+ let toTime;
47
+ let commitFacts;
48
+ try {
49
+ fromOid = git(cwd, ['rev-parse', '--verify', `${input.fromTag}^{commit}`]);
50
+ toOid = git(cwd, ['rev-parse', '--verify', `${input.toTag}^{commit}`]);
51
+ git(cwd, ['merge-base', '--is-ancestor', fromOid, toOid]);
52
+ fromTime = git(cwd, ['show', '-s', '--format=%cI', fromOid]);
53
+ toTime = git(cwd, ['show', '-s', '--format=%cI', toOid]);
54
+ const raw = git(cwd, ['log', '--reverse', '--no-merges', '--format=%H%x00%s', `${fromOid}..${toOid}`]);
55
+ commitFacts = raw ? raw.split('\n').map((line) => {
56
+ const [oid = '', title = ''] = line.split('\0');
57
+ return { oid, title };
58
+ }) : [];
59
+ }
60
+ catch {
61
+ return { ...baseResult('failed'), error: { code: 'RELEASE_NOTES_RANGE_INVALID', message: 'The release tag range is missing or is not ancestral', retryable: false } };
62
+ }
63
+ const context = resolvePlatformContext({ cwd, platformType, client: options.client });
64
+ if (!context.platform.repository) {
65
+ return {
66
+ ...baseResult(context.status === 'blocked' ? 'blocked' : 'failed'),
67
+ error: context.error || { code: 'PLATFORM_CONTEXT_UNAVAILABLE', message: 'Platform repository is unavailable', retryable: context.status === 'blocked' }
68
+ };
69
+ }
70
+ const platform = fetchGitHubReleaseNoteData({
71
+ repository: context.platform.repository,
72
+ commitOids: commitFacts.map((item) => item.oid),
73
+ branch: input.branch,
74
+ historyLimit,
75
+ fromTime,
76
+ toTime
77
+ }, { cwd, client: options.client });
78
+ if (platform.status === 'blocked' || platform.status === 'failed' || !('authors' in platform)) {
79
+ return { ...baseResult(platform.status), error: platform.error };
80
+ }
81
+ const commits = commitFacts.map((commit) => {
82
+ const seen = new Set();
83
+ const authors = (platform.authors.get(commit.oid) || []).filter((actor) => {
84
+ const key = actor.login || actor.email?.toLowerCase() || actor.name.toLowerCase();
85
+ if (seen.has(key))
86
+ return false;
87
+ seen.add(key);
88
+ return true;
89
+ });
90
+ return { ...commit, authors };
91
+ });
92
+ const pullRequests = platform.pullRequests
93
+ .map((item) => ({
94
+ number: Number(item.number),
95
+ title: String(item.title || ''),
96
+ body: String(item.body || ''),
97
+ url: String(item.url || ''),
98
+ mergedAt: String(item.mergedAt || ''),
99
+ labels: Array.isArray(item.labels) ? item.labels.map((label) => String(label.name || label)) : [],
100
+ author: item.author && typeof item.author === 'object'
101
+ ? { login: String(item.author.login || '').toLowerCase(), bot: String(item.author.login || '').endsWith('[bot]') }
102
+ : null,
103
+ closingIssues: Array.isArray(item.closingIssuesReferences)
104
+ ? item.closingIssuesReferences.map((issue) => {
105
+ const value = issue;
106
+ return {
107
+ number: Number(value.number),
108
+ title: String(value.title || ''),
109
+ url: String(value.url || ''),
110
+ labels: Array.isArray(value.labels) ? value.labels.map((label) => String(label.name || label)) : [],
111
+ author: value.author && typeof value.author === 'object'
112
+ ? { login: String(value.author.login || '').toLowerCase(), bot: String(value.author.login || '').endsWith('[bot]') }
113
+ : null
114
+ };
115
+ }) : []
116
+ }))
117
+ .sort((left, right) => left.number - right.number);
118
+ return {
119
+ status: 'no-op',
120
+ changed: false,
121
+ range: { fromTag: input.fromTag, toTag: input.toTag, fromOid, toOid },
122
+ history: platform.history,
123
+ pullRequests,
124
+ commits,
125
+ error: null
126
+ };
127
+ }
128
+ function publishReleaseNotes(input, options = {}) {
129
+ if (!input.tag || !input.title || !input.notesFile) {
130
+ return { status: 'failed', changed: false, operation: null, url: null, error: { code: 'RELEASE_NOTES_INPUT_INVALID', message: 'tag, title, and notes-file are required', retryable: false } };
131
+ }
132
+ const cwd = path.resolve(options.cwd || process.cwd());
133
+ const platformType = options.platformType ?? configuredPlatform(cwd);
134
+ if (platformType !== 'github')
135
+ return { status: 'no-op', changed: false, operation: null, url: null, error: unsupportedError };
136
+ const context = resolvePlatformContext({ cwd, platformType, client: options.client });
137
+ if (!context.platform.repository) {
138
+ return { status: context.status === 'blocked' ? 'blocked' : 'failed', changed: false, operation: null, url: null, error: context.error };
139
+ }
140
+ return publishGitHubReleaseNotes({ ...input, repository: context.platform.repository }, { cwd, client: options.client });
141
+ }
142
+ export { publishReleaseNotes, releaseNoteContext };
143
+ //# sourceMappingURL=release-notes.js.map