scenescout 3.15.0 → 3.17.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 (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
@@ -0,0 +1,877 @@
1
+ /**
2
+ * `scenescout export`: the rules for turning a project's findings into issues
3
+ * where a team works, in GitHub or Jira. Which findings go, what each issue
4
+ * says, the marker a later export recognises it by, what is already filed and
5
+ * how many one export may file. Nothing here touches the network or the disk:
6
+ * src/export-run.ts reads the files and sends the requests, so every rule in
7
+ * this file is table-tested in export-test without a tracker.
8
+ *
9
+ * Issue text is written by a model and quotes the app under test, and a
10
+ * tracker renders it: GitHub turns `@name` into a notification, `#12` into a
11
+ * cross-reference and an address into a link. Every piece of that text is
12
+ * made inert before it goes into an issue.
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ import { readFindingPicture } from "./capture.js";
16
+ import { readFindings } from "./ci.js";
17
+ import { isWorthALook, redactSecrets } from "./memory.js";
18
+ import { retryAfterMs } from "./provider.js";
19
+ import { answerTicket } from "./tickets.js";
20
+ const TRACKERS = ["github", "jira"];
21
+ export const TRACKER_LABEL = { github: "GitHub", jira: "Jira" };
22
+ export const SEVERITIES = ["high", "medium", "low"];
23
+ const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
24
+ /** Every option `scenescout export` accepts. guide-test holds the configuration reference equal to it. */
25
+ export const EXPORT_OPTION_NAMES = [
26
+ "to",
27
+ "repo",
28
+ "jira-url",
29
+ "jira-project",
30
+ "jira-issue-type",
31
+ "jira-link-type",
32
+ "jira-update",
33
+ "project",
34
+ "min-severity",
35
+ "only",
36
+ "max-issues",
37
+ "severity-map",
38
+ "labels",
39
+ "screenshots",
40
+ "refile-closed",
41
+ "include-worth-a-look",
42
+ "dry-run",
43
+ "yes",
44
+ ];
45
+ const SWITCHES = new Set(["refile-closed", "include-worth-a-look", "dry-run", "yes"]);
46
+ /** Options someone might reach for to pass a credential. Each is refused with where the credential comes from instead. */
47
+ const CREDENTIAL_FLAGS = new Set(["token", "github-token", "gh-token", "jira-token", "api-token", "jira-api-token", "jira-email", "email", "password"]);
48
+ /** The label every exported issue carries. A later export lists the issues with it and reads their markers. */
49
+ export const MARKER_LABEL = "scenescout";
50
+ const DEFAULT_MAX_ISSUES = 20;
51
+ const MAX_ISSUES_BOUNDS = [1, 100];
52
+ const DEFAULT_JIRA_ISSUE_TYPE = "Bug";
53
+ /** The link from a Jira issue to the ticket whose criterion it fails. Every Jira Cloud site has it; `none` links nothing. */
54
+ const DEFAULT_JIRA_LINK_TYPE = "Relates";
55
+ const DEFAULT_GITHUB_API_URL = "https://api.github.com";
56
+ /** What a severity becomes: a label on GitHub, a priority in Jira. `--severity-map` replaces any of them. */
57
+ export const DEFAULT_SEVERITY_MAP = {
58
+ github: { high: "severity: high", medium: "severity: medium", low: "severity: low" },
59
+ jira: { high: "High", medium: "Medium", low: "Low" },
60
+ };
61
+ /**
62
+ * Exit 0: the export did what it set out to, filing up to the cap (anything
63
+ * over it waits for the next export) or, on a dry run, listing. Exit 2: it
64
+ * could not finish, or a screenshot could not be attached to an issue it filed.
65
+ */
66
+ export const EXIT_EXPORT = { done: 0, couldNotExport: 2 };
67
+ /** A finding id that can sit in a marker and be read back out of one. Ids are short hashes; anything else is not exported. */
68
+ const FINDING_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
69
+ const MAX_ONLY = 200;
70
+ const MAX_LABELS = 10;
71
+ /** GitHub's own limit on a label's name, used for Jira labels and priorities too. */
72
+ const MAX_LABEL_LENGTH = 50;
73
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
74
+ /**
75
+ * An address a credential may be sent to: https, or plain http only to this
76
+ * machine; no user or password in it, since the credential comes from the
77
+ * environment; and no query or fragment, since paths are joined onto it.
78
+ */
79
+ export function checkTrackerUrl(raw, name) {
80
+ let u;
81
+ try {
82
+ u = new URL(raw.trim());
83
+ }
84
+ catch {
85
+ return { ok: false, error: `${name} is not a URL: ${oneLine(raw, 120)}` };
86
+ }
87
+ if (u.username || u.password)
88
+ return { ok: false, error: `${name} must carry no credentials: they are read from the environment` };
89
+ if (u.search || u.hash)
90
+ return { ok: false, error: `${name} must have no query or fragment` };
91
+ const secure = u.protocol === "https:" || (u.protocol === "http:" && LOOPBACK_HOSTS.has(u.hostname));
92
+ if (!secure)
93
+ return { ok: false, error: `${name} must be https (plain http only to 127.0.0.1 or localhost): a credential is sent to it` };
94
+ return { ok: true, value: u.toString().replace(/\/+$/, "") };
95
+ }
96
+ /** The text of a name or label: one line, no control characters, within a length. */
97
+ function plainName(raw, what, max) {
98
+ const value = raw.trim();
99
+ if (!value)
100
+ return { ok: false, error: `${what} is empty` };
101
+ if (/[\u0000-\u001f\u007f-\u009f]/.test(value))
102
+ return { ok: false, error: `${what} has a control character` };
103
+ if (value.length > max)
104
+ return { ok: false, error: `${what} is longer than ${max} characters` };
105
+ return { ok: true, value };
106
+ }
107
+ /**
108
+ * `--severity-map high=P1,medium=P2,low=P3`. A severity left out keeps its
109
+ * default; one given an empty name (`low=`) gets no label or priority; `none`
110
+ * maps no severity at all.
111
+ */
112
+ function parseSeverityMap(raw, to) {
113
+ const map = { ...DEFAULT_SEVERITY_MAP[to] };
114
+ if (raw === undefined)
115
+ return { ok: true, value: map };
116
+ if (raw.trim() === "none")
117
+ return { ok: true, value: { high: null, medium: null, low: null } };
118
+ const seen = new Set();
119
+ for (const part of raw.split(",")) {
120
+ const eq = part.indexOf("=");
121
+ if (eq < 0)
122
+ return { ok: false, error: "--severity-map takes severity=name pairs, e.g. high=P1,medium=P2,low=P3, or none" };
123
+ const key = part.slice(0, eq).trim().toLowerCase();
124
+ if (!SEVERITIES.includes(key))
125
+ return { ok: false, error: `--severity-map: ${oneLine(key, 40)} is not high, medium or low` };
126
+ if (seen.has(key))
127
+ return { ok: false, error: `--severity-map names ${key} twice` };
128
+ seen.add(key);
129
+ const name = part.slice(eq + 1);
130
+ if (name.trim() === "") {
131
+ map[key] = null;
132
+ continue;
133
+ }
134
+ const checked = plainName(name, `--severity-map's name for ${key}`, MAX_LABEL_LENGTH);
135
+ if (!checked.ok)
136
+ return checked;
137
+ map[key] = checked.value;
138
+ }
139
+ return { ok: true, value: map };
140
+ }
141
+ /** `--labels a,b`: at most ten, each a valid label for the tracker. Jira labels cannot hold a space. */
142
+ function parseLabels(raw, to) {
143
+ if (raw === undefined)
144
+ return { ok: true, value: [] };
145
+ const out = [];
146
+ for (const part of raw.split(",")) {
147
+ const checked = plainName(part, "a --labels label", MAX_LABEL_LENGTH);
148
+ if (!checked.ok)
149
+ return checked;
150
+ if (to === "jira" && /\s/.test(checked.value))
151
+ return { ok: false, error: `Jira labels cannot contain spaces: ${oneLine(checked.value, 60)}` };
152
+ if (checked.value.toLowerCase() === MARKER_LABEL || out.some((l) => l.toLowerCase() === checked.value.toLowerCase()))
153
+ continue;
154
+ out.push(checked.value);
155
+ }
156
+ if (out.length > MAX_LABELS)
157
+ return { ok: false, error: `--labels takes at most ${MAX_LABELS} labels` };
158
+ return { ok: true, value: out };
159
+ }
160
+ export function parseExportArgs(args, cwd, env) {
161
+ const known = new Set(EXPORT_OPTION_NAMES);
162
+ const flags = new Map();
163
+ for (let i = 0; i < args.length; i++) {
164
+ const a = args[i];
165
+ if (!a.startsWith("--"))
166
+ return { ok: false, error: `unexpected argument ${a}: export takes options only (the findings come from --project)` };
167
+ const eq = a.indexOf("=");
168
+ const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
169
+ if (CREDENTIAL_FLAGS.has(name))
170
+ return {
171
+ ok: false,
172
+ error: `there is no --${name}: credentials are read from the environment only (GH_TOKEN or GITHUB_TOKEN for GitHub; JIRA_EMAIL and JIRA_API_TOKEN for Jira)`,
173
+ };
174
+ if (!known.has(name))
175
+ return { ok: false, error: `unknown option --${name}` };
176
+ if (flags.has(name))
177
+ return { ok: false, error: `--${name} is given twice` };
178
+ if (SWITCHES.has(name)) {
179
+ if (eq > 0)
180
+ return { ok: false, error: `--${name} takes no value` };
181
+ flags.set(name, "true");
182
+ continue;
183
+ }
184
+ const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
185
+ if (value === undefined || (eq < 0 && value.startsWith("--")))
186
+ return { ok: false, error: `--${name} needs a value` };
187
+ if (eq < 0)
188
+ i += 1;
189
+ flags.set(name, value);
190
+ }
191
+ const to = flags.get("to");
192
+ if (to === undefined)
193
+ return { ok: false, error: "say where the issues go: --to github or --to jira" };
194
+ if (!TRACKERS.includes(to))
195
+ return { ok: false, error: `--to must be one of ${TRACKERS.join(", ")}` };
196
+ const tracker = to;
197
+ const otherTrackers = { github: ["jira-url", "jira-project", "jira-issue-type", "jira-link-type", "jira-update"], jira: ["repo"] };
198
+ for (const name of otherTrackers[tracker])
199
+ if (flags.has(name))
200
+ return { ok: false, error: `--${name} is not an option of --to ${tracker}` };
201
+ let target;
202
+ if (tracker === "github") {
203
+ const repo = flags.get("repo");
204
+ if (repo === undefined)
205
+ return { ok: false, error: "--to github needs --repo owner/name" };
206
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || repo.split("/").some((part) => /^\.+$/.test(part)))
207
+ return { ok: false, error: `--repo is not owner/name: ${oneLine(repo, 120)}` };
208
+ const apiUrl = checkTrackerUrl(env.GITHUB_API_URL?.trim() || DEFAULT_GITHUB_API_URL, "GITHUB_API_URL");
209
+ if (!apiUrl.ok)
210
+ return apiUrl;
211
+ target = { to: "github", github: { repo, apiUrl: apiUrl.value } };
212
+ }
213
+ else {
214
+ const rawUrl = flags.get("jira-url") ?? env.JIRA_BASE_URL?.trim();
215
+ if (!rawUrl)
216
+ return { ok: false, error: "--to jira needs the site's address: --jira-url https://your-site.atlassian.net, or JIRA_BASE_URL" };
217
+ const baseUrl = checkTrackerUrl(rawUrl, flags.has("jira-url") ? "--jira-url" : "JIRA_BASE_URL");
218
+ if (!baseUrl.ok)
219
+ return baseUrl;
220
+ const projectKey = (flags.get("jira-project") ?? env.JIRA_PROJECT_KEY ?? "").trim();
221
+ if (!projectKey)
222
+ return { ok: false, error: "--to jira needs a project key: --jira-project KEY, or JIRA_PROJECT_KEY" };
223
+ if (!/^[A-Z][A-Z0-9_]{1,49}$/.test(projectKey))
224
+ return {
225
+ ok: false,
226
+ error: `the Jira project key is not a key (capital letters, digits and underscores, starting with a letter): ${oneLine(projectKey, 60)}`,
227
+ };
228
+ const issueType = plainName(flags.get("jira-issue-type") ?? env.JIRA_ISSUE_TYPE ?? DEFAULT_JIRA_ISSUE_TYPE, "the Jira issue type", 60);
229
+ if (!issueType.ok)
230
+ return issueType;
231
+ const rawLinkType = flags.get("jira-link-type") ?? env.JIRA_LINK_TYPE ?? DEFAULT_JIRA_LINK_TYPE;
232
+ const linkType = rawLinkType.trim().toLowerCase() === "none" ? null : plainName(rawLinkType, "the Jira link type", 60);
233
+ if (linkType && !linkType.ok)
234
+ return linkType;
235
+ const update = flags.get("jira-update") ?? "on";
236
+ if (update !== "on" && update !== "off")
237
+ return { ok: false, error: "--jira-update must be on or off" };
238
+ target = {
239
+ to: "jira",
240
+ jira: { baseUrl: baseUrl.value, projectKey, issueType: issueType.value, linkType: linkType ? linkType.value : null, update: update === "on" },
241
+ };
242
+ }
243
+ const minSeverity = flags.get("min-severity") ?? "low";
244
+ if (!SEVERITIES.includes(minSeverity))
245
+ return { ok: false, error: `--min-severity must be one of ${SEVERITIES.join(", ")}` };
246
+ let only;
247
+ if (flags.has("only")) {
248
+ only = [
249
+ ...new Set(flags
250
+ .get("only")
251
+ .split(/[\s,]+/)
252
+ .filter(Boolean)),
253
+ ];
254
+ if (only.length === 0)
255
+ return { ok: false, error: "--only needs finding ids, comma-separated" };
256
+ if (only.length > MAX_ONLY)
257
+ return { ok: false, error: `--only takes at most ${MAX_ONLY} ids` };
258
+ const bad = only.find((id) => !FINDING_ID_RE.test(id));
259
+ if (bad !== undefined)
260
+ return { ok: false, error: `--only: ${oneLine(bad, 70)} is not a finding id` };
261
+ }
262
+ let maxIssues = DEFAULT_MAX_ISSUES;
263
+ if (flags.has("max-issues")) {
264
+ const n = Number(flags.get("max-issues"));
265
+ const [lo, hi] = MAX_ISSUES_BOUNDS;
266
+ if (!Number.isInteger(n) || n < lo || n > hi)
267
+ return { ok: false, error: `--max-issues must be a whole number from ${lo} to ${hi}` };
268
+ maxIssues = n;
269
+ }
270
+ const severityMap = parseSeverityMap(flags.get("severity-map"), tracker);
271
+ if (!severityMap.ok)
272
+ return severityMap;
273
+ const labels = parseLabels(flags.get("labels"), tracker);
274
+ if (!labels.ok)
275
+ return labels;
276
+ const screenshots = flags.get("screenshots") ?? "on";
277
+ if (screenshots !== "on" && screenshots !== "off")
278
+ return { ok: false, error: "--screenshots must be on or off" };
279
+ if (flags.has("dry-run") && flags.has("yes"))
280
+ return { ok: false, error: "--dry-run and --yes contradict each other: give one" };
281
+ const resolve = (p) => (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`);
282
+ return {
283
+ ok: true,
284
+ options: {
285
+ ...target,
286
+ projectDir: resolve(flags.get("project") ?? cwd),
287
+ minSeverity: minSeverity,
288
+ ...(only ? { only } : {}),
289
+ maxIssues,
290
+ severityMap: severityMap.value,
291
+ labels: labels.value,
292
+ screenshots: screenshots === "on",
293
+ refileClosed: flags.has("refile-closed"),
294
+ includeWorthALook: flags.has("include-worth-a-look"),
295
+ dryRun: !flags.has("yes"),
296
+ },
297
+ };
298
+ }
299
+ /** The tracker's credentials, from the environment and nowhere else. An error names the variables to set, never a value. */
300
+ export function trackerCredentials(to, env) {
301
+ if (to === "github") {
302
+ const fromGh = env.GH_TOKEN?.trim();
303
+ const token = fromGh || env.GITHUB_TOKEN?.trim();
304
+ if (!token)
305
+ return { ok: false, error: "no GitHub token: set GH_TOKEN or GITHUB_TOKEN to a token that can create issues in the repository" };
306
+ return { ok: true, headers: { authorization: `Bearer ${token}` }, source: fromGh ? "GH_TOKEN" : "GITHUB_TOKEN" };
307
+ }
308
+ const email = env.JIRA_EMAIL?.trim();
309
+ const token = env.JIRA_API_TOKEN?.trim();
310
+ const missing = [email ? null : "JIRA_EMAIL", token ? null : "JIRA_API_TOKEN"].filter((n) => n !== null);
311
+ if (!email || !token)
312
+ return { ok: false, error: `no Jira credentials: set JIRA_EMAIL and JIRA_API_TOKEN (missing: ${missing.join(", ")})` };
313
+ return { ok: true, headers: { authorization: `Basic ${Buffer.from(`${email}:${token}`).toString("base64")}` }, source: "JIRA_EMAIL and JIRA_API_TOKEN" };
314
+ }
315
+ /**
316
+ * Every string that must never be printed: each credential variable that is
317
+ * set, whichever tracker the export is for and whether or not the set is
318
+ * complete, and the encoded header Jira's would make. A credential pasted
319
+ * into an option by mistake is redacted from the refusal too.
320
+ */
321
+ export function credentialSecrets(env) {
322
+ const values = [env.GH_TOKEN, env.GITHUB_TOKEN, env.JIRA_API_TOKEN].map((v) => v?.trim() ?? "").filter((v) => v.length > 0);
323
+ const email = env.JIRA_EMAIL?.trim();
324
+ const jiraToken = env.JIRA_API_TOKEN?.trim();
325
+ if (email && jiraToken)
326
+ values.push(Buffer.from(`${email}:${jiraToken}`).toString("base64"));
327
+ return values;
328
+ }
329
+ /** The findings an export files, from memory.json as read: open defects at or above the minimum severity. */
330
+ export function selectFindings(memory, o) {
331
+ const listed = memory && typeof memory === "object" ? memory.findings : undefined;
332
+ const all = readFindings(memory).filter((f) => FINDING_ID_RE.test(f.id));
333
+ const readable = new Set(all);
334
+ const unreadable = (Array.isArray(listed) ? listed : []).flatMap((entry, i) => readable.has(entry)
335
+ ? []
336
+ : [entry && typeof entry === "object" && typeof entry.id === "string" ? oneLine(entry.id, 70) : `#${i + 1}`]);
337
+ const counts = { open: 0, resolved: 0, worthALook: 0, belowSeverity: 0 };
338
+ const only = o.only ? new Set(o.only) : null;
339
+ const leftOut = [];
340
+ const candidates = [];
341
+ for (const f of all) {
342
+ const named = only?.has(f.id) ?? false;
343
+ if (only && !named)
344
+ continue;
345
+ let reason = null;
346
+ if (f.status === "resolved") {
347
+ counts.resolved++;
348
+ reason = "resolved";
349
+ }
350
+ else {
351
+ counts.open++;
352
+ if (isWorthALook(f) && !o.includeWorthALook) {
353
+ counts.worthALook++;
354
+ reason = "worth a look (add --include-worth-a-look)";
355
+ }
356
+ else if (SEVERITY_RANK[f.severity] > SEVERITY_RANK[o.minSeverity]) {
357
+ counts.belowSeverity++;
358
+ reason = `below --min-severity ${o.minSeverity}`;
359
+ }
360
+ }
361
+ if (reason === null)
362
+ candidates.push(f);
363
+ else if (named)
364
+ leftOut.push({ id: f.id, reason });
365
+ }
366
+ candidates.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || timeOf(a.foundAt) - timeOf(b.foundAt) || a.id.localeCompare(b.id));
367
+ const ids = new Set(all.map((f) => f.id));
368
+ return { candidates, counts, unreadable, unknownOnly: (o.only ?? []).filter((id) => !ids.has(id)), leftOut };
369
+ }
370
+ function timeOf(iso) {
371
+ const t = typeof iso === "string" ? Date.parse(iso) : NaN;
372
+ return Number.isNaN(t) ? 0 : t;
373
+ }
374
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
375
+ const MAX_CRITERIA_PER_ISSUE = 10;
376
+ /**
377
+ * Which criteria each finding shows failing, from the tickets and verdicts in
378
+ * memory.json, answered as the report answers them (tickets.ts answerTicket):
379
+ * a fail from any session decides a criterion, and a verdict on a criterion
380
+ * reworded since is left behind. Entries that do not read are skipped: a
381
+ * ticket is context for an issue, never a reason to refuse an export.
382
+ */
383
+ export function failedCriteriaByFinding(memory) {
384
+ const out = new Map();
385
+ if (!isObject(memory))
386
+ return out;
387
+ const tickets = (Array.isArray(memory.tickets) ? memory.tickets : []).filter((t) => isObject(t) &&
388
+ typeof t.id === "string" &&
389
+ Array.isArray(t.criteria) &&
390
+ t.criteria.every((c) => isObject(c) && typeof c.id === "string" && typeof c.text === "string"));
391
+ const verdicts = (Array.isArray(memory.criterionVerdicts) ? memory.criterionVerdicts : []).filter((v) => isObject(v) &&
392
+ typeof v.ticket === "string" &&
393
+ typeof v.criterion === "string" &&
394
+ typeof v.verdict === "string" &&
395
+ Array.isArray(v.findings) &&
396
+ v.findings.every((id) => typeof id === "string") &&
397
+ typeof v.confidence === "number" &&
398
+ typeof v.session === "string");
399
+ for (const ticket of tickets)
400
+ for (const answer of answerTicket(ticket, verdicts)) {
401
+ if (answer.verdict !== "fail")
402
+ continue;
403
+ for (const id of answer.findings) {
404
+ const list = out.get(id) ?? [];
405
+ if (list.length < MAX_CRITERIA_PER_ISSUE)
406
+ list.push({ ticket: ticket.id, criterion: answer.criterion.id, text: answer.criterion.text });
407
+ out.set(id, list);
408
+ }
409
+ }
410
+ return out;
411
+ }
412
+ /** A ticket id that is a Jira issue key, so the issue can be linked to it: "PROJ-12", not "#12" or "T1". */
413
+ export function isJiraKey(id) {
414
+ return /^[A-Z][A-Z0-9_]{1,49}-[1-9]\d{0,9}$/.test(id);
415
+ }
416
+ /** The Jira tickets an issue is linked to: each failed criterion's ticket that is a Jira key, once. */
417
+ export function ticketsToLink(criteria) {
418
+ return [...new Set(criteria.map((c) => c.ticket).filter(isJiraKey))];
419
+ }
420
+ /** The finding's own picture (scout_finding, #347), as a path under `.scenescout/`, when the path is one the engine writes. */
421
+ export function findingPicture(f) {
422
+ const p = readFindingPicture(f);
423
+ return p ? { rel: p.file, ...(p.at ? { at: p.at } : {}) } : null;
424
+ }
425
+ // ── the marker ──────────────────────────────────────────────────────────────
426
+ /** The first line of a GitHub issue's body: an HTML comment, so it is not shown. */
427
+ export function githubMarker(id) {
428
+ return `<!-- scenescout-finding: ${id} -->`;
429
+ }
430
+ /** The finding ids a GitHub issue's body carries markers for. Escaped text never matches: its `<` is `&lt;`. */
431
+ export function findingIdsInGithubBody(body) {
432
+ if (typeof body !== "string")
433
+ return [];
434
+ return [...body.matchAll(/<!-- scenescout-finding: ([A-Za-z0-9_-]{1,64}) -->/g)].map((m) => m[1]);
435
+ }
436
+ /**
437
+ * A Jira description cannot hide text, so the marker is a last, short line.
438
+ * It carries the revision of the summary and description it was written
439
+ * with, so a later export can tell whether anyone has edited them since.
440
+ */
441
+ function jiraMarkerText(id, revision) {
442
+ return `scenescout-finding: ${id} rev ${revision}`;
443
+ }
444
+ /**
445
+ * The finding ids a Jira description (Atlassian Document Format, as the API
446
+ * returns it) carries markers for. Model-written text has the marker's word
447
+ * broken by a zero-width space, so only a marker this export wrote, or one a
448
+ * person copied in on purpose, is read.
449
+ */
450
+ export function findingIdsInJiraDescription(description) {
451
+ if (description === null || description === undefined)
452
+ return [];
453
+ const text = typeof description === "string" ? description : JSON.stringify(description);
454
+ return [...text.matchAll(/scenescout-finding: ([A-Za-z0-9_-]{1,64})/g)].map((m) => m[1]);
455
+ }
456
+ /** The revision a Jira issue's marker for this finding records, or null when it records none (an issue filed before revisions were kept). */
457
+ export function jiraMarkerRevision(description, id) {
458
+ const text = typeof description === "string" ? description : JSON.stringify(description ?? "");
459
+ // An id is letters, digits, "_" and "-" (FINDING_ID_RE), none of which is special in a pattern.
460
+ if (!FINDING_ID_RE.test(id))
461
+ return null;
462
+ return new RegExp(`scenescout-finding: ${id} rev ([0-9a-f]{16})\\b`).exec(text)?.[1] ?? null;
463
+ }
464
+ /** The node types and marks a description SceneScout writes is made of (jiraDescription). */
465
+ const OWN_NODES = new Set(["doc", "paragraph", "text", "heading", "bulletList", "orderedList", "listItem", "codeBlock", "rule"]);
466
+ const OWN_MARKS = new Set(["code"]);
467
+ /**
468
+ * Every text node of a document, in order, leaving out the marker's
469
+ * paragraph, and a token for every node or mark SceneScout never writes: a
470
+ * picture, a mention, a link card or emoji a person pasted carries no text,
471
+ * and must still count as an edit.
472
+ */
473
+ function textsOf(node, out = []) {
474
+ if (Array.isArray(node)) {
475
+ for (const n of node)
476
+ textsOf(n, out);
477
+ return out;
478
+ }
479
+ if (!isObject(node))
480
+ return out;
481
+ if (node.type === "paragraph" && /scenescout-finding: /.test(JSON.stringify(node.content ?? "")))
482
+ return out;
483
+ if (!OWN_NODES.has(String(node.type)))
484
+ out.push(`\u0001node:${String(node.type)}:${JSON.stringify(node.attrs ?? null)}`);
485
+ for (const mark of Array.isArray(node.marks) ? node.marks : [])
486
+ if (!isObject(mark) || !OWN_MARKS.has(String(mark.type)))
487
+ out.push(`\u0001mark:${JSON.stringify(mark)}`);
488
+ if (node.type === "text" && typeof node.text === "string")
489
+ out.push(node.text);
490
+ textsOf(node.content, out);
491
+ return out;
492
+ }
493
+ /**
494
+ * The revision of an issue's summary and description: a hash of their text,
495
+ * with the marker's paragraph and all white space left out, and of anything
496
+ * in them SceneScout does not write. Not the whole document, so the
497
+ * attributes Jira adds to the nodes SceneScout wrote do not change it, while
498
+ * any word, picture, mention or link a person adds, removes or rewords does.
499
+ */
500
+ export function jiraRevision(summary, description) {
501
+ const text = [typeof summary === "string" ? summary : "", ...textsOf(description)].join("").replace(/\s+/g, "");
502
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
503
+ }
504
+ // ── inert text ──────────────────────────────────────────────────────────────
505
+ /** One line: control, bidirectional-override and line-break characters become spaces; runs of space collapse; at most `max`. */
506
+ export function oneLine(text, max) {
507
+ let s = String(text ?? "")
508
+ .replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]+/g, " ")
509
+ .replace(/\s+/g, " ")
510
+ .trim();
511
+ if (s.length > max)
512
+ s = `${s.slice(0, max - 1)}…`;
513
+ return s;
514
+ }
515
+ /**
516
+ * Zero-width spaces where a tracker would otherwise act on text: after `@`
517
+ * (a mention, an email autolink), inside `://` and `www.` (a link), between
518
+ * `#` or `GH-` and a number (a cross-reference), and inside the marker's word
519
+ * (so quoted text cannot claim to be another finding's issue).
520
+ */
521
+ function breakTriggers(s) {
522
+ return s
523
+ .replace(/@/g, "@\u200b")
524
+ .replace(/:\/\//g, ":\u200b//")
525
+ .replace(/www\./gi, (m) => `${m.slice(0, 3)}\u200b.`)
526
+ .replace(/#(?=\d)/g, "#\u200b")
527
+ .replace(/\bgh-(?=\d)/gi, (m) => `${m.slice(0, 2)}\u200b-`)
528
+ .replace(/scenescout-finding/gi, (m) => `${m.slice(0, 10)}\u200b${m.slice(10)}`);
529
+ }
530
+ /**
531
+ * Text for a GitHub Markdown body: one line, HTML-escaped, every Markdown
532
+ * punctuation mark backslash-escaped, and every trigger broken. The escaping
533
+ * of the `/scenescout qa` reply (action/qa-action.mjs `inert`), which
534
+ * export-test holds it to, plus `$` (GitHub renders `$…$` as maths), the
535
+ * cross-reference and marker breaks, and no bidirectional or C1 control
536
+ * characters.
537
+ */
538
+ export function inertMarkdown(text, max = 160) {
539
+ return breakTriggers(oneLine(text, max)
540
+ .replace(/&/g, "&amp;")
541
+ .replace(/</g, "&lt;")
542
+ .replace(/>/g, "&gt;")
543
+ .replace(/([\\`*_{}[\]()#+!|~$])/g, "\\$1"));
544
+ }
545
+ /** Text a tracker shows as it is (an issue title, a Jira text node): one line, every trigger broken, nothing escaped. */
546
+ export function inertPlain(text, max = 160) {
547
+ return breakTriggers(oneLine(text, max));
548
+ }
549
+ // ── what an issue says ──────────────────────────────────────────────────────
550
+ const MAX_TITLE = 200;
551
+ const MAX_DETAIL = 4000;
552
+ const MAX_STEP = 300;
553
+ const MAX_STEPS = 20;
554
+ const MAX_EVIDENCE = 2000;
555
+ /** A finding's fields as they are rendered: redacted again on the way out, and of the right type whatever the file held. */
556
+ function fieldsOf(f) {
557
+ const text = (v) => redactSecrets(typeof v === "string" ? v : "");
558
+ const url = text(f.url);
559
+ const route = redactSecrets(typeof f.state === "string" && f.state ? f.state.split("#")[0] : pathOf(url));
560
+ return {
561
+ title: text(f.title),
562
+ detail: text(f.detail),
563
+ evidence: text(f.evidence),
564
+ url,
565
+ route,
566
+ category: typeof f.category === "string" && f.category ? redactSecrets(f.category) : "other",
567
+ repro: (Array.isArray(f.repro) ? f.repro : []).filter((s) => typeof s === "string").map((s) => redactSecrets(s)),
568
+ runs: Number.isInteger(f.runs) && f.runs > 0 ? f.runs : 1,
569
+ foundAt: dateOf(f.foundAt),
570
+ regressedAt: f.regressedAt ? dateOf(f.regressedAt) : null,
571
+ verdict: f.verdict && ["gone", "present", "changed"].includes(f.verdict) ? `${f.verdict}${f.verifiedAt ? ` (${dateOf(f.verifiedAt)})` : ""}` : null,
572
+ convention: isWorthALook(f) ? text(f.convention) || "a convention of the project" : null,
573
+ };
574
+ }
575
+ function pathOf(url) {
576
+ try {
577
+ const u = new URL(url);
578
+ return `${u.pathname}${u.search}`;
579
+ }
580
+ catch {
581
+ return url;
582
+ }
583
+ }
584
+ function dateOf(iso) {
585
+ const t = timeOf(iso);
586
+ return t ? new Date(t).toISOString().slice(0, 10) : "unknown";
587
+ }
588
+ function frameName(rel) {
589
+ return rel.split("/").pop() || rel;
590
+ }
591
+ /** The line saying which criteria a finding fails, one per criterion. */
592
+ function criterionLine(c, inert) {
593
+ return `${inert(c.ticket, 60)} ${inert(c.criterion, 20)}: ${inert(c.text, 300)}`;
594
+ }
595
+ /** Why an issue has no screenshots: the run recorded none, or the ones it recorded are gone. */
596
+ function noFramesSentence(ctx) {
597
+ return ctx.framesLeftOut
598
+ ? "None: the run's frames of these steps are missing, too large, or were rewritten by a later run."
599
+ : "None: only a recorded run keeps a frame of each step (scout_attach with record: true).";
600
+ }
601
+ /** The issue's title, as both trackers take it. Both refuse one past 255 characters, which the breaks could otherwise reach. */
602
+ function issueTitle(f) {
603
+ const title = inertPlain(fieldsOf(f).title, MAX_TITLE);
604
+ return (title.length > 250 ? `${title.slice(0, 249)}…` : title) || "SceneScout finding";
605
+ }
606
+ /** Every label an issue carries: the marker label first, then the severity's, then the extras, with no repeats. */
607
+ function issueLabels(ctx) {
608
+ const out = [];
609
+ for (const l of [MARKER_LABEL, ...(ctx.severityName ? [ctx.severityName] : []), ...ctx.labels])
610
+ if (!out.some((x) => x.toLowerCase() === l.toLowerCase()))
611
+ out.push(l);
612
+ return out;
613
+ }
614
+ /** A GitHub issue for a finding: its title, a Markdown body whose first line is the hidden marker, and its labels. */
615
+ export function githubIssue(f, ctx) {
616
+ const x = fieldsOf(f);
617
+ const rows = [
618
+ ["Severity", f.severity],
619
+ ["Category", inertMarkdown(x.category, 40)],
620
+ ["Page", inertMarkdown(x.route, 200)],
621
+ ["Address", inertMarkdown(x.url, 300)],
622
+ ["Last found", x.foundAt],
623
+ ["Runs that found it", String(x.runs)],
624
+ ];
625
+ if (x.regressedAt)
626
+ rows.push(["Regressed", `${x.regressedAt}: it had been marked resolved`]);
627
+ if (x.verdict)
628
+ rows.push(["Last re-test", inertMarkdown(x.verdict, 60)]);
629
+ if (x.convention !== null)
630
+ rows.push(["Tier", `worth a look: a defect only if the project uses ${inertMarkdown(x.convention, 200)}`]);
631
+ const steps = x.repro.slice(-MAX_STEPS);
632
+ const lines = [
633
+ githubMarker(f.id),
634
+ "",
635
+ `> ${inertMarkdown(x.detail || x.title, MAX_DETAIL)}`,
636
+ "",
637
+ "| | |",
638
+ "|---|---|",
639
+ ...rows.map(([k, v]) => `| ${k} | ${v} |`),
640
+ "",
641
+ "### Steps to reproduce",
642
+ "",
643
+ ...(steps.length > 0 ? steps.map((s, i) => `${i + 1}. ${inertMarkdown(s, MAX_STEP)}`) : ["No steps were recorded with this finding."]),
644
+ "",
645
+ "### Evidence",
646
+ "",
647
+ x.evidence ? inertMarkdown(x.evidence, MAX_EVIDENCE) : "No machine evidence was recorded with this finding.",
648
+ "",
649
+ ];
650
+ if (ctx.criteria?.length)
651
+ lines.push("### Acceptance criteria it fails", "", ...ctx.criteria.map((c) => `- ${criterionLine(c, inertMarkdown)}`), "");
652
+ if (ctx.screenshots !== "off") {
653
+ lines.push("### Screenshots", "");
654
+ if (ctx.picture)
655
+ lines.push(`The picture taken when it was filed is in the run's \`.scenescout/\` folder, not attached (GitHub's API cannot upload a file to an issue): ${inertMarkdown(ctx.picture, 200)}`, "");
656
+ if (ctx.screenshots.length === 0)
657
+ lines.push(ctx.picture ? "No frames of the steps before it were kept." : noFramesSentence(ctx));
658
+ else
659
+ lines.push("Not attached: GitHub's API cannot upload a file to an issue. The run kept these frames of the steps before it was last found, in its `.scenescout/` folder:", "", ...ctx.screenshots.map((s) => `- ${inertMarkdown(s, 200)}`));
660
+ lines.push("");
661
+ }
662
+ lines.push("---", `Filed by SceneScout from finding \`${f.id}\`. A later \`scenescout export\` finds this issue by its \`${MARKER_LABEL}\` label and the hidden marker in this description, and does not file it again.`, "");
663
+ return { title: issueTitle(f), body: lines.join("\n"), labels: issueLabels(ctx) };
664
+ }
665
+ /** A text node; ADF refuses an empty one, so empty text becomes a dash. */
666
+ const text = (t, marks) => ({ type: "text", text: t || "-", ...(marks ? { marks } : {}) });
667
+ const paragraph = (...content) => ({ type: "paragraph", content });
668
+ const heading = (t) => ({ type: "heading", attrs: { level: 3 }, content: [text(t)] });
669
+ const listItems = (items) => items.map((t) => ({ type: "listItem", content: [paragraph(text(t))] }));
670
+ /**
671
+ * The description of a Jira issue for a finding, in Atlassian Document Format.
672
+ * Every text node holds plain text, so nothing in it is markup. Its last line
673
+ * is the marker, which records the revision of the summary and the rest of
674
+ * the description (jiraRevision).
675
+ */
676
+ export function jiraDescription(f, ctx) {
677
+ const x = fieldsOf(f);
678
+ const facts = [
679
+ `Severity: ${f.severity}`,
680
+ `Category: ${inertPlain(x.category, 40)}`,
681
+ `Page: ${inertPlain(x.route, 200)}`,
682
+ `Address: ${inertPlain(x.url, 300)}`,
683
+ `Last found: ${x.foundAt}`,
684
+ `Runs that found it: ${x.runs}`,
685
+ ...(x.regressedAt ? [`Regressed: ${x.regressedAt}: it had been marked resolved`] : []),
686
+ ...(x.verdict ? [`Last re-test: ${inertPlain(x.verdict, 60)}`] : []),
687
+ ...(x.convention !== null ? [`Tier: worth a look: a defect only if the project uses ${inertPlain(x.convention, 200)}`] : []),
688
+ ];
689
+ const steps = x.repro.slice(-MAX_STEPS).map((s) => inertPlain(s, MAX_STEP));
690
+ const content = [
691
+ paragraph(text(inertPlain(x.detail || x.title, MAX_DETAIL))),
692
+ { type: "bulletList", content: listItems(facts) },
693
+ heading("Steps to reproduce"),
694
+ steps.length > 0 ? { type: "orderedList", content: listItems(steps) } : paragraph(text("No steps were recorded with this finding.")),
695
+ heading("Evidence"),
696
+ x.evidence
697
+ ? { type: "codeBlock", content: [text(inertPlain(x.evidence, MAX_EVIDENCE))] }
698
+ : paragraph(text("No machine evidence was recorded with this finding.")),
699
+ ];
700
+ if (ctx.criteria?.length)
701
+ content.push(heading("Acceptance criteria it fails"), { type: "bulletList", content: listItems(ctx.criteria.map((c) => criterionLine(c, inertPlain))) });
702
+ if (ctx.screenshots !== "off") {
703
+ content.push(heading("Screenshots"));
704
+ if (ctx.picture)
705
+ content.push(paragraph(text(`Attached: ${inertPlain(uploadName(ctx.picture, ctx.pictureAt), 100)}, the picture taken when it was filed.`)));
706
+ if (ctx.screenshots.length > 0)
707
+ content.push(paragraph(text(`Attached: ${ctx.screenshots.map((s, i) => inertPlain(uploadName(s, ctx.screenshotTimes?.[i]), 100)).join(", ")}, the frames of the steps before it was last found.`)));
708
+ else
709
+ content.push(paragraph(text(ctx.picture ? "No frames of the steps before it were kept." : noFramesSentence(ctx))));
710
+ }
711
+ const revision = jiraRevision(issueTitle(f), content);
712
+ content.push({ type: "rule" }, paragraph(text("Filed by SceneScout. A later export finds this issue by this line, and updates it while nobody has edited its summary or description: "), text(jiraMarkerText(f.id, revision), [{ type: "code" }])));
713
+ return { type: "doc", version: 1, content };
714
+ }
715
+ /** The fields of a new Jira issue for a finding. An update sets only `summary` and `description` (jiraEditFields). */
716
+ export function jiraIssueFields(f, ctx) {
717
+ return {
718
+ project: { key: ctx.projectKey },
719
+ issuetype: { name: ctx.issueType },
720
+ summary: issueTitle(f),
721
+ description: jiraDescription(f, ctx),
722
+ labels: issueLabels({ severityName: null, labels: ctx.labels }),
723
+ ...(ctx.severityName ? { priority: { name: ctx.severityName } } : {}),
724
+ };
725
+ }
726
+ /** The fields an update of a Jira issue sets: the summary and description SceneScout writes, and nothing a team sets in triage (priority, labels, assignee). */
727
+ export function jiraEditFields(f, ctx) {
728
+ return { summary: issueTitle(f), description: jiraDescription(f, ctx) };
729
+ }
730
+ /** What an update does to an open issue filed earlier. Nothing is ever removed: a file or link someone took off is added again only while the finding still calls for it. */
731
+ export function planJiraUpdate(f, state, wanted) {
732
+ const recorded = jiraMarkerRevision(state.description, f.id);
733
+ let fields;
734
+ if (recorded === null)
735
+ fields = "no-revision";
736
+ // A second finding's marker was put there by a person (the marker's paragraph is not hashed), and a rewrite would drop it.
737
+ else if (jiraRevision(state.summary, state.description) !== recorded || findingIdsInJiraDescription(state.description).some((id) => id !== f.id))
738
+ fields = "edited";
739
+ else
740
+ fields = jiraMarkerRevision(wanted.description, f.id) === recorded ? "same" : "change";
741
+ const held = new Set(state.attachments);
742
+ const linked = new Set(state.links);
743
+ return { fields, attach: [...new Set(wanted.files)].filter((n) => !held.has(n)), link: wanted.tickets.filter((k) => !linked.has(k)) };
744
+ }
745
+ // ── screenshots ─────────────────────────────────────────────────────────────
746
+ /** Most frames one issue names or attaches: the steps right before the finding was last found. */
747
+ const MAX_SCREENSHOTS = 3;
748
+ /** Frames older than this before a finding's time belong to some earlier step of the run, not to the finding. */
749
+ const FRAME_WINDOW_MS = 10 * 60_000;
750
+ /** How far a frame file's modification time may be from its step's time and still be that step's picture. */
751
+ const FRAME_MTIME_SLACK_MS = 2 * 60_000;
752
+ /**
753
+ * The frames kept for a finding: from the session that filed it, the last few
754
+ * steps before it was last found (`foundAt` moves each time a run finds it
755
+ * again), within a window. A finding with no session names none, since
756
+ * another session's frame would be another page.
757
+ */
758
+ export function framesFor(f, log, most = MAX_SCREENSHOTS) {
759
+ const found = timeOf(f.foundAt);
760
+ if (!f.session || !found)
761
+ return [];
762
+ return log
763
+ .filter((e) => typeof e.frame === "string" && e.frame !== "" && e.session === f.session)
764
+ .map((e) => ({ frame: e.frame, at: e.at, t: timeOf(e.at) }))
765
+ .filter((e) => e.t > 0 && e.t <= found && found - e.t <= FRAME_WINDOW_MS)
766
+ .sort((a, b) => a.t - b.t)
767
+ .slice(-most)
768
+ .map(({ frame, at }) => ({ frame, at }));
769
+ }
770
+ /** Whether a frame file is still the picture written at its step: a later run reusing the session's name rewrites the same file names. */
771
+ export function frameIsOriginal(stepAt, mtimeMs) {
772
+ const t = timeOf(stepAt);
773
+ return t > 0 && Math.abs(mtimeMs - t) <= FRAME_MTIME_SLACK_MS;
774
+ }
775
+ /**
776
+ * What one export does with each candidate: skip what the tracker already
777
+ * holds, file the rest up to the cap, and leave what is over the cap for the
778
+ * next export, which files it because these are skipped by then. `existing`
779
+ * null means the tracker was not asked (a dry run with no credentials).
780
+ */
781
+ export function planExport(candidates, existing, cap) {
782
+ let toFile = 0;
783
+ return candidates.map((finding) => {
784
+ const issue = existing?.get(finding.id);
785
+ if (issue)
786
+ return { finding, outcome: "already-filed", issue };
787
+ if (toFile >= cap)
788
+ return { finding, outcome: "over-cap" };
789
+ toFile++;
790
+ return { finding, outcome: "file" };
791
+ });
792
+ }
793
+ /**
794
+ * Fold another issue into what is already filed for a finding. An open issue
795
+ * wins over a closed one, then the earlier one, so a finding filed twice by
796
+ * hand still reads as filed once.
797
+ */
798
+ export function rememberFiled(map, id, issue) {
799
+ const held = map.get(id);
800
+ if (!held || (issue.open && !held.open) || (issue.open === held.open && issue.number < held.number))
801
+ map.set(id, issue);
802
+ }
803
+ // ── talking to a tracker ────────────────────────────────────────────────────
804
+ /** The longest a rate limit may ask an export to wait before it gives up and says when to try again. */
805
+ export const MAX_RATE_WAIT_MS = 60_000;
806
+ /** How long GitHub asks a client to wait after a secondary rate limit that names no time. */
807
+ const SECONDARY_LIMIT_WAIT_MS = 60_000;
808
+ /**
809
+ * Whether a response is a rate limit, and how long it asks to wait: 429
810
+ * always is; a 403 only when it says so, by `retry-after`, by
811
+ * `x-ratelimit-remaining: 0` (GitHub's primary limit) or by its message
812
+ * (GitHub's secondary limit, which may send neither header and then asks for
813
+ * a minute). Any other 403 is a permission the token lacks. `waitMs` null:
814
+ * no wait was named.
815
+ */
816
+ export function rateLimit(status, header, nowMs, message = "") {
817
+ if (status !== 429 && status !== 403)
818
+ return { limited: false };
819
+ const named = retryAfterMs(header("retry-after")?.trim(), nowMs);
820
+ if (named !== undefined)
821
+ return { limited: true, waitMs: named };
822
+ if (header("x-ratelimit-remaining")?.trim() === "0") {
823
+ const reset = Number(header("x-ratelimit-reset"));
824
+ return { limited: true, waitMs: Number.isFinite(reset) && reset > 0 ? Math.max(0, reset * 1000 - nowMs) : null };
825
+ }
826
+ if (status === 403 && /rate limit/i.test(message))
827
+ return { limited: true, waitMs: SECONDARY_LIMIT_WAIT_MS };
828
+ return status === 429 ? { limited: true, waitMs: null } : { limited: false };
829
+ }
830
+ /** What a tracker said when it refused a request, as one short line: GitHub's `message` and `errors`, Jira's `errorMessages` and `errors`. */
831
+ export function trackerMessage(body) {
832
+ if (!body || typeof body !== "object")
833
+ return "";
834
+ const b = body;
835
+ const parts = [];
836
+ if (typeof b.message === "string")
837
+ parts.push(b.message);
838
+ if (Array.isArray(b.errorMessages))
839
+ parts.push(...b.errorMessages.filter((m) => typeof m === "string"));
840
+ if (Array.isArray(b.errors))
841
+ for (const e of b.errors) {
842
+ if (typeof e === "string")
843
+ parts.push(e);
844
+ else if (e && typeof e === "object") {
845
+ const o = e;
846
+ const said = [o.field, o.message ?? o.code].filter((v) => typeof v === "string").join(": ");
847
+ if (said)
848
+ parts.push(said);
849
+ }
850
+ }
851
+ else if (b.errors && typeof b.errors === "object")
852
+ for (const [field, message] of Object.entries(b.errors))
853
+ if (typeof message === "string")
854
+ parts.push(`${field}: ${message}`);
855
+ return oneLine(parts.join("; "), 300);
856
+ }
857
+ /**
858
+ * The name a picture or frame is attached to a Jira issue under: its file
859
+ * name after the time it was taken. File names repeat (a session's frames
860
+ * are numbered from one again when a later run reuses its name, and a
861
+ * finding's picture keeps its name when it is retaken), so the name alone
862
+ * cannot say whether an issue already holds this picture.
863
+ */
864
+ export function uploadName(rel, at) {
865
+ const t = timeOf(at);
866
+ const name = frameName(rel);
867
+ return t
868
+ ? `${new Date(t)
869
+ .toISOString()
870
+ .replace(/[-:]/g, "")
871
+ .replace(/\.\d+Z$/, "Z")}-${name}`
872
+ : name;
873
+ }
874
+ /** A line about a finding for the terminal: its severity, its title (no control characters, so no terminal escapes) and its id. */
875
+ export function findingLine(f) {
876
+ return `${f.severity.padEnd(6)} ${oneLine(redactSecrets(f.title), 100)} [finding ${f.id}]`;
877
+ }