@fitlab-ai/agent-infra 0.11.5 → 0.11.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 (42) hide show
  1. package/dist/lib/merge.js +62 -10
  2. package/dist/lib/platform/github-provider.js +9 -14
  3. package/dist/lib/platform/github-release-notes.js +80 -11
  4. package/dist/lib/platform/none-provider.js +1 -1
  5. package/dist/lib/platform/provider-contract.d.ts +39 -16
  6. package/dist/lib/platform/provider-contract.js +1 -1
  7. package/dist/lib/platform/provider-validation.js +83 -13
  8. package/dist/lib/platform/release-notes.js +7 -8
  9. package/dist/lib/sandbox/capture.js +1 -1
  10. package/dist/lib/sandbox/commands/create.js +46 -1
  11. package/dist/lib/sandbox/commands/enter.js +1 -1
  12. package/dist/lib/sandbox/commands/start.js +1 -1
  13. package/dist/lib/sandbox/config.js +10 -0
  14. package/dist/lib/sandbox/control/lifecycle.js +18 -13
  15. package/dist/lib/sandbox/task-resolver.js +62 -2
  16. package/dist/lib/task/resolve-ref.js +3 -3
  17. package/dist/lib/task/write.js +121 -1
  18. package/dist/lifecycle-build-manifest.json +3 -3
  19. package/lib/merge.ts +59 -10
  20. package/lib/platform/github-provider.ts +11 -18
  21. package/lib/platform/github-release-notes.ts +89 -14
  22. package/lib/platform/none-provider.ts +1 -1
  23. package/lib/platform/provider-contract.ts +38 -12
  24. package/lib/platform/provider-validation.ts +83 -12
  25. package/lib/platform/release-notes.ts +11 -12
  26. package/lib/sandbox/capture.ts +1 -1
  27. package/lib/sandbox/commands/create.ts +52 -1
  28. package/lib/sandbox/commands/enter.ts +1 -1
  29. package/lib/sandbox/commands/start.ts +1 -1
  30. package/lib/sandbox/config.ts +12 -0
  31. package/lib/sandbox/control/lifecycle.ts +22 -13
  32. package/lib/sandbox/task-resolver.ts +51 -2
  33. package/lib/task/resolve-ref.ts +3 -3
  34. package/lib/task/write.ts +115 -1
  35. package/package.json +1 -1
  36. package/templates/.agents/rules/evidence-reporting.en.md +1 -2
  37. package/templates/.agents/rules/evidence-reporting.zh-CN.md +1 -2
  38. package/templates/.agents/skills/archive-tasks/SKILL.en.md +2 -2
  39. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +2 -2
  40. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +109 -6
  41. package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -11
  42. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +17 -11
package/dist/lib/merge.js CHANGED
@@ -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 { removeDirRecursive } from './remove-dir.js';
4
5
  import { validateCurrentTaskContract } from './task/current-contract.js';
5
6
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
@@ -44,7 +45,7 @@ function extractTitle(content) {
44
45
  .replace(/\|/g, '\\|') || null;
45
46
  }
46
47
  function normalizeTaskRecord(taskDir, taskFile, dateParts) {
47
- const taskId = path.basename(taskDir);
48
+ const taskId = path.basename(taskDir) === 'local' ? path.basename(path.dirname(taskDir)) : path.basename(taskDir);
48
49
  const content = fs.readFileSync(taskFile, 'utf8');
49
50
  const completedAt = extractField(content, 'completed_at');
50
51
  const updatedAt = extractField(content, 'updated_at');
@@ -85,7 +86,7 @@ function scanSourceTasks(sourceDir) {
85
86
  if (!taskEntry.isDirectory() || !TASK_ID_RE.test(taskEntry.name)) {
86
87
  continue;
87
88
  }
88
- const taskDir = path.join(dayDir, taskEntry.name);
89
+ const taskDir = path.join(dayDir, taskEntry.name, 'local');
89
90
  const taskFile = path.join(taskDir, 'task.md');
90
91
  if (!fs.existsSync(taskFile)) {
91
92
  continue;
@@ -165,7 +166,7 @@ function collectArchiveEntries(archiveDir) {
165
166
  continue;
166
167
  }
167
168
  const taskDir = path.join(dayDir, taskEntry.name);
168
- const taskFile = path.join(taskDir, 'task.md');
169
+ const taskFile = path.join(taskDir, 'local', 'task.md');
169
170
  const relativePath = `${yearEntry.name}/${monthEntry.name}/${dayEntry.name}/${taskEntry.name}/`;
170
171
  let title = taskEntry.name;
171
172
  let type = 'unknown';
@@ -471,9 +472,10 @@ function validateSourceRoot(sourceRoot) {
471
472
  invalidSource('source path must be a real directory');
472
473
  }
473
474
  }
474
- function validateTaskDirectory(sourceRoot, taskDir, seenTaskIds) {
475
+ function validateTaskDirectory(sourceRoot, taskDir, seenTaskIds, archived = false) {
475
476
  const taskId = path.basename(taskDir);
476
- const taskFile = path.join(taskDir, 'task.md');
477
+ const contentDir = archived ? path.join(taskDir, 'local') : taskDir;
478
+ const taskFile = path.join(contentDir, 'task.md');
477
479
  const taskDirStat = sourcePathStat(sourceRoot, taskDir);
478
480
  if (!taskDirStat || !taskDirStat.isDirectory()) {
479
481
  invalidSource(`${sourceRelativePath(sourceRoot, taskDir)} must be a directory`);
@@ -491,12 +493,63 @@ function validateTaskDirectory(sourceRoot, taskDir, seenTaskIds) {
491
493
  invalidSource(`${sourceRelativePath(sourceRoot, taskFile)} id '${String(contract.metadata.id ?? 'missing')}' does not match directory '${taskId}'`);
492
494
  }
493
495
  validateTaskTree(sourceRoot, taskDir);
496
+ if (archived) {
497
+ for (const entry of fs.readdirSync(taskDir, { withFileTypes: true })) {
498
+ if (!['local', 'github', 'derived'].includes(entry.name) || !entry.isDirectory()) {
499
+ invalidSource(`${sourceRelativePath(sourceRoot, path.join(taskDir, entry.name))} is not an allowed archive source directory`);
500
+ }
501
+ }
502
+ validateArchiveSource(sourceRoot, contentDir);
503
+ const githubDir = path.join(taskDir, 'github');
504
+ if (fs.existsSync(githubDir)) {
505
+ for (const capture of fs.readdirSync(githubDir, { withFileTypes: true })) {
506
+ const captureDir = path.join(githubDir, capture.name);
507
+ if (!capture.isDirectory() || !/^capture-[A-Za-z0-9._-]+$/.test(capture.name) || !fs.existsSync(path.join(captureDir, 'capture.md'))) {
508
+ invalidSource(`${sourceRelativePath(sourceRoot, captureDir)} is not a valid GitHub capture`);
509
+ }
510
+ validateArchiveSource(sourceRoot, captureDir);
511
+ }
512
+ }
513
+ const derivedDir = path.join(taskDir, 'derived');
514
+ if (fs.existsSync(derivedDir)) {
515
+ for (const view of fs.readdirSync(derivedDir, { withFileTypes: true })) {
516
+ const viewDir = path.join(derivedDir, view.name);
517
+ if (!view.isDirectory() || !fs.existsSync(path.join(viewDir, 'inputs.md'))) {
518
+ invalidSource(`${sourceRelativePath(sourceRoot, viewDir)} requires inputs.md`);
519
+ }
520
+ }
521
+ }
522
+ }
494
523
  const previousPath = seenTaskIds.get(taskId);
495
524
  if (previousPath) {
496
525
  invalidSource(`duplicate task ID ${taskId} at ${previousPath} and ${sourceRelativePath(sourceRoot, taskDir)}`);
497
526
  }
498
527
  seenTaskIds.set(taskId, sourceRelativePath(sourceRoot, taskDir));
499
528
  }
529
+ function validateArchiveSource(sourceRoot, sourceDir) {
530
+ const hashFile = path.join(sourceDir, 'contents.sha256');
531
+ const hashStat = sourcePathStat(sourceRoot, hashFile);
532
+ if (!hashStat?.isFile())
533
+ invalidSource(`${sourceRelativePath(sourceRoot, sourceDir)} requires contents.sha256`);
534
+ const files = [];
535
+ const walk = (dir) => {
536
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
537
+ const target = path.join(dir, entry.name);
538
+ if (entry.name === 'manifest.md')
539
+ invalidSource(`${sourceRelativePath(sourceRoot, target)} is forbidden inside a TASK`);
540
+ if (entry.isDirectory())
541
+ walk(target);
542
+ else if (entry.isFile() && target !== hashFile)
543
+ files.push(target);
544
+ }
545
+ };
546
+ walk(sourceDir);
547
+ const expected = files.sort((a, b) => Buffer.compare(Buffer.from(path.relative(sourceDir, a).split(path.sep).join('/')), Buffer.from(path.relative(sourceDir, b).split(path.sep).join('/'))))
548
+ .map((file) => `${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')} ${path.relative(sourceDir, file).split(path.sep).join('/')}`);
549
+ const actual = fs.readFileSync(hashFile, 'utf8').trimEnd().split(/\r?\n/).filter(Boolean);
550
+ if (JSON.stringify(expected) !== JSON.stringify(actual))
551
+ invalidSource(`${sourceRelativePath(sourceRoot, hashFile)} does not match local files`);
552
+ }
500
553
  function validateMutableSection(sourceRoot, section, seenTaskIds) {
501
554
  const sectionDir = path.join(sourceRoot, section);
502
555
  const sectionStat = sourcePathStat(sourceRoot, sectionDir);
@@ -561,9 +614,6 @@ function validateArchiveSection(sourceRoot, seenTaskIds) {
561
614
  const monthDir = monthPath;
562
615
  for (const dayEntry of fs.readdirSync(monthDir, { withFileTypes: true })) {
563
616
  const dayPath = path.join(monthDir, dayEntry.name);
564
- if (allowManifest(monthDir, dayEntry)) {
565
- continue;
566
- }
567
617
  const dayStat = sourcePathStat(sourceRoot, dayPath);
568
618
  if (!dayStat?.isDirectory() || !/^\d{2}$/.test(dayEntry.name)) {
569
619
  invalidSource(`${sourceRelativePath(sourceRoot, dayPath)} is not a DD directory or manifest.md`);
@@ -575,7 +625,7 @@ function validateArchiveSection(sourceRoot, seenTaskIds) {
575
625
  if (!taskStat?.isDirectory() || !TASK_ID_RE.test(taskEntry.name)) {
576
626
  invalidSource(`${sourceRelativePath(sourceRoot, taskPath)} is not a TASK-YYYYMMDD-HHMMSS directory`);
577
627
  }
578
- validateTaskDirectory(sourceRoot, taskPath, seenTaskIds);
628
+ validateTaskDirectory(sourceRoot, taskPath, seenTaskIds, true);
579
629
  }
580
630
  }
581
631
  }
@@ -716,9 +766,10 @@ function mergeArchiveSection(sourceArchive, localArchive, report) {
716
766
  });
717
767
  continue;
718
768
  }
769
+ const sourceTaskDir = path.dirname(task.taskDir);
719
770
  const destinationDir = path.join(localArchive, task.relativePath);
720
771
  fs.mkdirSync(path.dirname(destinationDir), { recursive: true });
721
- fs.cpSync(task.taskDir, destinationDir, { recursive: true });
772
+ fs.cpSync(sourceTaskDir, destinationDir, { recursive: true });
722
773
  recordArchive(report, 'copied', {
723
774
  action: 'copied',
724
775
  symbol: '✓',
@@ -829,6 +880,7 @@ async function cmdMerge(args) {
829
880
  const report = createReport(resolvedSource, backupRootRelative);
830
881
  detectSourceMode(resolvedSource);
831
882
  validateSourceWorkspace(resolvedSource);
883
+ validateArchiveSection(workspaceDir, new Map());
832
884
  for (const section of ALL_SECTIONS) {
833
885
  fs.mkdirSync(path.join(workspaceDir, section), { recursive: true });
834
886
  }
@@ -921,26 +921,21 @@ function createGitHubOperations(client) {
921
921
  }
922
922
  const mergedPullRequests = collected.pullRequests.map((item) => ({
923
923
  id: String(item.number), identity: { kind: 'number', value: Number(item.number) }, number: Number(item.number),
924
- state: 'closed', title: String(item.title || ''), body: String(item.body || ''),
924
+ title: String(item.title || ''), body: String(item.body || ''),
925
925
  mergedAt: new Date(String(item.mergedAt)).toISOString(), displayUrl: String(item.url || ''),
926
926
  labels: Array.isArray(item.labels) ? item.labels.map((label) => String(label.name || label)) : [],
927
- assignees: [], mergeCommitSha: null
927
+ author: item.author,
928
+ closingIssues: item.closingIssues.map((issue) => ({
929
+ id: String(issue.number), identity: { kind: 'number', value: Number(issue.number) }, number: Number(issue.number),
930
+ title: String(issue.title || ''), displayUrl: String(issue.url || ''), author: issue.author
931
+ }))
928
932
  }));
929
- const closingIssues = collected.pullRequests.flatMap((item) => Array.isArray(item.closingIssuesReferences) ? item.closingIssuesReferences.map((issue) => ({
930
- id: String(issue.number), identity: { kind: 'number', value: Number(issue.number) }, number: Number(issue.number),
931
- state: 'closed', title: String(issue.title || ''), body: '', labels: Array.isArray(issue.labels) ? issue.labels.map((label) => String(label.name || label)) : [],
932
- assignees: [], milestone: null, fields: {}, displayUrl: String(issue.url || '')
933
- })) : []);
934
- const actors = [...collected.authors.values()].flatMap((items) => items.map((item) => ({
935
- ...(item.login ? { id: String(item.login) } : {}), ...(item.name ? { name: String(item.name) } : {})
936
- })));
937
933
  return {
938
934
  ok: true,
939
935
  value: {
940
- history: commitOids.map((sha) => ({ sha, message: '', authoredAt: toTime, author: actors.find((actor) => actor.id) || null })),
936
+ history: collected.history,
937
+ commits: collected.commits,
941
938
  mergedPullRequests,
942
- closingIssues,
943
- actors
944
939
  }
945
940
  };
946
941
  }
@@ -1121,7 +1116,7 @@ function createGitHubOperations(client) {
1121
1116
  function createGitHubProvider(input, client = createGitHubClient()) {
1122
1117
  return {
1123
1118
  type: input.providerType,
1124
- contractVersion: 1,
1119
+ contractVersion: 2,
1125
1120
  identity: { issue: 'number', 'pull-request': 'number', comment: 'number', release: 'key' },
1126
1121
  ...createGitHubOperations(client),
1127
1122
  context: {
@@ -9,12 +9,22 @@ function normalizeGitHubActor(actor) {
9
9
  const login = platformLogin || noReplyLogin;
10
10
  return {
11
11
  name,
12
- email,
13
12
  login,
14
13
  bot: Boolean(login?.endsWith('[bot]')),
15
14
  resolution: platformLogin ? 'platform-user' : noReplyLogin ? 'platform-noreply' : 'unresolved'
16
15
  };
17
16
  }
17
+ function normalizePlatformAuthor(author) {
18
+ if (!author)
19
+ return null;
20
+ const login = author.login?.trim().toLowerCase() || null;
21
+ return {
22
+ name: login || '',
23
+ login,
24
+ bot: Boolean(login?.endsWith('[bot]')),
25
+ resolution: login ? 'platform-user' : 'unresolved'
26
+ };
27
+ }
18
28
  function failure(error) {
19
29
  return {
20
30
  status: error.retryable ? 'blocked' : 'failed',
@@ -66,31 +76,90 @@ function fetchGitHubReleaseNoteData(input, options = {}) {
66
76
  return failure(viewed.error);
67
77
  history.push({ tag: String(item.tagName), body: String(viewed.value.body || ''), url: viewed.value.url || null });
68
78
  }
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 });
79
+ const prs = client.json(['pr', 'list', '--repo', input.repository, '--state', 'merged', '--base', input.branch, '--limit', '1000', '--json', 'number,title,body,url,mergedAt,labels,author'], { cwd: options.cwd });
70
80
  if (!prs.ok)
71
81
  return failure(prs.error);
72
82
  const authors = new Map();
83
+ const fromTime = Date.parse(input.fromTime);
84
+ const toTime = Date.parse(input.toTime);
85
+ const pullRequests = prs.value.filter((item) => {
86
+ const mergedAt = Date.parse(String(item.mergedAt || ''));
87
+ return Number.isFinite(mergedAt) && mergedAt > fromTime && mergedAt <= toTime;
88
+ });
89
+ const eligiblePullRequestNumbers = new Set(pullRequests.map((item) => Number(item.number)));
90
+ const pullRequestNumbersByCommit = new Map();
73
91
  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}}}}}}`;
92
+ 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}} associatedPullRequests(first:100){nodes{number} pageInfo{hasNextPage}}}}}}`;
75
93
  const [owner, name] = input.repository.split('/');
76
94
  const result = client.json([
77
95
  'api', 'graphql', '-f', `query=${query}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `oid=${oid}`
78
96
  ], { cwd: options.cwd });
79
97
  if (!result.ok)
80
98
  return failure(result.error);
81
- const connection = result.value.data?.repository?.object?.authors;
99
+ const object = result.value.data?.repository?.object;
100
+ const connection = object?.authors;
82
101
  if (connection?.pageInfo?.hasNextPage) {
83
102
  return failure({ code: 'RELEASE_NOTES_AUTHORS_TRUNCATED', message: `Commit authors exceeded the supported page size for ${oid}`, retryable: false });
84
103
  }
85
104
  authors.set(oid, (connection?.nodes || []).map(normalizeGitHubActor));
105
+ const associatedPullRequests = object?.associatedPullRequests;
106
+ if (!associatedPullRequests || !Array.isArray(associatedPullRequests.nodes) || !associatedPullRequests.pageInfo || typeof associatedPullRequests.pageInfo.hasNextPage !== 'boolean') {
107
+ return failure({ code: 'INVALID_PLATFORM_RESPONSE', message: `Associated pull request data is incomplete for commit ${oid}`, retryable: false });
108
+ }
109
+ if (associatedPullRequests.pageInfo.hasNextPage) {
110
+ return failure({ code: 'RELEASE_NOTES_ASSOCIATED_PULL_REQUESTS_TRUNCATED', message: `Associated pull requests exceeded the supported page size for ${oid}`, retryable: false });
111
+ }
112
+ const numbers = [];
113
+ for (const association of associatedPullRequests.nodes) {
114
+ if (typeof association?.number !== 'number' || !Number.isSafeInteger(association.number)) {
115
+ return failure({ code: 'INVALID_PLATFORM_RESPONSE', message: `Associated pull request identity is incomplete for commit ${oid}`, retryable: false });
116
+ }
117
+ if (eligiblePullRequestNumbers.has(association.number))
118
+ numbers.push(association.number);
119
+ }
120
+ pullRequestNumbersByCommit.set(oid, [...new Set(numbers)]);
86
121
  }
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 };
122
+ const resolvedPullRequests = [];
123
+ const [owner, name] = input.repository.split('/');
124
+ const closingIssuesQuery = 'query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){closingIssuesReferences(first:100,after:$cursor){nodes{number title url author{login}} pageInfo{hasNextPage endCursor}}}}}';
125
+ for (const item of pullRequests) {
126
+ const closingIssues = [];
127
+ let cursor = null;
128
+ for (;;) {
129
+ const args = ['api', 'graphql', '-f', `query=${closingIssuesQuery}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${String(item.number)}`];
130
+ if (cursor)
131
+ args.push('-F', `cursor=${cursor}`);
132
+ const response = client.json(args, { cwd: options.cwd });
133
+ if (!response.ok)
134
+ return failure(response.error);
135
+ const connection = response.value.data?.repository?.pullRequest?.closingIssuesReferences;
136
+ if (!connection || !Array.isArray(connection.nodes) || !connection.pageInfo || typeof connection.pageInfo.hasNextPage !== 'boolean') {
137
+ return failure({ code: 'INVALID_PLATFORM_RESPONSE', message: `Closing Issue data is incomplete for pull request ${String(item.number)}`, retryable: false });
138
+ }
139
+ closingIssues.push(...connection.nodes.map((issue) => ({
140
+ number: Number(issue.number), title: String(issue.title || ''), url: String(issue.url || ''),
141
+ author: normalizePlatformAuthor(issue.author)
142
+ })));
143
+ if (!connection.pageInfo.hasNextPage)
144
+ break;
145
+ if (!connection.pageInfo.endCursor || connection.pageInfo.endCursor === cursor) {
146
+ return failure({ code: 'PAGINATION_INVALID', message: `Closing Issue pagination is incomplete for pull request ${String(item.number)}`, retryable: false });
147
+ }
148
+ cursor = connection.pageInfo.endCursor;
149
+ }
150
+ resolvedPullRequests.push({
151
+ ...item,
152
+ author: normalizePlatformAuthor(item.author || null),
153
+ closingIssues
154
+ });
155
+ }
156
+ const commits = input.commitOids.map((sha) => ({
157
+ sha,
158
+ url: `https://github.com/${input.repository}/commit/${sha}`,
159
+ pullRequestNumbers: pullRequestNumbersByCommit.get(sha) || [],
160
+ authors: authors.get(sha) || []
161
+ }));
162
+ return { status: 'no-op', changed: false, history, pullRequests: resolvedPullRequests, commits, error: null };
94
163
  }
95
164
  export { fetchGitHubReleaseNoteData, normalizeGitHubActor, publishGitHubReleaseNotes };
96
165
  //# sourceMappingURL=github-release-notes.js.map
@@ -1,7 +1,7 @@
1
1
  function createNoneProvider(input) {
2
2
  return {
3
3
  type: input.providerType,
4
- contractVersion: 1,
4
+ contractVersion: 2,
5
5
  context: {
6
6
  async resolve() {
7
7
  return {
@@ -242,22 +242,45 @@ type RepositoryMetadataSnapshot = {
242
242
  }>;
243
243
  }>;
244
244
  };
245
+ type ReleaseNoteAuthor = {
246
+ name: string;
247
+ login: string | null;
248
+ bot: boolean;
249
+ resolution: 'platform-user' | 'platform-noreply' | 'unresolved';
250
+ };
251
+ type ReleaseNoteIssue = {
252
+ id: string;
253
+ identity: ResourceIdentity;
254
+ number: number;
255
+ title: string;
256
+ displayUrl: string;
257
+ author: ReleaseNoteAuthor | null;
258
+ };
259
+ type ReleaseNotePullRequest = {
260
+ id: string;
261
+ identity: ResourceIdentity;
262
+ number: number;
263
+ title: string;
264
+ body: string;
265
+ mergedAt: string;
266
+ displayUrl: string;
267
+ labels: string[];
268
+ author: ReleaseNoteAuthor | null;
269
+ closingIssues: ReleaseNoteIssue[];
270
+ };
245
271
  type ReleaseNotesFacts = {
246
272
  history: Array<{
247
- sha: string;
248
- message: string;
249
- authoredAt: string;
250
- author: {
251
- id?: string;
252
- name?: string;
253
- } | null;
273
+ tag: string;
274
+ body: string;
275
+ url: string | null;
254
276
  }>;
255
- mergedPullRequests: Array<ChangeRequestSnapshot>;
256
- closingIssues: Array<IssueSnapshot>;
257
- actors: Array<{
258
- id?: string;
259
- name?: string;
277
+ commits: Array<{
278
+ sha: string;
279
+ url: string | null;
280
+ pullRequestNumbers: number[];
281
+ authors: ReleaseNoteAuthor[];
260
282
  }>;
283
+ mergedPullRequests: ReleaseNotePullRequest[];
261
284
  };
262
285
  type MilestoneReconciliation = {
263
286
  changed: boolean;
@@ -276,13 +299,13 @@ type VerificationRemoteFacts = {
276
299
  };
277
300
  type PlatformProviderFactoryInput = {
278
301
  providerType: string;
279
- contractVersion: 1;
302
+ contractVersion: 2;
280
303
  repositoryRoot: string;
281
304
  config: Readonly<Record<string, JsonValue>>;
282
305
  };
283
306
  type PlatformProvider = {
284
307
  type: string;
285
- contractVersion: 1;
308
+ contractVersion: 2;
286
309
  identity?: ProviderIdentityDeclaration;
287
310
  context: {
288
311
  resolve(input: {
@@ -548,8 +571,8 @@ type PlatformProvider = {
548
571
  };
549
572
  };
550
573
  type PlatformProviderFactory = (input: PlatformProviderFactoryInput) => Promise<PlatformProvider>;
551
- declare const PLATFORM_PROVIDER_CONTRACT_VERSION: 1;
574
+ declare const PLATFORM_PROVIDER_CONTRACT_VERSION: 2;
552
575
  declare function isPlatformProviderFactory(value: unknown): value is PlatformProviderFactory;
553
576
  declare function validatePlatformProvider(value: unknown, providerType: string): ProviderResult<PlatformProvider>;
554
577
  export { PLATFORM_PROVIDER_CONTRACT_VERSION, isPlatformProviderFactory, validatePlatformProvider };
555
- export type { ChangeRequestSnapshot, CheckLogSnapshot, CheckRunSnapshot, GitEvidenceSnapshot, IssueSnapshot, JsonValue, MilestoneReconciliation, MilestoneDefinition, MilestoneInitialization, LabelDefinition, LabelReconciliation, MutationIdentity, MutationReceipt, PlatformCapabilities, PlatformContextSnapshot, PlatformError, PlatformProvider, PlatformProviderFactory, PlatformProviderFactoryInput, ProviderOperationContext, ProviderResult, ReleaseSnapshot, RemoteCommentSnapshot, RequiredCheckSnapshot, RepositoryMetadataSnapshot, ReleaseNotesFacts, ResourceIdentity, SecurityAlertKind, SecurityAlertSnapshot, ReviewSnapshot, VerificationRemoteFacts };
578
+ export type { ChangeRequestSnapshot, CheckLogSnapshot, CheckRunSnapshot, GitEvidenceSnapshot, IssueSnapshot, JsonValue, MilestoneReconciliation, MilestoneDefinition, MilestoneInitialization, LabelDefinition, LabelReconciliation, MutationIdentity, MutationReceipt, PlatformCapabilities, PlatformContextSnapshot, PlatformError, PlatformProvider, PlatformProviderFactory, PlatformProviderFactoryInput, ProviderOperationContext, ProviderResult, ReleaseSnapshot, RemoteCommentSnapshot, RequiredCheckSnapshot, RepositoryMetadataSnapshot, ReleaseNotesFacts, ReleaseNoteAuthor, ReleaseNoteIssue, ReleaseNotePullRequest, ResourceIdentity, SecurityAlertKind, SecurityAlertSnapshot, ReviewSnapshot, VerificationRemoteFacts };
@@ -1,4 +1,4 @@
1
- const PLATFORM_PROVIDER_CONTRACT_VERSION = 1;
1
+ const PLATFORM_PROVIDER_CONTRACT_VERSION = 2;
2
2
  function isRecord(value) {
3
3
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
4
4
  }
@@ -78,6 +78,14 @@ function arrayValue(value, label) {
78
78
  throw new Error(`${label} must be an array`);
79
79
  return value;
80
80
  }
81
+ function positiveNumberValue(value, label) {
82
+ if (!Number.isSafeInteger(value) || Number(value) <= 0)
83
+ throw new Error(`${label} must be a positive integer`);
84
+ return Number(value);
85
+ }
86
+ function stringArray(value, label) {
87
+ return arrayValue(value, label).map((entry, index) => stringValue(entry, `${label}[${index}]`));
88
+ }
81
89
  function stableUnique(values, key, label) {
82
90
  const seen = new Set();
83
91
  for (const value of values) {
@@ -88,6 +96,16 @@ function stableUnique(values, key, label) {
88
96
  }
89
97
  return [...values].sort((left, right) => key(left).localeCompare(key(right)));
90
98
  }
99
+ function uniqueInOrder(values, key, label) {
100
+ const seen = new Set();
101
+ for (const value of values) {
102
+ const identityKey = key(value);
103
+ if (seen.has(identityKey))
104
+ throw new Error(`${label} contains duplicate identities`);
105
+ seen.add(identityKey);
106
+ }
107
+ return values;
108
+ }
91
109
  function declaredIdentityKind(declaration, resourceKind) {
92
110
  const kind = declaration?.[resourceKind];
93
111
  return kind === 'id' || kind === 'number' || kind === 'key' ? kind : undefined;
@@ -358,21 +376,73 @@ function validateMetadata(value) {
358
376
  }
359
377
  function validateReleaseNotes(value, declaration) {
360
378
  const item = record(value, 'release notes');
361
- exactKeys(item, ['history', 'mergedPullRequests', 'closingIssues', 'actors'], 'release notes');
379
+ exactKeys(item, ['history', 'commits', 'mergedPullRequests'], 'release notes');
380
+ const releaseAuthor = (raw, label) => {
381
+ if (raw === null)
382
+ return null;
383
+ const entry = record(raw, label);
384
+ exactKeys(entry, ['name', 'login', 'bot', 'resolution'], label);
385
+ if (typeof entry.bot !== 'boolean')
386
+ throw new Error(`${label}.bot must be boolean`);
387
+ if (!['platform-user', 'platform-noreply', 'unresolved'].includes(String(entry.resolution))) {
388
+ throw new Error(`${label}.resolution is invalid`);
389
+ }
390
+ return {
391
+ name: stringValue(entry.name, `${label}.name`, true),
392
+ login: nullableString(entry.login, `${label}.login`),
393
+ bot: entry.bot,
394
+ resolution: entry.resolution
395
+ };
396
+ };
397
+ const issue = (raw, label) => {
398
+ const entry = record(raw, label);
399
+ exactKeys(entry, ['id', 'identity', 'number', 'title', 'displayUrl', 'author'], label);
400
+ return {
401
+ id: stringValue(entry.id, `${label}.id`),
402
+ identity: identity(entry.identity, `${label}.identity`, declaredIdentityKind(declaration, 'issue')),
403
+ number: positiveNumberValue(entry.number, `${label}.number`),
404
+ title: stringValue(entry.title, `${label}.title`, true),
405
+ displayUrl: stringValue(entry.displayUrl, `${label}.displayUrl`, true),
406
+ author: releaseAuthor(entry.author, `${label}.author`)
407
+ };
408
+ };
362
409
  return {
363
- history: stableUnique(arrayValue(item.history, 'release notes.history').map((raw) => {
410
+ history: uniqueInOrder(arrayValue(item.history, 'release notes.history').map((raw) => {
364
411
  const entry = record(raw, 'release notes.history[]');
365
- exactKeys(entry, ['sha', 'message', 'authoredAt', 'author'], 'release notes.history[]');
366
- return { sha: stringValue(entry.sha, 'history.sha'), message: stringValue(entry.message, 'history.message', true), authoredAt: utcTimestamp(entry.authoredAt, 'history.authoredAt'), author: author(entry.author, 'history.author') };
367
- }), (entry) => entry.sha, 'release notes.history'),
368
- mergedPullRequests: stableUnique(arrayValue(item.mergedPullRequests, 'release notes.mergedPullRequests').map((entry) => validateChangeRequest(entry, declaredIdentityKind(declaration, 'pull-request'), true)), (entry) => serializeResourceIdentity(entry.identity), 'release notes.mergedPullRequests'),
369
- closingIssues: stableUnique(arrayValue(item.closingIssues, 'release notes.closingIssues').map((entry) => validateIssue(entry, declaredIdentityKind(declaration, 'issue'), true)), (entry) => serializeResourceIdentity(entry.identity), 'release notes.closingIssues'),
370
- actors: stableUnique(arrayValue(item.actors, 'release notes.actors').map((entry) => {
371
- const actor = author(entry, 'release notes.actor');
372
- if (!actor)
373
- throw new Error('release notes.actor must identify an actor');
374
- return actor;
375
- }), (entry) => entry.id || entry.name || '', 'release notes.actors')
412
+ exactKeys(entry, ['tag', 'body', 'url'], 'release notes.history[]');
413
+ return { tag: stringValue(entry.tag, 'history.tag'), body: stringValue(entry.body, 'history.body', true), url: nullableString(entry.url, 'history.url') };
414
+ }), (entry) => entry.tag, 'release notes.history'),
415
+ commits: stableUnique(arrayValue(item.commits, 'release notes.commits').map((raw) => {
416
+ const entry = record(raw, 'release notes.commits[]');
417
+ exactKeys(entry, ['sha', 'url', 'pullRequestNumbers', 'authors'], 'release notes.commits[]');
418
+ return {
419
+ sha: stringValue(entry.sha, 'commits.sha'),
420
+ url: nullableString(entry.url, 'commits.url'),
421
+ pullRequestNumbers: stableUnique(arrayValue(entry.pullRequestNumbers, 'commits.pullRequestNumbers').map((number) => positiveNumberValue(number, 'commits.pullRequestNumbers[]')), (number) => String(number), 'commits.pullRequestNumbers'),
422
+ authors: arrayValue(entry.authors, 'commits.authors').map((rawAuthor) => {
423
+ const normalized = releaseAuthor(rawAuthor, 'commits.author');
424
+ if (!normalized)
425
+ throw new Error('commits.author must identify an author');
426
+ return normalized;
427
+ })
428
+ };
429
+ }), (entry) => entry.sha, 'release notes.commits'),
430
+ mergedPullRequests: stableUnique(arrayValue(item.mergedPullRequests, 'release notes.mergedPullRequests').map((raw) => {
431
+ const entry = record(raw, 'release notes.mergedPullRequests[]');
432
+ exactKeys(entry, ['id', 'identity', 'number', 'title', 'body', 'mergedAt', 'displayUrl', 'labels', 'author', 'closingIssues'], 'release notes.mergedPullRequests[]');
433
+ return {
434
+ id: stringValue(entry.id, 'pullRequest.id'),
435
+ identity: identity(entry.identity, 'pullRequest.identity', declaredIdentityKind(declaration, 'pull-request')),
436
+ number: positiveNumberValue(entry.number, 'pullRequest.number'),
437
+ title: stringValue(entry.title, 'pullRequest.title', true),
438
+ body: stringValue(entry.body, 'pullRequest.body', true),
439
+ mergedAt: utcTimestamp(entry.mergedAt, 'pullRequest.mergedAt'),
440
+ displayUrl: stringValue(entry.displayUrl, 'pullRequest.displayUrl', true),
441
+ labels: stringArray(entry.labels, 'pullRequest.labels'),
442
+ author: releaseAuthor(entry.author, 'pullRequest.author'),
443
+ closingIssues: stableUnique(arrayValue(entry.closingIssues, 'pullRequest.closingIssues').map((entry) => issue(entry, 'pullRequest.closingIssues[]')), (entry) => serializeResourceIdentity(entry.identity), 'pullRequest.closingIssues')
444
+ };
445
+ }), (entry) => serializeResourceIdentity(entry.identity), 'release notes.mergedPullRequests')
376
446
  };
377
447
  }
378
448
  function utcTimestamp(value, label) {
@@ -154,16 +154,16 @@ async function releaseNoteContext(input, options = {}) {
154
154
  return { ...baseResult(platform.error.retryable ? 'blocked' : 'failed'), error: providerError(platform.error, 'PLATFORM_PROVIDER_OPERATION_FAILED') };
155
155
  const commits = commitFacts.map((commit) => {
156
156
  const seen = new Set();
157
- const authors = platform.value.history.filter((item) => item.sha === commit.oid).flatMap((item) => item.author ? [item.author] : [])
158
- .concat(platform.value.actors)
157
+ const facts = platform.value.commits.find((item) => item.sha === commit.oid);
158
+ const authors = (facts?.authors || [])
159
159
  .filter((actor) => {
160
- const key = actor.id || actor.name || '';
160
+ const key = actor.login || actor.name;
161
161
  if (seen.has(key))
162
162
  return false;
163
163
  seen.add(key);
164
164
  return true;
165
165
  });
166
- return { ...commit, authors };
166
+ return { ...commit, url: facts?.url || null, pullRequestNumbers: facts?.pullRequestNumbers || [], authors };
167
167
  });
168
168
  const pullRequests = platform.value.mergedPullRequests
169
169
  .map((item) => ({
@@ -174,14 +174,13 @@ async function releaseNoteContext(input, options = {}) {
174
174
  url: item.displayUrl || '',
175
175
  mergedAt: item.mergedAt || '',
176
176
  labels: item.labels || [],
177
- author: item.author ? { login: item.author.name || item.author.id || '', bot: false } : null,
178
- closingIssues: platform.value.closingIssues.filter((issue) => issue.id === item.id).map((issue) => ({
177
+ author: item.author,
178
+ closingIssues: item.closingIssues.map((issue) => ({
179
179
  number: issue.number || resourceIdentityNumber(issue.identity) || 0,
180
180
  ...(issue.identity ? { identity: issue.identity } : {}),
181
181
  title: issue.title,
182
182
  url: issue.displayUrl || '',
183
- labels: issue.labels || [],
184
- author: issue.author ? { login: issue.author.name || issue.author.id || '', bot: false } : null
183
+ author: issue.author
185
184
  }))
186
185
  }))
187
186
  .sort((left, right) => left.number - right.number);
@@ -154,7 +154,7 @@ export async function runInSandbox(request, options = {}) {
154
154
  allowRecreate: request.recreate,
155
155
  recreate: async () => {
156
156
  const { create } = await import('./commands/create.js');
157
- await create([request.taskRef, '--no-refresh']);
157
+ await create([request.taskRef, '--no-refresh'], { runProjectInitCommand: false });
158
158
  }
159
159
  })).container;
160
160
  }