@kungfu-tech/buildchain 2.4.4 → 2.4.5-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,6 +106,23 @@ writeDiagnosticsArtifact(".buildchain/artifacts/diagnostics.json", {
106
106
  platform manifest. It includes lifecycle-wide observability, runner/tool/cache
107
107
  snapshots, Git state, and links to the larger manifest and artifact outputs.
108
108
 
109
+ Consumers can report Buildchain-owned workflow failures directly to the
110
+ Buildchain repository with a scoped issue-write token:
111
+
112
+ ```yaml
113
+ - uses: kungfu-systems/buildchain/actions/report-buildchain-issue@v2
114
+ if: failure()
115
+ with:
116
+ token: ${{ steps.buildchain-issue-token.outputs.token }}
117
+ summary: "Reusable build failed before artifact finalization"
118
+ failure-code: reusable-build-failed
119
+ buildchain-ref: v2
120
+ diagnostics-path: .buildchain/artifacts/diagnostics.json
121
+ ```
122
+
123
+ The action deduplicates by fingerprint, comments on existing open reports, and
124
+ is fail-soft by default so issue reporting does not hide the original failure.
125
+
109
126
  ## Use Buildchain
110
127
 
111
128
  Bootstrap a repository:
@@ -125,6 +142,7 @@ Buildchain's active GitHub Action surface is deliberately small:
125
142
  - `actions/validate-config`
126
143
  - `actions/run-lifecycle`
127
144
  - `actions/promote-buildchain-ref`
145
+ - `actions/report-buildchain-issue`
128
146
 
129
147
  The active reusable workflow surfaces are:
130
148
 
@@ -234,5 +252,6 @@ npm pack --dry-run --json --registry=https://registry.npmjs.org/
234
252
  - [Site bundle contract](docs/site-bundle-contract.md)
235
253
  - [Lifecycle protocol](docs/lifecycle-protocol.md)
236
254
  - [Reusable build surface](docs/reusable-build-surface.md)
255
+ - [Consumer issue reporting](docs/consumer-issue-reporting.md)
237
256
  - [Publish transaction](docs/publish-transaction.md)
238
257
  - [Release governance](docs/release-governance.md)
@@ -0,0 +1,46 @@
1
+ # report-buildchain-issue
2
+
3
+ Create or update a Buildchain repository issue from a consumer workflow.
4
+
5
+ This action is intended for consumer repositories that need to report
6
+ Buildchain-owned failures with enough evidence for maintainers to act. It
7
+ requires a token that can write issues on the target Buildchain repository.
8
+ For cross-repository consumers, generate that token with a GitHub App
9
+ installation token or another scoped credential owned by the consumer
10
+ organization.
11
+
12
+ ```yaml
13
+ - uses: actions/create-github-app-token@v2
14
+ id: buildchain-issue-token
15
+ with:
16
+ app-id: ${{ secrets.BUILDCHAIN_ISSUE_APP_ID }}
17
+ private-key: ${{ secrets.BUILDCHAIN_ISSUE_APP_PRIVATE_KEY }}
18
+ owner: kungfu-systems
19
+ repositories: buildchain
20
+
21
+ - uses: kungfu-systems/buildchain/actions/report-buildchain-issue@v2
22
+ if: failure()
23
+ with:
24
+ token: ${{ steps.buildchain-issue-token.outputs.token }}
25
+ summary: "Reusable build failed before artifact finalization"
26
+ failure-code: reusable-build-failed
27
+ buildchain-ref: ${{ inputs.buildchain-ref || 'v2' }}
28
+ diagnostics-path: .buildchain/artifacts/diagnostics.json
29
+ ```
30
+
31
+ The action computes a stable fingerprint from the consumer repository,
32
+ workflow, job, failure code, and Buildchain ref. When an open issue already
33
+ exists for that fingerprint, it comments with the new run instead of opening a
34
+ duplicate issue.
35
+
36
+ By default issue reporting is fail-soft:
37
+
38
+ - `fail-on-error: "false"` prevents a reporting outage from hiding the original
39
+ build failure.
40
+ - transient GitHub API 429/5xx errors and connection failures are retried.
41
+ - if a configured label is missing, issue creation is retried without labels.
42
+ - common token, private-key, password, and authorization values are redacted
43
+ before submission.
44
+
45
+ Use `dry-run: "true"` to verify the computed fingerprint and body shape without
46
+ calling GitHub.
@@ -11,6 +11,7 @@
11
11
  ".": "./packages/core/index.js",
12
12
  "./core": "./packages/core/index.js",
13
13
  "./diagnostics": "./packages/core/diagnostics.js",
14
+ "./issue-reporting": "./packages/core/issue-reporting.js",
14
15
  "./logging": "./packages/core/logging.js",
15
16
  "./release-passport": "./packages/core/release-passport.js",
16
17
  "./site/buildchain-site.json": "./dist/site/buildchain-site.json",
package/docs/MAP.md CHANGED
@@ -38,7 +38,8 @@ running artifact), *use* (consume / extend) - and a **status**:
38
38
  | How do I validate an unreleased Buildchain runtime train while keeping `@v2`? | [`runtime-train-validation.md`](runtime-train-validation.md) | use | stable |
39
39
  | How do I deploy a site/app preview, staging, or production surface? | [`web-surface-deployments.md`](web-surface-deployments.md) | use | stable |
40
40
  | How do I publish observed infrastructure contracts for downstream consumers? | [`infra-contract.md`](infra-contract.md) | use | preview |
41
- | How do I use the active actions directly? | [`../actions/validate-config/README.md`](../actions/validate-config/README.md), [`../actions/run-lifecycle/README.md`](../actions/run-lifecycle/README.md), [`../actions/promote-buildchain-ref/README.md`](../actions/promote-buildchain-ref/README.md) | use | stable |
41
+ | How do I use the active actions directly? | [`../actions/validate-config/README.md`](../actions/validate-config/README.md), [`../actions/run-lifecycle/README.md`](../actions/run-lifecycle/README.md), [`../actions/promote-buildchain-ref/README.md`](../actions/promote-buildchain-ref/README.md), [`../actions/report-buildchain-issue/README.md`](../actions/report-buildchain-issue/README.md) | use | stable |
42
+ | How can a consumer workflow report a Buildchain-owned failure back to Buildchain? | [`consumer-issue-reporting.md`](consumer-issue-reporting.md) + [`../actions/report-buildchain-issue/README.md`](../actions/report-buildchain-issue/README.md) | use | stable |
42
43
  | What do the fixture repositories demonstrate? | [`../fixtures/libnode-shaped/README.md`](../fixtures/libnode-shaped/README.md), [`../fixtures/publish-transaction-shaped/README.md`](../fixtures/publish-transaction-shaped/README.md), [`../fixtures/web-surface-shaped/README.md`](../fixtures/web-surface-shaped/README.md) | verify | stable |
43
44
  | What license and contribution terms apply? | [`../LICENSE`](../LICENSE) + [`../LICENSE-POLICY.md`](../LICENSE-POLICY.md) | use | stable |
44
45
  | How do I report a vulnerability? | [`../SECURITY.md`](../SECURITY.md) | use | stable |
@@ -64,6 +65,8 @@ running artifact), *use* (consume / extend) - and a **status**:
64
65
  - **runtime train validation / temporary `buildchain-ref` override** ->
65
66
  [`runtime-train-validation.md`](runtime-train-validation.md) and
66
67
  [`reusable-build-surface.md`](reusable-build-surface.md).
68
+ - **consumer workflow feedback / automatic Buildchain GitHub issues** ->
69
+ [`consumer-issue-reporting.md`](consumer-issue-reporting.md).
67
70
  - **infra contract / observed infrastructure outputs / downstream contract propagation** ->
68
71
  [`infra-contract.md`](infra-contract.md).
69
72
  - **standalone binary install / platform archives / GitHub Release bundle** ->
@@ -0,0 +1,82 @@
1
+ # Consumer Issue Reporting
2
+
3
+ Buildchain ships a first-class issue reporting surface for consumer workflows.
4
+ It lets a repository open or update a Buildchain-owned GitHub issue when a
5
+ Buildchain reusable workflow, action, or toolkit API produces a failure that
6
+ needs Buildchain maintainers.
7
+
8
+ ## Trust model
9
+
10
+ The consumer workflow must provide a token with issue-write access to the
11
+ target repository. A repository's default `GITHUB_TOKEN` only writes to its own
12
+ repository, so cross-repository reports should use a GitHub App installation
13
+ token scoped to:
14
+
15
+ - the `kungfu-systems/buildchain` repository;
16
+ - Issues: read and write;
17
+ - no content write permission unless another workflow step needs it.
18
+
19
+ The recommended pattern is:
20
+
21
+ ```yaml
22
+ - uses: actions/create-github-app-token@v2
23
+ id: buildchain-issue-token
24
+ with:
25
+ app-id: ${{ secrets.BUILDCHAIN_ISSUE_APP_ID }}
26
+ private-key: ${{ secrets.BUILDCHAIN_ISSUE_APP_PRIVATE_KEY }}
27
+ owner: kungfu-systems
28
+ repositories: buildchain
29
+
30
+ - uses: kungfu-systems/buildchain/actions/report-buildchain-issue@v2
31
+ if: failure()
32
+ with:
33
+ token: ${{ steps.buildchain-issue-token.outputs.token }}
34
+ summary: "Buildchain reusable build failed before artifact finalization"
35
+ failure-code: reusable-build-failed
36
+ diagnostics-path: .buildchain/artifacts/diagnostics.json
37
+ buildchain-ref: ${{ inputs.buildchain-ref || 'v2' }}
38
+ ```
39
+
40
+ ## Behavior
41
+
42
+ `report-buildchain-issue` builds an issue body with consumer repository,
43
+ workflow, ref, SHA, Buildchain ref/version, diagnostics links, and optional
44
+ details. It computes a stable fingerprint and embeds a hidden marker:
45
+
46
+ ```text
47
+ buildchain-consumer-issue:fingerprint=<sha256-prefix>
48
+ ```
49
+
50
+ When an open issue with the same marker exists, the action comments on it with
51
+ the new run evidence. Otherwise it creates a new issue. Consumers may pass an
52
+ explicit `fingerprint` when they need a different dedupe boundary.
53
+
54
+ The action is fail-soft by default. It retries GitHub API 429/5xx responses and
55
+ connection failures, redacts common secret/token/private-key patterns, truncates
56
+ large bodies, and retries issue creation without labels if the target
57
+ repository does not have the configured labels yet.
58
+
59
+ Set `fail-on-error: "true"` only when the reporting step is part of a release
60
+ gate and missing Buildchain feedback should stop the workflow.
61
+
62
+ ## JavaScript API
63
+
64
+ Consumer scripts can import the same implementation:
65
+
66
+ ```js
67
+ import {
68
+ buildConsumerIssueReport,
69
+ reportBuildchainIssue,
70
+ } from "@kungfu-tech/buildchain/issue-reporting";
71
+
72
+ const result = await reportBuildchainIssue({
73
+ token: process.env.BUILDCHAIN_ISSUE_TOKEN,
74
+ summary: "Native artifact manifest is incomplete",
75
+ failureCode: "native-manifest-incomplete",
76
+ buildchainRef: "v2",
77
+ diagnosticsPath: ".buildchain/artifacts/diagnostics.json",
78
+ });
79
+ ```
80
+
81
+ Use `buildConsumerIssueReport` for dry-run validation, previewing redaction, or
82
+ unit tests without calling GitHub.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.4.4",
3
+ "version": "2.4.5-alpha.1",
4
4
  "private": false,
5
5
  "description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -14,6 +14,7 @@
14
14
  ".": "./packages/core/index.js",
15
15
  "./core": "./packages/core/index.js",
16
16
  "./diagnostics": "./packages/core/diagnostics.js",
17
+ "./issue-reporting": "./packages/core/issue-reporting.js",
17
18
  "./logging": "./packages/core/logging.js",
18
19
  "./release-passport": "./packages/core/release-passport.js",
19
20
  "./site/buildchain-site.json": "./dist/site/buildchain-site.json",
@@ -42,6 +43,7 @@
42
43
  "docs/cli.md",
43
44
  "docs/install.md",
44
45
  "docs/infra-contract.md",
46
+ "docs/consumer-issue-reporting.md",
45
47
  "docs/lifecycle-protocol.md",
46
48
  "docs/migration-inventory.md",
47
49
  "docs/ownership.md",
@@ -104,3 +104,19 @@ export {
104
104
  validateKnownReleasePassportContracts,
105
105
  verifyReleasePassport,
106
106
  } from "./release-passport.js";
107
+
108
+ export {
109
+ BUILDCHAIN_CONSUMER_ISSUE_CONTRACT,
110
+ DEFAULT_BUILDCHAIN_ISSUE_REPOSITORY,
111
+ GitHubIssueRequestError,
112
+ buildConsumerIssueReport,
113
+ computeConsumerIssueFingerprint,
114
+ consumerIssueMarker,
115
+ createGitHubIssueRequest,
116
+ normalizeIssueRepository,
117
+ parseIssueLabels,
118
+ readOptionalIssueBodyFile,
119
+ redactIssueText,
120
+ reportBuildchainIssue,
121
+ truncateUtf8,
122
+ } from "./issue-reporting.js";
@@ -0,0 +1,388 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+
4
+ export const BUILDCHAIN_CONSUMER_ISSUE_CONTRACT = "kungfu-buildchain-consumer-issue";
5
+ export const DEFAULT_BUILDCHAIN_ISSUE_REPOSITORY = "kungfu-systems/buildchain";
6
+
7
+ const DEFAULT_LABELS = ["buildchain-consumer-feedback"];
8
+ const DEFAULT_MAX_BODY_BYTES = 60_000;
9
+ const DEFAULT_RETRY_DELAYS_MS = [500, 1_500, 3_000];
10
+
11
+ const SECRET_PATTERNS = [
12
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
13
+ /\bgh[oprsu]_[A-Za-z0-9_]{20,}\b/g,
14
+ /\b[A-Z0-9]{20}:[A-Za-z0-9_=-]{20,}\b/g,
15
+ /\bAKIA[0-9A-Z]{16}\b/g,
16
+ /(authorization\s*:\s*)(bearer\s+)?[^\s]+/gi,
17
+ /(token|secret|password|private[_-]?key)(\s*[:=]\s*)[^\s"'`]+/gi,
18
+ /-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/g,
19
+ ];
20
+
21
+ export class GitHubIssueRequestError extends Error {
22
+ constructor(message, { status, response, body } = {}) {
23
+ super(message);
24
+ this.name = "GitHubIssueRequestError";
25
+ this.status = status;
26
+ this.response = response;
27
+ this.body = body;
28
+ }
29
+ }
30
+
31
+ export function normalizeIssueRepository(repository = DEFAULT_BUILDCHAIN_ISSUE_REPOSITORY) {
32
+ const match = String(repository || "").trim().match(/^([^/\s]+)\/([^/\s]+)$/);
33
+ if (!match) {
34
+ throw new Error(`Invalid GitHub repository: ${repository}`);
35
+ }
36
+ return { owner: match[1], repo: match[2], fullName: `${match[1]}/${match[2]}` };
37
+ }
38
+
39
+ export function parseIssueLabels(input = DEFAULT_LABELS) {
40
+ if (Array.isArray(input)) {
41
+ return input.map((label) => String(label).trim()).filter(Boolean);
42
+ }
43
+ return String(input || "")
44
+ .split(/[,\n]/)
45
+ .map((label) => label.trim())
46
+ .filter(Boolean);
47
+ }
48
+
49
+ export function redactIssueText(value) {
50
+ let text = String(value ?? "");
51
+ for (const pattern of SECRET_PATTERNS) {
52
+ text = text.replace(pattern, (...args) => {
53
+ if (args.length >= 4 && typeof args[1] === "string" && typeof args[2] === "string") {
54
+ return `${args[1]}${args[2]}[REDACTED]`;
55
+ }
56
+ return "[REDACTED]";
57
+ });
58
+ }
59
+ return text;
60
+ }
61
+
62
+ export function computeConsumerIssueFingerprint(fields = {}) {
63
+ const normalized = {};
64
+ for (const key of Object.keys(fields).sort()) {
65
+ const value = fields[key];
66
+ if (value === undefined || value === null || value === "") {
67
+ continue;
68
+ }
69
+ normalized[key] = String(value);
70
+ }
71
+ return crypto.createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 32);
72
+ }
73
+
74
+ export function consumerIssueMarker(fingerprint) {
75
+ return `buildchain-consumer-issue:fingerprint=${fingerprint}`;
76
+ }
77
+
78
+ export function truncateUtf8(text, maxBytes = DEFAULT_MAX_BODY_BYTES) {
79
+ const value = String(text ?? "");
80
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0 || Buffer.byteLength(value, "utf8") <= maxBytes) {
81
+ return value;
82
+ }
83
+ const suffix = "\n\n[buildchain truncated issue body]\n";
84
+ const budget = Math.max(0, maxBytes - Buffer.byteLength(suffix, "utf8"));
85
+ let output = "";
86
+ let used = 0;
87
+ for (const char of value) {
88
+ const bytes = Buffer.byteLength(char, "utf8");
89
+ if (used + bytes > budget) {
90
+ break;
91
+ }
92
+ output += char;
93
+ used += bytes;
94
+ }
95
+ return `${output}${suffix}`;
96
+ }
97
+
98
+ export function buildConsumerIssueReport(options = {}) {
99
+ const target = normalizeIssueRepository(options.targetRepository);
100
+ const env = options.env || process.env;
101
+ const consumerRepository = options.consumerRepository || env.GITHUB_REPOSITORY || "";
102
+ const runId = options.runId || env.GITHUB_RUN_ID || "";
103
+ const runUrl = options.runUrl || githubRunUrl(env, consumerRepository, runId);
104
+ const workflow = options.workflow || env.GITHUB_WORKFLOW || "";
105
+ const job = options.job || env.GITHUB_JOB || "";
106
+ const consumerRef = options.consumerRef || env.GITHUB_REF || "";
107
+ const consumerSha = options.consumerSha || env.GITHUB_SHA || "";
108
+ const failureCode = options.failureCode || "consumer-report";
109
+ const fingerprint =
110
+ options.fingerprint ||
111
+ computeConsumerIssueFingerprint({
112
+ targetRepository: target.fullName,
113
+ consumerRepository,
114
+ workflow,
115
+ job,
116
+ failureCode,
117
+ buildchainRef: options.buildchainRef,
118
+ buildchainVersion: options.buildchainVersion,
119
+ title: options.title,
120
+ });
121
+ const title = redactIssueText(
122
+ options.title ||
123
+ `[Buildchain consumer] ${consumerRepository || "unknown repository"}: ${failureCode}`,
124
+ );
125
+ const marker = consumerIssueMarker(fingerprint);
126
+ const body = truncateUtf8(
127
+ redactIssueText(
128
+ [
129
+ `<!-- ${marker} -->`,
130
+ `# Buildchain consumer report`,
131
+ ``,
132
+ options.summary ? `## Summary\n\n${options.summary}` : "",
133
+ `## Consumer`,
134
+ ``,
135
+ `- Repository: ${consumerRepository || "(unknown)"}`,
136
+ `- Ref: ${consumerRef || "(unknown)"}`,
137
+ `- SHA: ${consumerSha || "(unknown)"}`,
138
+ `- Workflow: ${workflow || "(unknown)"}`,
139
+ `- Job: ${job || "(unknown)"}`,
140
+ `- Run: ${runUrl || runId || "(unknown)"}`,
141
+ ``,
142
+ `## Buildchain`,
143
+ ``,
144
+ `- Ref: ${options.buildchainRef || "(unknown)"}`,
145
+ `- Version: ${options.buildchainVersion || "(unknown)"}`,
146
+ `- Failure code: ${failureCode}`,
147
+ `- Fingerprint: ${fingerprint}`,
148
+ ``,
149
+ optionalLine("## Release Passport", options.passportUrl || options.passportPath),
150
+ optionalLine("## Diagnostics", options.diagnosticsUrl || options.diagnosticsPath),
151
+ optionalLine("## Artifact", options.artifactUrl),
152
+ options.body ? `## Details\n\n${options.body}` : "",
153
+ ]
154
+ .filter(Boolean)
155
+ .join("\n"),
156
+ ),
157
+ Number(options.maxBodyBytes || DEFAULT_MAX_BODY_BYTES),
158
+ );
159
+ const commentBody = truncateUtf8(
160
+ redactIssueText(
161
+ [
162
+ `New matching Buildchain consumer report observed.`,
163
+ ``,
164
+ `- Repository: ${consumerRepository || "(unknown)"}`,
165
+ `- Ref: ${consumerRef || "(unknown)"}`,
166
+ `- SHA: ${consumerSha || "(unknown)"}`,
167
+ `- Workflow: ${workflow || "(unknown)"}`,
168
+ `- Run: ${runUrl || runId || "(unknown)"}`,
169
+ `- Failure code: ${failureCode}`,
170
+ `- Fingerprint: ${fingerprint}`,
171
+ options.summary ? `\n${options.summary}` : "",
172
+ ].join("\n"),
173
+ ),
174
+ Number(options.maxBodyBytes || DEFAULT_MAX_BODY_BYTES),
175
+ );
176
+ return {
177
+ contract: BUILDCHAIN_CONSUMER_ISSUE_CONTRACT,
178
+ targetRepository: target.fullName,
179
+ fingerprint,
180
+ marker,
181
+ title,
182
+ body,
183
+ commentBody,
184
+ labels: parseIssueLabels(options.labels ?? DEFAULT_LABELS),
185
+ };
186
+ }
187
+
188
+ export async function reportBuildchainIssue(options = {}) {
189
+ const report = buildConsumerIssueReport(options);
190
+ const target = normalizeIssueRepository(report.targetRepository);
191
+ const mode = options.mode || "create-or-comment";
192
+ if (options.dryRun) {
193
+ return {
194
+ ok: true,
195
+ action: "dry-run",
196
+ created: false,
197
+ commented: false,
198
+ issueNumber: "",
199
+ issueUrl: "",
200
+ fingerprint: report.fingerprint,
201
+ report,
202
+ };
203
+ }
204
+ if (!["create", "create-or-comment", "comment"].includes(mode)) {
205
+ throw new Error(`Unsupported consumer issue mode: ${mode}`);
206
+ }
207
+ const request = options.request || createGitHubIssueRequest(options);
208
+ const existing = mode === "create" ? undefined : await findOpenConsumerIssue({ request, target, report });
209
+ if (existing) {
210
+ if (mode !== "create-or-comment" && mode !== "comment") {
211
+ return {
212
+ ok: true,
213
+ action: "found",
214
+ created: false,
215
+ commented: false,
216
+ issueNumber: existing.number,
217
+ issueUrl: existing.html_url || existing.url || "",
218
+ fingerprint: report.fingerprint,
219
+ report,
220
+ };
221
+ }
222
+ await request({
223
+ method: "POST",
224
+ path: `/repos/${target.owner}/${target.repo}/issues/${existing.number}/comments`,
225
+ body: { body: report.commentBody },
226
+ });
227
+ return {
228
+ ok: true,
229
+ action: "commented",
230
+ created: false,
231
+ commented: true,
232
+ issueNumber: existing.number,
233
+ issueUrl: existing.html_url || existing.url || "",
234
+ fingerprint: report.fingerprint,
235
+ report,
236
+ };
237
+ }
238
+ if (mode === "comment") {
239
+ throw new Error(`No open Buildchain consumer issue found for fingerprint ${report.fingerprint}`);
240
+ }
241
+ const issue = await createIssueWithLabelFallback({ request, target, report });
242
+ return {
243
+ ok: true,
244
+ action: "created",
245
+ created: true,
246
+ commented: false,
247
+ issueNumber: issue.number,
248
+ issueUrl: issue.html_url || issue.url || "",
249
+ fingerprint: report.fingerprint,
250
+ report,
251
+ };
252
+ }
253
+
254
+ export function readOptionalIssueBodyFile(filePath) {
255
+ const resolved = String(filePath || "").trim();
256
+ if (!resolved) {
257
+ return "";
258
+ }
259
+ return fs.readFileSync(resolved, "utf8");
260
+ }
261
+
262
+ export function createGitHubIssueRequest({
263
+ token,
264
+ apiUrl = process.env.GITHUB_API_URL || "https://api.github.com",
265
+ fetchImpl = globalThis.fetch,
266
+ retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
267
+ } = {}) {
268
+ if (!token) {
269
+ throw new Error("A GitHub token is required to report a Buildchain issue");
270
+ }
271
+ if (typeof fetchImpl !== "function") {
272
+ throw new Error("A fetch implementation is required to report a Buildchain issue");
273
+ }
274
+ const baseUrl = String(apiUrl || "https://api.github.com").replace(/\/+$/, "");
275
+ return async function githubIssueRequest({ method = "GET", path, body }) {
276
+ const url = `${baseUrl}${path}`;
277
+ let attempt = 0;
278
+ while (true) {
279
+ try {
280
+ const response = await fetchImpl(url, {
281
+ method,
282
+ headers: {
283
+ accept: "application/vnd.github+json",
284
+ authorization: `Bearer ${token}`,
285
+ "content-type": "application/json",
286
+ "x-github-api-version": "2022-11-28",
287
+ },
288
+ body: body === undefined ? undefined : JSON.stringify(body),
289
+ });
290
+ const text = await response.text();
291
+ const parsed = text ? parseJsonResponse(text) : {};
292
+ if (response.ok) {
293
+ return parsed;
294
+ }
295
+ const retryAfterMs = retryAfterHeaderMs(response.headers?.get?.("retry-after"));
296
+ if (shouldRetryStatus(response.status) && attempt < retryDelaysMs.length) {
297
+ await sleep(retryAfterMs ?? retryDelaysMs[attempt]);
298
+ attempt += 1;
299
+ continue;
300
+ }
301
+ throw new GitHubIssueRequestError(
302
+ `GitHub API ${method} ${path} failed with ${response.status}: ${parsed.message || text}`,
303
+ { status: response.status, response: parsed, body },
304
+ );
305
+ } catch (error) {
306
+ if (error instanceof GitHubIssueRequestError) {
307
+ throw error;
308
+ }
309
+ if (attempt < retryDelaysMs.length) {
310
+ await sleep(retryDelaysMs[attempt]);
311
+ attempt += 1;
312
+ continue;
313
+ }
314
+ throw new GitHubIssueRequestError(`GitHub API ${method} ${path} failed: ${error.message}`, {
315
+ body,
316
+ });
317
+ }
318
+ }
319
+ };
320
+ }
321
+
322
+ async function findOpenConsumerIssue({ request, target, report }) {
323
+ const query = encodeURIComponent(
324
+ `repo:${target.fullName} is:issue is:open "${consumerIssueMarker(report.fingerprint)}"`,
325
+ );
326
+ const result = await request({ method: "GET", path: `/search/issues?q=${query}&per_page=1` });
327
+ const items = Array.isArray(result.items) ? result.items : [];
328
+ return items[0];
329
+ }
330
+
331
+ async function createIssueWithLabelFallback({ request, target, report }) {
332
+ const payload = {
333
+ title: report.title,
334
+ body: report.body,
335
+ };
336
+ if (report.labels.length > 0) {
337
+ payload.labels = report.labels;
338
+ }
339
+ try {
340
+ return await request({
341
+ method: "POST",
342
+ path: `/repos/${target.owner}/${target.repo}/issues`,
343
+ body: payload,
344
+ });
345
+ } catch (error) {
346
+ if (error?.status === 422 && payload.labels) {
347
+ return await request({
348
+ method: "POST",
349
+ path: `/repos/${target.owner}/${target.repo}/issues`,
350
+ body: { title: payload.title, body: payload.body },
351
+ });
352
+ }
353
+ throw error;
354
+ }
355
+ }
356
+
357
+ function optionalLine(title, value) {
358
+ return value ? `${title}\n\n${value}` : "";
359
+ }
360
+
361
+ function githubRunUrl(env, repository, runId) {
362
+ if (!repository || !runId) {
363
+ return "";
364
+ }
365
+ const server = env.GITHUB_SERVER_URL || "https://github.com";
366
+ return `${server}/${repository}/actions/runs/${runId}`;
367
+ }
368
+
369
+ function parseJsonResponse(text) {
370
+ try {
371
+ return JSON.parse(text);
372
+ } catch {
373
+ return { message: text };
374
+ }
375
+ }
376
+
377
+ function shouldRetryStatus(status) {
378
+ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
379
+ }
380
+
381
+ function retryAfterHeaderMs(value) {
382
+ const seconds = Number(value || "");
383
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
384
+ }
385
+
386
+ async function sleep(ms) {
387
+ await new Promise((resolve) => setTimeout(resolve, ms));
388
+ }
@@ -17,6 +17,7 @@ const requiredPaths = [
17
17
  "docs/MAP.md",
18
18
  "docs/binary-distribution.md",
19
19
  "docs/cli.md",
20
+ "docs/consumer-issue-reporting.md",
20
21
  "docs/install.md",
21
22
  "docs/product-mechanism.md",
22
23
  "docs/release-passport.md",
@@ -69,6 +70,9 @@ if (rootPackage.exports?.["."] !== "./packages/core/index.js") {
69
70
  if (rootPackage.exports?.["./diagnostics"] !== "./packages/core/diagnostics.js") {
70
71
  throw new Error("root package must export @kungfu-tech/buildchain/diagnostics");
71
72
  }
73
+ if (rootPackage.exports?.["./issue-reporting"] !== "./packages/core/issue-reporting.js") {
74
+ throw new Error("root package must export @kungfu-tech/buildchain/issue-reporting");
75
+ }
72
76
  if (rootPackage.exports?.["./logging"] !== "./packages/core/logging.js") {
73
77
  throw new Error("root package must export @kungfu-tech/buildchain/logging");
74
78
  }
@@ -108,6 +112,9 @@ if (!coreIndexSource.includes("verifyBuildchainLogEvents")) {
108
112
  if (!coreIndexSource.includes("collectBuildchainDiagnostics")) {
109
113
  throw new Error("packages/core/index.js must export collectBuildchainDiagnostics");
110
114
  }
115
+ if (!coreIndexSource.includes("reportBuildchainIssue")) {
116
+ throw new Error("packages/core/index.js must export reportBuildchainIssue");
117
+ }
111
118
  for (const requiredSnippet of [
112
119
  "Release passport and binary distribution are a minor surface.",
113
120
  "`v2.2`",