@binclusive/a11y 0.1.0
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 +285 -0
- package/bin/a11y.mjs +36 -0
- package/bin/diff-scope.mjs +29 -0
- package/data/baseline-rules.json +892 -0
- package/package.json +68 -0
- package/src/agent-lane.ts +138 -0
- package/src/agents-block.ts +157 -0
- package/src/baseline/gen-baseline.ts +166 -0
- package/src/cli.ts +1026 -0
- package/src/collect-dom.ts +119 -0
- package/src/collect-liquid.ts +103 -0
- package/src/collect-swift.ts +227 -0
- package/src/collect-unity.ts +99 -0
- package/src/collect.ts +54 -0
- package/src/commands.ts +314 -0
- package/src/config-scan.ts +177 -0
- package/src/contract.ts +355 -0
- package/src/core.ts +546 -0
- package/src/detect-stack.ts +207 -0
- package/src/diff-scope-cli.ts +12 -0
- package/src/diff-scope.ts +150 -0
- package/src/emit-contract.ts +181 -0
- package/src/enforce.ts +1125 -0
- package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
- package/src/evidence.ts +308 -0
- package/src/github-identity.ts +201 -0
- package/src/hook.ts +242 -0
- package/src/impact-gate.ts +107 -0
- package/src/imports-resolve.ts +248 -0
- package/src/index.ts +183 -0
- package/src/liquid-ast.ts +203 -0
- package/src/liquid-rules.ts +691 -0
- package/src/mcp.ts +363 -0
- package/src/module-scope.ts +137 -0
- package/src/phone-home.ts +470 -0
- package/src/pr-comment.ts +250 -0
- package/src/pr-summary-cli.ts +182 -0
- package/src/pr-summary.ts +291 -0
- package/src/registry.ts +605 -0
- package/src/reporter/contract.ts +154 -0
- package/src/reporter/finding.ts +87 -0
- package/src/reporter/github-adapter.ts +183 -0
- package/src/reporter/null-adapter.ts +51 -0
- package/src/reporter/registry.ts +22 -0
- package/src/reporter-cli.ts +48 -0
- package/src/resolve-components.ts +579 -0
- package/src/runner/budget.ts +90 -0
- package/src/runner/codegraph-lookup.test.ts +93 -0
- package/src/runner/codegraph-lookup.ts +197 -0
- package/src/runner/index.ts +86 -0
- package/src/runner/lookup.ts +69 -0
- package/src/runner/provider.ts +72 -0
- package/src/runner/providers/anthropic.ts +168 -0
- package/src/runner/providers/openai.ts +191 -0
- package/src/runner/reasoner.ts +73 -0
- package/src/runner/reasoning/index.ts +53 -0
- package/src/runner/reasoning/prompt.ts +200 -0
- package/src/runner/reasoning/react.ts +149 -0
- package/src/runner/reasoning/shopify.ts +214 -0
- package/src/runner/reasoning/skills-reasoner.ts +117 -0
- package/src/runner/reasoning/types.ts +99 -0
- package/src/runner/runner.ts +203 -0
- package/src/sarif.ts +148 -0
- package/src/source-identity.ts +0 -0
- package/src/source-trace.ts +814 -0
- package/src/suggest.ts +328 -0
- package/src/suppression-ranges.ts +354 -0
- package/src/suppressor-map.ts +189 -0
- package/src/suppressors.ts +284 -0
- package/src/tsconfig-aliases.ts +155 -0
- package/src/unity-ast.ts +331 -0
- package/src/unity-findings.ts +91 -0
- package/src/unity-guid-registry.ts +82 -0
- package/src/unity-label-resolve.ts +249 -0
- package/src/unity-rule-color-only.ts +127 -0
- package/src/unity-rule-missing-label.ts +156 -0
- package/src/unity-rules-baseline.ts +273 -0
- package/src/wcag-map.ts +35 -0
- package/src/wcag-tags.ts +35 -0
- package/src/workspace-resolve.ts +405 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* De-duplicating reconciler for inline PR review comments (issue #2131).
|
|
3
|
+
*
|
|
4
|
+
* The CI Action posts one inline review comment per a11y finding. Without a
|
|
5
|
+
* stable identity, every push re-POSTs every finding — spamming the PR thread
|
|
6
|
+
* with a fresh copy of the same comment on each run. This module gives each
|
|
7
|
+
* finding a stable hidden MARKER and reconciles the desired findings against the
|
|
8
|
+
* comments already on the PR, so a re-run converges to exactly one comment per
|
|
9
|
+
* finding instead of accumulating duplicates:
|
|
10
|
+
*
|
|
11
|
+
* - a finding already commented → UPDATE its comment in place (or leave it
|
|
12
|
+
* untouched when the body is identical) — never a second POST,
|
|
13
|
+
* - a brand-new finding → CREATE one comment,
|
|
14
|
+
* - a finding that was fixed → DELETE its now-orphaned comment.
|
|
15
|
+
*
|
|
16
|
+
* The marker is an HTML comment (`<!-- binclusive-a11y-agent:<key> -->`) so it
|
|
17
|
+
* is invisible in the rendered comment yet machine-findable. Reconciliation only
|
|
18
|
+
* ever touches comments carrying that marker, so human review comments are never
|
|
19
|
+
* matched, updated, or removed.
|
|
20
|
+
*
|
|
21
|
+
* Contract-independent and side-effect-free at its core: {@link reconcile} is a
|
|
22
|
+
* pure function of (desired findings, existing comments); the effectful GitHub
|
|
23
|
+
* calls are injected through {@link PrCommentClient} so the reconcile logic is
|
|
24
|
+
* testable against an in-memory fake.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// The canonical finding shape a reporter renders from now lives in the seam
|
|
28
|
+
// (`reporter/finding.ts`), so the platform-neutral input type is not owned by
|
|
29
|
+
// this GitHub-specific surface. Re-exported here for existing importers.
|
|
30
|
+
import { type Finding, type Impact, parseFindings } from "./reporter/finding";
|
|
31
|
+
export { type Finding, type Impact, parseFindings };
|
|
32
|
+
|
|
33
|
+
/** An existing inline review comment already on the PR (id + rendered body). */
|
|
34
|
+
export interface ReviewComment {
|
|
35
|
+
readonly id: number;
|
|
36
|
+
readonly body: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MARKER_TAG = "binclusive-a11y-agent";
|
|
40
|
+
const MARKER_RE = /<!--\s*binclusive-a11y-agent:(.*?)\s*-->/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A live rendered element locator. Mirrors `emit-contract.ts`'s `hasSelector`:
|
|
44
|
+
* empty / whitespace-only is NOT a selector (a source pass has no DOM node), so
|
|
45
|
+
* it does not disambiguate and the base `ruleId:file:line` identity stands.
|
|
46
|
+
* Kept local so this CI-comment surface doesn't drag in the contract package.
|
|
47
|
+
*/
|
|
48
|
+
function hasSelector(selector: string | undefined): selector is string {
|
|
49
|
+
return selector !== undefined && selector.trim() !== "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* FNV-1a → 8 hex chars. The selector is folded into the marker, and a raw CSS
|
|
54
|
+
* selector can carry spaces, `>>>`, `-->`, or newlines — any of which would
|
|
55
|
+
* break the single-line HTML-comment marker (a `-->` closes it early; a newline
|
|
56
|
+
* makes {@link keyOf} fail to match, so the comment reads as not-ours and gets
|
|
57
|
+
* re-posted every push). Hashing into a fixed `[0-9a-f]` token is marker-safe by
|
|
58
|
+
* construction; we never decode it back — it exists only to make distinct
|
|
59
|
+
* selectors produce distinct keys.
|
|
60
|
+
*/
|
|
61
|
+
function selectorToken(selector: string): string {
|
|
62
|
+
let h = 0x811c9dc5;
|
|
63
|
+
for (let i = 0; i < selector.length; i++) {
|
|
64
|
+
h ^= selector.charCodeAt(i);
|
|
65
|
+
h = Math.imul(h, 0x01000193);
|
|
66
|
+
}
|
|
67
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The stable identity of a finding across pushes. Base identity is rule +
|
|
72
|
+
* location (`ruleId:file:line`); when the finding carries a live DOM selector
|
|
73
|
+
* it is folded in (as a marker-safe hash) so two same-rule findings co-located
|
|
74
|
+
* at one `file:line` — distinguished ONLY by their selector — get DISTINCT keys
|
|
75
|
+
* instead of the second silently collapsing onto the first (the #2131 review's
|
|
76
|
+
* blocking defect). This mirrors the engine's own `element = selector ?? ruleId`
|
|
77
|
+
* disambiguation (`emit-contract.ts`). A source finding with no selector keeps
|
|
78
|
+
* the bare `ruleId:file:line` key it always had.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately EXCLUDES the message — when only the wording changes for the same
|
|
81
|
+
* rule/spot/element we update the existing comment in place, not orphan it and
|
|
82
|
+
* post a new one. A finding that genuinely moved to a different line (or a
|
|
83
|
+
* different element) is a different anchor and reconciles as delete-old +
|
|
84
|
+
* create-new.
|
|
85
|
+
*/
|
|
86
|
+
export function findingKey(f: Finding): string {
|
|
87
|
+
const base = `${f.ruleId}:${f.file}:${f.line}`;
|
|
88
|
+
return hasSelector(f.selector) ? `${base}:${selectorToken(f.selector)}` : base;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The hidden marker embedded in every agent-authored comment for `f`. */
|
|
92
|
+
export function markerFor(f: Finding): string {
|
|
93
|
+
return `<!-- ${MARKER_TAG}:${findingKey(f)} -->`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The finding key carried by a comment, or `null` if the comment is not one of
|
|
98
|
+
* ours (no marker) — the sole gate that keeps reconciliation off human comments.
|
|
99
|
+
*/
|
|
100
|
+
export function keyOf(body: string): string | null {
|
|
101
|
+
const m = MARKER_RE.exec(body);
|
|
102
|
+
return m && m[1] !== undefined ? m[1] : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Render the full comment body for `f`, marker appended so it round-trips. */
|
|
106
|
+
export function renderBody(f: Finding): string {
|
|
107
|
+
const wcag = (f.wcag ?? []).map((s) => `WCAG ${s}`).join(", ") || "no WCAG mapping";
|
|
108
|
+
return `**a11y: \`${f.ruleId}\`** (${wcag})\n\n${f.message}\n\n${markerFor(f)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The reconciliation plan: what to do to converge the PR to `findings`. */
|
|
112
|
+
export interface ReconcilePlan {
|
|
113
|
+
/** Findings with no existing comment — POST a new one. */
|
|
114
|
+
readonly create: readonly Finding[];
|
|
115
|
+
/** Findings whose comment exists but whose body drifted — PATCH in place. */
|
|
116
|
+
readonly update: readonly { readonly id: number; readonly finding: Finding }[];
|
|
117
|
+
/** Comment ids to DELETE: a fixed finding, or a leftover duplicate. */
|
|
118
|
+
readonly remove: readonly number[];
|
|
119
|
+
/** Comment ids left untouched (body already identical) — no API call. */
|
|
120
|
+
readonly unchanged: readonly number[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Pure reconciliation: given the findings a run wants on the PR and the comments
|
|
125
|
+
* already there, decide the minimal set of create / update / delete operations
|
|
126
|
+
* that converges the PR to exactly one comment per finding. Idempotent — running
|
|
127
|
+
* it twice with the same inputs yields an all-`unchanged` plan (no churn).
|
|
128
|
+
*
|
|
129
|
+
* Only marker-carrying comments (ours) are considered; everything else is left
|
|
130
|
+
* alone. A pre-dedup PR may hold several comments for one key (the old spamming
|
|
131
|
+
* behavior); the canonical one (first seen) is kept/updated and the rest are
|
|
132
|
+
* folded into `remove`, so the very first reconciling run also cleans up.
|
|
133
|
+
*/
|
|
134
|
+
export function reconcile(
|
|
135
|
+
findings: readonly Finding[],
|
|
136
|
+
existing: readonly ReviewComment[],
|
|
137
|
+
): ReconcilePlan {
|
|
138
|
+
// Desired findings keyed by identity; first occurrence wins on collision.
|
|
139
|
+
const desired = new Map<string, Finding>();
|
|
140
|
+
for (const f of findings) {
|
|
141
|
+
const k = findingKey(f);
|
|
142
|
+
if (!desired.has(k)) desired.set(k, f);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Our existing comments keyed by their marker. A key seen more than once is a
|
|
146
|
+
// leftover duplicate from before dedup existed — keep the first, delete the rest.
|
|
147
|
+
const ours = new Map<string, ReviewComment>();
|
|
148
|
+
const remove: number[] = [];
|
|
149
|
+
for (const c of existing) {
|
|
150
|
+
const k = keyOf(c.body);
|
|
151
|
+
if (k === null) continue; // not ours — never touch human comments
|
|
152
|
+
if (ours.has(k)) remove.push(c.id);
|
|
153
|
+
else ours.set(k, c);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const create: Finding[] = [];
|
|
157
|
+
const update: { id: number; finding: Finding }[] = [];
|
|
158
|
+
const unchanged: number[] = [];
|
|
159
|
+
|
|
160
|
+
for (const [k, f] of desired) {
|
|
161
|
+
const c = ours.get(k);
|
|
162
|
+
if (!c) create.push(f);
|
|
163
|
+
else if (c.body === renderBody(f)) unchanged.push(c.id);
|
|
164
|
+
else update.push({ id: c.id, finding: f });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Any of our comments whose finding is no longer present was fixed → remove it.
|
|
168
|
+
for (const [k, c] of ours) {
|
|
169
|
+
if (!desired.has(k)) remove.push(c.id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { create, update, remove, unchanged };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The effectful surface reconciliation drives, injected so the orchestration is
|
|
177
|
+
* testable against an in-memory fake. Every method is best-effort: an
|
|
178
|
+
* implementation logs and swallows failures rather than throwing, so a single
|
|
179
|
+
* bad call never aborts the rest of the sync (the Action stays advisory).
|
|
180
|
+
*/
|
|
181
|
+
export interface PrCommentClient {
|
|
182
|
+
/**
|
|
183
|
+
* All inline review comments currently on the PR (paginated upstream). MUST
|
|
184
|
+
* resolve to a COMPLETE view or THROW — never a partial list. If any page
|
|
185
|
+
* fetch fails, reconciling against the truncated result would read an existing
|
|
186
|
+
* comment (on an unfetched page) as absent and re-CREATE it → a duplicate. So
|
|
187
|
+
* a page failure aborts the whole sync (via {@link syncCommentsBestEffort},
|
|
188
|
+
* which swallows the throw and skips the run) rather than half-reconciling.
|
|
189
|
+
*/
|
|
190
|
+
list(): Promise<ReviewComment[]>;
|
|
191
|
+
/** POST a new inline review comment for `f`. */
|
|
192
|
+
create(f: Finding): Promise<void>;
|
|
193
|
+
/** PATCH the body of comment `id` to match `f`. */
|
|
194
|
+
update(id: number, f: Finding): Promise<void>;
|
|
195
|
+
/** DELETE comment `id` (its finding was fixed, or it is a leftover duplicate). */
|
|
196
|
+
remove(id: number): Promise<void>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Reconcile the PR's inline comments to `findings` through `client`, returning
|
|
201
|
+
* the plan that was executed (handy for logging and assertions). Updates in
|
|
202
|
+
* place and removes fixed findings instead of ever posting a duplicate.
|
|
203
|
+
*/
|
|
204
|
+
export async function syncComments(
|
|
205
|
+
findings: readonly Finding[],
|
|
206
|
+
client: PrCommentClient,
|
|
207
|
+
log: (msg: string) => void = () => {},
|
|
208
|
+
): Promise<ReconcilePlan> {
|
|
209
|
+
const existing = await client.list();
|
|
210
|
+
const plan = reconcile(findings, existing);
|
|
211
|
+
|
|
212
|
+
for (const f of plan.create) {
|
|
213
|
+
await client.create(f);
|
|
214
|
+
log(`created comment for ${findingKey(f)}`);
|
|
215
|
+
}
|
|
216
|
+
for (const { id, finding } of plan.update) {
|
|
217
|
+
await client.update(id, finding);
|
|
218
|
+
log(`updated comment ${id} for ${findingKey(finding)}`);
|
|
219
|
+
}
|
|
220
|
+
for (const id of plan.remove) {
|
|
221
|
+
await client.remove(id);
|
|
222
|
+
log(`removed comment ${id} (finding fixed or duplicate)`);
|
|
223
|
+
}
|
|
224
|
+
log(
|
|
225
|
+
`sync: ${plan.create.length} created, ${plan.update.length} updated, ` +
|
|
226
|
+
`${plan.remove.length} removed, ${plan.unchanged.length} unchanged`,
|
|
227
|
+
);
|
|
228
|
+
return plan;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The best-effort boundary the CI entrypoint runs: {@link syncComments} wrapped
|
|
233
|
+
* so it NEVER throws. Comment de-duplication is advisory — a failure (a
|
|
234
|
+
* partial-list abort, a mid-sync API error, a bad JSON parse) must log and skip
|
|
235
|
+
* the run, never fail the CI job. Returns the executed plan, or `null` when the
|
|
236
|
+
* sync was aborted/skipped. This is the invariant the top-level CLI relies on to
|
|
237
|
+
* always exit 0.
|
|
238
|
+
*/
|
|
239
|
+
export async function syncCommentsBestEffort(
|
|
240
|
+
findings: readonly Finding[],
|
|
241
|
+
client: PrCommentClient,
|
|
242
|
+
log: (msg: string) => void = () => {},
|
|
243
|
+
): Promise<ReconcilePlan | null> {
|
|
244
|
+
try {
|
|
245
|
+
return await syncComments(findings, client, log);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
log(`sync aborted (best-effort, no-op): ${e instanceof Error ? e.message : String(e)}`);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thin CLI over the PR-summary rollup (issue #2132) that `entrypoint.sh`
|
|
3
|
+
* invokes (through the root `pr-summary.mjs` tsx wrapper) after a scan.
|
|
4
|
+
*
|
|
5
|
+
* It does two independent, best-effort things:
|
|
6
|
+
*
|
|
7
|
+
* 1. Writes a GitHub Actions **job summary** ($GITHUB_STEP_SUMMARY) with the
|
|
8
|
+
* run's rollup — ALWAYS, even with no PR context (push / manual dispatch),
|
|
9
|
+
* so the run page shows the shape of the run.
|
|
10
|
+
* 2. When a PR context + token are present, posts/updates the ONE rollup
|
|
11
|
+
* comment on the PR conversation — found by a stable marker and updated in
|
|
12
|
+
* place across pushes, never re-posted (the #2131 dedup discipline, one
|
|
13
|
+
* level up).
|
|
14
|
+
*
|
|
15
|
+
* Best-effort by design: any missing context, failed read, or failed API call is
|
|
16
|
+
* logged to stderr and skipped — never thrown — so the calling entrypoint still
|
|
17
|
+
* exits 0. The rollup posts through the shared identity resolver (issue #2130):
|
|
18
|
+
* the Binclusive GitHub App when configured, else the Action's own `GITHUB_TOKEN`
|
|
19
|
+
* — the SAME identity the inline comments post under (both go through
|
|
20
|
+
* `resolvePostingToken`, so the two comment surfaces never diverge).
|
|
21
|
+
*
|
|
22
|
+
* Env: GITHUB_TOKEN, GITHUB_REPOSITORY ("owner/name"), PR_NUMBER, HEAD_SHA,
|
|
23
|
+
* GITHUB_STEP_SUMMARY, GITHUB_SERVER_URL, GITHUB_API_URL (all optional; the
|
|
24
|
+
* job summary needs only GITHUB_STEP_SUMMARY, the comment needs the PR set);
|
|
25
|
+
* BINCLUSIVE_APP_ID / BINCLUSIVE_APP_PRIVATE_KEY (optional, opt into the
|
|
26
|
+
* branded App identity).
|
|
27
|
+
*/
|
|
28
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
29
|
+
import { resolvePostingToken } from "./github-identity";
|
|
30
|
+
import { type Finding, parseFindings } from "./pr-comment";
|
|
31
|
+
import {
|
|
32
|
+
computeRollup,
|
|
33
|
+
type RenderOptions,
|
|
34
|
+
renderJobSummary,
|
|
35
|
+
type RollupClient,
|
|
36
|
+
type RollupComment,
|
|
37
|
+
syncRollupBestEffort,
|
|
38
|
+
} from "./pr-summary";
|
|
39
|
+
|
|
40
|
+
const reportPath = process.argv[2];
|
|
41
|
+
const log = (msg: string): void => console.error(`pr-summary: ${msg}`);
|
|
42
|
+
|
|
43
|
+
if (!reportPath) {
|
|
44
|
+
log("no report path argument; skipping");
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const loadFindings = (path: string): Finding[] => {
|
|
49
|
+
try {
|
|
50
|
+
return parseFindings(JSON.parse(readFileSync(path, "utf8")));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
log(`could not read findings JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const findings = loadFindings(reportPath);
|
|
57
|
+
|
|
58
|
+
const repo = process.env.GITHUB_REPOSITORY;
|
|
59
|
+
const headSha = process.env.HEAD_SHA;
|
|
60
|
+
const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com";
|
|
61
|
+
|
|
62
|
+
// Link each finding to its changed file at the run's head sha — the same local
|
|
63
|
+
// navigation the inline comments anchor on, not a wire-crossing source snippet.
|
|
64
|
+
const linkFor: RenderOptions["linkFor"] =
|
|
65
|
+
repo && headSha
|
|
66
|
+
? (f: Finding) => `${serverUrl}/${repo}/blob/${headSha}/${encodeURI(f.file)}#L${f.line}`
|
|
67
|
+
: undefined;
|
|
68
|
+
const renderOpts: RenderOptions = linkFor ? { linkFor } : {};
|
|
69
|
+
|
|
70
|
+
// ---- 1. Job summary — always, even with no PR context ----------------------
|
|
71
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
72
|
+
if (summaryPath) {
|
|
73
|
+
try {
|
|
74
|
+
const rollup = computeRollup(findings);
|
|
75
|
+
appendFileSync(summaryPath, `${renderJobSummary(rollup, findings, renderOpts)}\n`);
|
|
76
|
+
log("wrote job summary");
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// Best-effort: a failed summary write must never fail the job.
|
|
79
|
+
log(`job summary write failed (ignored): ${e instanceof Error ? e.message : String(e)}`);
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
log("no GITHUB_STEP_SUMMARY — skipping job summary");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- 2. Rollup PR comment — only with a PR context -------------------------
|
|
86
|
+
const pr = process.env.PR_NUMBER;
|
|
87
|
+
const api = process.env.GITHUB_API_URL || "https://api.github.com";
|
|
88
|
+
|
|
89
|
+
if (!repo || !pr) {
|
|
90
|
+
log("no PR context — skipping rollup comment");
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Resolve WHO posts once, the SAME resolver the inline comments use, so both
|
|
95
|
+
// surfaces post under one identity. Never throws — degrades to GITHUB_TOKEN.
|
|
96
|
+
const { token, identity } = await resolvePostingToken(process.env, { repo, api }, log);
|
|
97
|
+
if (!token) {
|
|
98
|
+
log("no posting token (no GitHub App configured and no GITHUB_TOKEN) — skipping rollup comment");
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
log(`posting rollup comment as ${identity}`);
|
|
102
|
+
|
|
103
|
+
const headers: Record<string, string> = {
|
|
104
|
+
Authorization: `Bearer ${token}`,
|
|
105
|
+
Accept: "application/vnd.github+json",
|
|
106
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
107
|
+
"User-Agent": "binclusive-a11y-agent",
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* GitHub REST implementation over the PR-conversation (issue) comments endpoints
|
|
113
|
+
* — distinct from the inline review-comment endpoints the per-finding reconciler
|
|
114
|
+
* uses. `list` fetches a COMPLETE view or throws (a partial view could re-CREATE
|
|
115
|
+
* an existing rollup on an unfetched page); create/update/remove are best-effort.
|
|
116
|
+
*/
|
|
117
|
+
const client: RollupClient = {
|
|
118
|
+
async list(): Promise<RollupComment[]> {
|
|
119
|
+
const out: RollupComment[] = [];
|
|
120
|
+
for (let page = 1; ; page++) {
|
|
121
|
+
const url = `${api}/repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`;
|
|
122
|
+
let res: Response;
|
|
123
|
+
try {
|
|
124
|
+
res = await fetch(url, { headers });
|
|
125
|
+
} catch (e) {
|
|
126
|
+
throw new Error(`list page ${page} fetch failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
127
|
+
}
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
throw new Error(`list page ${page} -> ${res.status} ${(await res.text().catch(() => "")).slice(0, 200)}`);
|
|
130
|
+
}
|
|
131
|
+
let batch: unknown;
|
|
132
|
+
try {
|
|
133
|
+
batch = await res.json();
|
|
134
|
+
} catch (e) {
|
|
135
|
+
throw new Error(`list page ${page} JSON parse failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
136
|
+
}
|
|
137
|
+
if (!Array.isArray(batch) || batch.length === 0) break;
|
|
138
|
+
for (const c of batch) {
|
|
139
|
+
if (c && typeof c === "object" && typeof (c as { id?: unknown }).id === "number" && typeof (c as { body?: unknown }).body === "string") {
|
|
140
|
+
out.push({ id: (c as { id: number }).id, body: (c as { body: string }).body });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (batch.length < 100) break;
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
async create(body: string): Promise<void> {
|
|
149
|
+
const url = `${api}/repos/${repo}/issues/${pr}/comments`;
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify({ body }) });
|
|
152
|
+
if (!res.ok) log(`create rollup -> ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
log(`create rollup failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
async update(id: number, body: string): Promise<void> {
|
|
159
|
+
const url = `${api}/repos/${repo}/issues/comments/${id}`;
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ body }) });
|
|
162
|
+
if (!res.ok) log(`update rollup ${id} -> ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
log(`update rollup ${id} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async remove(id: number): Promise<void> {
|
|
169
|
+
const url = `${api}/repos/${repo}/issues/comments/${id}`;
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(url, { method: "DELETE", headers });
|
|
172
|
+
if (!res.ok && res.status !== 404) log(`remove rollup ${id} -> ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
log(`remove rollup ${id} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Best-effort by contract: syncRollupBestEffort swallows any throw (a partial-list
|
|
180
|
+
// abort, a mid-sync API error) so the entrypoint always exits 0 — the rollup is
|
|
181
|
+
// advisory and must never fail the CI job.
|
|
182
|
+
await syncRollupBestEffort(findings, client, renderOpts, log);
|