@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
@@ -4,9 +4,11 @@ import path from "node:path";
4
4
  import process from "node:process";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
- import { extractReviewBaseline, extractReviewDiffFingerprint, extractReviewedSnapshotTree, findAuthoritativeReviewCodeArtifact, parseReviewVerdict, resolvePostReviewGlobs } from "./verification-review.js";
7
+ import { extractReviewBaseline, extractReviewDiffFingerprint, extractReviewedSnapshotTree, findAuthoritativeReviewCodeArtifact, loadPostReviewConfig, parseReviewVerdict, resolvePostReviewGlobs } from "./verification-review.js";
8
8
  import { check as checkPlatformSync } from "../platform/verification-sync.js";
9
9
  import { check as checkRequiredChecks } from "../platform/verification-required.js";
10
+ import { inspectPlatformPullRequest } from "../platform/pull-requests.js";
11
+ import { resolveReviewedHeadRelation } from "../platform/merged-pr-equivalence.js";
10
12
  import { parseTypedTaskFrontmatter } from "./frontmatter.js";
11
13
  import { parseLedger } from "./ledger.js";
12
14
  import { loadVerificationConfig } from "./verification-config.js";
@@ -210,19 +212,6 @@ function loadProjectName() {
210
212
  return "";
211
213
  }
212
214
  }
213
- function loadReviewConfig() {
214
- const configPath = path.join(repoRoot, ".agents", ".airc.json");
215
- if (!fs.existsSync(configPath)) {
216
- return {};
217
- }
218
- try {
219
- const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
220
- return config.review && typeof config.review === "object" ? config.review : {};
221
- }
222
- catch {
223
- return {};
224
- }
225
- }
226
215
  function checkArtifact({ taskDir, config, artifactFile }) {
227
216
  const resolvedArtifact = resolveArtifactPath(taskDir, config.file_pattern, artifactFile);
228
217
  if (!resolvedArtifact.ok) {
@@ -495,7 +484,7 @@ function resolveReviewSetting(config, key, fallback) {
495
484
  if (config && config[key] !== undefined && config[key] !== null) {
496
485
  return config[key];
497
486
  }
498
- const reviewConfig = loadReviewConfig();
487
+ const reviewConfig = loadPostReviewConfig(repoRoot);
499
488
  if (reviewConfig[key] !== undefined && reviewConfig[key] !== null) {
500
489
  return reviewConfig[key];
501
490
  }
@@ -590,7 +579,7 @@ function checkPostReviewCommit({ taskDir, config }) {
590
579
  return baselineSource.result;
591
580
  }
592
581
  const sha = baselineSource.sha;
593
- const globs = resolvePostReviewGlobs(config, loadReviewConfig());
582
+ const globs = resolvePostReviewGlobs(config, loadPostReviewConfig(repoRoot));
594
583
  let commits;
595
584
  try {
596
585
  const out = execFileSync("git", ["-C", gitRoot, "rev-list", `${sha}..HEAD`, "--", ...globs], { encoding: "utf8" });
@@ -602,6 +591,34 @@ function checkPostReviewCommit({ taskDir, config }) {
602
591
  if (commits.length === 0) {
603
592
  return passResult("post-review-commit", `No post-review commits to code/rule paths since ${sha.slice(0, 8)}`);
604
593
  }
594
+ if (task.ok && Number(task.metadata.pr_number) > 0) {
595
+ const inspected = inspectPlatformPullRequest(task.metadata.id, { cwd: gitRoot });
596
+ if (inspected.pullRequest) {
597
+ let localHead = "";
598
+ try {
599
+ localHead = execFileSync("git", ["-C", gitRoot, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
600
+ }
601
+ catch {
602
+ return blockedResult("post-review-commit", "Unable to resolve the local HEAD");
603
+ }
604
+ const relation = resolveReviewedHeadRelation({
605
+ gitRoot,
606
+ localHead,
607
+ lastReviewedCommit: sha,
608
+ pullRequest: inspected.pullRequest,
609
+ pathspecs: globs
610
+ });
611
+ if (relation.status === "merged-equivalent") {
612
+ return passResult("post-review-commit", `Reviewed PR head is content-equivalent to squash merge ${relation.mergeCommit.slice(0, 8)}`);
613
+ }
614
+ if (relation.status === "blocked") {
615
+ return blockedResult("post-review-commit", relation.message);
616
+ }
617
+ }
618
+ else if (inspected.status === "blocked") {
619
+ return blockedResult("post-review-commit", inspected.error?.message || "PR merge snapshot is unavailable");
620
+ }
621
+ }
605
622
  let exempt = false;
606
623
  try {
607
624
  exempt = task.ok && parseLedger(task.content).some((row) => row.stage === POST_REVIEW_COMMIT_STAGE && row.status === "human-decided");
@@ -647,7 +664,7 @@ function checkReviewFact({ taskDir, artifactFile }) {
647
664
  }
648
665
  let actualSnapshot;
649
666
  try {
650
- actualSnapshot = snapshotReview({ cwd: gitRoot, mode: "worktree", baseline, globs: resolvePostReviewGlobs({}, loadReviewConfig()) });
667
+ actualSnapshot = snapshotReview({ cwd: gitRoot, mode: "worktree", baseline, globs: resolvePostReviewGlobs({}, loadPostReviewConfig(repoRoot)) });
651
668
  }
652
669
  catch {
653
670
  return blockedResult("review-fact", `Unable to recompute reviewed diff fingerprint from baseline ${baseline.slice(0, 8)}; re-run review-code`);
@@ -1,3 +1,3 @@
1
- export { extractReviewBaseline, extractReviewDiffFingerprint, extractReviewedSnapshotTree, findAuthoritativeReviewCodeArtifact, parseReviewVerdict, resolvePostReviewGlobs } from './review-fingerprint.js';
1
+ export { extractReviewBaseline, extractReviewDiffFingerprint, extractReviewedSnapshotTree, findAuthoritativeReviewCodeArtifact, loadPostReviewConfig, parseReviewVerdict, resolvePostReviewGlobs } from './review-fingerprint.js';
2
2
  export { parseLedger, summarizeLedgerStage, validateLedgerRows } from './ledger.js';
3
3
  //# sourceMappingURL=verification-review.js.map
@@ -19,7 +19,7 @@ const VERIFICATION_CATALOG = {
19
19
  'block-task.completed': gate('block-task', 'blocked'),
20
20
  'cancel-task.completed': gate('cancel-task', 'completed'),
21
21
  'commit.completed': gate('commit', 'active'),
22
- 'complete-task.preflight': { skill: 'complete-task', expectedState: 'active', mode: 'checks', checks: ['review-ledger', 'post-review-commit', 'required-checks', 'platform-sync-preflight'] },
22
+ 'complete-task.preflight': { skill: 'complete-task', expectedState: 'active', mode: 'checks', checks: ['review-ledger', 'post-review-commit', 'platform-sync-preflight'] },
23
23
  'complete-task.completed': gate('complete-task', 'completed'),
24
24
  'create-pr.completed': gate('create-pr', 'active'),
25
25
  'create-task.completed': gate('create-task', 'active'),
@@ -0,0 +1,55 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { publishReleaseNotes, releaseNoteContext } from '../platform/release-notes.ts';
5
+
6
+ function option(args: string[], name: string): string | null {
7
+ const index = args.indexOf(name);
8
+ return index >= 0 && args[index + 1] ? args[index + 1]! : null;
9
+ }
10
+
11
+ function finish(result: { status: string; [key: string]: unknown }): void {
12
+ process.stdout.write(`${JSON.stringify(result)}\n`);
13
+ process.exitCode = result.status === 'blocked' ? 2 : result.status === 'failed' ? 1 : 0;
14
+ }
15
+
16
+ function platformReleaseNotes(args: string[] = []): void {
17
+ const [action, ...rest] = args;
18
+ const cwd = path.resolve(option(rest, '--cwd') || process.cwd());
19
+ if (action === 'context') {
20
+ const historyRaw = option(rest, '--history-limit');
21
+ finish(releaseNoteContext({
22
+ fromTag: option(rest, '--from-tag') || '',
23
+ toTag: option(rest, '--to-tag') || '',
24
+ branch: option(rest, '--branch') || '',
25
+ historyLimit: historyRaw === null ? undefined : Number(historyRaw)
26
+ }, { cwd }));
27
+ return;
28
+ }
29
+ if (action === 'publish') {
30
+ const notesPath = option(rest, '--notes-file');
31
+ if (!notesPath) {
32
+ finish({ status: 'failed', changed: false, error: { code: 'RELEASE_NOTES_INPUT_INVALID', message: 'notes-file is required', retryable: false } });
33
+ return;
34
+ }
35
+ let resolvedNotesPath = notesPath;
36
+ if (notesPath === '-') {
37
+ resolvedNotesPath = path.join(process.env.TMPDIR || '/tmp', `agent-infra-release-notes-${process.pid}.md`);
38
+ fs.writeFileSync(resolvedNotesPath, fs.readFileSync(0));
39
+ }
40
+ try {
41
+ finish(publishReleaseNotes({
42
+ tag: option(rest, '--tag') || '',
43
+ title: option(rest, '--title') || option(rest, '--tag') || '',
44
+ notesFile: resolvedNotesPath,
45
+ dryRun: rest.includes('--dry-run')
46
+ }, { cwd }));
47
+ } finally {
48
+ if (notesPath === '-') fs.rmSync(resolvedNotesPath, { force: true });
49
+ }
50
+ return;
51
+ }
52
+ finish({ status: 'failed', changed: false, error: { code: 'RELEASE_NOTES_INPUT_INVALID', message: 'Usage: platform-release-notes context|publish', retryable: false } });
53
+ }
54
+
55
+ export { platformReleaseNotes };
@@ -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
 
@@ -17,12 +18,101 @@ type CommandResult = ReturnType<typeof command>;
17
18
  type CommandRunner = (cwd: string, executable: string, args: string[]) => CommandResult;
18
19
  type DemoResult = {
19
20
  status: 'recorded' | 'skipped' | 'failed';
20
- reasonCode: 'VHS_MISSING' | 'FFMPEG_MISSING' | 'DEMO_COMMAND_FAILED' | 'DEMO_OUTPUT_MISSING' | null;
21
+ reasonCode: 'DEMO_INPUTS_UNCHANGED' | 'GIT_LFS_MISSING' | 'VHS_MISSING' | 'FFMPEG_MISSING'
22
+ | 'DEMO_COMMAND_FAILED' | 'DEMO_OUTPUT_MISSING' | 'DEMO_OUTPUT_INVALID'
23
+ | 'DEMO_OUTPUT_TOO_LARGE' | 'DEMO_DIGEST_FAILED' | null;
21
24
  message: string | null;
22
25
  outputPath: string | null;
23
26
  };
24
27
 
28
+ const DEMO_INPUT_PATHS = [
29
+ 'assets/demo-init.tape',
30
+ 'scripts/demo-regen.sh',
31
+ 'scripts/normalize-gif-duration.py',
32
+ 'bin/cli.ts',
33
+ 'lib/init.ts',
34
+ 'lib/log.ts',
35
+ 'lib/prompt.ts',
36
+ 'lib/paths.ts',
37
+ 'lib/render.ts',
38
+ 'lib/builtin-tuis.ts',
39
+ 'lib/sandbox/engines/',
40
+ 'src/sync-templates.js',
41
+ 'templates/'
42
+ ] as const;
43
+ const DEMO_DIGEST_PATH = 'assets/demo-init.inputs.sha256';
44
+ const DEMO_OUTPUT_PATH = 'assets/demo-init.gif';
45
+ const DEMO_MAX_BYTES = 4 * 1024 * 1024;
46
+
47
+ function demoInputFiles(cwd: string): string[] {
48
+ const files = new Set<string>();
49
+ for (const input of DEMO_INPUT_PATHS) {
50
+ if (input.endsWith('/')) {
51
+ const listed = command(cwd, 'git', ['ls-files', '--', input]);
52
+ if (listed.status !== 0) throw new Error(String(listed.stderr || `Unable to list ${input}`));
53
+ const directoryFiles = String(listed.stdout).split('\n').filter(Boolean);
54
+ if (!directoryFiles.length) throw new Error(`Canonical demo input directory is empty: ${input}`);
55
+ for (const file of directoryFiles) files.add(file.replaceAll('\\', '/'));
56
+ } else {
57
+ files.add(input);
58
+ }
59
+ }
60
+ const sorted = [...files].sort((left, right) => Buffer.from(left).compare(Buffer.from(right)));
61
+ if (!sorted.length) throw new Error('Canonical demo input set is empty');
62
+ for (const file of sorted) {
63
+ const absolute = path.join(cwd, file);
64
+ if (!fs.statSync(absolute).isFile()) throw new Error(`Canonical demo input is not a file: ${file}`);
65
+ }
66
+ return sorted;
67
+ }
68
+
69
+ function computeDemoInputDigest(cwd: string): string {
70
+ const hash = crypto.createHash('sha256');
71
+ for (const file of demoInputFiles(cwd)) {
72
+ const bytes = fs.readFileSync(path.join(cwd, file));
73
+ hash.update(file);
74
+ hash.update('\0');
75
+ hash.update(String(bytes.byteLength));
76
+ hash.update('\0');
77
+ hash.update(bytes);
78
+ hash.update('\0');
79
+ }
80
+ return hash.digest('hex');
81
+ }
82
+
83
+ function validGif(filePath: string): boolean {
84
+ if (!fs.existsSync(filePath)) return false;
85
+ const header = fs.readFileSync(filePath).subarray(0, 6).toString('ascii');
86
+ return header === 'GIF87a' || header === 'GIF89a';
87
+ }
88
+
89
+ function writeDigestAtomically(cwd: string, digest: string): void {
90
+ const target = path.join(cwd, DEMO_DIGEST_PATH);
91
+ const temporary = `${target}.tmp-${process.pid}`;
92
+ fs.writeFileSync(temporary, `${digest}\n`);
93
+ fs.renameSync(temporary, target);
94
+ }
95
+
25
96
  function runOptionalDemo(cwd: string, run: CommandRunner = command): DemoResult {
97
+ let digest: string;
98
+ try {
99
+ digest = computeDemoInputDigest(cwd);
100
+ } catch (error) {
101
+ return { status: 'failed', reasonCode: 'DEMO_DIGEST_FAILED', message: String(error), outputPath: null };
102
+ }
103
+ const digestPath = path.join(cwd, DEMO_DIGEST_PATH);
104
+ const priorDigest = fs.existsSync(digestPath) ? fs.readFileSync(digestPath, 'utf8').trim() : '';
105
+ if (/^[0-9a-f]{64}$/.test(priorDigest) && priorDigest === digest) {
106
+ return { status: 'skipped', reasonCode: 'DEMO_INPUTS_UNCHANGED', message: null, outputPath: null };
107
+ }
108
+ const lfs = run(cwd, 'git', ['lfs', 'version']);
109
+ if (lfs.status !== 0) {
110
+ return { status: 'failed', reasonCode: 'GIT_LFS_MISSING', message: String(lfs.stderr || lfs.stdout), outputPath: null };
111
+ }
112
+ const lfsAttribute = run(cwd, 'git', ['check-attr', 'filter', '--', DEMO_OUTPUT_PATH]);
113
+ if (lfsAttribute.status !== 0 || !String(lfsAttribute.stdout).trim().endsWith(': lfs')) {
114
+ return { status: 'failed', reasonCode: 'GIT_LFS_MISSING', message: `${DEMO_OUTPUT_PATH} is not tracked by Git LFS`, outputPath: null };
115
+ }
26
116
  const vhs = run(cwd, 'vhs', ['--version']);
27
117
  if (vhs.status !== 0) return { status: 'skipped', reasonCode: 'VHS_MISSING', message: null, outputPath: null };
28
118
  const ffmpeg = run(cwd, 'ffmpeg', ['-version']);
@@ -36,7 +126,7 @@ function runOptionalDemo(cwd: string, run: CommandRunner = command): DemoResult
36
126
  outputPath: null
37
127
  };
38
128
  }
39
- const outputPath = 'assets/demo-init.gif';
129
+ const outputPath = DEMO_OUTPUT_PATH;
40
130
  if (!fs.existsSync(path.join(cwd, outputPath))) {
41
131
  return {
42
132
  status: 'failed',
@@ -45,6 +135,18 @@ function runOptionalDemo(cwd: string, run: CommandRunner = command): DemoResult
45
135
  outputPath: null
46
136
  };
47
137
  }
138
+ const absoluteOutput = path.join(cwd, outputPath);
139
+ if (!validGif(absoluteOutput)) {
140
+ return { status: 'failed', reasonCode: 'DEMO_OUTPUT_INVALID', message: `${outputPath} is not a GIF`, outputPath: null };
141
+ }
142
+ if (fs.statSync(absoluteOutput).size > DEMO_MAX_BYTES) {
143
+ return { status: 'failed', reasonCode: 'DEMO_OUTPUT_TOO_LARGE', message: `${outputPath} exceeds 4 MiB`, outputPath: null };
144
+ }
145
+ const pointer = run(cwd, 'git', ['lfs', 'pointer', `--file=${outputPath}`]);
146
+ if (pointer.status !== 0 || !String(pointer.stdout).includes(`size ${fs.statSync(absoluteOutput).size}`)) {
147
+ return { status: 'failed', reasonCode: 'DEMO_OUTPUT_INVALID', message: 'Git LFS could not produce a matching pointer', outputPath: null };
148
+ }
149
+ writeDigestAtomically(cwd, digest);
48
150
  return { status: 'recorded', reasonCode: null, message: null, outputPath };
49
151
  }
50
152
 
@@ -182,5 +284,5 @@ async function releaseWorkflow(args: string[] = []): Promise<void> {
182
284
  process.stdout.write(`${JSON.stringify({ ...pushed, demo, snapshot: releaseSnapshot(version, await inspectFacts(cwd, version)) })}\n`); process.exitCode = pushed.status === 'failed' ? 1 : pushed.status === 'degraded' ? 2 : 0;
183
285
  }
184
286
 
185
- export { inspectFacts, releaseWorkflow, runOptionalDemo };
287
+ export { computeDemoInputDigest, inspectFacts, releaseWorkflow, runOptionalDemo };
186
288
  export type { CommandRunner, DemoResult };
@@ -0,0 +1,132 @@
1
+ import type { GitHubClient } from './github-client.ts';
2
+ import { createGitHubClient } from './github-client.ts';
3
+
4
+ type GitHubActorInput = {
5
+ name?: string | null;
6
+ email?: string | null;
7
+ user?: { login?: string | null } | null;
8
+ };
9
+
10
+ type ReleaseNoteActor = {
11
+ name: string;
12
+ email: string | null;
13
+ login: string | null;
14
+ bot: boolean;
15
+ resolution: 'platform-user' | 'platform-noreply' | 'unresolved';
16
+ };
17
+
18
+ type GitHubReleaseNoteOptions = { cwd?: string; client?: GitHubClient };
19
+
20
+ function normalizeGitHubActor(actor: GitHubActorInput): ReleaseNoteActor {
21
+ const name = String(actor.name || '').trim();
22
+ const email = actor.email ? String(actor.email).trim() : null;
23
+ const platformLogin = actor.user?.login?.trim().toLowerCase() || null;
24
+ const noReplyLogin = email
25
+ ? /^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/i.exec(email)?.[1]?.toLowerCase() || null
26
+ : null;
27
+ const login = platformLogin || noReplyLogin;
28
+ return {
29
+ name,
30
+ email,
31
+ login,
32
+ bot: Boolean(login?.endsWith('[bot]')),
33
+ resolution: platformLogin ? 'platform-user' : noReplyLogin ? 'platform-noreply' : 'unresolved'
34
+ };
35
+ }
36
+
37
+ function failure(error: { code: string; message: string; retryable: boolean }) {
38
+ return {
39
+ status: error.retryable ? 'blocked' as const : 'failed' as const,
40
+ changed: false,
41
+ operation: null,
42
+ url: null,
43
+ error
44
+ };
45
+ }
46
+
47
+ function publishGitHubReleaseNotes(
48
+ input: { repository: string; tag: string; title: string; notesFile: string; dryRun?: boolean },
49
+ options: GitHubReleaseNoteOptions = {}
50
+ ) {
51
+ const client = options.client ?? createGitHubClient();
52
+ const inspected = client.json<{ url?: string }>(
53
+ ['release', 'view', input.tag, '--repo', input.repository, '--json', 'url'],
54
+ { cwd: options.cwd }
55
+ );
56
+ if (!inspected.ok && inspected.error.code !== 'RESOURCE_NOT_FOUND') return failure(inspected.error);
57
+ const exists = inspected.ok;
58
+ const operation = exists ? 'release:update-notes' as const : 'release:create' as const;
59
+ if (input.dryRun) {
60
+ return {
61
+ status: 'planned' as const,
62
+ changed: false,
63
+ operation,
64
+ url: inspected.ok ? inspected.value.url || null : null,
65
+ error: null
66
+ };
67
+ }
68
+ const args = exists
69
+ ? ['release', 'edit', input.tag, '--repo', input.repository, '--notes-file', input.notesFile]
70
+ : ['release', 'create', input.tag, '--repo', input.repository, '--title', input.title, '--notes-file', input.notesFile];
71
+ const written = client.text(args, { cwd: options.cwd, method: exists ? 'PATCH' : 'POST' });
72
+ if (!written.ok) return failure(written.error);
73
+ return {
74
+ status: 'applied' as const,
75
+ changed: true,
76
+ operation,
77
+ url: written.value || (inspected.ok ? inspected.value.url || null : null),
78
+ error: null
79
+ };
80
+ }
81
+
82
+ function fetchGitHubReleaseNoteData(
83
+ input: { repository: string; commitOids: string[]; branch: string; historyLimit: number; fromTime: string; toTime: string },
84
+ options: GitHubReleaseNoteOptions = {}
85
+ ) {
86
+ const client = options.client ?? createGitHubClient();
87
+ const releases = client.json<Array<{ tagName?: string; isDraft?: boolean; isPrerelease?: boolean }>>(
88
+ ['release', 'list', '--repo', input.repository, '--limit', String(input.historyLimit + 10), '--json', 'tagName,isDraft,isPrerelease'],
89
+ { cwd: options.cwd }
90
+ );
91
+ if (!releases.ok) return failure(releases.error);
92
+ const history = [];
93
+ for (const item of releases.value.filter((entry) => !entry.isDraft && !entry.isPrerelease).slice(0, input.historyLimit)) {
94
+ const viewed = client.json<{ body?: string; url?: string }>(
95
+ ['release', 'view', String(item.tagName), '--repo', input.repository, '--json', 'body,url'],
96
+ { cwd: options.cwd }
97
+ );
98
+ if (!viewed.ok) return failure(viewed.error);
99
+ history.push({ tag: String(item.tagName), body: String(viewed.value.body || ''), url: viewed.value.url || null });
100
+ }
101
+ const prs = client.json<Array<Record<string, unknown>>>(
102
+ ['pr', 'list', '--repo', input.repository, '--state', 'merged', '--base', input.branch, '--limit', '1000', '--json', 'number,title,body,url,mergedAt,labels,author,closingIssuesReferences'],
103
+ { cwd: options.cwd }
104
+ );
105
+ if (!prs.ok) return failure(prs.error);
106
+ const authors = new Map<string, ReleaseNoteActor[]>();
107
+ for (const oid of input.commitOids) {
108
+ 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}}}}}}`;
109
+ const [owner, name] = input.repository.split('/');
110
+ const result = client.json<{
111
+ data?: { repository?: { object?: { authors?: { nodes?: GitHubActorInput[]; pageInfo?: { hasNextPage?: boolean } } } } };
112
+ }>([
113
+ 'api', 'graphql', '-f', `query=${query}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `oid=${oid}`
114
+ ], { cwd: options.cwd });
115
+ if (!result.ok) return failure(result.error);
116
+ const connection = result.value.data?.repository?.object?.authors;
117
+ if (connection?.pageInfo?.hasNextPage) {
118
+ return failure({ code: 'RELEASE_NOTES_AUTHORS_TRUNCATED', message: `Commit authors exceeded the supported page size for ${oid}`, retryable: false });
119
+ }
120
+ authors.set(oid, (connection?.nodes || []).map(normalizeGitHubActor));
121
+ }
122
+ const fromTime = Date.parse(input.fromTime);
123
+ const toTime = Date.parse(input.toTime);
124
+ const pullRequests = prs.value.filter((item) => {
125
+ const mergedAt = Date.parse(String(item.mergedAt || ''));
126
+ return Number.isFinite(mergedAt) && mergedAt > fromTime && mergedAt <= toTime;
127
+ });
128
+ return { status: 'no-op' as const, changed: false, history, pullRequests, authors, error: null };
129
+ }
130
+
131
+ export { fetchGitHubReleaseNoteData, normalizeGitHubActor, publishGitHubReleaseNotes };
132
+ export type { ReleaseNoteActor };
@@ -6,7 +6,7 @@ import { buildDefaultBody } from '../task/commands/issue-body.ts';
6
6
  import { extractTitle, parseTaskFrontmatter } from '../task/frontmatter.ts';
7
7
  import { requirementFieldLabels, renderTemplateBody } from '../task/issue-form.ts';
8
8
  import { resolveTaskRef } from '../task/resolve-ref.ts';
9
- import { extractSection } from '../task/sections.ts';
9
+ import { extractSection, findSectionHeading } from '../task/sections.ts';
10
10
  import { writeTask } from '../task/write.ts';
11
11
  import { resolvePlatformContext } from './context.ts';
12
12
  import { createGitHubClient } from './github-client.ts';
@@ -308,9 +308,11 @@ function deterministicIssueBody(repoRoot: string, content: string, taskType: str
308
308
  if (selected) {
309
309
  try {
310
310
  return renderTemplateBody(fs.readFileSync(selected, 'utf8'), {
311
- title: taskTitle(content),
312
- description: extractSection(content, ['描述', 'Description']),
313
- requirements: extractSection(content, ['需求', 'Requirements'])
311
+ title: taskTitle(content),
312
+ description: extractSection(content, ['描述', 'Description']),
313
+ requirements: extractSection(content, ['需求', 'Requirements']),
314
+ taskInput: extractSection(content, ['任务输入', 'Task Input']),
315
+ taskInputHeading: findSectionHeading(content, ['任务输入', 'Task Input'])
314
316
  });
315
317
  } catch {
316
318
  // Invalid or unavailable forms fall back to the deterministic default body.
@@ -0,0 +1,70 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ import type { PullRequestSnapshot } from "./pull-requests.ts";
4
+
5
+ const SHA_PATTERN = /^[0-9a-f]{40}$/i;
6
+
7
+ type ReviewedHeadRelation =
8
+ | { status: "strict"; reviewedHead: string }
9
+ | { status: "merged-equivalent"; reviewedHead: string; mergeCommit: string }
10
+ | { status: "failed" | "blocked"; code: string; message: string };
11
+
12
+ function git(cwd: string, args: string[]): string {
13
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
14
+ }
15
+
16
+ function commitExists(gitRoot: string, sha: string): boolean {
17
+ try {
18
+ git(gitRoot, ["cat-file", "-e", `${sha}^{commit}`]);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ export function resolveReviewedHeadRelation(input: {
26
+ gitRoot: string;
27
+ localHead: string;
28
+ lastReviewedCommit: string;
29
+ pullRequest: PullRequestSnapshot;
30
+ pathspecs: string[];
31
+ }): ReviewedHeadRelation {
32
+ const { gitRoot, localHead, lastReviewedCommit, pullRequest, pathspecs } = input;
33
+ if (localHead === lastReviewedCommit && lastReviewedCommit === pullRequest.head.sha) {
34
+ return { status: "strict", reviewedHead: lastReviewedCommit };
35
+ }
36
+ if (pullRequest.state !== "closed" || !pullRequest.mergedAt || !pullRequest.mergeCommitSha ||
37
+ lastReviewedCommit !== pullRequest.head.sha) {
38
+ return { status: "failed", code: "PR_MERGE_IDENTITY_INVALID", message: "PR merge identity does not match the reviewed head" };
39
+ }
40
+ const shas = [pullRequest.base.sha, pullRequest.head.sha, pullRequest.mergeCommitSha, localHead];
41
+ if (shas.some((sha) => !SHA_PATTERN.test(sha) || !commitExists(gitRoot, sha))) {
42
+ return { status: "blocked", code: "PR_MERGE_OBJECT_MISSING", message: "Required PR merge Git objects are unavailable" };
43
+ }
44
+ try {
45
+ git(gitRoot, ["merge-base", "--is-ancestor", pullRequest.mergeCommitSha, localHead]);
46
+ const parents = git(gitRoot, ["rev-list", "--parents", "-n", "1", pullRequest.mergeCommitSha]).trim().split(/\s+/);
47
+ if (parents.length !== 2) {
48
+ return { status: "failed", code: "PR_MERGE_TOPOLOGY_INVALID", message: "PR merge commit is not a single-parent squash commit" };
49
+ }
50
+ const diffArgs = ["diff", "--no-renames", "--binary", "--full-index"];
51
+ const reviewedDiff = git(gitRoot, [...diffArgs, pullRequest.base.sha, pullRequest.head.sha]);
52
+ const mergedDiff = git(gitRoot, [...diffArgs, `${pullRequest.mergeCommitSha}^`, pullRequest.mergeCommitSha]);
53
+ if (reviewedDiff !== mergedDiff) {
54
+ return { status: "failed", code: "PR_MERGE_CONTENT_MISMATCH", message: "Squash merge content differs from the reviewed PR changes" };
55
+ }
56
+ const postMerge = git(gitRoot, ["rev-list", `${pullRequest.mergeCommitSha}..${localHead}`, "--", ...pathspecs]).trim();
57
+ if (postMerge) {
58
+ return { status: "failed", code: "POST_MERGE_CHANGES", message: "Protected paths changed after the squash merge" };
59
+ }
60
+ } catch {
61
+ return { status: "blocked", code: "PR_MERGE_GIT_EVIDENCE_UNAVAILABLE", message: "Unable to verify PR merge topology or content" };
62
+ }
63
+ return {
64
+ status: "merged-equivalent",
65
+ reviewedHead: lastReviewedCommit,
66
+ mergeCommit: pullRequest.mergeCommitSha
67
+ };
68
+ }
69
+
70
+ export type { ReviewedHeadRelation };
@@ -8,6 +8,7 @@ import type { GitHubClient } from './github-client.ts';
8
8
  import { inspectPlatformPullRequest } from './pull-requests.ts';
9
9
  import { platformResult } from './types.ts';
10
10
  import type { PlatformResult } from './types.ts';
11
+ import type { PullRequestSnapshot } from './pull-requests.ts';
11
12
 
12
13
  type CheckBucket = 'pass' | 'fail' | 'pending' | 'cancel';
13
14
  type CheckState = 'passed' | 'failed' | 'pending' | 'timed-out' | 'cancelled' | 'no-required';
@@ -23,7 +24,7 @@ type CheckSnapshot = {
23
24
  type ChecksSnapshot = { state: Exclude<CheckState, 'timed-out'>; required: CheckSnapshot[] };
24
25
  type RunCandidate = { id: number; name: string; headSha: string; jobId?: number | null };
25
26
  type ChecksResult = PlatformResult & {
26
- pullRequest: { number: number; headSha: string } | null;
27
+ pullRequest: PullRequestSnapshot | null;
27
28
  checks: { state: CheckState; required: CheckSnapshot[] };
28
29
  resolution?: { status: 'resolved' | 'missing' | 'ambiguous'; runId: number | null; jobId: number | null };
29
30
  logs?: { runId: number; jobId?: number; text: string };
@@ -140,7 +141,7 @@ function inspectRequiredChecks(taskRef: string, options: SharedOptions = {}): Ch
140
141
  return checksResult(inspected.error.retryable ? 'blocked' : 'failed', {
141
142
  platform: base.context.platform, capabilities: base.context.capabilities,
142
143
  resource: { kind: 'pull-request', number: base.prNumber },
143
- pullRequest: { number: base.prNumber, headSha: base.pullRequest.head.sha }, error: inspected.error
144
+ pullRequest: base.pullRequest, error: inspected.error
144
145
  });
145
146
  }
146
147
  const classified = classifyRequiredChecks(normalizeChecks(inspected.value));
@@ -150,7 +151,7 @@ function inspectRequiredChecks(taskRef: string, options: SharedOptions = {}): Ch
150
151
  return checksResult(status, {
151
152
  platform: base.context.platform, capabilities: base.context.capabilities,
152
153
  resource: { kind: 'pull-request', number: base.prNumber },
153
- pullRequest: { number: base.prNumber, headSha: base.pullRequest.head.sha }, checks: classified,
154
+ pullRequest: base.pullRequest, checks: classified,
154
155
  error: status === 'no-op' ? null : {
155
156
  code: classified.state === 'pending' ? 'REQUIRED_CHECKS_PENDING' : `REQUIRED_CHECKS_${classified.state.toUpperCase()}`,
156
157
  message: `Required checks are ${classified.state}`,
@@ -197,9 +198,9 @@ function resolvePlatformCheckRun(taskRef: string, options: SharedOptions & { che
197
198
  const direct = options.detailsUrl ? parseRunJobIdentity(options.detailsUrl) : null;
198
199
  if (direct) {
199
200
  const run = base.client.json<{ id?: number; head_sha?: string }>(['api', `repos/${base.context.platform.repository}/actions/runs/${direct.runId}`], { cwd: base.resolved.repoRoot });
200
- if (!run.ok) 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 });
201
+ if (!run.ok) return checksResult(run.error.retryable ? 'blocked' : 'failed', { platform: base.context.platform, capabilities: base.context.capabilities, pullRequest: base.pullRequest, error: run.error });
201
202
  if (run.value.head_sha !== base.pullRequest.head.sha) return checksResult('failed', { error: { code: 'CHECK_RUN_HEAD_MISMATCH', message: 'Resolved run does not belong to the PR head SHA', retryable: false } });
202
- 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 });
203
+ 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 });
203
204
  }
204
205
  const listed = base.client.json<{ check_runs?: Array<{ name?: string; details_url?: string }> }>(['api', `repos/${base.context.platform.repository}/commits/${base.pullRequest.head.sha}/check-runs`], { cwd: base.resolved.repoRoot });
205
206
  if (!listed.ok) return checksResult(listed.error.retryable ? 'blocked' : 'failed', { error: listed.error });
@@ -211,7 +212,7 @@ function resolvePlatformCheckRun(taskRef: string, options: SharedOptions & { che
211
212
  const resolution = resolveRunCandidate(candidates, base.pullRequest.head.sha, options.checkName);
212
213
  return checksResult(resolution.status === 'resolved' ? 'no-op' : 'failed', {
213
214
  platform: base.context.platform, capabilities: base.context.capabilities,
214
- resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: { number: base.prNumber, headSha: base.pullRequest.head.sha },
215
+ resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: base.pullRequest,
215
216
  resolution,
216
217
  error: resolution.status === 'resolved' ? null : { code: resolution.status === 'ambiguous' ? 'CHECK_RUN_AMBIGUOUS' : 'CHECK_RUN_NOT_FOUND', message: `Check run is ${resolution.status}`, retryable: false }
217
218
  });
@@ -228,7 +229,7 @@ function fetchPlatformCheckLogs(taskRef: string, options: SharedOptions & { run:
228
229
  if (!fetched.value) return checksResult('failed', { error: { code: 'CHECK_LOGS_MISSING', message: 'No failed logs are available', retryable: false } });
229
230
  return checksResult('no-op', {
230
231
  platform: base.context.platform, capabilities: base.context.capabilities,
231
- resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: { number: base.prNumber, headSha: base.pullRequest.head.sha },
232
+ resource: { kind: 'pull-request', number: base.prNumber }, pullRequest: base.pullRequest,
232
233
  logs: { runId: options.run, ...(options.job ? { jobId: options.job } : {}), text: fetched.value }, error: null
233
234
  });
234
235
  }
@@ -22,7 +22,9 @@ type PullRequestSnapshot = {
22
22
  body: string;
23
23
  draft: boolean;
24
24
  head: { repository: string; ref: string; sha: string };
25
- base: { repository: string; ref: string };
25
+ base: { repository: string; ref: string; sha: string };
26
+ mergedAt: string | null;
27
+ mergeCommitSha: string | null;
26
28
  labels: string[];
27
29
  assignees: string[];
28
30
  milestone: string | null;
@@ -55,7 +57,9 @@ type RemotePullRequest = {
55
57
  draft?: boolean;
56
58
  pull_request?: unknown;
57
59
  head?: { ref?: string; sha?: string; repo?: { full_name?: string } | null };
58
- base?: { ref?: string; repo?: { full_name?: string } | null };
60
+ base?: { ref?: string; sha?: string; repo?: { full_name?: string } | null };
61
+ merged_at?: string | null;
62
+ merge_commit_sha?: string | null;
59
63
  labels?: Array<string | { name?: string }>;
60
64
  assignees?: Array<{ login?: string }>;
61
65
  milestone?: { title?: string } | null;
@@ -92,7 +96,9 @@ function normalizePullRequest(remote: RemotePullRequest, repository: string): Pu
92
96
  body: remote.body || '',
93
97
  draft: Boolean(remote.draft),
94
98
  head: { repository: headRepository, ref: remote.head.ref, sha: remote.head.sha },
95
- base: { repository: baseRepository, ref: remote.base.ref },
99
+ base: { repository: baseRepository, ref: remote.base.ref, sha: remote.base.sha || '' },
100
+ mergedAt: remote.merged_at || null,
101
+ mergeCommitSha: remote.merge_commit_sha || null,
96
102
  labels: (remote.labels || []).map((label) => typeof label === 'string' ? label : label.name || '').filter(Boolean).sort(),
97
103
  assignees: (remote.assignees || []).map((assignee) => assignee.login || '').filter(Boolean).sort(),
98
104
  milestone: remote.milestone?.title || null