@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.
- package/dist/lib/merge.js +62 -10
- package/dist/lib/platform/github-provider.js +9 -14
- package/dist/lib/platform/github-release-notes.js +80 -11
- package/dist/lib/platform/none-provider.js +1 -1
- package/dist/lib/platform/provider-contract.d.ts +39 -16
- package/dist/lib/platform/provider-contract.js +1 -1
- package/dist/lib/platform/provider-validation.js +83 -13
- package/dist/lib/platform/release-notes.js +7 -8
- package/dist/lib/sandbox/capture.js +1 -1
- package/dist/lib/sandbox/commands/create.js +46 -1
- package/dist/lib/sandbox/commands/enter.js +1 -1
- package/dist/lib/sandbox/commands/start.js +1 -1
- package/dist/lib/sandbox/config.js +10 -0
- package/dist/lib/sandbox/control/lifecycle.js +18 -13
- package/dist/lib/sandbox/task-resolver.js +62 -2
- package/dist/lib/task/resolve-ref.js +3 -3
- package/dist/lib/task/write.js +121 -1
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/merge.ts +59 -10
- package/lib/platform/github-provider.ts +11 -18
- package/lib/platform/github-release-notes.ts +89 -14
- package/lib/platform/none-provider.ts +1 -1
- package/lib/platform/provider-contract.ts +38 -12
- package/lib/platform/provider-validation.ts +83 -12
- package/lib/platform/release-notes.ts +11 -12
- package/lib/sandbox/capture.ts +1 -1
- package/lib/sandbox/commands/create.ts +52 -1
- package/lib/sandbox/commands/enter.ts +1 -1
- package/lib/sandbox/commands/start.ts +1 -1
- package/lib/sandbox/config.ts +12 -0
- package/lib/sandbox/control/lifecycle.ts +22 -13
- package/lib/sandbox/task-resolver.ts +51 -2
- package/lib/task/resolve-ref.ts +3 -3
- package/lib/task/write.ts +115 -1
- package/package.json +1 -1
- package/templates/.agents/rules/evidence-reporting.en.md +1 -2
- package/templates/.agents/rules/evidence-reporting.zh-CN.md +1 -2
- package/templates/.agents/skills/archive-tasks/SKILL.en.md +2 -2
- package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +109 -6
- package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -11
- package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +17 -11
|
@@ -923,30 +923,23 @@ function createGitHubOperations(client: GitHubClient): Pick<PlatformProvider, 'i
|
|
|
923
923
|
if (collected.status === 'failed' || collected.status === 'blocked' || !('pullRequests' in collected)) {
|
|
924
924
|
return { ok: false, error: collected.error || { code: 'RELEASE_NOTES_COLLECTION_FAILED', message: 'Release notes could not be collected', retryable: false } };
|
|
925
925
|
}
|
|
926
|
-
const mergedPullRequests = collected.pullRequests.map((item: any)
|
|
927
|
-
id: String(item.number), identity: { kind: 'number', value: Number(item.number) }, number: Number(item.number),
|
|
928
|
-
|
|
926
|
+
const mergedPullRequests = collected.pullRequests.map((item: any) => ({
|
|
927
|
+
id: String(item.number), identity: { kind: 'number' as const, value: Number(item.number) }, number: Number(item.number),
|
|
928
|
+
title: String(item.title || ''), body: String(item.body || ''),
|
|
929
929
|
mergedAt: new Date(String(item.mergedAt)).toISOString(), displayUrl: String(item.url || ''),
|
|
930
930
|
labels: Array.isArray(item.labels) ? item.labels.map((label: any) => String(label.name || label)) : [],
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
const closingIssues: IssueSnapshot[] = collected.pullRequests.flatMap((item: any) =>
|
|
934
|
-
Array.isArray(item.closingIssuesReferences) ? item.closingIssuesReferences.map((issue: any) => ({
|
|
931
|
+
author: item.author,
|
|
932
|
+
closingIssues: item.closingIssues.map((issue: any) => ({
|
|
935
933
|
id: String(issue.number), identity: { kind: 'number', value: Number(issue.number) }, number: Number(issue.number),
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
);
|
|
940
|
-
const actors = [...collected.authors.values()].flatMap((items) => items.map((item: any) => ({
|
|
941
|
-
...(item.login ? { id: String(item.login) } : {}), ...(item.name ? { name: String(item.name) } : {})
|
|
942
|
-
})));
|
|
934
|
+
title: String(issue.title || ''), displayUrl: String(issue.url || ''), author: issue.author
|
|
935
|
+
}))
|
|
936
|
+
}));
|
|
943
937
|
return {
|
|
944
938
|
ok: true,
|
|
945
939
|
value: {
|
|
946
|
-
history:
|
|
940
|
+
history: collected.history,
|
|
941
|
+
commits: collected.commits,
|
|
947
942
|
mergedPullRequests,
|
|
948
|
-
closingIssues,
|
|
949
|
-
actors
|
|
950
943
|
}
|
|
951
944
|
};
|
|
952
945
|
}
|
|
@@ -1116,7 +1109,7 @@ function createGitHubProvider(
|
|
|
1116
1109
|
): PlatformProvider {
|
|
1117
1110
|
return {
|
|
1118
1111
|
type: input.providerType,
|
|
1119
|
-
contractVersion:
|
|
1112
|
+
contractVersion: 2,
|
|
1120
1113
|
identity: { issue: 'number', 'pull-request': 'number', comment: 'number', release: 'key' },
|
|
1121
1114
|
...createGitHubOperations(client),
|
|
1122
1115
|
context: {
|
|
@@ -9,7 +9,6 @@ type GitHubActorInput = {
|
|
|
9
9
|
|
|
10
10
|
type ReleaseNoteActor = {
|
|
11
11
|
name: string;
|
|
12
|
-
email: string | null;
|
|
13
12
|
login: string | null;
|
|
14
13
|
bot: boolean;
|
|
15
14
|
resolution: 'platform-user' | 'platform-noreply' | 'unresolved';
|
|
@@ -27,13 +26,23 @@ function normalizeGitHubActor(actor: GitHubActorInput): ReleaseNoteActor {
|
|
|
27
26
|
const login = platformLogin || noReplyLogin;
|
|
28
27
|
return {
|
|
29
28
|
name,
|
|
30
|
-
email,
|
|
31
29
|
login,
|
|
32
30
|
bot: Boolean(login?.endsWith('[bot]')),
|
|
33
31
|
resolution: platformLogin ? 'platform-user' : noReplyLogin ? 'platform-noreply' : 'unresolved'
|
|
34
32
|
};
|
|
35
33
|
}
|
|
36
34
|
|
|
35
|
+
function normalizePlatformAuthor(author: { login?: string | null } | null | undefined): ReleaseNoteActor | null {
|
|
36
|
+
if (!author) return null;
|
|
37
|
+
const login = author.login?.trim().toLowerCase() || null;
|
|
38
|
+
return {
|
|
39
|
+
name: login || '',
|
|
40
|
+
login,
|
|
41
|
+
bot: Boolean(login?.endsWith('[bot]')),
|
|
42
|
+
resolution: login ? 'platform-user' : 'unresolved'
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
function failure(error: { code: string; message: string; retryable: boolean }) {
|
|
38
47
|
return {
|
|
39
48
|
status: error.retryable ? 'blocked' as const : 'failed' as const,
|
|
@@ -89,7 +98,7 @@ function fetchGitHubReleaseNoteData(
|
|
|
89
98
|
{ cwd: options.cwd }
|
|
90
99
|
);
|
|
91
100
|
if (!releases.ok) return failure(releases.error);
|
|
92
|
-
const history = [];
|
|
101
|
+
const history: Array<{ tag: string; body: string; url: string | null }> = [];
|
|
93
102
|
for (const item of releases.value.filter((entry) => !entry.isDraft && !entry.isPrerelease).slice(0, input.historyLimit)) {
|
|
94
103
|
const viewed = client.json<{ body?: string; url?: string }>(
|
|
95
104
|
['release', 'view', String(item.tagName), '--repo', input.repository, '--json', 'body,url'],
|
|
@@ -99,33 +108,99 @@ function fetchGitHubReleaseNoteData(
|
|
|
99
108
|
history.push({ tag: String(item.tagName), body: String(viewed.value.body || ''), url: viewed.value.url || null });
|
|
100
109
|
}
|
|
101
110
|
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
|
|
111
|
+
['pr', 'list', '--repo', input.repository, '--state', 'merged', '--base', input.branch, '--limit', '1000', '--json', 'number,title,body,url,mergedAt,labels,author'],
|
|
103
112
|
{ cwd: options.cwd }
|
|
104
113
|
);
|
|
105
114
|
if (!prs.ok) return failure(prs.error);
|
|
106
115
|
const authors = new Map<string, ReleaseNoteActor[]>();
|
|
116
|
+
const fromTime = Date.parse(input.fromTime);
|
|
117
|
+
const toTime = Date.parse(input.toTime);
|
|
118
|
+
const pullRequests = prs.value.filter((item) => {
|
|
119
|
+
const mergedAt = Date.parse(String(item.mergedAt || ''));
|
|
120
|
+
return Number.isFinite(mergedAt) && mergedAt > fromTime && mergedAt <= toTime;
|
|
121
|
+
});
|
|
122
|
+
const eligiblePullRequestNumbers = new Set(pullRequests.map((item) => Number(item.number)));
|
|
123
|
+
const pullRequestNumbersByCommit = new Map<string, number[]>();
|
|
107
124
|
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}}}}}}`;
|
|
125
|
+
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}}}}}}`;
|
|
109
126
|
const [owner, name] = input.repository.split('/');
|
|
110
127
|
const result = client.json<{
|
|
111
|
-
data?: { repository?: { object?: {
|
|
128
|
+
data?: { repository?: { object?: {
|
|
129
|
+
authors?: { nodes?: GitHubActorInput[]; pageInfo?: { hasNextPage?: boolean } };
|
|
130
|
+
associatedPullRequests?: {
|
|
131
|
+
nodes?: Array<{ number?: number | null }>;
|
|
132
|
+
pageInfo?: { hasNextPage?: boolean };
|
|
133
|
+
};
|
|
134
|
+
} } };
|
|
112
135
|
}>([
|
|
113
136
|
'api', 'graphql', '-f', `query=${query}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `oid=${oid}`
|
|
114
137
|
], { cwd: options.cwd });
|
|
115
138
|
if (!result.ok) return failure(result.error);
|
|
116
|
-
const
|
|
139
|
+
const object = result.value.data?.repository?.object;
|
|
140
|
+
const connection = object?.authors;
|
|
117
141
|
if (connection?.pageInfo?.hasNextPage) {
|
|
118
142
|
return failure({ code: 'RELEASE_NOTES_AUTHORS_TRUNCATED', message: `Commit authors exceeded the supported page size for ${oid}`, retryable: false });
|
|
119
143
|
}
|
|
120
144
|
authors.set(oid, (connection?.nodes || []).map(normalizeGitHubActor));
|
|
145
|
+
const associatedPullRequests = object?.associatedPullRequests;
|
|
146
|
+
if (!associatedPullRequests || !Array.isArray(associatedPullRequests.nodes) || !associatedPullRequests.pageInfo || typeof associatedPullRequests.pageInfo.hasNextPage !== 'boolean') {
|
|
147
|
+
return failure({ code: 'INVALID_PLATFORM_RESPONSE', message: `Associated pull request data is incomplete for commit ${oid}`, retryable: false });
|
|
148
|
+
}
|
|
149
|
+
if (associatedPullRequests.pageInfo.hasNextPage) {
|
|
150
|
+
return failure({ code: 'RELEASE_NOTES_ASSOCIATED_PULL_REQUESTS_TRUNCATED', message: `Associated pull requests exceeded the supported page size for ${oid}`, retryable: false });
|
|
151
|
+
}
|
|
152
|
+
const numbers: number[] = [];
|
|
153
|
+
for (const association of associatedPullRequests.nodes) {
|
|
154
|
+
if (typeof association?.number !== 'number' || !Number.isSafeInteger(association.number)) {
|
|
155
|
+
return failure({ code: 'INVALID_PLATFORM_RESPONSE', message: `Associated pull request identity is incomplete for commit ${oid}`, retryable: false });
|
|
156
|
+
}
|
|
157
|
+
if (eligiblePullRequestNumbers.has(association.number)) numbers.push(association.number);
|
|
158
|
+
}
|
|
159
|
+
pullRequestNumbersByCommit.set(oid, [...new Set(numbers)]);
|
|
121
160
|
}
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
161
|
+
const resolvedPullRequests = [];
|
|
162
|
+
const [owner, name] = input.repository.split('/');
|
|
163
|
+
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}}}}}';
|
|
164
|
+
for (const item of pullRequests) {
|
|
165
|
+
const closingIssues = [];
|
|
166
|
+
let cursor: string | null = null;
|
|
167
|
+
for (;;) {
|
|
168
|
+
const args = ['api', 'graphql', '-f', `query=${closingIssuesQuery}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${String(item.number)}`];
|
|
169
|
+
if (cursor) args.push('-F', `cursor=${cursor}`);
|
|
170
|
+
const response = client.json<{
|
|
171
|
+
data?: { repository?: { pullRequest?: { closingIssuesReferences?: {
|
|
172
|
+
nodes?: Array<{ number?: number; title?: string; url?: string; author?: { login?: string | null } | null }>;
|
|
173
|
+
pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
|
|
174
|
+
} } } };
|
|
175
|
+
}>(args, { cwd: options.cwd });
|
|
176
|
+
if (!response.ok) return failure(response.error);
|
|
177
|
+
const connection = response.value.data?.repository?.pullRequest?.closingIssuesReferences;
|
|
178
|
+
if (!connection || !Array.isArray(connection.nodes) || !connection.pageInfo || typeof connection.pageInfo.hasNextPage !== 'boolean') {
|
|
179
|
+
return failure({ code: 'INVALID_PLATFORM_RESPONSE', message: `Closing Issue data is incomplete for pull request ${String(item.number)}`, retryable: false });
|
|
180
|
+
}
|
|
181
|
+
closingIssues.push(...connection.nodes.map((issue) => ({
|
|
182
|
+
number: Number(issue.number), title: String(issue.title || ''), url: String(issue.url || ''),
|
|
183
|
+
author: normalizePlatformAuthor(issue.author)
|
|
184
|
+
})));
|
|
185
|
+
if (!connection.pageInfo.hasNextPage) break;
|
|
186
|
+
if (!connection.pageInfo.endCursor || connection.pageInfo.endCursor === cursor) {
|
|
187
|
+
return failure({ code: 'PAGINATION_INVALID', message: `Closing Issue pagination is incomplete for pull request ${String(item.number)}`, retryable: false });
|
|
188
|
+
}
|
|
189
|
+
cursor = connection.pageInfo.endCursor;
|
|
190
|
+
}
|
|
191
|
+
resolvedPullRequests.push({
|
|
192
|
+
...item,
|
|
193
|
+
author: normalizePlatformAuthor((item.author as { login?: string | null } | null) || null),
|
|
194
|
+
closingIssues
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const commits = input.commitOids.map((sha) => ({
|
|
198
|
+
sha,
|
|
199
|
+
url: `https://github.com/${input.repository}/commit/${sha}`,
|
|
200
|
+
pullRequestNumbers: pullRequestNumbersByCommit.get(sha) || [],
|
|
201
|
+
authors: authors.get(sha) || []
|
|
202
|
+
}));
|
|
203
|
+
return { status: 'no-op' as const, changed: false, history, pullRequests: resolvedPullRequests, commits, error: null };
|
|
129
204
|
}
|
|
130
205
|
|
|
131
206
|
export { fetchGitHubReleaseNoteData, normalizeGitHubActor, publishGitHubReleaseNotes };
|
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
function createNoneProvider(input: PlatformProviderFactoryInput): PlatformProvider {
|
|
9
9
|
return {
|
|
10
10
|
type: input.providerType,
|
|
11
|
-
contractVersion:
|
|
11
|
+
contractVersion: 2,
|
|
12
12
|
context: {
|
|
13
13
|
async resolve(): Promise<ProviderResult<PlatformContextSnapshot>> {
|
|
14
14
|
return {
|
|
@@ -221,16 +221,39 @@ type RepositoryMetadataSnapshot = {
|
|
|
221
221
|
}>;
|
|
222
222
|
};
|
|
223
223
|
|
|
224
|
+
type ReleaseNoteAuthor = {
|
|
225
|
+
name: string;
|
|
226
|
+
login: string | null;
|
|
227
|
+
bot: boolean;
|
|
228
|
+
resolution: 'platform-user' | 'platform-noreply' | 'unresolved';
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
type ReleaseNoteIssue = {
|
|
232
|
+
id: string;
|
|
233
|
+
identity: ResourceIdentity;
|
|
234
|
+
number: number;
|
|
235
|
+
title: string;
|
|
236
|
+
displayUrl: string;
|
|
237
|
+
author: ReleaseNoteAuthor | null;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
type ReleaseNotePullRequest = {
|
|
241
|
+
id: string;
|
|
242
|
+
identity: ResourceIdentity;
|
|
243
|
+
number: number;
|
|
244
|
+
title: string;
|
|
245
|
+
body: string;
|
|
246
|
+
mergedAt: string;
|
|
247
|
+
displayUrl: string;
|
|
248
|
+
labels: string[];
|
|
249
|
+
author: ReleaseNoteAuthor | null;
|
|
250
|
+
closingIssues: ReleaseNoteIssue[];
|
|
251
|
+
};
|
|
252
|
+
|
|
224
253
|
type ReleaseNotesFacts = {
|
|
225
|
-
history: Array<{
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
authoredAt: string;
|
|
229
|
-
author: { id?: string; name?: string } | null;
|
|
230
|
-
}>;
|
|
231
|
-
mergedPullRequests: Array<ChangeRequestSnapshot>;
|
|
232
|
-
closingIssues: Array<IssueSnapshot>;
|
|
233
|
-
actors: Array<{ id?: string; name?: string }>;
|
|
254
|
+
history: Array<{ tag: string; body: string; url: string | null }>;
|
|
255
|
+
commits: Array<{ sha: string; url: string | null; pullRequestNumbers: number[]; authors: ReleaseNoteAuthor[] }>;
|
|
256
|
+
mergedPullRequests: ReleaseNotePullRequest[];
|
|
234
257
|
};
|
|
235
258
|
|
|
236
259
|
type MilestoneReconciliation = {
|
|
@@ -249,14 +272,14 @@ type VerificationRemoteFacts = {
|
|
|
249
272
|
|
|
250
273
|
type PlatformProviderFactoryInput = {
|
|
251
274
|
providerType: string;
|
|
252
|
-
contractVersion:
|
|
275
|
+
contractVersion: 2;
|
|
253
276
|
repositoryRoot: string;
|
|
254
277
|
config: Readonly<Record<string, JsonValue>>;
|
|
255
278
|
};
|
|
256
279
|
|
|
257
280
|
type PlatformProvider = {
|
|
258
281
|
type: string;
|
|
259
|
-
contractVersion:
|
|
282
|
+
contractVersion: 2;
|
|
260
283
|
identity?: ProviderIdentityDeclaration;
|
|
261
284
|
context: {
|
|
262
285
|
resolve(input: {
|
|
@@ -466,7 +489,7 @@ type PlatformProviderFactory = (
|
|
|
466
489
|
input: PlatformProviderFactoryInput
|
|
467
490
|
) => Promise<PlatformProvider>;
|
|
468
491
|
|
|
469
|
-
const PLATFORM_PROVIDER_CONTRACT_VERSION =
|
|
492
|
+
const PLATFORM_PROVIDER_CONTRACT_VERSION = 2 as const;
|
|
470
493
|
|
|
471
494
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
472
495
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -605,6 +628,9 @@ export type {
|
|
|
605
628
|
RequiredCheckSnapshot,
|
|
606
629
|
RepositoryMetadataSnapshot,
|
|
607
630
|
ReleaseNotesFacts,
|
|
631
|
+
ReleaseNoteAuthor,
|
|
632
|
+
ReleaseNoteIssue,
|
|
633
|
+
ReleaseNotePullRequest,
|
|
608
634
|
ResourceIdentity,
|
|
609
635
|
SecurityAlertKind,
|
|
610
636
|
SecurityAlertSnapshot,
|
|
@@ -16,6 +16,8 @@ import type {
|
|
|
16
16
|
PlatformError,
|
|
17
17
|
PlatformProvider,
|
|
18
18
|
ProviderResult,
|
|
19
|
+
ReleaseNoteAuthor,
|
|
20
|
+
ReleaseNoteIssue,
|
|
19
21
|
ReleaseNotesFacts,
|
|
20
22
|
ReleaseSnapshot,
|
|
21
23
|
RemoteCommentSnapshot,
|
|
@@ -109,6 +111,15 @@ function arrayValue(value: unknown, label: string): unknown[] {
|
|
|
109
111
|
return value;
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
function positiveNumberValue(value: unknown, label: string): number {
|
|
115
|
+
if (!Number.isSafeInteger(value) || Number(value) <= 0) throw new Error(`${label} must be a positive integer`);
|
|
116
|
+
return Number(value);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stringArray(value: unknown, label: string): string[] {
|
|
120
|
+
return arrayValue(value, label).map((entry, index) => stringValue(entry, `${label}[${index}]`));
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
function stableUnique<T>(values: T[], key: (value: T) => string, label: string): T[] {
|
|
113
124
|
const seen = new Set<string>();
|
|
114
125
|
for (const value of values) {
|
|
@@ -119,6 +130,16 @@ function stableUnique<T>(values: T[], key: (value: T) => string, label: string):
|
|
|
119
130
|
return [...values].sort((left, right) => key(left).localeCompare(key(right)));
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
function uniqueInOrder<T>(values: T[], key: (value: T) => string, label: string): T[] {
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
for (const value of values) {
|
|
136
|
+
const identityKey = key(value);
|
|
137
|
+
if (seen.has(identityKey)) throw new Error(`${label} contains duplicate identities`);
|
|
138
|
+
seen.add(identityKey);
|
|
139
|
+
}
|
|
140
|
+
return values;
|
|
141
|
+
}
|
|
142
|
+
|
|
122
143
|
function declaredIdentityKind(
|
|
123
144
|
declaration: ProviderIdentityDeclaration | undefined,
|
|
124
145
|
resourceKind: 'issue' | 'pull-request' | 'comment' | 'release'
|
|
@@ -389,20 +410,70 @@ function validateMetadata(value: unknown): RepositoryMetadataSnapshot {
|
|
|
389
410
|
|
|
390
411
|
function validateReleaseNotes(value: unknown, declaration?: ProviderIdentityDeclaration): ReleaseNotesFacts {
|
|
391
412
|
const item = record(value, 'release notes');
|
|
392
|
-
exactKeys(item, ['history', '
|
|
413
|
+
exactKeys(item, ['history', 'commits', 'mergedPullRequests'], 'release notes');
|
|
414
|
+
const releaseAuthor = (raw: unknown, label: string): ReleaseNoteAuthor | null => {
|
|
415
|
+
if (raw === null) return null;
|
|
416
|
+
const entry = record(raw, label);
|
|
417
|
+
exactKeys(entry, ['name', 'login', 'bot', 'resolution'], label);
|
|
418
|
+
if (typeof entry.bot !== 'boolean') throw new Error(`${label}.bot must be boolean`);
|
|
419
|
+
if (!['platform-user', 'platform-noreply', 'unresolved'].includes(String(entry.resolution))) {
|
|
420
|
+
throw new Error(`${label}.resolution is invalid`);
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
name: stringValue(entry.name, `${label}.name`, true),
|
|
424
|
+
login: nullableString(entry.login, `${label}.login`),
|
|
425
|
+
bot: entry.bot,
|
|
426
|
+
resolution: entry.resolution as ReleaseNoteAuthor['resolution']
|
|
427
|
+
};
|
|
428
|
+
};
|
|
429
|
+
const issue = (raw: unknown, label: string): ReleaseNoteIssue => {
|
|
430
|
+
const entry = record(raw, label);
|
|
431
|
+
exactKeys(entry, ['id', 'identity', 'number', 'title', 'displayUrl', 'author'], label);
|
|
432
|
+
return {
|
|
433
|
+
id: stringValue(entry.id, `${label}.id`),
|
|
434
|
+
identity: identity(entry.identity, `${label}.identity`, declaredIdentityKind(declaration, 'issue')),
|
|
435
|
+
number: positiveNumberValue(entry.number, `${label}.number`),
|
|
436
|
+
title: stringValue(entry.title, `${label}.title`, true),
|
|
437
|
+
displayUrl: stringValue(entry.displayUrl, `${label}.displayUrl`, true),
|
|
438
|
+
author: releaseAuthor(entry.author, `${label}.author`)
|
|
439
|
+
};
|
|
440
|
+
};
|
|
393
441
|
return {
|
|
394
|
-
history:
|
|
442
|
+
history: uniqueInOrder(arrayValue(item.history, 'release notes.history').map((raw) => {
|
|
395
443
|
const entry = record(raw, 'release notes.history[]');
|
|
396
|
-
exactKeys(entry, ['
|
|
397
|
-
return {
|
|
398
|
-
}), (entry) => entry.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
444
|
+
exactKeys(entry, ['tag', 'body', 'url'], 'release notes.history[]');
|
|
445
|
+
return { tag: stringValue(entry.tag, 'history.tag'), body: stringValue(entry.body, 'history.body', true), url: nullableString(entry.url, 'history.url') };
|
|
446
|
+
}), (entry) => entry.tag, 'release notes.history'),
|
|
447
|
+
commits: stableUnique(arrayValue(item.commits, 'release notes.commits').map((raw) => {
|
|
448
|
+
const entry = record(raw, 'release notes.commits[]');
|
|
449
|
+
exactKeys(entry, ['sha', 'url', 'pullRequestNumbers', 'authors'], 'release notes.commits[]');
|
|
450
|
+
return {
|
|
451
|
+
sha: stringValue(entry.sha, 'commits.sha'),
|
|
452
|
+
url: nullableString(entry.url, 'commits.url'),
|
|
453
|
+
pullRequestNumbers: stableUnique(arrayValue(entry.pullRequestNumbers, 'commits.pullRequestNumbers').map((number) => positiveNumberValue(number, 'commits.pullRequestNumbers[]')), (number) => String(number), 'commits.pullRequestNumbers'),
|
|
454
|
+
authors: arrayValue(entry.authors, 'commits.authors').map((rawAuthor) => {
|
|
455
|
+
const normalized = releaseAuthor(rawAuthor, 'commits.author');
|
|
456
|
+
if (!normalized) throw new Error('commits.author must identify an author');
|
|
457
|
+
return normalized;
|
|
458
|
+
})
|
|
459
|
+
};
|
|
460
|
+
}), (entry) => entry.sha, 'release notes.commits'),
|
|
461
|
+
mergedPullRequests: stableUnique(arrayValue(item.mergedPullRequests, 'release notes.mergedPullRequests').map((raw) => {
|
|
462
|
+
const entry = record(raw, 'release notes.mergedPullRequests[]');
|
|
463
|
+
exactKeys(entry, ['id', 'identity', 'number', 'title', 'body', 'mergedAt', 'displayUrl', 'labels', 'author', 'closingIssues'], 'release notes.mergedPullRequests[]');
|
|
464
|
+
return {
|
|
465
|
+
id: stringValue(entry.id, 'pullRequest.id'),
|
|
466
|
+
identity: identity(entry.identity, 'pullRequest.identity', declaredIdentityKind(declaration, 'pull-request')),
|
|
467
|
+
number: positiveNumberValue(entry.number, 'pullRequest.number'),
|
|
468
|
+
title: stringValue(entry.title, 'pullRequest.title', true),
|
|
469
|
+
body: stringValue(entry.body, 'pullRequest.body', true),
|
|
470
|
+
mergedAt: utcTimestamp(entry.mergedAt, 'pullRequest.mergedAt'),
|
|
471
|
+
displayUrl: stringValue(entry.displayUrl, 'pullRequest.displayUrl', true),
|
|
472
|
+
labels: stringArray(entry.labels, 'pullRequest.labels'),
|
|
473
|
+
author: releaseAuthor(entry.author, 'pullRequest.author'),
|
|
474
|
+
closingIssues: stableUnique(arrayValue(entry.closingIssues, 'pullRequest.closingIssues').map((entry) => issue(entry, 'pullRequest.closingIssues[]')), (entry) => serializeResourceIdentity(entry.identity), 'pullRequest.closingIssues')
|
|
475
|
+
};
|
|
476
|
+
}), (entry) => serializeResourceIdentity(entry.identity), 'release notes.mergedPullRequests')
|
|
406
477
|
};
|
|
407
478
|
}
|
|
408
479
|
|
|
@@ -7,6 +7,7 @@ import { resolvePlatformProviderContext } from './context.ts';
|
|
|
7
7
|
import type { PlatformClient } from './context.ts';
|
|
8
8
|
import { providerError, providerOperationContext, providerStatus, unsupportedProviderOperation } from './provider-bridge.ts';
|
|
9
9
|
import { resourceIdentityNumber } from './resource-identity.ts';
|
|
10
|
+
import type { ReleaseNoteAuthor } from './provider-contract.ts';
|
|
10
11
|
import type { ResourceIdentity } from './resource-identity.ts';
|
|
11
12
|
|
|
12
13
|
type ReleaseNoteOptions = {
|
|
@@ -15,10 +16,9 @@ type ReleaseNoteOptions = {
|
|
|
15
16
|
client?: PlatformClient;
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
type
|
|
19
|
-
type
|
|
20
|
-
type
|
|
21
|
-
type ReleaseNotePullRequest = { number: number; identity?: ResourceIdentity; title: string; body: string; url: string; mergedAt: string; labels: string[]; author: { login: string; bot: boolean } | null; closingIssues: ReleaseNoteIssue[] };
|
|
19
|
+
type ReleaseNoteCommit = { oid: string; title: string; url: string | null; pullRequestNumbers: number[]; authors: ReleaseNoteAuthor[] };
|
|
20
|
+
type ReleaseNoteIssue = { number: number; identity?: ResourceIdentity; title: string; url: string; author: ReleaseNoteAuthor | null };
|
|
21
|
+
type ReleaseNotePullRequest = { number: number; identity?: ResourceIdentity; title: string; body: string; url: string; mergedAt: string; labels: string[]; author: ReleaseNoteAuthor | null; closingIssues: ReleaseNoteIssue[] };
|
|
22
22
|
|
|
23
23
|
const unsupportedError = {
|
|
24
24
|
code: 'PLATFORM_RELEASE_NOTES_UNSUPPORTED',
|
|
@@ -180,15 +180,15 @@ async function releaseNoteContext(
|
|
|
180
180
|
if (!platform.ok) return { ...baseResult(platform.error.retryable ? 'blocked' : 'failed'), error: providerError(platform.error, 'PLATFORM_PROVIDER_OPERATION_FAILED') };
|
|
181
181
|
const commits = commitFacts.map((commit) => {
|
|
182
182
|
const seen = new Set<string>();
|
|
183
|
-
const
|
|
184
|
-
|
|
183
|
+
const facts = platform.value.commits.find((item) => item.sha === commit.oid);
|
|
184
|
+
const authors = (facts?.authors || [])
|
|
185
185
|
.filter((actor) => {
|
|
186
|
-
const key = actor.
|
|
186
|
+
const key = actor.login || actor.name;
|
|
187
187
|
if (seen.has(key)) return false;
|
|
188
188
|
seen.add(key);
|
|
189
189
|
return true;
|
|
190
190
|
});
|
|
191
|
-
return { ...commit, authors };
|
|
191
|
+
return { ...commit, url: facts?.url || null, pullRequestNumbers: facts?.pullRequestNumbers || [], authors };
|
|
192
192
|
});
|
|
193
193
|
const pullRequests: ReleaseNotePullRequest[] = platform.value.mergedPullRequests
|
|
194
194
|
.map((item) => ({
|
|
@@ -199,14 +199,13 @@ async function releaseNoteContext(
|
|
|
199
199
|
url: item.displayUrl || '',
|
|
200
200
|
mergedAt: item.mergedAt || '',
|
|
201
201
|
labels: item.labels || [],
|
|
202
|
-
author: item.author
|
|
203
|
-
closingIssues:
|
|
202
|
+
author: item.author,
|
|
203
|
+
closingIssues: item.closingIssues.map((issue) => ({
|
|
204
204
|
number: issue.number || resourceIdentityNumber(issue.identity) || 0,
|
|
205
205
|
...(issue.identity ? { identity: issue.identity } : {}),
|
|
206
206
|
title: issue.title,
|
|
207
207
|
url: issue.displayUrl || '',
|
|
208
|
-
|
|
209
|
-
author: issue.author ? { login: issue.author.name || issue.author.id || '', bot: false } : null
|
|
208
|
+
author: issue.author
|
|
210
209
|
}))
|
|
211
210
|
}))
|
|
212
211
|
.sort((left, right) => left.number - right.number);
|
package/lib/sandbox/capture.ts
CHANGED
|
@@ -219,7 +219,7 @@ export async function runInSandbox(
|
|
|
219
219
|
allowRecreate: request.recreate,
|
|
220
220
|
recreate: async () => {
|
|
221
221
|
const { create } = await import('./commands/create.ts');
|
|
222
|
-
await create([request.taskRef, '--no-refresh']);
|
|
222
|
+
await create([request.taskRef, '--no-refresh'], { runProjectInitCommand: false });
|
|
223
223
|
}
|
|
224
224
|
})).container;
|
|
225
225
|
} else if (!found.running) {
|
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
recoverSandboxControlReplacement,
|
|
80
80
|
readSandboxControlManifest
|
|
81
81
|
} from '../control/lifecycle.ts';
|
|
82
|
+
import { atomicWriteJson } from '../control/state.ts';
|
|
82
83
|
import { inspectSandboxControlContainer } from '../control/container-identity.ts';
|
|
83
84
|
import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.ts';
|
|
84
85
|
import { sandboxCoreBindMounts } from '../mounts.ts';
|
|
@@ -1008,7 +1009,27 @@ function readImageLabels(config: Pick<SandboxCreateConfig, 'imageName'> & Pick<S
|
|
|
1008
1009
|
]));
|
|
1009
1010
|
}
|
|
1010
1011
|
|
|
1011
|
-
|
|
1012
|
+
function readProjectInitCommandDigest(controlRoot: string): string | null {
|
|
1013
|
+
const markerPath = path.join(controlRoot, 'project-init.json');
|
|
1014
|
+
if (!fs.existsSync(markerPath)) return null;
|
|
1015
|
+
try {
|
|
1016
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8')) as { version?: unknown; commandSha256?: unknown };
|
|
1017
|
+
return marker.version === 1 && typeof marker.commandSha256 === 'string' && /^[a-f0-9]{64}$/.test(marker.commandSha256)
|
|
1018
|
+
? marker.commandSha256
|
|
1019
|
+
: null;
|
|
1020
|
+
} catch {
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function writeProjectInitCommandDigest(controlRoot: string, commandSha256: string): void {
|
|
1026
|
+
atomicWriteJson(path.join(controlRoot, 'project-init.json'), { version: 1, commandSha256 });
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
export async function create(
|
|
1030
|
+
args: string[],
|
|
1031
|
+
{ runProjectInitCommand = true }: { runProjectInitCommand?: boolean } = {}
|
|
1032
|
+
): Promise<void> {
|
|
1012
1033
|
const { values, positionals } = parseArgs({
|
|
1013
1034
|
args,
|
|
1014
1035
|
allowPositionals: true,
|
|
@@ -1079,6 +1100,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1079
1100
|
identity: target.workspace
|
|
1080
1101
|
});
|
|
1081
1102
|
const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
|
|
1103
|
+
let worktreeCreated = false;
|
|
1082
1104
|
const shareCommon = shareCommonDir(effectiveConfig);
|
|
1083
1105
|
const shareBranch = shareBranchDir(effectiveConfig, branch);
|
|
1084
1106
|
const preparedDockerfile = prepareDockerfile(effectiveConfig, capabilityPlan.image);
|
|
@@ -1203,6 +1225,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1203
1225
|
toEnginePath(engine, worktree),
|
|
1204
1226
|
branch
|
|
1205
1227
|
]);
|
|
1228
|
+
worktreeCreated = true;
|
|
1206
1229
|
} else {
|
|
1207
1230
|
message(`Creating branch '${branch}' from '${baseBranch}'...`);
|
|
1208
1231
|
runEngineTaskCommand(engine, 'git', [
|
|
@@ -1215,6 +1238,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1215
1238
|
toEnginePath(engine, worktree),
|
|
1216
1239
|
baseBranch
|
|
1217
1240
|
]);
|
|
1241
|
+
worktreeCreated = true;
|
|
1218
1242
|
}
|
|
1219
1243
|
|
|
1220
1244
|
return `Worktree ready at ${worktree}`;
|
|
@@ -1272,6 +1296,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1272
1296
|
let replacementCutover: ReturnType<typeof beginSandboxControlReplacement> | null = null;
|
|
1273
1297
|
try {
|
|
1274
1298
|
const recoveryResult = await recoverSandboxControlReplacement(controlPaths.root, replacementLease);
|
|
1299
|
+
const previousProjectInitCommandDigest = readProjectInitCommandDigest(controlPaths.root);
|
|
1275
1300
|
const hadExistingControlRoot = hadControlRootBeforeAcquire || recoveryResult === 'restored';
|
|
1276
1301
|
const previousManifest = fs.existsSync(controlPaths.manifestPath)
|
|
1277
1302
|
? readSandboxControlManifest(controlPaths.manifestPath)
|
|
@@ -1728,6 +1753,32 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1728
1753
|
);
|
|
1729
1754
|
}
|
|
1730
1755
|
|
|
1756
|
+
const initCommand = effectiveConfig.initCommand;
|
|
1757
|
+
const initCommandDigest = initCommand === null ? null : createHash('sha256').update(initCommand).digest('hex');
|
|
1758
|
+
const shouldRunProjectInitCommand = runProjectInitCommand
|
|
1759
|
+
&& initCommand !== null
|
|
1760
|
+
&& initCommandDigest !== null
|
|
1761
|
+
&& (worktreeCreated || previousProjectInitCommandDigest !== initCommandDigest);
|
|
1762
|
+
let projectInitCompleted = !worktreeCreated
|
|
1763
|
+
&& initCommandDigest !== null
|
|
1764
|
+
&& previousProjectInitCommandDigest === initCommandDigest;
|
|
1765
|
+
|
|
1766
|
+
if (shouldRunProjectInitCommand && initCommand !== null) {
|
|
1767
|
+
try {
|
|
1768
|
+
runVerboseEngine(engine, 'docker', [
|
|
1769
|
+
'exec', '--workdir', '/workspace', container, 'bash', '-lc', initCommand
|
|
1770
|
+
]);
|
|
1771
|
+
projectInitCompleted = true;
|
|
1772
|
+
} catch (error) {
|
|
1773
|
+
throw new Error(
|
|
1774
|
+
`Project initialization failed for '${branch}': ${error instanceof Error ? error.message : String(error)}`
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
if (projectInitCompleted && initCommandDigest !== null) {
|
|
1779
|
+
writeProjectInitCommandDigest(controlPaths.root, initCommandDigest);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1731
1782
|
replacementLease.release();
|
|
1732
1783
|
replacementLeaseHeld = false;
|
|
1733
1784
|
return 'Container started';
|
|
@@ -169,7 +169,7 @@ export async function enter(args: string[]): Promise<number> {
|
|
|
169
169
|
allowRecreate: parsed.recreate,
|
|
170
170
|
recreate: async () => {
|
|
171
171
|
const { create } = await import('./create.ts');
|
|
172
|
-
await create([target.requestedRef, '--no-refresh']);
|
|
172
|
+
await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
|
|
173
173
|
}
|
|
174
174
|
});
|
|
175
175
|
const container = ready.container;
|
|
@@ -86,7 +86,7 @@ export async function start(args: string[]): Promise<void> {
|
|
|
86
86
|
forceRecreate: parsed.recreate,
|
|
87
87
|
recreate: async () => {
|
|
88
88
|
const { create } = await import('./create.ts');
|
|
89
|
-
await create([target.requestedRef, '--no-refresh']);
|
|
89
|
+
await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
|
|
90
90
|
}
|
|
91
91
|
});
|
|
92
92
|
const message = ready.path === 'healthy'
|