@akagilnc/pi-workflow-roles 0.1.5241 → 0.1.5252

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 (41) hide show
  1. package/README.md +2 -3
  2. package/README.zh-CN.md +2 -3
  3. package/dist/acp-host/production-host.js +482 -3141
  4. package/dist/collector-config.js +0 -1
  5. package/dist/headless-host/production-host.js +590 -3249
  6. package/dist/migrate-book-topology.js +40 -107
  7. package/dist/navigator-invocation-identity.js +0 -57
  8. package/dist/package-contracts/collector-output.js +1 -90
  9. package/dist/package-contracts/terminating-tools.js +2 -2
  10. package/dist/packaged-role-registry.js +8 -11
  11. package/dist/public-cli/invocation.js +2 -22
  12. package/dist/public-cli/main.js +256 -749
  13. package/dist/public-cli/option-definitions.js +4 -19
  14. package/dist/public-cli/settlement.js +12 -138
  15. package/package.json +1 -1
  16. package/resources/collector-bot-handbook.md +4 -2
  17. package/src/collector-config.ts +0 -1
  18. package/src/collector-role.ts +40 -535
  19. package/src/collector-tool-schemas.ts +7 -117
  20. package/src/gh-api-runner.ts +102 -0
  21. package/src/navigator-invocation-identity.ts +4 -121
  22. package/src/package-contracts/collector-output.ts +15 -174
  23. package/src/package-contracts/terminating-tools.ts +0 -2
  24. package/src/packaged-role-registry.ts +7 -10
  25. package/src/public-cli/invocation.ts +3 -27
  26. package/src/public-cli/option-definitions.ts +4 -19
  27. package/src/public-cli/settlement.ts +11 -177
  28. package/src/role-runtime-dependencies.ts +0 -4
  29. package/src/role-runtime.ts +39 -103
  30. package/src/ticket-snapshot.ts +1 -1
  31. package/dist/collector-evidence.js +0 -319
  32. package/dist/collector-github.js +0 -725
  33. package/dist/collector-identity.js +0 -92
  34. package/dist/collector-ledger.js +0 -888
  35. package/dist/collector-target.js +0 -169
  36. package/src/collector-evidence.ts +0 -466
  37. package/src/collector-github.ts +0 -1032
  38. package/src/collector-handbook.ts +0 -194
  39. package/src/collector-identity.ts +0 -151
  40. package/src/collector-ledger.ts +0 -1186
  41. package/src/collector-target.ts +0 -207
@@ -1,1032 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
-
4
- import { parseCollectorPrNumber } from "./collector-config.ts";
5
-
6
- export type GitHubPullRequest = {
7
- number: number;
8
- state: string;
9
- headOid: string;
10
- /** GitHub PR created_at — work-step start for new-PR auto-trigger rounds (#678 D4). */
11
- createdAt?: string;
12
- updatedAt?: string;
13
- url: string;
14
- raw: unknown;
15
- };
16
-
17
- export type GitHubUser = {
18
- login: string;
19
- raw: unknown;
20
- };
21
-
22
- export type GitHubMachineIdentity = {
23
- userType: string;
24
- userId: number;
25
- appId?: number;
26
- };
27
-
28
- export type GitHubPullRequestReaction = {
29
- id: number;
30
- userLogin: string | null;
31
- machineIdentity?: GitHubMachineIdentity | null;
32
- content: string;
33
- createdAt: string;
34
- raw: unknown;
35
- };
36
-
37
- export type GitHubReview = {
38
- id: number;
39
- nodeId?: string;
40
- /** Null when GitHub tombstones the author (`user: null`). */
41
- userLogin: string | null;
42
- machineIdentity?: GitHubMachineIdentity | null;
43
- state: string;
44
- body: string;
45
- commitId: string | null;
46
- submittedAt: string | null;
47
- htmlUrl: string;
48
- raw: unknown;
49
- };
50
-
51
- export type GitHubIssueComment = {
52
- id: number;
53
- /** Null when GitHub tombstones the author (`user: null`). */
54
- userLogin: string | null;
55
- machineIdentity?: GitHubMachineIdentity | null;
56
- body: string;
57
- createdAt: string;
58
- updatedAt: string;
59
- htmlUrl: string;
60
- raw: unknown;
61
- };
62
-
63
- export type GitHubReviewComment = {
64
- id: number;
65
- pullRequestReviewId: number | null;
66
- /** Null when GitHub tombstones the author (`user: null`). */
67
- userLogin: string | null;
68
- machineIdentity?: GitHubMachineIdentity | null;
69
- body: string;
70
- path: string;
71
- line: number | null;
72
- originalLine: number | null;
73
- side: string | null;
74
- position: number | null;
75
- originalPosition: number | null;
76
- commitId: string | null;
77
- originalCommitId: string | null;
78
- createdAt: string;
79
- updatedAt: string;
80
- htmlUrl: string;
81
- raw: unknown;
82
- };
83
-
84
- export type GitHubPageDiagnostics = {
85
- path: string;
86
- page: number;
87
- status: number;
88
- itemCount: number;
89
- linkHeader?: string;
90
- };
91
-
92
- export type GitHubCreateCommentResult =
93
- | {
94
- kind: "success";
95
- comment: GitHubIssueComment;
96
- }
97
- | {
98
- kind: "ambiguous_loss";
99
- diagnostics: string;
100
- cause?: { name: string; message: string; evidenceId: string };
101
- }
102
- | {
103
- kind: "rejected";
104
- status?: number;
105
- diagnostics: string;
106
- };
107
-
108
- export type CollectorGitHubTransport = {
109
- getAuthenticatedUser(options?: {
110
- signal?: AbortSignal;
111
- }): Promise<GitHubUser>;
112
- getPullRequest(input: {
113
- owner: string;
114
- repo: string;
115
- prNumber: number;
116
- signal?: AbortSignal;
117
- }): Promise<GitHubPullRequest>;
118
- listPullRequestReviews(input: {
119
- owner: string;
120
- repo: string;
121
- prNumber: number;
122
- signal?: AbortSignal;
123
- /** Charge observation budget before aggregate append / next-page fetch. */
124
- retainPage?: (items: GitHubReview[]) => void;
125
- }): Promise<{ items: GitHubReview[]; pages: GitHubPageDiagnostics[] }>;
126
- listPullRequestReactions?(input: {
127
- owner: string;
128
- repo: string;
129
- prNumber: number;
130
- signal?: AbortSignal;
131
- retainPage?: (items: GitHubPullRequestReaction[]) => void;
132
- }): Promise<{ items: GitHubPullRequestReaction[]; pages: GitHubPageDiagnostics[] }>;
133
- listIssueComments(input: {
134
- owner: string;
135
- repo: string;
136
- prNumber: number;
137
- signal?: AbortSignal;
138
- retainPage?: (items: GitHubIssueComment[]) => void;
139
- }): Promise<{ items: GitHubIssueComment[]; pages: GitHubPageDiagnostics[] }>;
140
- listReviewComments(input: {
141
- owner: string;
142
- repo: string;
143
- prNumber: number;
144
- signal?: AbortSignal;
145
- retainPage?: (items: GitHubReviewComment[]) => void;
146
- }): Promise<{ items: GitHubReviewComment[]; pages: GitHubPageDiagnostics[] }>;
147
- createIssueComment(input: {
148
- owner: string;
149
- repo: string;
150
- prNumber: number;
151
- body: string;
152
- signal?: AbortSignal;
153
- }): Promise<GitHubCreateCommentResult>;
154
- };
155
-
156
- export type GhApiResponse = {
157
- status: number;
158
- headers: Record<string, string>;
159
- bodyText: string;
160
- };
161
-
162
- export type GhApiRunner = (
163
- args: string[],
164
- options?: { stdin?: string; signal?: AbortSignal },
165
- ) => Promise<GhApiResponse>;
166
-
167
- function isRecord(value: unknown): value is Record<string, unknown> {
168
- return typeof value === "object" && value !== null && !Array.isArray(value);
169
- }
170
-
171
- function requireString(value: unknown, label: string): string {
172
- if (typeof value !== "string") {
173
- throw new Error(`GitHub payload missing string ${label}`);
174
- }
175
- return value;
176
- }
177
-
178
- function requireNumber(value: unknown, label: string): number {
179
- if (typeof value !== "number" || !Number.isFinite(value)) {
180
- throw new Error(`GitHub payload missing number ${label}`);
181
- }
182
- return value;
183
- }
184
-
185
- function optionalString(value: unknown): string | null {
186
- return typeof value === "string" ? value : null;
187
- }
188
-
189
- function parseLinkNext(linkHeader: string | undefined): string | undefined {
190
- if (linkHeader === undefined || linkHeader.length === 0) return undefined;
191
- for (const part of linkHeader.split(",")) {
192
- const match = part.trim().match(/^<([^>]+)>\s*;\s*rel="next"$/i);
193
- if (match?.[1]) return match[1];
194
- }
195
- return undefined;
196
- }
197
-
198
- function parseJson(text: string, label: string): unknown {
199
- try {
200
- return JSON.parse(text);
201
- } catch (error) {
202
- throw new Error(`GitHub ${label} returned malformed JSON`, { cause: error });
203
- }
204
- }
205
-
206
- /**
207
- * Shared PR-list payload parse for admission target lookup (#676 D1/D7).
208
- * Every entry must carry a positive safe-integer number — malformed items fail
209
- * the response (do not skip then claim uniqueness).
210
- */
211
- export function parsePullRequestNumberList(raw: unknown, label: string): number[] {
212
- if (!Array.isArray(raw)) {
213
- throw new Error(`GitHub ${label} payload is not a list`);
214
- }
215
- const numbers: number[] = [];
216
- for (const item of raw) {
217
- if (!isRecord(item)) {
218
- throw new Error(`GitHub ${label} payload contains a non-object pull request entry`);
219
- }
220
- try {
221
- numbers.push(parseCollectorPrNumber(item["number"]));
222
- } catch (error) {
223
- throw new Error(`GitHub ${label} payload contains an invalid pull request number`, {
224
- cause: error,
225
- });
226
- }
227
- }
228
- return numbers;
229
- }
230
-
231
- /**
232
- * Online PR association by head owner:ref (state=all). Caller supplies the real
233
- * head owner from branch context — never assume base repository owner is the fork head.
234
- * Transport/HTTP/JSON failures throw with true cause (not target-ambiguity wash).
235
- */
236
- export async function listPullRequestNumbersByHead(
237
- runner: GhApiRunner,
238
- input: {
239
- readonly owner: string;
240
- readonly repo: string;
241
- readonly headOwner: string;
242
- readonly headRef: string;
243
- readonly signal?: AbortSignal;
244
- },
245
- ): Promise<number[]> {
246
- const head = `${input.headOwner}:${input.headRef}`;
247
- const path =
248
- `/repos/${input.owner}/${input.repo}/pulls?head=${encodeURIComponent(head)}&state=all&per_page=100`;
249
- const response = await runner(
250
- ["api", "--hostname", "github.com", "--include", "-X", "GET", path],
251
- input.signal === undefined ? {} : { signal: input.signal },
252
- );
253
- if (response.status < 200 || response.status >= 300) {
254
- throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, {
255
- cause: {
256
- endpoint: path,
257
- status: response.status,
258
- headers: response.headers,
259
- body: response.bodyText,
260
- },
261
- });
262
- }
263
- return parsePullRequestNumberList(parseJson(response.bodyText, path), path);
264
- }
265
-
266
- /**
267
- * Online PR association for a commit SHA (fork-safe; works when the commit is on the PR).
268
- * Transport/HTTP/JSON failures throw with true cause.
269
- */
270
- export async function listPullRequestNumbersByCommit(
271
- runner: GhApiRunner,
272
- input: {
273
- readonly owner: string;
274
- readonly repo: string;
275
- readonly commitSha: string;
276
- readonly signal?: AbortSignal;
277
- },
278
- ): Promise<number[]> {
279
- const path = `/repos/${input.owner}/${input.repo}/commits/${encodeURIComponent(input.commitSha)}/pulls`;
280
- const response = await runner(
281
- ["api", "--hostname", "github.com", "--include", "-X", "GET", path],
282
- input.signal === undefined ? {} : { signal: input.signal },
283
- );
284
- if (response.status < 200 || response.status >= 300) {
285
- throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, {
286
- cause: {
287
- endpoint: path,
288
- status: response.status,
289
- headers: response.headers,
290
- body: response.bodyText,
291
- },
292
- });
293
- }
294
- return parsePullRequestNumberList(parseJson(response.bodyText, path), path);
295
- }
296
-
297
- /**
298
- * Online association for a structured ticket number (#676 D1):
299
- * - Number is itself a pull request → that PR.
300
- * - Number is an issue → PRs linked via timeline cross-reference / closed-by.
301
- * Transport/HTTP/JSON failures throw with true cause.
302
- * 404 / empty association → [].
303
- */
304
- export async function listPullRequestNumbersByTicket(
305
- runner: GhApiRunner,
306
- input: {
307
- readonly owner: string;
308
- readonly repo: string;
309
- readonly ticketNumber: number;
310
- readonly signal?: AbortSignal;
311
- },
312
- ): Promise<number[]> {
313
- const issuePath = `/repos/${input.owner}/${input.repo}/issues/${input.ticketNumber}`;
314
- const issueResponse = await runner(
315
- ["api", "--hostname", "github.com", "--include", "-X", "GET", issuePath],
316
- input.signal === undefined ? {} : { signal: input.signal },
317
- );
318
- if (issueResponse.status === 404) return [];
319
- if (issueResponse.status < 200 || issueResponse.status >= 300) {
320
- throw new Error(`GitHub ${issuePath} failed with HTTP ${issueResponse.status}`, {
321
- cause: {
322
- endpoint: issuePath,
323
- status: issueResponse.status,
324
- headers: issueResponse.headers,
325
- body: issueResponse.bodyText,
326
- },
327
- });
328
- }
329
- const issueRaw = parseJson(issueResponse.bodyText, issuePath);
330
- if (!isRecord(issueRaw)) {
331
- throw new Error(`GitHub ${issuePath} payload is not an object`);
332
- }
333
- // Issues endpoint returns PRs too — own-key pull_request means the number is the PR.
334
- if (Object.hasOwn(issueRaw, "pull_request")) {
335
- return [parseCollectorPrNumber(issueRaw["number"] ?? input.ticketNumber)];
336
- }
337
-
338
- // Linked PRs: GraphQL closed-by + cross-referenced PR sources (existing gh runner seam).
339
- const query = `query($owner: String!, $repo: String!, $number: Int!) {
340
- repository(owner: $owner, name: $repo) {
341
- issue(number: $number) {
342
- closedByPullRequestsReferences(first: 50) { nodes { number } }
343
- timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
344
- nodes {
345
- __typename
346
- ... on CrossReferencedEvent {
347
- source { ... on PullRequest { number } }
348
- }
349
- ... on ConnectedEvent {
350
- subject { ... on PullRequest { number } }
351
- }
352
- }
353
- }
354
- }
355
- }
356
- }`;
357
- const args = [
358
- "api",
359
- "graphql",
360
- "--hostname",
361
- "github.com",
362
- "--include",
363
- "-f",
364
- `query=${query}`,
365
- "-f",
366
- `owner=${input.owner}`,
367
- "-f",
368
- `repo=${input.repo}`,
369
- "-F",
370
- `number=${input.ticketNumber}`,
371
- ];
372
- const gqlResponse = await runner(
373
- args,
374
- input.signal === undefined ? {} : { signal: input.signal },
375
- );
376
- if (gqlResponse.status < 200 || gqlResponse.status >= 300) {
377
- throw new Error(`GitHub GraphQL issue→PR failed with HTTP ${gqlResponse.status}`, {
378
- cause: {
379
- endpoint: "graphql",
380
- status: gqlResponse.status,
381
- headers: gqlResponse.headers,
382
- body: gqlResponse.bodyText,
383
- },
384
- });
385
- }
386
- let payload: unknown;
387
- try {
388
- payload = JSON.parse(gqlResponse.bodyText);
389
- } catch (error) {
390
- throw new Error("GitHub GraphQL issue→PR returned malformed JSON", { cause: error });
391
- }
392
- if (!isRecord(payload)) {
393
- throw new Error("GitHub GraphQL issue→PR payload is not an object");
394
- }
395
- if (payload.errors !== undefined) {
396
- throw new Error(`GitHub GraphQL issue→PR errors: ${JSON.stringify(payload.errors).slice(0, 600)}`, {
397
- cause: { body: gqlResponse.bodyText, errors: payload.errors },
398
- });
399
- }
400
- const data = payload.data;
401
- if (!isRecord(data)) return [];
402
- const repository = data["repository"];
403
- if (!isRecord(repository)) return [];
404
- const issue = repository["issue"];
405
- if (!isRecord(issue)) return [];
406
- const numbers: number[] = [];
407
- const closedBy = issue["closedByPullRequestsReferences"];
408
- if (isRecord(closedBy) && Array.isArray(closedBy["nodes"])) {
409
- for (const node of closedBy["nodes"]) {
410
- if (isRecord(node) && typeof node["number"] === "number") {
411
- numbers.push(parseCollectorPrNumber(node["number"]));
412
- }
413
- }
414
- }
415
- const timeline = issue["timelineItems"];
416
- if (isRecord(timeline) && Array.isArray(timeline["nodes"])) {
417
- for (const node of timeline["nodes"]) {
418
- if (!isRecord(node)) continue;
419
- const source = node["source"];
420
- if (isRecord(source) && typeof source["number"] === "number") {
421
- numbers.push(parseCollectorPrNumber(source["number"]));
422
- }
423
- const subject = node["subject"];
424
- if (isRecord(subject) && typeof subject["number"] === "number") {
425
- numbers.push(parseCollectorPrNumber(subject["number"]));
426
- }
427
- }
428
- }
429
- return [...new Set(numbers)];
430
- }
431
-
432
- let commentFailureEvidence = 0;
433
- function commentFailureCause(error: unknown) {
434
- return {
435
- name: error instanceof Error ? error.name : typeof error,
436
- message: error instanceof Error ? error.message : String(error),
437
- evidenceId: `github-comment-failure-${++commentFailureEvidence}`,
438
- };
439
- }
440
-
441
- /** Authenticated /user requires a login; throws when absent. */
442
- function requireUserLogin(raw: unknown): string {
443
- if (!isRecord(raw) || typeof raw["login"] !== "string") {
444
- throw new Error("GitHub payload missing user.login");
445
- }
446
- return raw["login"];
447
- }
448
-
449
- /**
450
- * Surface authors may be tombstoned only as literal JSON null (`user: null`).
451
- * Preserve that case with null login; every other shape fails closed.
452
- */
453
- function optionalUserLogin(raw: unknown): string | null {
454
- if (raw === null) return null;
455
- if (!isRecord(raw) || typeof raw["login"] !== "string") {
456
- throw new Error("GitHub payload missing user.login");
457
- }
458
- return raw["login"];
459
- }
460
-
461
- function machineIdentity(raw: Record<string, unknown>): GitHubMachineIdentity | null {
462
- const user = raw["user"];
463
- if (!isRecord(user) || typeof user["type"] !== "string" || typeof user["id"] !== "number") {
464
- return null;
465
- }
466
- const app = raw["performed_via_github_app"];
467
- const appId = isRecord(app) && typeof app["id"] === "number" ? app["id"] : undefined;
468
- return {
469
- userType: user["type"],
470
- userId: user["id"],
471
- ...(appId === undefined ? {} : { appId }),
472
- };
473
- }
474
-
475
- export function normalizePullRequest(raw: unknown): GitHubPullRequest {
476
- if (!isRecord(raw)) throw new Error("GitHub pull request payload must be an object");
477
- const head = raw["head"];
478
- if (!isRecord(head) || typeof head["sha"] !== "string" || head["sha"].length === 0) {
479
- throw new Error("GitHub pull request payload missing head.sha");
480
- }
481
- const number = requireNumber(raw["number"], "number");
482
- // GitHub REST: merged PRs keep state="closed" and set merged/merged_at. Project the
483
- // real merge fact so receipt/settlement can distinguish merged from merely closed (#676 D6).
484
- const mergedFlag = raw["merged"] === true
485
- || (typeof raw["merged_at"] === "string" && raw["merged_at"].length > 0);
486
- const rawState = requireString(raw["state"], "state").toUpperCase();
487
- // After toUpperCase the only OPEN spelling is "OPEN"; keep MERGED vs CLOSED distinction.
488
- const state = mergedFlag ? "MERGED" : rawState;
489
- const htmlUrl = typeof raw["html_url"] === "string"
490
- ? raw["html_url"]
491
- : `https://github.com/unknown/unknown/pull/${number}`;
492
- return {
493
- number,
494
- state,
495
- headOid: head["sha"],
496
- ...(typeof raw["created_at"] === "string" ? { createdAt: raw["created_at"] } : {}),
497
- ...(typeof raw["updated_at"] === "string" ? { updatedAt: raw["updated_at"] } : {}),
498
- url: htmlUrl,
499
- raw,
500
- };
501
- }
502
-
503
- export function normalizePullRequestReaction(raw: unknown): GitHubPullRequestReaction {
504
- if (!isRecord(raw)) throw new Error("GitHub reaction payload must be an object");
505
- return {
506
- id: requireNumber(raw["id"], "reaction.id"),
507
- userLogin: optionalUserLogin(raw["user"]),
508
- machineIdentity: machineIdentity(raw),
509
- content: requireString(raw["content"], "reaction.content"),
510
- createdAt: requireString(raw["created_at"], "reaction.created_at"),
511
- raw,
512
- };
513
- }
514
-
515
- export function normalizeReview(raw: unknown): GitHubReview {
516
- if (!isRecord(raw)) throw new Error("GitHub review payload must be an object");
517
- return {
518
- id: requireNumber(raw["id"], "review.id"),
519
- ...(typeof raw["node_id"] === "string" ? { nodeId: raw["node_id"] } : {}),
520
- userLogin: optionalUserLogin(raw["user"]),
521
- machineIdentity: machineIdentity(raw),
522
- state: requireString(raw["state"], "review.state").toUpperCase(),
523
- body: typeof raw["body"] === "string" ? raw["body"] : "",
524
- commitId: optionalString(raw["commit_id"]),
525
- submittedAt: optionalString(raw["submitted_at"]),
526
- htmlUrl: typeof raw["html_url"] === "string" ? raw["html_url"] : "",
527
- raw,
528
- };
529
- }
530
-
531
- export function normalizeIssueComment(raw: unknown): GitHubIssueComment {
532
- if (!isRecord(raw)) throw new Error("GitHub issue comment payload must be an object");
533
- return {
534
- id: requireNumber(raw["id"], "comment.id"),
535
- userLogin: optionalUserLogin(raw["user"]),
536
- machineIdentity: machineIdentity(raw),
537
- body: typeof raw["body"] === "string" ? raw["body"] : "",
538
- createdAt: requireString(raw["created_at"], "comment.created_at"),
539
- updatedAt: requireString(raw["updated_at"], "comment.updated_at"),
540
- htmlUrl: typeof raw["html_url"] === "string" ? raw["html_url"] : "",
541
- raw,
542
- };
543
- }
544
-
545
- export function normalizeReviewComment(raw: unknown): GitHubReviewComment {
546
- if (!isRecord(raw)) throw new Error("GitHub review comment payload must be an object");
547
- return {
548
- id: requireNumber(raw["id"], "review_comment.id"),
549
- pullRequestReviewId: typeof raw["pull_request_review_id"] === "number"
550
- ? raw["pull_request_review_id"]
551
- : null,
552
- userLogin: optionalUserLogin(raw["user"]),
553
- machineIdentity: machineIdentity(raw),
554
- body: typeof raw["body"] === "string" ? raw["body"] : "",
555
- path: requireString(raw["path"], "review_comment.path"),
556
- line: typeof raw["line"] === "number" ? raw["line"] : null,
557
- originalLine: typeof raw["original_line"] === "number" ? raw["original_line"] : null,
558
- side: optionalString(raw["side"]),
559
- position: typeof raw["position"] === "number" ? raw["position"] : null,
560
- originalPosition: typeof raw["original_position"] === "number"
561
- ? raw["original_position"]
562
- : null,
563
- commitId: optionalString(raw["commit_id"]),
564
- originalCommitId: optionalString(raw["original_commit_id"]),
565
- createdAt: requireString(raw["created_at"], "review_comment.created_at"),
566
- updatedAt: requireString(raw["updated_at"], "review_comment.updated_at"),
567
- htmlUrl: typeof raw["html_url"] === "string" ? raw["html_url"] : "",
568
- raw,
569
- };
570
- }
571
-
572
- export function createGhApiRunner(
573
- options: {
574
- spawnImpl?: typeof spawn;
575
- env?: NodeJS.ProcessEnv;
576
- } = {},
577
- ): GhApiRunner {
578
- const spawnImpl = options.spawnImpl ?? spawn;
579
- return async (args, runOptions = {}) => {
580
- return await new Promise<GhApiResponse>((resolve, reject) => {
581
- const signal = runOptions.signal;
582
- if (signal?.aborted) {
583
- reject(signal.reason ?? new Error("aborted"));
584
- return;
585
- }
586
- const child = spawnImpl("gh", args, {
587
- env: options.env ?? process.env,
588
- stdio: ["pipe", "pipe", "pipe"],
589
- });
590
- let stdout = "";
591
- let stderr = "";
592
- let settled = false;
593
- const settle = (fn: () => void) => {
594
- if (settled) return;
595
- settled = true;
596
- if (signal !== undefined) {
597
- signal.removeEventListener("abort", onAbort);
598
- }
599
- fn();
600
- };
601
- const onAbort = () => {
602
- try {
603
- child.kill("SIGTERM");
604
- } catch (error) {
605
- settle(() => reject(error));
606
- return;
607
- }
608
- settle(() => {
609
- reject(signal?.reason ?? new Error("aborted"));
610
- });
611
- };
612
- if (signal !== undefined) {
613
- signal.addEventListener("abort", onAbort, { once: true });
614
- }
615
- child.stdout.setEncoding("utf8").on("data", (chunk) => {
616
- stdout += chunk;
617
- });
618
- child.stderr.setEncoding("utf8").on("data", (chunk) => {
619
- stderr += chunk;
620
- });
621
- child.on("error", (error) => {
622
- settle(() => reject(error));
623
- });
624
- // Attach before write/end so early child exit cannot raise uncaught EPIPE.
625
- child.stdin.on("error", (error) => {
626
- settle(() => {
627
- if (signal?.aborted) {
628
- reject(signal.reason ?? new Error("aborted"));
629
- return;
630
- }
631
- const err = error instanceof Error ? error : new Error(String(error));
632
- reject(Object.assign(err, { ambiguousGhFailure: true }));
633
- });
634
- });
635
- if (runOptions.stdin !== undefined) {
636
- child.stdin.write(runOptions.stdin);
637
- }
638
- child.stdin.end();
639
- child.on("close", (code, signal) => {
640
- settle(() => {
641
- // gh api --include prints: HTTP/ headers blank-line body
642
- const match = stdout.match(/^HTTP\/[\d.]+\s+(\d+)[^\n]*\r?\n([\s\S]*?)\r?\n\r?\n([\s\S]*)$/);
643
- if (match) {
644
- const status = Number(match[1]);
645
- const headerText = match[2] ?? "";
646
- const bodyText = match[3] ?? "";
647
- const headers: Record<string, string> = {};
648
- for (const line of headerText.split(/\r?\n/)) {
649
- const idx = line.indexOf(":");
650
- if (idx === -1) continue;
651
- const name = line.slice(0, idx).trim().toLowerCase();
652
- const value = line.slice(idx + 1).trim();
653
- headers[name] = value;
654
- }
655
- resolve({ status, headers, bodyText });
656
- return;
657
- }
658
- if (code === 0) {
659
- resolve({ status: 200, headers: {}, bodyText: stdout });
660
- return;
661
- }
662
- // Ambiguous: process failed without parseable HTTP response
663
- const failure = new Error(
664
- `gh api failed without a parseable HTTP response (code=${String(code)}): ${stderr || stdout}`,
665
- { cause: { code, signal, stderr, stdout } },
666
- );
667
- reject(Object.assign(failure, { ambiguousGhFailure: true, stderr, stdout, code, signal }));
668
- });
669
- });
670
- });
671
- };
672
- }
673
-
674
- export type GhIssueSoftFetchResult = Readonly<{ body: string }>;
675
-
676
- /** Input for the shared single-issue body projection. */
677
- export type GhIssueBodyFetchInput = {
678
- readonly owner: string;
679
- readonly repo: string;
680
- readonly ticketNumber: number;
681
- readonly signal?: AbortSignal;
682
- };
683
-
684
- /**
685
- * Shared projection of one GitHub issue body over the gh api runner.
686
- * Sole authority for transport argv, HTTP handling, JSON/object/PR/body parse.
687
- * Callers map disposition (soft undefined vs hard typed error) — they do not reimplement fetch.
688
- */
689
- export type GhIssueBodyProjection =
690
- | { readonly status: "available"; readonly body: string }
691
- | {
692
- readonly status: "unavailable";
693
- readonly reason: "transport" | "http-non-2xx" | "pull-request";
694
- readonly cause?: unknown;
695
- readonly httpStatus?: number;
696
- }
697
- | {
698
- readonly status: "invalid";
699
- readonly reason: "not-json" | "not-object" | "body-invalid";
700
- readonly cause?: unknown;
701
- };
702
-
703
- /**
704
- * Soft single-issue fetch over the shared gh api runner.
705
- * undefined = confirmed tracker unreachable / issue not found / gh tool unavailable:
706
- * - HTTP non-2xx, or
707
- * - runner-tagged ambiguousGhFailure (gh ran but no parseable HTTP — auth/network/transport), or
708
- * - gh process could not start (ENOENT / spawn syscall failure).
709
- * After gh starts successfully: response JSON/shape/implementation errors propagate with true cause.
710
- */
711
- export type GhIssueSoftFetcher = (
712
- input: GhIssueBodyFetchInput,
713
- ) => Promise<GhIssueSoftFetchResult | undefined>;
714
-
715
- function isAmbiguousGhFailure(error: unknown): boolean {
716
- return (
717
- typeof error === "object" &&
718
- error !== null &&
719
- (error as { ambiguousGhFailure?: unknown }).ambiguousGhFailure === true
720
- );
721
- }
722
-
723
- /** gh binary missing or otherwise unable to launch — ticket-authorized soft unavailable. */
724
- function isGhProcessStartFailure(error: unknown): boolean {
725
- if (typeof error !== "object" || error === null) return false;
726
- const code = (error as NodeJS.ErrnoException).code;
727
- if (code === "ENOENT") return true;
728
- const syscall = (error as NodeJS.ErrnoException).syscall;
729
- return typeof syscall === "string" && (syscall === "spawn" || syscall.startsWith("spawn "));
730
- }
731
-
732
- /**
733
- * Sole shared issue-body fetch + projection over createGhApiRunner.
734
- * Soft and hard callers only map this result; they do not duplicate argv/parse.
735
- */
736
- export async function projectGhIssueBody(
737
- runner: GhApiRunner,
738
- input: GhIssueBodyFetchInput,
739
- ): Promise<GhIssueBodyProjection> {
740
- const path = `repos/${input.owner}/${input.repo}/issues/${input.ticketNumber}`;
741
- let response: GhApiResponse;
742
- try {
743
- response = await runner(
744
- ["api", "--hostname", "github.com", "--include", "-X", "GET", path],
745
- input.signal === undefined ? {} : { signal: input.signal },
746
- );
747
- } catch (error) {
748
- // Transport ambiguity / gh never started → unavailable. Other failures keep true cause as throw.
749
- if (isAmbiguousGhFailure(error) || isGhProcessStartFailure(error)) {
750
- return { status: "unavailable", reason: "transport", cause: error };
751
- }
752
- throw error;
753
- }
754
- if (response.status < 200 || response.status >= 300) {
755
- return {
756
- status: "unavailable",
757
- reason: "http-non-2xx",
758
- httpStatus: response.status,
759
- };
760
- }
761
- let parsed: unknown;
762
- try {
763
- parsed = JSON.parse(response.bodyText);
764
- } catch (error) {
765
- return { status: "invalid", reason: "not-json", cause: error };
766
- }
767
- if (typeof parsed !== "object" || parsed === null) {
768
- return { status: "invalid", reason: "not-object" };
769
- }
770
- // Issues endpoint also returns PRs. Own-key presence of pull_request → not an issue face.
771
- if (Object.hasOwn(parsed, "pull_request")) {
772
- return { status: "unavailable", reason: "pull-request" };
773
- }
774
- // Match former gh --jq `(.body // "")`: null/missing body projects to empty string.
775
- const bodyRaw = (parsed as { body?: unknown }).body;
776
- if (bodyRaw !== undefined && bodyRaw !== null && typeof bodyRaw !== "string") {
777
- return { status: "invalid", reason: "body-invalid" };
778
- }
779
- const body = typeof bodyRaw === "string" ? bodyRaw : "";
780
- return { status: "available", body };
781
- }
782
-
783
- /**
784
- * Production issue-fetch capability owned by the shared gh execution seam.
785
- * Reuses projectGhIssueBody. Softens only ticket-authorized unavailable results
786
- * (tracker unreachable / issue not found / gh cannot start / PR marker);
787
- * invalid payload shapes propagate with true cause.
788
- */
789
- export function createGhIssueSoftFetcher(
790
- runner: GhApiRunner = createGhApiRunner(),
791
- ): GhIssueSoftFetcher {
792
- return async (input) => {
793
- const projected = await projectGhIssueBody(runner, input);
794
- if (projected.status === "available") {
795
- return Object.freeze({ body: projected.body });
796
- }
797
- if (projected.status === "unavailable") {
798
- return undefined;
799
- }
800
- // invalid — keep true cause (historical soft-fetcher contract).
801
- if (projected.reason === "not-json") {
802
- throw new Error("GitHub issue payload is not JSON", {
803
- cause: projected.cause,
804
- });
805
- }
806
- if (projected.reason === "not-object") {
807
- throw new Error("GitHub issue payload must be a JSON object");
808
- }
809
- throw new Error("GitHub issue payload body must be string or null");
810
- };
811
- }
812
-
813
- export function createGhCollectorGitHubTransport(
814
- runner: GhApiRunner = createGhApiRunner(),
815
- ): CollectorGitHubTransport {
816
- const hostnameArgs = ["api", "--hostname", "github.com", "--include"] as const;
817
-
818
- async function apiGet(
819
- path: string,
820
- signal?: AbortSignal,
821
- ): Promise<GhApiResponse> {
822
- return await runner(
823
- [...hostnameArgs, "-X", "GET", path],
824
- signal === undefined ? {} : { signal },
825
- );
826
- }
827
-
828
- async function paginate<T>(
829
- path: string,
830
- mapItem: (raw: unknown) => T,
831
- options: {
832
- signal?: AbortSignal;
833
- retainPage?: (items: T[]) => void;
834
- } = {},
835
- ): Promise<{ items: T[]; pages: GitHubPageDiagnostics[] }> {
836
- const { signal, retainPage } = options;
837
- const items: T[] = [];
838
- const pages: GitHubPageDiagnostics[] = [];
839
- let nextPath: string | undefined = path;
840
- let page = 1;
841
- const seen = new Set<string>();
842
- while (nextPath !== undefined) {
843
- if (signal?.aborted) {
844
- throw signal.reason ?? new Error("aborted");
845
- }
846
- if (seen.has(nextPath)) {
847
- throw new Error(`GitHub pagination repeated page: ${nextPath}`);
848
- }
849
- seen.add(nextPath);
850
- const response = await apiGet(nextPath, signal);
851
- const diagnostics: GitHubPageDiagnostics = {
852
- path: nextPath,
853
- page,
854
- status: response.status,
855
- itemCount: 0,
856
- ...(response.headers["link"] === undefined
857
- ? {}
858
- : { linkHeader: response.headers["link"] }),
859
- };
860
- if (response.status === 429) {
861
- throw Object.assign(
862
- new Error(`GitHub API rate limited on ${nextPath} (HTTP 429)`),
863
- { githubStatus: 429, page: diagnostics },
864
- );
865
- }
866
- if (response.status < 200 || response.status >= 300) {
867
- throw Object.assign(
868
- new Error(`GitHub API ${nextPath} failed with HTTP ${response.status}`),
869
- { githubStatus: response.status, page: diagnostics },
870
- );
871
- }
872
- const parsed = parseJson(response.bodyText, nextPath);
873
- if (!Array.isArray(parsed)) {
874
- throw new Error(`GitHub API ${nextPath} did not return a JSON array`);
875
- }
876
- diagnostics.itemCount = parsed.length;
877
- pages.push(diagnostics);
878
- const pageItems = parsed.map((entry) => mapItem(entry));
879
- // Observation budget hook: charge before aggregate append / next-page fetch.
880
- retainPage?.(pageItems);
881
- for (const item of pageItems) items.push(item);
882
- const nextUrl = parseLinkNext(response.headers["link"]);
883
- if (nextUrl === undefined) {
884
- nextPath = undefined;
885
- } else {
886
- // Accept absolute or path-style next links; normalize to API path + query.
887
- if (nextUrl.startsWith("/")) {
888
- nextPath = nextUrl;
889
- } else {
890
- const url = new URL(nextUrl);
891
- if (url.hostname !== "api.github.com" && url.hostname !== "github.com") {
892
- throw new Error(`unexpected pagination host ${url.hostname}`);
893
- }
894
- nextPath = `${url.pathname}${url.search}`;
895
- if (nextPath.startsWith("/api/v3/")) {
896
- nextPath = nextPath.slice("/api/v3".length);
897
- }
898
- }
899
- }
900
- page += 1;
901
- }
902
- return { items, pages };
903
- }
904
-
905
- return {
906
- async getAuthenticatedUser(options = {}) {
907
- const response = await apiGet("/user", options.signal);
908
- if (response.status < 200 || response.status >= 300) {
909
- throw new Error(`GitHub /user failed with HTTP ${response.status}`, { cause: { endpoint: "/user", status: response.status, headers: response.headers, body: response.bodyText } });
910
- }
911
- const raw = parseJson(response.bodyText, "/user");
912
- return { login: requireUserLogin(raw).toLowerCase(), raw };
913
- },
914
-
915
- async getPullRequest(input) {
916
- const path =
917
- `/repos/${input.owner}/${input.repo}/pulls/${input.prNumber}`;
918
- const response = await apiGet(path, input.signal);
919
- if (response.status < 200 || response.status >= 300) {
920
- throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, { cause: { endpoint: path, status: response.status, headers: response.headers, body: response.bodyText } });
921
- }
922
- return normalizePullRequest(parseJson(response.bodyText, path));
923
- },
924
-
925
- async listPullRequestReviews(input) {
926
- const path =
927
- `/repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/reviews?per_page=100`;
928
- return await paginate(path, normalizeReview, {
929
- ...(input.signal === undefined ? {} : { signal: input.signal }),
930
- ...(input.retainPage === undefined ? {} : { retainPage: input.retainPage }),
931
- });
932
- },
933
-
934
- async listPullRequestReactions(input) {
935
- const path =
936
- `/repos/${input.owner}/${input.repo}/issues/${input.prNumber}/reactions?per_page=100`;
937
- return await paginate(path, normalizePullRequestReaction, {
938
- ...(input.signal === undefined ? {} : { signal: input.signal }),
939
- ...(input.retainPage === undefined ? {} : { retainPage: input.retainPage }),
940
- });
941
- },
942
-
943
- async listIssueComments(input) {
944
- const path =
945
- `/repos/${input.owner}/${input.repo}/issues/${input.prNumber}/comments?per_page=100`;
946
- return await paginate(path, normalizeIssueComment, {
947
- ...(input.signal === undefined ? {} : { signal: input.signal }),
948
- ...(input.retainPage === undefined ? {} : { retainPage: input.retainPage }),
949
- });
950
- },
951
-
952
- async listReviewComments(input) {
953
- const path =
954
- `/repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments?per_page=100`;
955
- return await paginate(path, normalizeReviewComment, {
956
- ...(input.signal === undefined ? {} : { signal: input.signal }),
957
- ...(input.retainPage === undefined ? {} : { retainPage: input.retainPage }),
958
- });
959
- },
960
-
961
- async createIssueComment(input) {
962
- const path =
963
- `/repos/${input.owner}/${input.repo}/issues/${input.prNumber}/comments`;
964
- try {
965
- const response = await runner(
966
- [...hostnameArgs, "-X", "POST", path, "--input", "-"],
967
- input.signal === undefined
968
- ? { stdin: JSON.stringify({ body: input.body }) }
969
- : { stdin: JSON.stringify({ body: input.body }), signal: input.signal },
970
- );
971
- if (response.status >= 200 && response.status < 300) {
972
- // Contract: README.md#Collector — a successful write with unreadable response is recoverable only as an observable ambiguous result.
973
- // 2xx means the comment may already exist; parse/normalize failure is
974
- // ambiguous_loss so ledger marker recovery can resolve without repost.
975
- try {
976
- return {
977
- kind: "success",
978
- comment: normalizeIssueComment(parseJson(response.bodyText, path)),
979
- };
980
- } catch (error) {
981
- const cause = commentFailureCause(error);
982
- return { kind: "ambiguous_loss", diagnostics: cause.message, cause };
983
- }
984
- }
985
- return {
986
- kind: "rejected",
987
- status: response.status,
988
- diagnostics: `HTTP ${response.status}: ${response.bodyText.slice(0, 500)}`,
989
- };
990
- } catch (error) {
991
- // Signal state is authoritative cancel; rethrow exact caught value.
992
- if (input.signal?.aborted) {
993
- throw error;
994
- }
995
- // Non-aborted transport tag only (after signal).
996
- if (isRecord(error) && error["ambiguousGhFailure"] === true) {
997
- const cause = commentFailureCause(error);
998
- return { kind: "ambiguous_loss", diagnostics: cause.message, cause };
999
- }
1000
- // Non-signal AbortError belt only (after tag).
1001
- if (isRecord(error) && error["name"] === "AbortError") {
1002
- throw error;
1003
- }
1004
- throw error;
1005
- }
1006
- },
1007
- };
1008
- }
1009
-
1010
- export function buildCollectorRequestMarker(input: {
1011
- manifestDigest: string;
1012
- requestId: string;
1013
- headOid: string;
1014
- }): string {
1015
- const prefix = input.manifestDigest.slice(0, 12);
1016
- const requestMarkerId = createHash("sha256").update(input.requestId).digest("hex");
1017
- return `<!-- ak-collector:v1 manifest=${prefix} request=${requestMarkerId} head=${input.headOid} -->`;
1018
- }
1019
-
1020
- export function buildCollectorRequestBody(input: {
1021
- configuredBody: string;
1022
- manifestDigest: string;
1023
- requestId: string;
1024
- headOid: string;
1025
- }): { body: string; marker: string } {
1026
- const marker = buildCollectorRequestMarker(input);
1027
- // Preserve configured body byte-for-byte; append marker on its own line.
1028
- const body = input.configuredBody.endsWith("\n")
1029
- ? `${input.configuredBody}${marker}\n`
1030
- : `${input.configuredBody}\n${marker}\n`;
1031
- return { body, marker };
1032
- }