@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,725 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { parseCollectorPrNumber } from "./collector-config.js";
4
- function isRecord(value) {
5
- return typeof value === "object" && value !== null && !Array.isArray(value);
6
- }
7
- function requireString(value, label) {
8
- if (typeof value !== "string") {
9
- throw new Error(`GitHub payload missing string ${label}`);
10
- }
11
- return value;
12
- }
13
- function requireNumber(value, label) {
14
- if (typeof value !== "number" || !Number.isFinite(value)) {
15
- throw new Error(`GitHub payload missing number ${label}`);
16
- }
17
- return value;
18
- }
19
- function optionalString(value) {
20
- return typeof value === "string" ? value : null;
21
- }
22
- function parseLinkNext(linkHeader) {
23
- if (linkHeader === undefined || linkHeader.length === 0)
24
- return undefined;
25
- for (const part of linkHeader.split(",")) {
26
- const match = part.trim().match(/^<([^>]+)>\s*;\s*rel="next"$/i);
27
- if (match?.[1])
28
- return match[1];
29
- }
30
- return undefined;
31
- }
32
- function parseJson(text, label) {
33
- try {
34
- return JSON.parse(text);
35
- }
36
- catch (error) {
37
- throw new Error(`GitHub ${label} returned malformed JSON`, { cause: error });
38
- }
39
- }
40
- /**
41
- * Shared PR-list payload parse for admission target lookup (#676 D1/D7).
42
- * Every entry must carry a positive safe-integer number — malformed items fail
43
- * the response (do not skip then claim uniqueness).
44
- */
45
- export function parsePullRequestNumberList(raw, label) {
46
- if (!Array.isArray(raw)) {
47
- throw new Error(`GitHub ${label} payload is not a list`);
48
- }
49
- const numbers = [];
50
- for (const item of raw) {
51
- if (!isRecord(item)) {
52
- throw new Error(`GitHub ${label} payload contains a non-object pull request entry`);
53
- }
54
- try {
55
- numbers.push(parseCollectorPrNumber(item["number"]));
56
- }
57
- catch (error) {
58
- throw new Error(`GitHub ${label} payload contains an invalid pull request number`, {
59
- cause: error,
60
- });
61
- }
62
- }
63
- return numbers;
64
- }
65
- /**
66
- * Online PR association by head owner:ref (state=all). Caller supplies the real
67
- * head owner from branch context — never assume base repository owner is the fork head.
68
- * Transport/HTTP/JSON failures throw with true cause (not target-ambiguity wash).
69
- */
70
- export async function listPullRequestNumbersByHead(runner, input) {
71
- const head = `${input.headOwner}:${input.headRef}`;
72
- const path = `/repos/${input.owner}/${input.repo}/pulls?head=${encodeURIComponent(head)}&state=all&per_page=100`;
73
- const response = await runner(["api", "--hostname", "github.com", "--include", "-X", "GET", path], input.signal === undefined ? {} : { signal: input.signal });
74
- if (response.status < 200 || response.status >= 300) {
75
- throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, {
76
- cause: {
77
- endpoint: path,
78
- status: response.status,
79
- headers: response.headers,
80
- body: response.bodyText,
81
- },
82
- });
83
- }
84
- return parsePullRequestNumberList(parseJson(response.bodyText, path), path);
85
- }
86
- /**
87
- * Online PR association for a commit SHA (fork-safe; works when the commit is on the PR).
88
- * Transport/HTTP/JSON failures throw with true cause.
89
- */
90
- export async function listPullRequestNumbersByCommit(runner, input) {
91
- const path = `/repos/${input.owner}/${input.repo}/commits/${encodeURIComponent(input.commitSha)}/pulls`;
92
- const response = await runner(["api", "--hostname", "github.com", "--include", "-X", "GET", path], input.signal === undefined ? {} : { signal: input.signal });
93
- if (response.status < 200 || response.status >= 300) {
94
- throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, {
95
- cause: {
96
- endpoint: path,
97
- status: response.status,
98
- headers: response.headers,
99
- body: response.bodyText,
100
- },
101
- });
102
- }
103
- return parsePullRequestNumberList(parseJson(response.bodyText, path), path);
104
- }
105
- /**
106
- * Online association for a structured ticket number (#676 D1):
107
- * - Number is itself a pull request → that PR.
108
- * - Number is an issue → PRs linked via timeline cross-reference / closed-by.
109
- * Transport/HTTP/JSON failures throw with true cause.
110
- * 404 / empty association → [].
111
- */
112
- export async function listPullRequestNumbersByTicket(runner, input) {
113
- const issuePath = `/repos/${input.owner}/${input.repo}/issues/${input.ticketNumber}`;
114
- const issueResponse = await runner(["api", "--hostname", "github.com", "--include", "-X", "GET", issuePath], input.signal === undefined ? {} : { signal: input.signal });
115
- if (issueResponse.status === 404)
116
- return [];
117
- if (issueResponse.status < 200 || issueResponse.status >= 300) {
118
- throw new Error(`GitHub ${issuePath} failed with HTTP ${issueResponse.status}`, {
119
- cause: {
120
- endpoint: issuePath,
121
- status: issueResponse.status,
122
- headers: issueResponse.headers,
123
- body: issueResponse.bodyText,
124
- },
125
- });
126
- }
127
- const issueRaw = parseJson(issueResponse.bodyText, issuePath);
128
- if (!isRecord(issueRaw)) {
129
- throw new Error(`GitHub ${issuePath} payload is not an object`);
130
- }
131
- // Issues endpoint returns PRs too — own-key pull_request means the number is the PR.
132
- if (Object.hasOwn(issueRaw, "pull_request")) {
133
- return [parseCollectorPrNumber(issueRaw["number"] ?? input.ticketNumber)];
134
- }
135
- // Linked PRs: GraphQL closed-by + cross-referenced PR sources (existing gh runner seam).
136
- const query = `query($owner: String!, $repo: String!, $number: Int!) {
137
- repository(owner: $owner, name: $repo) {
138
- issue(number: $number) {
139
- closedByPullRequestsReferences(first: 50) { nodes { number } }
140
- timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
141
- nodes {
142
- __typename
143
- ... on CrossReferencedEvent {
144
- source { ... on PullRequest { number } }
145
- }
146
- ... on ConnectedEvent {
147
- subject { ... on PullRequest { number } }
148
- }
149
- }
150
- }
151
- }
152
- }
153
- }`;
154
- const args = [
155
- "api",
156
- "graphql",
157
- "--hostname",
158
- "github.com",
159
- "--include",
160
- "-f",
161
- `query=${query}`,
162
- "-f",
163
- `owner=${input.owner}`,
164
- "-f",
165
- `repo=${input.repo}`,
166
- "-F",
167
- `number=${input.ticketNumber}`,
168
- ];
169
- const gqlResponse = await runner(args, input.signal === undefined ? {} : { signal: input.signal });
170
- if (gqlResponse.status < 200 || gqlResponse.status >= 300) {
171
- throw new Error(`GitHub GraphQL issue→PR failed with HTTP ${gqlResponse.status}`, {
172
- cause: {
173
- endpoint: "graphql",
174
- status: gqlResponse.status,
175
- headers: gqlResponse.headers,
176
- body: gqlResponse.bodyText,
177
- },
178
- });
179
- }
180
- let payload;
181
- try {
182
- payload = JSON.parse(gqlResponse.bodyText);
183
- }
184
- catch (error) {
185
- throw new Error("GitHub GraphQL issue→PR returned malformed JSON", { cause: error });
186
- }
187
- if (!isRecord(payload)) {
188
- throw new Error("GitHub GraphQL issue→PR payload is not an object");
189
- }
190
- if (payload.errors !== undefined) {
191
- throw new Error(`GitHub GraphQL issue→PR errors: ${JSON.stringify(payload.errors).slice(0, 600)}`, {
192
- cause: { body: gqlResponse.bodyText, errors: payload.errors },
193
- });
194
- }
195
- const data = payload.data;
196
- if (!isRecord(data))
197
- return [];
198
- const repository = data["repository"];
199
- if (!isRecord(repository))
200
- return [];
201
- const issue = repository["issue"];
202
- if (!isRecord(issue))
203
- return [];
204
- const numbers = [];
205
- const closedBy = issue["closedByPullRequestsReferences"];
206
- if (isRecord(closedBy) && Array.isArray(closedBy["nodes"])) {
207
- for (const node of closedBy["nodes"]) {
208
- if (isRecord(node) && typeof node["number"] === "number") {
209
- numbers.push(parseCollectorPrNumber(node["number"]));
210
- }
211
- }
212
- }
213
- const timeline = issue["timelineItems"];
214
- if (isRecord(timeline) && Array.isArray(timeline["nodes"])) {
215
- for (const node of timeline["nodes"]) {
216
- if (!isRecord(node))
217
- continue;
218
- const source = node["source"];
219
- if (isRecord(source) && typeof source["number"] === "number") {
220
- numbers.push(parseCollectorPrNumber(source["number"]));
221
- }
222
- const subject = node["subject"];
223
- if (isRecord(subject) && typeof subject["number"] === "number") {
224
- numbers.push(parseCollectorPrNumber(subject["number"]));
225
- }
226
- }
227
- }
228
- return [...new Set(numbers)];
229
- }
230
- let commentFailureEvidence = 0;
231
- function commentFailureCause(error) {
232
- return {
233
- name: error instanceof Error ? error.name : typeof error,
234
- message: error instanceof Error ? error.message : String(error),
235
- evidenceId: `github-comment-failure-${++commentFailureEvidence}`,
236
- };
237
- }
238
- /** Authenticated /user requires a login; throws when absent. */
239
- function requireUserLogin(raw) {
240
- if (!isRecord(raw) || typeof raw["login"] !== "string") {
241
- throw new Error("GitHub payload missing user.login");
242
- }
243
- return raw["login"];
244
- }
245
- /**
246
- * Surface authors may be tombstoned only as literal JSON null (`user: null`).
247
- * Preserve that case with null login; every other shape fails closed.
248
- */
249
- function optionalUserLogin(raw) {
250
- if (raw === null)
251
- return null;
252
- if (!isRecord(raw) || typeof raw["login"] !== "string") {
253
- throw new Error("GitHub payload missing user.login");
254
- }
255
- return raw["login"];
256
- }
257
- function machineIdentity(raw) {
258
- const user = raw["user"];
259
- if (!isRecord(user) || typeof user["type"] !== "string" || typeof user["id"] !== "number") {
260
- return null;
261
- }
262
- const app = raw["performed_via_github_app"];
263
- const appId = isRecord(app) && typeof app["id"] === "number" ? app["id"] : undefined;
264
- return {
265
- userType: user["type"],
266
- userId: user["id"],
267
- ...(appId === undefined ? {} : { appId }),
268
- };
269
- }
270
- export function normalizePullRequest(raw) {
271
- if (!isRecord(raw))
272
- throw new Error("GitHub pull request payload must be an object");
273
- const head = raw["head"];
274
- if (!isRecord(head) || typeof head["sha"] !== "string" || head["sha"].length === 0) {
275
- throw new Error("GitHub pull request payload missing head.sha");
276
- }
277
- const number = requireNumber(raw["number"], "number");
278
- // GitHub REST: merged PRs keep state="closed" and set merged/merged_at. Project the
279
- // real merge fact so receipt/settlement can distinguish merged from merely closed (#676 D6).
280
- const mergedFlag = raw["merged"] === true
281
- || (typeof raw["merged_at"] === "string" && raw["merged_at"].length > 0);
282
- const rawState = requireString(raw["state"], "state").toUpperCase();
283
- // After toUpperCase the only OPEN spelling is "OPEN"; keep MERGED vs CLOSED distinction.
284
- const state = mergedFlag ? "MERGED" : rawState;
285
- const htmlUrl = typeof raw["html_url"] === "string"
286
- ? raw["html_url"]
287
- : `https://github.com/unknown/unknown/pull/${number}`;
288
- return {
289
- number,
290
- state,
291
- headOid: head["sha"],
292
- ...(typeof raw["created_at"] === "string" ? { createdAt: raw["created_at"] } : {}),
293
- ...(typeof raw["updated_at"] === "string" ? { updatedAt: raw["updated_at"] } : {}),
294
- url: htmlUrl,
295
- raw,
296
- };
297
- }
298
- export function normalizePullRequestReaction(raw) {
299
- if (!isRecord(raw))
300
- throw new Error("GitHub reaction payload must be an object");
301
- return {
302
- id: requireNumber(raw["id"], "reaction.id"),
303
- userLogin: optionalUserLogin(raw["user"]),
304
- machineIdentity: machineIdentity(raw),
305
- content: requireString(raw["content"], "reaction.content"),
306
- createdAt: requireString(raw["created_at"], "reaction.created_at"),
307
- raw,
308
- };
309
- }
310
- export function normalizeReview(raw) {
311
- if (!isRecord(raw))
312
- throw new Error("GitHub review payload must be an object");
313
- return {
314
- id: requireNumber(raw["id"], "review.id"),
315
- ...(typeof raw["node_id"] === "string" ? { nodeId: raw["node_id"] } : {}),
316
- userLogin: optionalUserLogin(raw["user"]),
317
- machineIdentity: machineIdentity(raw),
318
- state: requireString(raw["state"], "review.state").toUpperCase(),
319
- body: typeof raw["body"] === "string" ? raw["body"] : "",
320
- commitId: optionalString(raw["commit_id"]),
321
- submittedAt: optionalString(raw["submitted_at"]),
322
- htmlUrl: typeof raw["html_url"] === "string" ? raw["html_url"] : "",
323
- raw,
324
- };
325
- }
326
- export function normalizeIssueComment(raw) {
327
- if (!isRecord(raw))
328
- throw new Error("GitHub issue comment payload must be an object");
329
- return {
330
- id: requireNumber(raw["id"], "comment.id"),
331
- userLogin: optionalUserLogin(raw["user"]),
332
- machineIdentity: machineIdentity(raw),
333
- body: typeof raw["body"] === "string" ? raw["body"] : "",
334
- createdAt: requireString(raw["created_at"], "comment.created_at"),
335
- updatedAt: requireString(raw["updated_at"], "comment.updated_at"),
336
- htmlUrl: typeof raw["html_url"] === "string" ? raw["html_url"] : "",
337
- raw,
338
- };
339
- }
340
- export function normalizeReviewComment(raw) {
341
- if (!isRecord(raw))
342
- throw new Error("GitHub review comment payload must be an object");
343
- return {
344
- id: requireNumber(raw["id"], "review_comment.id"),
345
- pullRequestReviewId: typeof raw["pull_request_review_id"] === "number"
346
- ? raw["pull_request_review_id"]
347
- : null,
348
- userLogin: optionalUserLogin(raw["user"]),
349
- machineIdentity: machineIdentity(raw),
350
- body: typeof raw["body"] === "string" ? raw["body"] : "",
351
- path: requireString(raw["path"], "review_comment.path"),
352
- line: typeof raw["line"] === "number" ? raw["line"] : null,
353
- originalLine: typeof raw["original_line"] === "number" ? raw["original_line"] : null,
354
- side: optionalString(raw["side"]),
355
- position: typeof raw["position"] === "number" ? raw["position"] : null,
356
- originalPosition: typeof raw["original_position"] === "number"
357
- ? raw["original_position"]
358
- : null,
359
- commitId: optionalString(raw["commit_id"]),
360
- originalCommitId: optionalString(raw["original_commit_id"]),
361
- createdAt: requireString(raw["created_at"], "review_comment.created_at"),
362
- updatedAt: requireString(raw["updated_at"], "review_comment.updated_at"),
363
- htmlUrl: typeof raw["html_url"] === "string" ? raw["html_url"] : "",
364
- raw,
365
- };
366
- }
367
- export function createGhApiRunner(options = {}) {
368
- const spawnImpl = options.spawnImpl ?? spawn;
369
- return async (args, runOptions = {}) => {
370
- return await new Promise((resolve, reject) => {
371
- const signal = runOptions.signal;
372
- if (signal?.aborted) {
373
- reject(signal.reason ?? new Error("aborted"));
374
- return;
375
- }
376
- const child = spawnImpl("gh", args, {
377
- env: options.env ?? process.env,
378
- stdio: ["pipe", "pipe", "pipe"],
379
- });
380
- let stdout = "";
381
- let stderr = "";
382
- let settled = false;
383
- const settle = (fn) => {
384
- if (settled)
385
- return;
386
- settled = true;
387
- if (signal !== undefined) {
388
- signal.removeEventListener("abort", onAbort);
389
- }
390
- fn();
391
- };
392
- const onAbort = () => {
393
- try {
394
- child.kill("SIGTERM");
395
- }
396
- catch (error) {
397
- settle(() => reject(error));
398
- return;
399
- }
400
- settle(() => {
401
- reject(signal?.reason ?? new Error("aborted"));
402
- });
403
- };
404
- if (signal !== undefined) {
405
- signal.addEventListener("abort", onAbort, { once: true });
406
- }
407
- child.stdout.setEncoding("utf8").on("data", (chunk) => {
408
- stdout += chunk;
409
- });
410
- child.stderr.setEncoding("utf8").on("data", (chunk) => {
411
- stderr += chunk;
412
- });
413
- child.on("error", (error) => {
414
- settle(() => reject(error));
415
- });
416
- // Attach before write/end so early child exit cannot raise uncaught EPIPE.
417
- child.stdin.on("error", (error) => {
418
- settle(() => {
419
- if (signal?.aborted) {
420
- reject(signal.reason ?? new Error("aborted"));
421
- return;
422
- }
423
- const err = error instanceof Error ? error : new Error(String(error));
424
- reject(Object.assign(err, { ambiguousGhFailure: true }));
425
- });
426
- });
427
- if (runOptions.stdin !== undefined) {
428
- child.stdin.write(runOptions.stdin);
429
- }
430
- child.stdin.end();
431
- child.on("close", (code, signal) => {
432
- settle(() => {
433
- // gh api --include prints: HTTP/ headers blank-line body
434
- const match = stdout.match(/^HTTP\/[\d.]+\s+(\d+)[^\n]*\r?\n([\s\S]*?)\r?\n\r?\n([\s\S]*)$/);
435
- if (match) {
436
- const status = Number(match[1]);
437
- const headerText = match[2] ?? "";
438
- const bodyText = match[3] ?? "";
439
- const headers = {};
440
- for (const line of headerText.split(/\r?\n/)) {
441
- const idx = line.indexOf(":");
442
- if (idx === -1)
443
- continue;
444
- const name = line.slice(0, idx).trim().toLowerCase();
445
- const value = line.slice(idx + 1).trim();
446
- headers[name] = value;
447
- }
448
- resolve({ status, headers, bodyText });
449
- return;
450
- }
451
- if (code === 0) {
452
- resolve({ status: 200, headers: {}, bodyText: stdout });
453
- return;
454
- }
455
- // Ambiguous: process failed without parseable HTTP response
456
- const failure = new Error(`gh api failed without a parseable HTTP response (code=${String(code)}): ${stderr || stdout}`, { cause: { code, signal, stderr, stdout } });
457
- reject(Object.assign(failure, { ambiguousGhFailure: true, stderr, stdout, code, signal }));
458
- });
459
- });
460
- });
461
- };
462
- }
463
- function isAmbiguousGhFailure(error) {
464
- return (typeof error === "object" &&
465
- error !== null &&
466
- error.ambiguousGhFailure === true);
467
- }
468
- /** gh binary missing or otherwise unable to launch — ticket-authorized soft unavailable. */
469
- function isGhProcessStartFailure(error) {
470
- if (typeof error !== "object" || error === null)
471
- return false;
472
- const code = error.code;
473
- if (code === "ENOENT")
474
- return true;
475
- const syscall = error.syscall;
476
- return typeof syscall === "string" && (syscall === "spawn" || syscall.startsWith("spawn "));
477
- }
478
- /**
479
- * Sole shared issue-body fetch + projection over createGhApiRunner.
480
- * Soft and hard callers only map this result; they do not duplicate argv/parse.
481
- */
482
- export async function projectGhIssueBody(runner, input) {
483
- const path = `repos/${input.owner}/${input.repo}/issues/${input.ticketNumber}`;
484
- let response;
485
- try {
486
- response = await runner(["api", "--hostname", "github.com", "--include", "-X", "GET", path], input.signal === undefined ? {} : { signal: input.signal });
487
- }
488
- catch (error) {
489
- // Transport ambiguity / gh never started → unavailable. Other failures keep true cause as throw.
490
- if (isAmbiguousGhFailure(error) || isGhProcessStartFailure(error)) {
491
- return { status: "unavailable", reason: "transport", cause: error };
492
- }
493
- throw error;
494
- }
495
- if (response.status < 200 || response.status >= 300) {
496
- return {
497
- status: "unavailable",
498
- reason: "http-non-2xx",
499
- httpStatus: response.status,
500
- };
501
- }
502
- let parsed;
503
- try {
504
- parsed = JSON.parse(response.bodyText);
505
- }
506
- catch (error) {
507
- return { status: "invalid", reason: "not-json", cause: error };
508
- }
509
- if (typeof parsed !== "object" || parsed === null) {
510
- return { status: "invalid", reason: "not-object" };
511
- }
512
- // Issues endpoint also returns PRs. Own-key presence of pull_request → not an issue face.
513
- if (Object.hasOwn(parsed, "pull_request")) {
514
- return { status: "unavailable", reason: "pull-request" };
515
- }
516
- // Match former gh --jq `(.body // "")`: null/missing body projects to empty string.
517
- const bodyRaw = parsed.body;
518
- if (bodyRaw !== undefined && bodyRaw !== null && typeof bodyRaw !== "string") {
519
- return { status: "invalid", reason: "body-invalid" };
520
- }
521
- const body = typeof bodyRaw === "string" ? bodyRaw : "";
522
- return { status: "available", body };
523
- }
524
- /**
525
- * Production issue-fetch capability owned by the shared gh execution seam.
526
- * Reuses projectGhIssueBody. Softens only ticket-authorized unavailable results
527
- * (tracker unreachable / issue not found / gh cannot start / PR marker);
528
- * invalid payload shapes propagate with true cause.
529
- */
530
- export function createGhIssueSoftFetcher(runner = createGhApiRunner()) {
531
- return async (input) => {
532
- const projected = await projectGhIssueBody(runner, input);
533
- if (projected.status === "available") {
534
- return Object.freeze({ body: projected.body });
535
- }
536
- if (projected.status === "unavailable") {
537
- return undefined;
538
- }
539
- // invalid — keep true cause (historical soft-fetcher contract).
540
- if (projected.reason === "not-json") {
541
- throw new Error("GitHub issue payload is not JSON", {
542
- cause: projected.cause,
543
- });
544
- }
545
- if (projected.reason === "not-object") {
546
- throw new Error("GitHub issue payload must be a JSON object");
547
- }
548
- throw new Error("GitHub issue payload body must be string or null");
549
- };
550
- }
551
- export function createGhCollectorGitHubTransport(runner = createGhApiRunner()) {
552
- const hostnameArgs = ["api", "--hostname", "github.com", "--include"];
553
- async function apiGet(path, signal) {
554
- return await runner([...hostnameArgs, "-X", "GET", path], signal === undefined ? {} : { signal });
555
- }
556
- async function paginate(path, mapItem, options = {}) {
557
- const { signal, retainPage } = options;
558
- const items = [];
559
- const pages = [];
560
- let nextPath = path;
561
- let page = 1;
562
- const seen = new Set();
563
- while (nextPath !== undefined) {
564
- if (signal?.aborted) {
565
- throw signal.reason ?? new Error("aborted");
566
- }
567
- if (seen.has(nextPath)) {
568
- throw new Error(`GitHub pagination repeated page: ${nextPath}`);
569
- }
570
- seen.add(nextPath);
571
- const response = await apiGet(nextPath, signal);
572
- const diagnostics = {
573
- path: nextPath,
574
- page,
575
- status: response.status,
576
- itemCount: 0,
577
- ...(response.headers["link"] === undefined
578
- ? {}
579
- : { linkHeader: response.headers["link"] }),
580
- };
581
- if (response.status === 429) {
582
- throw Object.assign(new Error(`GitHub API rate limited on ${nextPath} (HTTP 429)`), { githubStatus: 429, page: diagnostics });
583
- }
584
- if (response.status < 200 || response.status >= 300) {
585
- throw Object.assign(new Error(`GitHub API ${nextPath} failed with HTTP ${response.status}`), { githubStatus: response.status, page: diagnostics });
586
- }
587
- const parsed = parseJson(response.bodyText, nextPath);
588
- if (!Array.isArray(parsed)) {
589
- throw new Error(`GitHub API ${nextPath} did not return a JSON array`);
590
- }
591
- diagnostics.itemCount = parsed.length;
592
- pages.push(diagnostics);
593
- const pageItems = parsed.map((entry) => mapItem(entry));
594
- // Observation budget hook: charge before aggregate append / next-page fetch.
595
- retainPage?.(pageItems);
596
- for (const item of pageItems)
597
- items.push(item);
598
- const nextUrl = parseLinkNext(response.headers["link"]);
599
- if (nextUrl === undefined) {
600
- nextPath = undefined;
601
- }
602
- else {
603
- // Accept absolute or path-style next links; normalize to API path + query.
604
- if (nextUrl.startsWith("/")) {
605
- nextPath = nextUrl;
606
- }
607
- else {
608
- const url = new URL(nextUrl);
609
- if (url.hostname !== "api.github.com" && url.hostname !== "github.com") {
610
- throw new Error(`unexpected pagination host ${url.hostname}`);
611
- }
612
- nextPath = `${url.pathname}${url.search}`;
613
- if (nextPath.startsWith("/api/v3/")) {
614
- nextPath = nextPath.slice("/api/v3".length);
615
- }
616
- }
617
- }
618
- page += 1;
619
- }
620
- return { items, pages };
621
- }
622
- return {
623
- async getAuthenticatedUser(options = {}) {
624
- const response = await apiGet("/user", options.signal);
625
- if (response.status < 200 || response.status >= 300) {
626
- throw new Error(`GitHub /user failed with HTTP ${response.status}`, { cause: { endpoint: "/user", status: response.status, headers: response.headers, body: response.bodyText } });
627
- }
628
- const raw = parseJson(response.bodyText, "/user");
629
- return { login: requireUserLogin(raw).toLowerCase(), raw };
630
- },
631
- async getPullRequest(input) {
632
- const path = `/repos/${input.owner}/${input.repo}/pulls/${input.prNumber}`;
633
- const response = await apiGet(path, input.signal);
634
- if (response.status < 200 || response.status >= 300) {
635
- throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, { cause: { endpoint: path, status: response.status, headers: response.headers, body: response.bodyText } });
636
- }
637
- return normalizePullRequest(parseJson(response.bodyText, path));
638
- },
639
- async listPullRequestReviews(input) {
640
- const path = `/repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/reviews?per_page=100`;
641
- return await paginate(path, normalizeReview, {
642
- ...(input.signal === undefined ? {} : { signal: input.signal }),
643
- ...(input.retainPage === undefined ? {} : { retainPage: input.retainPage }),
644
- });
645
- },
646
- async listPullRequestReactions(input) {
647
- const path = `/repos/${input.owner}/${input.repo}/issues/${input.prNumber}/reactions?per_page=100`;
648
- return await paginate(path, normalizePullRequestReaction, {
649
- ...(input.signal === undefined ? {} : { signal: input.signal }),
650
- ...(input.retainPage === undefined ? {} : { retainPage: input.retainPage }),
651
- });
652
- },
653
- async listIssueComments(input) {
654
- const path = `/repos/${input.owner}/${input.repo}/issues/${input.prNumber}/comments?per_page=100`;
655
- return await paginate(path, normalizeIssueComment, {
656
- ...(input.signal === undefined ? {} : { signal: input.signal }),
657
- ...(input.retainPage === undefined ? {} : { retainPage: input.retainPage }),
658
- });
659
- },
660
- async listReviewComments(input) {
661
- const path = `/repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments?per_page=100`;
662
- return await paginate(path, normalizeReviewComment, {
663
- ...(input.signal === undefined ? {} : { signal: input.signal }),
664
- ...(input.retainPage === undefined ? {} : { retainPage: input.retainPage }),
665
- });
666
- },
667
- async createIssueComment(input) {
668
- const path = `/repos/${input.owner}/${input.repo}/issues/${input.prNumber}/comments`;
669
- try {
670
- const response = await runner([...hostnameArgs, "-X", "POST", path, "--input", "-"], input.signal === undefined
671
- ? { stdin: JSON.stringify({ body: input.body }) }
672
- : { stdin: JSON.stringify({ body: input.body }), signal: input.signal });
673
- if (response.status >= 200 && response.status < 300) {
674
- // Contract: README.md#Collector — a successful write with unreadable response is recoverable only as an observable ambiguous result.
675
- // 2xx means the comment may already exist; parse/normalize failure is
676
- // ambiguous_loss so ledger marker recovery can resolve without repost.
677
- try {
678
- return {
679
- kind: "success",
680
- comment: normalizeIssueComment(parseJson(response.bodyText, path)),
681
- };
682
- }
683
- catch (error) {
684
- const cause = commentFailureCause(error);
685
- return { kind: "ambiguous_loss", diagnostics: cause.message, cause };
686
- }
687
- }
688
- return {
689
- kind: "rejected",
690
- status: response.status,
691
- diagnostics: `HTTP ${response.status}: ${response.bodyText.slice(0, 500)}`,
692
- };
693
- }
694
- catch (error) {
695
- // Signal state is authoritative cancel; rethrow exact caught value.
696
- if (input.signal?.aborted) {
697
- throw error;
698
- }
699
- // Non-aborted transport tag only (after signal).
700
- if (isRecord(error) && error["ambiguousGhFailure"] === true) {
701
- const cause = commentFailureCause(error);
702
- return { kind: "ambiguous_loss", diagnostics: cause.message, cause };
703
- }
704
- // Non-signal AbortError belt only (after tag).
705
- if (isRecord(error) && error["name"] === "AbortError") {
706
- throw error;
707
- }
708
- throw error;
709
- }
710
- },
711
- };
712
- }
713
- export function buildCollectorRequestMarker(input) {
714
- const prefix = input.manifestDigest.slice(0, 12);
715
- const requestMarkerId = createHash("sha256").update(input.requestId).digest("hex");
716
- return `<!-- ak-collector:v1 manifest=${prefix} request=${requestMarkerId} head=${input.headOid} -->`;
717
- }
718
- export function buildCollectorRequestBody(input) {
719
- const marker = buildCollectorRequestMarker(input);
720
- // Preserve configured body byte-for-byte; append marker on its own line.
721
- const body = input.configuredBody.endsWith("\n")
722
- ? `${input.configuredBody}${marker}\n`
723
- : `${input.configuredBody}\n${marker}\n`;
724
- return { body, marker };
725
- }