ultra11y 2.32.0 → 3.0.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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.opencode/plugins/ultra11y.js +1 -1
  5. package/README.md +14 -2
  6. package/package.json +1 -1
  7. package/scripts/ultra11y.mjs +1133 -222
  8. package/skills/review-a11y/SKILL.md +1 -1
  9. package/skills/review-a11y/scripts/ultra11y.mjs +1133 -222
  10. package/skills/ultra11y/SKILL.md +17 -11
  11. package/skills/ultra11y/references/audit.md +1 -1
  12. package/skills/ultra11y/references/ci.md +63 -0
  13. package/skills/ultra11y/references/judgment.md +8 -0
  14. package/skills/ultra11y/references/methodology.md +1 -1
  15. package/skills/ultra11y/references/orchestrators.md +26 -11
  16. package/skills/ultra11y/references/prd.md +9 -36
  17. package/skills/ultra11y/references/tickets.md +156 -0
  18. package/skills/ultra11y/scripts/ultra11y.mjs +1133 -222
  19. package/src/cli.ts +386 -63
  20. package/src/config.ts +10 -0
  21. package/src/gh-cli.ts +39 -0
  22. package/src/mcp/handlers.ts +28 -0
  23. package/src/mcp/tools.ts +27 -0
  24. package/src/pages.ts +13 -0
  25. package/src/pr-comment.ts +78 -0
  26. package/src/tickets/grain.ts +218 -0
  27. package/src/tickets/http.ts +107 -0
  28. package/src/tickets/providers/github.ts +155 -0
  29. package/src/tickets/providers/gitlab.ts +158 -0
  30. package/src/tickets/providers/jira.ts +152 -0
  31. package/src/tickets/providers/remote.ts +40 -0
  32. package/src/tickets/push.ts +94 -0
  33. package/src/tickets/registry.ts +45 -0
  34. package/src/tickets/render.ts +157 -0
  35. package/src/tickets/types.ts +154 -0
  36. package/src/types.ts +1 -1
  37. package/src/gh.ts +0 -333
package/src/mcp/tools.ts CHANGED
@@ -89,6 +89,28 @@ export const TOOLS: ToolDecl[] = [
89
89
  required: ["cwd"],
90
90
  },
91
91
  },
92
+ {
93
+ name: "ultra11y_tickets",
94
+ title: "Preview the tracker tickets an audit would file",
95
+ description:
96
+ "Show the GitHub/GitLab/Jira tickets this audit would open, at a granularity you choose: one per criterion (default), per page, per page+criterion, per " +
97
+ "file, or one consolidated. Returns the plan ONLY — it never creates anything; filing stays a deliberate `ultra11y tickets` run.",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ cwd: cwdProp,
102
+ globs: globsProp,
103
+ standard: standardProp,
104
+ lang: langProp,
105
+ grain: {
106
+ type: "string",
107
+ enum: ["criterion", "page", "page-criterion", "single", "file"],
108
+ description: "What ONE ticket is. Default 'criterion'.",
109
+ },
110
+ },
111
+ required: ["cwd"],
112
+ },
113
+ },
92
114
  {
93
115
  name: "ultra11y_criteria",
94
116
  title: "The offline standards reference",
@@ -302,6 +324,11 @@ export const TOOL_META: Record<string, { write?: boolean; destructive?: boolean;
302
324
  ultra11y_audit: { openWorld: false },
303
325
  ultra11y_report: { openWorld: false },
304
326
  ultra11y_prd: { openWorld: false },
327
+ // READ-ONLY and closed-world by construction: it returns the ticket PLAN and never files
328
+ // anything, so it neither touches the project nor reaches a tracker. Filing stays a
329
+ // deliberate `ultra11y tickets` run — an agent must not be able to open issues in
330
+ // somebody's tracker off the back of a prompt injection.
331
+ ultra11y_tickets: { openWorld: false },
305
332
  ultra11y_criteria: { openWorld: false },
306
333
  ultra11y_check: { openWorld: false },
307
334
  ultra11y_verify: { openWorld: false },
package/src/pages.ts CHANGED
@@ -225,6 +225,19 @@ const L = {
225
225
  },
226
226
  } as const;
227
227
 
228
+ /** Honesty rule 2, as one sentence: a page with no snapshot cannot earn conformity by
229
+ * silence. Exported so the grid, the per-page report AND the page-grain ticket all say the
230
+ * SAME thing — one string, several readers, no drift. Undefined for a snapshot page, which
231
+ * needs no caveat. */
232
+ export function pageBasisWarning(basis: PageScope["basis"], lang: Lang): string | undefined {
233
+ return basis === "snapshot" ? undefined : L[lang].basisNote;
234
+ }
235
+
236
+ /** Honesty rule 1, as one sentence: findings no page could claim are reported, never spread. */
237
+ export function unattributedNote(n: number, lang: Lang): string {
238
+ return L[lang].unattributed(n);
239
+ }
240
+
228
241
  interface Row {
229
242
  id: string;
230
243
  label: string;
@@ -0,0 +1,78 @@
1
+ // THE STICKY PULL-REQUEST COMMENT — a REPORT surface, not a ticket.
2
+ //
3
+ // A CI run that appends a new comment every push turns a busy PR into a wall of stale
4
+ // audits. This posts ONE comment and EDITS it on every subsequent run, keyed by an invisible
5
+ // marker. The marker is per-standard, so a WCAG run and an RGAA run coexist instead of
6
+ // overwriting one another (the same reason SARIF runs carry distinct automationDetails ids).
7
+ //
8
+ // It deliberately lives outside src/tickets/: it has no title, no de-dupe key, no labels and
9
+ // no grain, so folding it into `TicketProvider` would force GitLab and Jira to stub a
10
+ // `comment()` method they have no business implementing. It serves `report --format github`.
11
+ import type { StandardId } from "./standards/index.js";
12
+ import { ghExec, ghErrorReason } from "./gh-cli.js";
13
+
14
+ /** The hidden key identifying this tool's comment for a given standard. */
15
+ export function COMMENT_MARKER(standard: StandardId): string {
16
+ return `<!-- ultra11y:report standard="${standard}" -->`;
17
+ }
18
+
19
+ export function stickyBody(markdown: string, standard: StandardId): string {
20
+ return `${COMMENT_MARKER(standard)}\n${markdown}`;
21
+ }
22
+
23
+ /** The pull request to comment on: an explicit override, else the number GitHub Actions puts
24
+ * in GITHUB_REF on a pull_request event. Undefined off a PR — the caller then skips, rather
25
+ * than commenting on some unrelated issue. */
26
+ export function prNumberFromEnv(env: Record<string, string | undefined> = process.env): number | undefined {
27
+ const explicit = Number.parseInt(env.ULTRA11Y_PR ?? "", 10);
28
+ if (Number.isFinite(explicit) && explicit > 0) return explicit;
29
+ const m = /^refs\/pull\/(\d+)\//.exec(env.GITHUB_REF ?? "");
30
+ const n = m ? Number.parseInt(m[1] as string, 10) : Number.NaN;
31
+ return Number.isFinite(n) && n > 0 ? n : undefined;
32
+ }
33
+
34
+ export interface IssueComment {
35
+ id: number;
36
+ body?: string;
37
+ }
38
+
39
+ /** This tool's own previous comment for this standard, if any. Never adopts a human's comment
40
+ * nor another standard's — an edit is destructive, so the match must be exact. */
41
+ export function pickExistingComment(comments: IssueComment[], marker: string): IssueComment | undefined {
42
+ return comments.find((c) => typeof c.body === "string" && c.body.includes(marker));
43
+ }
44
+
45
+ export interface CommentResult {
46
+ ok: boolean;
47
+ action: "created" | "updated" | "skipped";
48
+ reason?: string;
49
+ }
50
+
51
+ /** Post (or update) the audit summary on the current pull request. Best-effort: with no `gh`,
52
+ * no auth or no PR, it reports `skipped` and the caller carries on — a comment is never
53
+ * worth failing a build over. */
54
+ export function pushPrComment(markdown: string, standard: StandardId = "wcag"): CommentResult {
55
+ const pr = prNumberFromEnv();
56
+ if (pr === undefined) return { ok: true, action: "skipped", reason: "not a pull-request run" };
57
+ const marker = COMMENT_MARKER(standard);
58
+ const body = stickyBody(markdown, standard);
59
+ try {
60
+ const repo = ghExec(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).trim();
61
+ let existing: IssueComment | undefined;
62
+ try {
63
+ const raw = ghExec(["api", `repos/${repo}/issues/${pr}/comments`, "--paginate"]);
64
+ existing = pickExistingComment(JSON.parse(raw) as IssueComment[], marker);
65
+ } catch {
66
+ // Listing failed (permissions, rate limit) — fall through and CREATE. A duplicate
67
+ // comment is a far smaller harm than dropping the report entirely.
68
+ }
69
+ if (existing) {
70
+ ghExec(["api", "--method", "PATCH", `repos/${repo}/issues/comments/${existing.id}`, "-f", `body=${body}`]);
71
+ return { ok: true, action: "updated" };
72
+ }
73
+ ghExec(["api", "--method", "POST", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`]);
74
+ return { ok: true, action: "created" };
75
+ } catch (e) {
76
+ return { ok: false, action: "skipped", reason: ghErrorReason(e) };
77
+ }
78
+ }
@@ -0,0 +1,218 @@
1
+ // THE GRAIN — one PURE function from an audit to a list of tickets.
2
+ //
3
+ // No fs, no network, no console, no process.exit, no Date.now. Everything it needs is on the
4
+ // AuditResult. That is what makes the ticket engine testable without a tracker, and what
5
+ // makes "decoupled" mean something: the providers below it never see a Finding, and this
6
+ // file never sees an HTTP request.
7
+ //
8
+ // It takes an AuditResult and NOT a PrdUnit[] on purpose: the page and file grains need
9
+ // `scope.pages`, `packFindings` and `pageView`, all of which `prdUnits()` has already
10
+ // discarded by the time it returns.
11
+ import type { AuditResult, Finding, Lang, PageResult, Severity } from "../types.js";
12
+ import { resolveMessage } from "../messages.js";
13
+ import { repoRelative } from "../util.js";
14
+ import { type PrdUnit, prdUnits } from "../prd.js";
15
+ import { renderAuditorBacklog } from "../auditor.js";
16
+ import { attributePages, derivePages, pageBasisWarning, pageView, pagesOf, unattributedFindings, unattributedNote } from "../pages.js";
17
+ import { type GrainOptions, type Ticket, type TicketOccurrence, type TicketPlan, UNATTRIBUTED_ID } from "./types.js";
18
+ import {
19
+ clampBody,
20
+ criterionTitle,
21
+ fileTitle,
22
+ filePreamble,
23
+ labelsFor,
24
+ pageCriterionTitle,
25
+ pagePreamble,
26
+ pageTitle,
27
+ renderCriterionBody,
28
+ singleTitle,
29
+ standardTag,
30
+ unattributedTitle,
31
+ } from "./render.js";
32
+
33
+ const SEV_RANK: Record<Severity, number> = { bloquant: 0, majeur: 1, mineur: 2 };
34
+
35
+ /** The most severe severity in a set — the ticket's own. Empty ⇒ `mineur`, the least
36
+ * alarming default, so an empty group can never masquerade as blocking. */
37
+ function worstOf(items: { severity: Severity }[]): Severity {
38
+ return items.reduce<Severity>((worst, x) => (SEV_RANK[x.severity] < SEV_RANK[worst] ? x.severity : worst), "mineur");
39
+ }
40
+
41
+ /** A view of the audit restricted to one FILE. Deliberately does NOT touch `criteria`: a file
42
+ * is not a page and earns no conformance verdict. Kept private so nobody is tempted to
43
+ * compute a per-file rate from it. */
44
+ function fileView(result: AuditResult, file: string, baseDir: string): AuditResult {
45
+ const owns = (f: Finding): boolean => sourceOf(f, baseDir) === file;
46
+ return {
47
+ ...result,
48
+ findings: result.findings.filter(owns),
49
+ ...(result.packFindings ? { packFindings: result.packFindings.filter(owns) } : {}),
50
+ };
51
+ }
52
+
53
+ /** The occurrences a ticket carries, so a consumer never has to parse them back out of the
54
+ * rendered body. Advisory findings ride along: they are part of the unit, just never NC. */
55
+ function occurrencesOf(units: PrdUnit[], lang: Lang): TicketOccurrence[] {
56
+ return units.flatMap((u) => u.findings.map((f) => ({ file: f.file, line: f.line, selector: f.selectorHint, message: resolveMessage(f, lang) })));
57
+ }
58
+
59
+ /** The file a finding belongs to, REPO-RELATIVE. A finding raised on a RENDERED capture is
60
+ * credited to the source component that produced it, not to the capture file nobody edits.
61
+ * The path is relativised because it lands in the ticket TITLE, which is the de-dupe key: an
62
+ * absolute path would make that key machine-specific and re-file everything from CI. */
63
+ function sourceOf(f: Finding, baseDir: string): string {
64
+ return repoRelative(f.origin?.sourceFile ?? f.file, baseDir);
65
+ }
66
+
67
+ export function buildTickets(result: AuditResult, opts: GrainOptions): TicketPlan {
68
+ const { grain, standard, lang } = opts;
69
+ const { label, tag } = standardTag(standard);
70
+ const limit = opts.bodyLimit ?? Number.POSITIVE_INFINITY;
71
+ const bodyOpts = {
72
+ ...(opts.format !== undefined ? { format: opts.format } : {}),
73
+ ...(opts.technical !== undefined ? { technical: opts.technical } : {}),
74
+ };
75
+ const backlogOpts = opts.technical !== undefined ? { technical: opts.technical } : {};
76
+ const baseDir = opts.baseDir ?? "";
77
+ const clamp = (body: string): string => clampBody(body, limit, lang);
78
+
79
+ const ticketFromUnit = (unit: PrdUnit, title: string, scope: Ticket["scope"]): Ticket => ({
80
+ title,
81
+ body: clamp(renderCriterionBody(unit, standard, lang, bodyOpts)),
82
+ labels: labelsFor(unit.severity, unit.advisory === true, tag),
83
+ severity: unit.severity,
84
+ advisory: unit.advisory === true,
85
+ scope,
86
+ occurrences: occurrencesOf([unit], lang),
87
+ });
88
+
89
+ if (grain === "criterion") {
90
+ const tickets = prdUnits(result, standard, lang).map((u) => ticketFromUnit(u, criterionTitle(u, label), { grain: "criterion", criteriaId: u.criteriaId }));
91
+ return { tickets, unattributed: 0 };
92
+ }
93
+
94
+ if (grain === "single") {
95
+ const units = prdUnits(result, standard, lang);
96
+ if (!units.length) return { tickets: [], unattributed: 0 };
97
+ return {
98
+ tickets: [
99
+ {
100
+ title: singleTitle(label),
101
+ // renderAuditorBacklog — not a second template. It also fixes a real inconsistency
102
+ // in the pre-v3 consolidated body, which sectioned advisory units AMONG the
103
+ // severity groups instead of isolating them in their own trailing section.
104
+ body: clamp(renderAuditorBacklog(result, lang, standard, backlogOpts)),
105
+ labels: labelsFor(
106
+ worstOf(units),
107
+ units.every((u) => u.advisory === true),
108
+ tag,
109
+ ),
110
+ severity: worstOf(units),
111
+ advisory: units.every((u) => u.advisory === true),
112
+ scope: { grain: "single" },
113
+ occurrences: occurrencesOf(units, lang),
114
+ },
115
+ ],
116
+ unattributed: 0,
117
+ };
118
+ }
119
+
120
+ if (grain === "file") {
121
+ const files = [...new Set(result.findings.map((f) => sourceOf(f, baseDir)))].sort();
122
+ const tickets: Ticket[] = [];
123
+ for (const file of files) {
124
+ const view = fileView(result, file, baseDir);
125
+ const units = prdUnits(view, standard, lang);
126
+ if (!units.length) continue;
127
+ const advisory = units.every((u) => u.advisory === true);
128
+ tickets.push({
129
+ title: fileTitle(file, label),
130
+ body: clamp([...filePreamble(file, lang), renderAuditorBacklog(view, lang, standard, backlogOpts)].join("\n")),
131
+ labels: labelsFor(worstOf(units), advisory, tag),
132
+ severity: worstOf(units),
133
+ advisory,
134
+ scope: { grain: "file", file },
135
+ occurrences: occurrencesOf(units, lang),
136
+ });
137
+ }
138
+ return { tickets, unattributed: 0 };
139
+ }
140
+
141
+ // ---- the page grains -------------------------------------------------------------------
142
+ // Same ordering cmdPages uses: pagesOf → guard → attributePages (which MUTATES `result` by
143
+ // stamping `f.page`) → derivePages. Nothing here re-decides a status.
144
+ const scope = pagesOf(result);
145
+ if (!scope.length) return { tickets: [], unattributed: 0, error: "no-pages" };
146
+ attributePages(result, scope);
147
+ const derived = derivePages(result, scope);
148
+ const orphans = unattributedFindings(result);
149
+ const tickets: Ticket[] = [];
150
+
151
+ for (const page of derived) {
152
+ const view = pageView(result, page);
153
+ const units = prdUnits(view, standard, lang);
154
+ if (!units.length) continue;
155
+ const warning = pageBasisWarning(page.basis, lang);
156
+ const preamble = pagePreamble(page, lang, warning);
157
+
158
+ if (grain === "page") {
159
+ const advisory = units.every((u) => u.advisory === true);
160
+ tickets.push({
161
+ title: pageTitle(page.id, label),
162
+ body: clamp([...preamble, renderAuditorBacklog(view, lang, standard, backlogOpts)].join("\n")),
163
+ labels: labelsFor(worstOf(units), advisory, tag),
164
+ severity: worstOf(units),
165
+ advisory,
166
+ scope: pageScopeOf(page),
167
+ occurrences: occurrencesOf(units, lang),
168
+ });
169
+ continue;
170
+ }
171
+
172
+ for (const u of units) {
173
+ const t = ticketFromUnit(u, pageCriterionTitle(page.id, u, label), {
174
+ grain: "page-criterion",
175
+ pageId: page.id,
176
+ pageName: page.name,
177
+ url: page.url,
178
+ ...(page.auth !== undefined ? { auth: page.auth } : {}),
179
+ basis: page.basis,
180
+ criteriaId: u.criteriaId,
181
+ });
182
+ tickets.push({ ...t, body: clamp([...preamble, t.body].join("\n")) });
183
+ }
184
+ }
185
+
186
+ // Honesty rule 1: findings no page could claim get their OWN ticket. Dropping them would
187
+ // hide non-conformities; spreading them across pages would invent them. Both grains emit
188
+ // it, so the orphan backlog is never a casualty of the chosen granularity.
189
+ if (orphans.length) {
190
+ const view: AuditResult = { ...result, findings: orphans, ...(result.packFindings ? { packFindings: result.packFindings.filter((f) => !f.page) } : {}) };
191
+ const units = prdUnits(view, standard, lang);
192
+ if (units.length) {
193
+ const advisory = units.every((u) => u.advisory === true);
194
+ tickets.push({
195
+ title: unattributedTitle(label),
196
+ body: clamp([`> ${unattributedNote(orphans.length, lang)}`, "", renderAuditorBacklog(view, lang, standard, backlogOpts)].join("\n")),
197
+ labels: labelsFor(worstOf(units), advisory, tag),
198
+ severity: worstOf(units),
199
+ advisory,
200
+ scope: { grain: "page", pageId: UNATTRIBUTED_ID, pageName: UNATTRIBUTED_ID, url: "", basis: "none" },
201
+ occurrences: occurrencesOf(units, lang),
202
+ });
203
+ }
204
+ }
205
+
206
+ return { tickets, unattributed: orphans.length };
207
+ }
208
+
209
+ function pageScopeOf(page: PageResult): Ticket["scope"] {
210
+ return {
211
+ grain: "page",
212
+ pageId: page.id,
213
+ pageName: page.name,
214
+ url: page.url,
215
+ ...(page.auth !== undefined ? { auth: page.auth } : {}),
216
+ basis: page.basis,
217
+ };
218
+ }
@@ -0,0 +1,107 @@
1
+ // THE REST TRANSPORT — global `fetch`, no SDK, no npm dependency.
2
+ //
3
+ // It exists because the CLI transports cannot cover CI: `gh` ships on GitHub-HOSTED runners
4
+ // only, so self-hosted runners, container jobs and every other CI (GitLab CI, Jenkins) have
5
+ // no binary to shell out to — and Jira has no CLI at all. A token in the environment is the
6
+ // one thing all of them do have.
7
+ //
8
+ // Two rules govern this file:
9
+ // • A token NEVER appears in anything it returns or throws. Errors name the header, never
10
+ // its value: an error message ends up in a log, and a log ends up somewhere public.
11
+ // • It never sleeps in tests. The delay function is injected, exactly as src/llm.ts does.
12
+ import { VERSION } from "../types.js";
13
+
14
+ export const USER_AGENT = `ultra11y/${VERSION}`;
15
+
16
+ /** Header names whose values must never be echoed back. */
17
+ const SECRET_HEADERS = ["authorization", "private-token", "x-atlassian-token"];
18
+
19
+ export class HttpError extends Error {
20
+ constructor(
21
+ readonly status: number,
22
+ /** Already scrubbed and truncated — safe to print. */
23
+ readonly detail: string,
24
+ readonly url: string,
25
+ ) {
26
+ super(`HTTP ${status}: ${detail}`);
27
+ this.name = "HttpError";
28
+ }
29
+ }
30
+
31
+ export interface HttpOptions {
32
+ method?: "GET" | "POST" | "PUT" | "PATCH";
33
+ headers?: Record<string, string>;
34
+ body?: unknown;
35
+ /** Injected for tests; defaults to the global. */
36
+ fetchImpl?: typeof fetch;
37
+ /** Injected for tests so a rate-limit retry never actually sleeps. */
38
+ sleep?: (ms: number) => Promise<void>;
39
+ /** Retries on 429/503 before giving up. */
40
+ retries?: number;
41
+ }
42
+
43
+ const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
44
+
45
+ /** Strip anything that looks like a credential out of a response body before it becomes an
46
+ * error message. Trackers echo request context in their errors more often than you'd like. */
47
+ function scrub(text: string, headers: Record<string, string>): string {
48
+ let out = text;
49
+ for (const [k, v] of Object.entries(headers)) {
50
+ if (v && SECRET_HEADERS.includes(k.toLowerCase())) out = out.split(v).join("<redacted>");
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** The one-line reason a caller surfaces. Prefers the tracker's own message field over a
56
+ * wall of JSON: GitHub uses `message`, GitLab `message`/`error`, Jira `errorMessages[]`. */
57
+ export function reasonFrom(detail: string): string {
58
+ try {
59
+ const j = JSON.parse(detail) as { message?: unknown; error?: unknown; errorMessages?: unknown; errors?: unknown };
60
+ if (Array.isArray(j.errorMessages) && typeof j.errorMessages[0] === "string") return j.errorMessages[0];
61
+ if (j.errors && typeof j.errors === "object" && !Array.isArray(j.errors)) {
62
+ const first = Object.entries(j.errors as Record<string, unknown>)[0];
63
+ if (first) return `${first[0]}: ${String(first[1])}`;
64
+ }
65
+ if (typeof j.message === "string") return j.message;
66
+ if (typeof j.error === "string") return j.error;
67
+ } catch {
68
+ /* not JSON — fall through to the raw text */
69
+ }
70
+ return detail.split("\n")[0]?.slice(0, 300) ?? detail.slice(0, 300);
71
+ }
72
+
73
+ export interface HttpResponse<T> {
74
+ data: T;
75
+ headers: Headers;
76
+ }
77
+
78
+ /** One JSON request. Throws HttpError on a non-2xx, after retrying a rate limit. */
79
+ export async function requestJson<T>(url: string, opts: HttpOptions = {}): Promise<HttpResponse<T>> {
80
+ const doFetch = opts.fetchImpl ?? fetch;
81
+ const sleep = opts.sleep ?? defaultSleep;
82
+ const retries = opts.retries ?? 2;
83
+ const headers: Record<string, string> = {
84
+ Accept: "application/json",
85
+ "User-Agent": USER_AGENT, // GitHub REJECTS a request without one.
86
+ ...(opts.body !== undefined ? { "Content-Type": "application/json" } : {}),
87
+ ...opts.headers,
88
+ };
89
+
90
+ for (let attempt = 0; ; attempt++) {
91
+ const res = await doFetch(url, {
92
+ method: opts.method ?? "GET",
93
+ headers,
94
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
95
+ });
96
+ if (res.ok) {
97
+ const text = await res.text();
98
+ return { data: (text ? JSON.parse(text) : {}) as T, headers: res.headers };
99
+ }
100
+ const detail = scrub(await res.text().catch(() => ""), headers);
101
+ const retryable = res.status === 429 || res.status === 503;
102
+ if (!retryable || attempt >= retries) throw new HttpError(res.status, detail, url);
103
+ // Honour the tracker's own backoff hint, capped so a bad header cannot hang a CI job.
104
+ const after = Number.parseInt(res.headers.get("retry-after") ?? "", 10);
105
+ await sleep(Math.min(Number.isFinite(after) ? after * 1000 : 1000 * 2 ** attempt, 30_000));
106
+ }
107
+ }
@@ -0,0 +1,155 @@
1
+ // GitHub — two transports behind one TicketProvider.
2
+ //
3
+ // CLI (`gh`) is preferred by `auto` because it carries the user's own auth and ultra11y
4
+ // never touches a token. REST is the CI path: `gh` ships on GitHub-HOSTED runners only, so a
5
+ // container job, a self-hosted runner or any non-GitHub CI has a GITHUB_TOKEN and no binary.
6
+ // GHES works through GITHUB_API_URL, which Actions sets for you.
7
+ import { ghAvailable, ghErrorReason, ghExec } from "../../gh-cli.js";
8
+ import { HttpError, reasonFrom, requestJson } from "../http.js";
9
+ import type { CreateOutcome, ExistingTicket, ProviderCapabilities, Ticket, TicketProvider, TransportMode } from "../types.js";
10
+ import { gitRemoteSlug } from "./remote.js";
11
+
12
+ // GitHub caps an issue body at 65536 characters and 422s past it.
13
+ const CAPABILITIES: ProviderCapabilities = { bodyLimit: 65536, labels: true };
14
+
15
+ export interface GithubOptions {
16
+ transport?: TransportMode;
17
+ env?: Record<string, string | undefined>;
18
+ fetchImpl?: typeof fetch;
19
+ sleep?: (ms: number) => Promise<void>;
20
+ /** Injected for tests, so the probe never shells out. */
21
+ cliAvailable?: () => boolean;
22
+ }
23
+
24
+ interface Resolved {
25
+ transport: "cli" | "rest";
26
+ repo?: string;
27
+ token?: string;
28
+ api: string;
29
+ reason?: string;
30
+ }
31
+
32
+ function resolve(opts: GithubOptions): Resolved {
33
+ const env = opts.env ?? process.env;
34
+ const mode = opts.transport ?? "auto";
35
+ const api = (env.GITHUB_API_URL || "https://api.github.com").replace(/\/+$/, "");
36
+ const token = env.GH_TOKEN || env.GITHUB_TOKEN;
37
+ const cliOk = (opts.cliAvailable ?? ghAvailable)();
38
+
39
+ if (mode === "cli") {
40
+ return cliOk ? { transport: "cli", api } : { transport: "cli", api, reason: "`gh` is not installed or not authenticated (run `gh auth login`)" };
41
+ }
42
+ // Only REST needs the repo slug, and only REST should pay for a `git` subprocess to find
43
+ // it — the CLI transport already knows which checkout it is standing in.
44
+ const repo = env.ULTRA11Y_GITHUB_REPO || env.GITHUB_REPOSITORY || (mode === "rest" || !cliOk ? gitRemoteSlug("github.com") : undefined);
45
+ if (mode === "rest") {
46
+ if (!token) return { transport: "rest", api, reason: "no GitHub token — set GH_TOKEN or GITHUB_TOKEN" };
47
+ if (!repo) return { transport: "rest", api, token, reason: "no repository — set GITHUB_REPOSITORY (owner/name) or ULTRA11Y_GITHUB_REPO" };
48
+ return { transport: "rest", api, token, repo };
49
+ }
50
+ // auto: the CLI first (it owns its auth), then REST, then say what BOTH were missing.
51
+ if (cliOk) return { transport: "cli", api };
52
+ if (token && repo) return { transport: "rest", api, token, repo };
53
+ const missing = [!token ? "a token (GH_TOKEN/GITHUB_TOKEN)" : "", !repo ? "a repository (GITHUB_REPOSITORY)" : ""].filter(Boolean);
54
+ return {
55
+ transport: "rest",
56
+ api,
57
+ ...(token ? { token } : {}),
58
+ ...(repo ? { repo } : {}),
59
+ reason: `\`gh\` is unavailable and REST is missing ${missing.join(" and ")}`,
60
+ };
61
+ }
62
+
63
+ export function createGithubProvider(opts: GithubOptions = {}): TicketProvider {
64
+ const r = resolve(opts);
65
+ const auth = { Authorization: `Bearer ${r.token ?? ""}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" };
66
+ const http = {
67
+ ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
68
+ ...(opts.sleep ? { sleep: opts.sleep } : {}),
69
+ };
70
+
71
+ async function listRest(): Promise<ExistingTicket[]> {
72
+ const out: ExistingTicket[] = [];
73
+ // 10 pages of 100 mirrors the CLI transport's `--limit 1000`.
74
+ for (let page = 1; page <= 10; page++) {
75
+ const url = `${r.api}/repos/${r.repo}/issues?state=all&per_page=100&page=${page}`;
76
+ const { data } = await requestJson<Array<{ title?: string; number?: number; html_url?: string; pull_request?: unknown }>>(url, {
77
+ headers: auth,
78
+ ...http,
79
+ });
80
+ if (!Array.isArray(data) || data.length === 0) break;
81
+ // The issues endpoint returns PULL REQUESTS too. A PR whose title happened to match
82
+ // would silently suppress a real ticket, so they are filtered, not merely tolerated.
83
+ for (const i of data) {
84
+ if (i.pull_request) continue;
85
+ if (i.title) out.push({ title: i.title, ...(i.number !== undefined ? { id: String(i.number) } : {}), ...(i.html_url ? { url: i.html_url } : {}) });
86
+ }
87
+ if (data.length < 100) break;
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function listCli(): ExistingTicket[] {
93
+ const raw = ghExec(["issue", "list", "--state", "all", "--limit", "1000", "--json", "title"]);
94
+ return (JSON.parse(raw) as Array<{ title?: string }>).filter((i) => i.title).map((i) => ({ title: i.title as string }));
95
+ }
96
+
97
+ async function createRest(t: Ticket): Promise<CreateOutcome> {
98
+ const url = `${r.api}/repos/${r.repo}/issues`;
99
+ const post = async (labels?: string[]) =>
100
+ requestJson<{ number?: number; html_url?: string }>(url, {
101
+ method: "POST",
102
+ headers: auth,
103
+ body: { title: t.title, body: t.body, ...(labels ? { labels } : {}) },
104
+ ...http,
105
+ });
106
+ try {
107
+ const { data } = await post(t.labels);
108
+ return { ok: true, ...(data.number !== undefined ? { id: String(data.number) } : {}), ...(data.html_url ? { url: data.html_url } : {}) };
109
+ } catch (e) {
110
+ // A repo whose labels do not exist 422s. Retry unlabelled rather than lose the ticket
111
+ // — the same fallback the CLI transport has always had.
112
+ if (e instanceof HttpError && e.status === 422) {
113
+ try {
114
+ const { data } = await post();
115
+ return { ok: true, ...(data.number !== undefined ? { id: String(data.number) } : {}), ...(data.html_url ? { url: data.html_url } : {}) };
116
+ } catch (retryErr) {
117
+ return { ok: false, reason: retryErr instanceof HttpError ? reasonFrom(retryErr.detail) : String(retryErr) };
118
+ }
119
+ }
120
+ return { ok: false, reason: e instanceof HttpError ? reasonFrom(e.detail) : e instanceof Error ? e.message : String(e) };
121
+ }
122
+ }
123
+
124
+ /** argv byte-identical to the pre-v3 `createIssue`, including the unlabelled retry. */
125
+ function createCli(t: Ticket): CreateOutcome {
126
+ const base = ["issue", "create", "--title", t.title, "--body-file", "-"];
127
+ try {
128
+ ghExec([...base, "--label", t.labels.join(",")], t.body);
129
+ return { ok: true };
130
+ } catch (labelledErr) {
131
+ try {
132
+ ghExec(base, t.body);
133
+ return { ok: true };
134
+ } catch (err) {
135
+ return { ok: false, reason: ghErrorReason(err) ?? ghErrorReason(labelledErr) };
136
+ }
137
+ }
138
+ }
139
+
140
+ return {
141
+ id: "github",
142
+ transport: r.transport,
143
+ capabilities: CAPABILITIES,
144
+ available: () => r.reason === undefined,
145
+ unavailableReason: () => r.reason,
146
+ list: async () => {
147
+ try {
148
+ return r.transport === "cli" ? listCli() : await listRest();
149
+ } catch {
150
+ return []; // degrade to "create": a duplicate beats dropping the backlog
151
+ }
152
+ },
153
+ create: async (t) => (r.transport === "cli" ? createCli(t) : createRest(t)),
154
+ };
155
+ }