@reddoorla/maintenance 0.56.0 → 0.57.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/dist/{chunk-VXSX2FHO.js → chunk-5L2QXUTU.js} +12 -2
- package/dist/chunk-5L2QXUTU.js.map +1 -0
- package/dist/{chunk-DIVB6ZF7.js → chunk-J4ECCFRT.js} +7 -3
- package/dist/chunk-J4ECCFRT.js.map +1 -0
- package/dist/{chunk-PF6BNYGE.js → chunk-MKSZBODM.js} +2 -2
- package/dist/chunk-PZQ3WED2.js +14 -0
- package/dist/chunk-PZQ3WED2.js.map +1 -0
- package/dist/cli/bin.js +5 -5
- package/dist/{github-signals-YD64R2AF.js → github-signals-XLQXXH6Q.js} +5 -11
- package/dist/github-signals-XLQXXH6Q.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/{launch-HXHEL6ID.js → launch-XW6PEKUL.js} +3 -3
- package/dist/{orchestrate-RMSBHLQK.js → orchestrate-LHH3LQNP.js} +8 -4
- package/dist/{renovate-dispatch-ZGLXNPEK.js → renovate-dispatch-YVBTBJJY.js} +9 -6
- package/dist/renovate-dispatch-YVBTBJJY.js.map +1 -0
- package/dist/{report-SD3CNBOQ.js → report-WK3SKWJI.js} +2 -2
- package/dist/{self-updating-CIUMOMYI.js → self-updating-BYUBUNO4.js} +3 -3
- package/package.json +1 -1
- package/dist/chunk-DIVB6ZF7.js.map +0 -1
- package/dist/chunk-VXSX2FHO.js.map +0 -1
- package/dist/github-signals-YD64R2AF.js.map +0 -1
- package/dist/renovate-dispatch-ZGLXNPEK.js.map +0 -1
- /package/dist/{chunk-PF6BNYGE.js.map → chunk-MKSZBODM.js.map} +0 -0
- /package/dist/{launch-HXHEL6ID.js.map → launch-XW6PEKUL.js.map} +0 -0
- /package/dist/{orchestrate-RMSBHLQK.js.map → orchestrate-LHH3LQNP.js.map} +0 -0
- /package/dist/{report-SD3CNBOQ.js.map → report-WK3SKWJI.js.map} +0 -0
- /package/dist/{self-updating-CIUMOMYI.js.map → self-updating-BYUBUNO4.js.map} +0 -0
|
@@ -62,6 +62,13 @@ async function prepareHeaderImage(bytes, options = {}) {
|
|
|
62
62
|
// src/reports/send/orchestrate.ts
|
|
63
63
|
var FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
|
|
64
64
|
var REPLY_TO = "info@reddoorla.com";
|
|
65
|
+
var GLOBAL_REPORT_CC = "info@reddoorla.com";
|
|
66
|
+
function withGlobalCc(perSiteCc, to) {
|
|
67
|
+
const cc = [...perSiteCc ?? []];
|
|
68
|
+
const present = new Set([...cc, ...to].map((a) => a.toLowerCase()));
|
|
69
|
+
if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);
|
|
70
|
+
return cc;
|
|
71
|
+
}
|
|
65
72
|
var MONTHS = [
|
|
66
73
|
"January",
|
|
67
74
|
"February",
|
|
@@ -222,7 +229,8 @@ async function sendOne(client, base, site, report) {
|
|
|
222
229
|
// Resend returns the original message id rather than sending a duplicate.
|
|
223
230
|
idempotencyKey: `report:${report.id}`
|
|
224
231
|
};
|
|
225
|
-
|
|
232
|
+
const finalCc = withGlobalCc(cc, to);
|
|
233
|
+
if (finalCc.length > 0) payload.cc = finalCc;
|
|
226
234
|
let result;
|
|
227
235
|
try {
|
|
228
236
|
result = await client.send(payload);
|
|
@@ -262,8 +270,10 @@ function isProbablyEmail(s) {
|
|
|
262
270
|
}
|
|
263
271
|
|
|
264
272
|
export {
|
|
273
|
+
GLOBAL_REPORT_CC,
|
|
274
|
+
withGlobalCc,
|
|
265
275
|
sendApprovedReports,
|
|
266
276
|
parseAddresses,
|
|
267
277
|
isProbablyEmail
|
|
268
278
|
};
|
|
269
|
-
//# sourceMappingURL=chunk-
|
|
279
|
+
//# sourceMappingURL=chunk-5L2QXUTU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/maintenance-email/header-image.ts","../src/reports/send/orchestrate.ts"],"sourcesContent":["import sharp from \"sharp\";\n\nexport type PreparedHeaderImage = {\n /** Resized JPEG bytes to attach inline (CID) in place of the Airtable original. */\n bytes: Uint8Array;\n /** Always \"image/jpeg\" — we re-encode for predictable size and a flat white background. */\n contentType: string;\n /** CSS display width in px (≤ requested, never wider than the source has pixels for). */\n displayWidth: number;\n /** CSS display height in px, source aspect ratio preserved (no distortion). */\n displayHeight: number;\n /** Dominant-color hex (e.g. \"#cfc3a8\"), used as the loading/blocked placeholder box. */\n placeholderColor: string;\n};\n\nexport type PrepareHeaderImageOptions = {\n /** Intended CSS display width. The email body is 600px, so that's the default. */\n displayWidth?: number;\n};\n\nconst DEFAULT_DISPLAY_WIDTH = 600;\n/** Encode the source at 2× display width so it stays crisp on retina screens. */\nconst RETINA_SCALE = 2;\n/** Quality is for *resized* pixels — at 1200px the texture/text read as sharp; bytes are tiny. */\nconst JPEG_QUALITY = 82;\n\nfunction channelToHex(value: number): string {\n return Math.max(0, Math.min(255, Math.round(value)))\n .toString(16)\n .padStart(2, \"0\");\n}\n\n/**\n * Downscale an oversized header image for email: 2× the display width (retina) at most,\n * never upscaled, re-encoded as JPEG on a flat white background. Also reports the display\n * dimensions (so the template can reserve the box and stop reflow) and a dominant color\n * (so the reserved box shows a matched placeholder while the image loads).\n *\n * Root cause this addresses: Airtable headers can be multi-MB / 2400px+ while the email\n * renders them at ~600px — shipping ~16× more pixels than the display can use.\n */\nexport async function prepareHeaderImage(\n bytes: Uint8Array,\n options: PrepareHeaderImageOptions = {},\n): Promise<PreparedHeaderImage> {\n const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;\n const input = Buffer.from(bytes);\n\n const meta = await sharp(input).metadata();\n const origWidth = meta.width;\n const origHeight = meta.height;\n if (!origWidth || !origHeight) {\n throw new Error(\"prepareHeaderImage: could not read source image dimensions\");\n }\n\n // Never claim a wider display than the source can fill at 1×.\n const displayWidth = Math.min(requestedDisplayWidth, origWidth);\n const displayHeight = Math.round((displayWidth * origHeight) / origWidth);\n\n // Encode at 2× display for retina, but never enlarge a smaller original.\n const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);\n\n const out = await sharp(input)\n .resize({ width: targetSourceWidth, withoutEnlargement: true })\n .flatten({ background: \"#ffffff\" })\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n\n const { dominant } = await sharp(out).stats();\n const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;\n\n return {\n bytes: new Uint8Array(out),\n contentType: \"image/jpeg\",\n displayWidth,\n displayHeight,\n placeholderColor,\n };\n}\n","import { openBase, readAirtableConfig } from \"../airtable/client.js\";\nimport { listSendableReports, stampSent } from \"../airtable/reports.js\";\nimport { listWebsites, siteSlug, updateLaunched } from \"../airtable/websites.js\";\nimport type { WebsiteRow } from \"../airtable/websites.js\";\nimport type { ReportRow } from \"../airtable/reports.js\";\nimport { fetchAttachmentBytes } from \"../airtable/attachments.js\";\nimport { renderReportHtml } from \"../render.js\";\nimport { resolveCopy } from \"../copy.js\";\nimport { announcementSiteExtras } from \"../announcement-email/template.js\";\nimport { loadBundledImages } from \"../maintenance-email/assets/index.js\";\nimport { prepareHeaderImage } from \"../maintenance-email/header-image.js\";\nimport { defaultResendClient, type ResendClient, type ResendSendInput } from \"./resend.js\";\nimport { isIdempotencyConflict } from \"./idempotency.js\";\nimport { checklistFor, isChecklistComplete } from \"../checklist.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\n/** Operations inbox CC'd on every outgoing report so there's always an internal\n * copy on file alongside the client recipients. */\nexport const GLOBAL_REPORT_CC = \"info@reddoorla.com\";\n\n/**\n * Append {@link GLOBAL_REPORT_CC} to a site's per-site CC list. The per-site CC is\n * passed through unchanged (preserving prior behavior); the global address is added\n * only when it isn't already present in the CC or To lists (case-insensitive), so a\n * report is never double-addressed to the ops inbox. Returns the final CC list (may\n * be empty if the global address is already the sole To recipient — the caller omits\n * an empty CC).\n */\nexport function withGlobalCc(perSiteCc: string[] | null, to: string[]): string[] {\n const cc = [...(perSiteCc ?? [])];\n const present = new Set([...cc, ...to].map((a) => a.toLowerCase()));\n if (!present.has(GLOBAL_REPORT_CC.toLowerCase())) cc.push(GLOBAL_REPORT_CC);\n return cc;\n}\n\nconst MONTHS = [\n \"January\",\n \"February\",\n \"March\",\n \"April\",\n \"May\",\n \"June\",\n \"July\",\n \"August\",\n \"September\",\n \"October\",\n \"November\",\n \"December\",\n];\n\n/** \"May 2026\" — UTC month/year, consistent with the rest of the reports pipeline's dates. */\nfunction monthYear(d: Date): string {\n return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;\n}\n\ntype InlineAttachment = NonNullable<ResendSendInput[\"attachments\"]>[number];\n\n/** Build a Resend inline (CID-referenced) attachment from raw bytes — the header\n * image and both bundled images share this exact shape. */\nfunction toInlineAttachment(a: {\n bytes: Uint8Array;\n filename: string;\n contentType: string;\n cid: string;\n}): InlineAttachment {\n return {\n filename: a.filename,\n content: Buffer.from(a.bytes).toString(\"base64\"),\n contentType: a.contentType,\n inlineContentId: a.cid,\n };\n}\n\nexport type OrchestrateOptions = {\n resend?: ResendClient;\n};\n\nexport async function sendApprovedReports(\n options: OrchestrateOptions = {},\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const client = options.resend ?? defaultResendClient();\n\n const sendable = await listSendableReports(base);\n if (sendable.length === 0) return { output: \"No reports ready to send.\", code: 0 };\n\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const lines: string[] = [];\n let anyFailed = false;\n for (const report of sendable) {\n const site = sites.get(report.siteId);\n if (!site) {\n lines.push(`✗ ${report.reportId} — Site row not found for id=${report.siteId}`);\n anyFailed = true;\n continue;\n }\n try {\n const messageId = await sendOne(client, base, site, report);\n lines.push(`✓ sent: ${report.reportId} (${messageId})`);\n if (report.reportType === \"Launch\") {\n try {\n await updateLaunched(base, site.id, new Date().toISOString());\n lines.push(` ↳ launched: ${site.name} flipped to maintenance`);\n } catch (e) {\n lines.push(` ⚠ launch flip failed for ${site.name}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n lines.push(`✗ ${report.reportId} — ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n return { output: lines.join(\"\\n\"), code: anyFailed ? 1 : 0 };\n}\n\nasync function sendOne(\n client: ResendClient,\n base: ReturnType<typeof openBase>,\n site: WebsiteRow,\n report: ReportRow,\n): Promise<string> {\n // Hard checklist gate: a Maintenance/Testing report whose operator checklist isn't\n // fully checked must never go out — even if \"Approved to send\" was ticked directly in\n // Airtable, bypassing the dashboard's approve gate. Throw so the report is skipped and\n // `Sent at` stays null (at-least-once retry preserved), exactly like the other sendOne\n // guards. Launch/Announcement have an empty checklist → vacuously complete, never gated.\n if (!isChecklistComplete(report)) {\n const items = checklistFor(report.reportType);\n const done = items.filter((i) => report.checklist[i.field] === true).length;\n throw new Error(\n `Report ${report.reportId} checklist incomplete — ${done}/${items.length} items checked`,\n );\n }\n if (!site.headerImage) {\n throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);\n }\n if (!report.lighthouse) {\n throw new Error(\n `Report ${report.reportId} has no Lighthouse scores — all four cells ` +\n `(Lighthouse — Performance / Accessibility / Best Practices / SEO) must be numeric ` +\n `on the Reports row; one non-numeric or blank cell nulls all four`,\n );\n }\n\n // Resolve + validate recipients BEFORE the expensive work (header fetch + sharp\n // downscale + full MJML render). A misconfigured-recipients site is a guaranteed\n // failure, so fail fast here rather than after burning that work. Same checks +\n // messages as before — only the position moved.\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n // Run pointOfContact through the parser too — operators sometimes paste\n // \"a@x, b@y\" into that single-line field.\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n throw new Error(\n `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`,\n );\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' recipient is malformed: ${addr} — use a bare address only ` +\n `(no \\`Name <addr>\\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`,\n );\n }\n }\n const cc = parseAddresses(site.reportRecipientsCc);\n if (cc) {\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' CC is malformed: ${addr} — fix Report recipients (CC) in Airtable`,\n );\n }\n }\n }\n\n const original = await fetchAttachmentBytes(site.headerImage.url);\n // Downscale the (often multi-MB / 2400px+) Airtable header to email display size, and get\n // back display dims + a placeholder color so the template can reserve the box.\n const header = await prepareHeaderImage(original.bytes);\n const bundled = await loadBundledImages();\n\n const slug = siteSlug(site.name);\n const cidName = `${slug}-header`;\n const { html } = await renderReportHtml({\n siteName: site.name,\n siteUrl: site.url,\n reportType: report.reportType,\n completedOn: report.completedOn ? new Date(report.completedOn) : new Date(),\n lighthouse: report.lighthouse,\n gaUsersCurrent: report.gaUsersCurrent ?? undefined,\n gaUsersPrevious: report.gaUsersPrevious ?? undefined,\n searchPosition:\n report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : undefined,\n lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,\n commentary: report.commentary,\n copy: resolveCopy(site),\n headerImageCid: cidName,\n headerWidth: header.displayWidth,\n headerHeight: header.displayHeight,\n headerBgColor: header.placeholderColor,\n // Announcement-only: re-derive cadence + improvements from the site row so the SENT email\n // keeps its cadence copy + improvement callouts. Without this the send-time re-render drops\n // them entirely (they're not stored on the Reports row). Ignored by the other templates.\n ...(report.reportType === \"Announcement\" ? announcementSiteExtras(site) : {}),\n });\n\n const reportDate = report.completedOn ? new Date(report.completedOn) : new Date();\n const subject =\n report.subjectOverride ?? `${site.name} — ${monthYear(reportDate)} ${report.reportType} Report`;\n\n // Inline attachments: the per-site header (every template renders it) plus only the bundled\n // images the rendered HTML actually references. The green check (cid:rd-check-png) appears in\n // every report's checklist/score rows EXCEPT Launch; the blurred-tests image\n // (cid:rd-blurred-tests-jpg) appears ONLY in the Maintenance email. Attaching a bundled image\n // the template doesn't reference leaves a dangling inline part that some mail clients surface as\n // a stray downloadable attachment — so gate each on its cid actually appearing in `html`. This\n // self-corrects if a template's image usage changes; there's no per-report-type list to keep in\n // sync. Self-contained inline (no external CDN, no image-blocked broken icons in webmail).\n const attachments: InlineAttachment[] = [\n toInlineAttachment({\n bytes: header.bytes,\n filename: `${cidName}.jpg`,\n contentType: header.contentType,\n cid: cidName,\n }),\n ];\n for (const img of [bundled.check, bundled.blurred]) {\n if (html.includes(`cid:${img.cid}`)) {\n attachments.push(\n toInlineAttachment({\n bytes: img.bytes,\n filename: img.filename,\n contentType: img.contentType,\n cid: img.cid,\n }),\n );\n }\n }\n\n const payload: Parameters<ResendClient[\"send\"]>[0] = {\n from: FROM_ADDRESS,\n to,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n // Stable across retries of the same row — if Airtable stamping fails after a\n // successful Resend, the next --send-ready replays with the same key and\n // Resend returns the original message id rather than sending a duplicate.\n idempotencyKey: `report:${report.id}`,\n };\n // Always CC the ops inbox (info@reddoorla.com), in addition to any per-site CC.\n const finalCc = withGlobalCc(cc, to);\n if (finalCc.length > 0) payload.cc = finalCc;\n\n let result: Awaited<ReturnType<ResendClient[\"send\"]>>;\n try {\n result = await client.send(payload);\n } catch (err) {\n // The send path is at-least-once: client.send succeeds → stampSent writes\n // `Sent at` (the ONLY thing that removes the row from listSendableReports). If\n // stampSent threw on a PRIOR run (an Airtable blip), `Sent at` stayed null and\n // the row replays here. By replay time the rendered body has usually changed\n // (operator Commentary edit, `report --due` rewrote scores, or the header\n // re-encodes non-deterministically), so Resend rejects the same-key\n // (`report:<id>`) / different-body re-send with a 409 (`invalid_idempotent_request`).\n //\n // That 409 means the email ALREADY WENT OUT under this key on the prior run.\n // Do NOT re-throw and do NOT re-send (re-throwing leaves the row unstamped, and\n // after the 24h key TTL a SECOND real email would go out). Instead stamp the row\n // so it stops replaying, then return success so the caller runs the Launch flip —\n // which self-heals a launch that sent-but-never-flipped on the prior run.\n //\n // Any OTHER error (real network/Resend failure) re-throws, exactly as before, so\n // a genuine failure still fails loudly and the row replays next run.\n if (isIdempotencyConflict(err)) {\n // Stamp `Sent at` ONLY — the original send's messageId is unrecoverable on\n // the 409 path, so we leave `Resend message ID` null rather than writing a\n // sentinel that would masquerade as a real id and orphan webhook lookups.\n // Still return the sentinel string so the caller logs the already-sent path\n // and runs the Launch flip.\n await stampSent(base, report.id, new Date(), null);\n console.log(`↻ already sent (idempotency conflict), stamped: ${report.reportId}`);\n return \"idempotent-conflict\";\n }\n throw err;\n }\n await stampSent(base, report.id, new Date(), result.messageId);\n return result.messageId;\n}\n\n/**\n * Split a comma/newline-separated address field into a clean array.\n * Lowercases (case-insensitive dedupe) and removes empty entries. Returns\n * null if nothing survives. Does NOT understand `Display Name <email>` —\n * operators should put a bare address in the Airtable field, or use multiple\n * lines if needing multiple recipients.\n */\nexport function parseAddresses(field: string | null): string[] | null {\n if (!field) return null;\n const seen = new Set<string>();\n const list: string[] = [];\n for (const raw of field.split(/[,\\n]/)) {\n const trimmed = raw.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list.length > 0 ? list : null;\n}\n\n/**\n * Cheap email shape check — must contain exactly one @, with non-empty\n * local and domain parts and at least one dot in the domain. We're not\n * trying to be a full RFC validator; we're trying to catch operator\n * mistakes like \"ops at acme dot com\" or a missing @ before they 422\n * at Resend.\n */\nexport function isProbablyEmail(s: string): boolean {\n const at = s.indexOf(\"@\");\n if (at < 1 || at !== s.lastIndexOf(\"@\")) return false;\n const local = s.slice(0, at);\n const domain = s.slice(at + 1);\n if (!local || !domain) return false;\n if (!domain.includes(\".\")) return false;\n if (/\\s/.test(s)) return false;\n return true;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -25,6 +25,9 @@ function mapRollupState(state) {
|
|
|
25
25
|
return "none";
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
function mapMergeable(state) {
|
|
29
|
+
return state === "MERGEABLE" || state === "CONFLICTING" ? state : "UNKNOWN";
|
|
30
|
+
}
|
|
28
31
|
function makeGitHub(deps) {
|
|
29
32
|
const spawn = deps.spawn ?? defaultSpawn;
|
|
30
33
|
const env = { ...process.env, GH_TOKEN: deps.token };
|
|
@@ -146,7 +149,7 @@ function makeGitHub(deps) {
|
|
|
146
149
|
if (!owner || !name || rest.length > 0) {
|
|
147
150
|
throw new Error(`openPullRequests: expected "owner/repo", got "${repo}"`);
|
|
148
151
|
}
|
|
149
|
-
const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url headRefName commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}";
|
|
152
|
+
const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url headRefName mergeable commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}";
|
|
150
153
|
const out = await gh([
|
|
151
154
|
"api",
|
|
152
155
|
"graphql",
|
|
@@ -164,7 +167,8 @@ function makeGitHub(deps) {
|
|
|
164
167
|
title: n.title,
|
|
165
168
|
url: n.url,
|
|
166
169
|
headRef: n.headRefName,
|
|
167
|
-
ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
|
|
170
|
+
ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state),
|
|
171
|
+
mergeable: mapMergeable(n.mergeable)
|
|
168
172
|
}));
|
|
169
173
|
},
|
|
170
174
|
async defaultBranchStatus(repo) {
|
|
@@ -214,4 +218,4 @@ function makeGitHub(deps) {
|
|
|
214
218
|
export {
|
|
215
219
|
makeGitHub
|
|
216
220
|
};
|
|
217
|
-
//# sourceMappingURL=chunk-
|
|
221
|
+
//# sourceMappingURL=chunk-J4ECCFRT.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/github/gh.ts"],"sourcesContent":["import { defaultSpawn, type SpawnFn } from \"../audits/util/spawn.js\";\n\n/** Aggregate CI state of a PR's head commit, normalized from GitHub's rollup. */\nexport type CiState = \"passing\" | \"failing\" | \"pending\" | \"none\";\n\n/** GitHub's computed mergeability of a PR. `UNKNOWN` is transient — GitHub is\n * still computing it (e.g. right after a push) — so it should be read as \"not\n * known to conflict\", never as conflicting. */\nexport type PrMergeable = \"MERGEABLE\" | \"CONFLICTING\" | \"UNKNOWN\";\n\n/** A minimal open-PR summary with its head-commit CI rollup state + mergeability. */\nexport type PullRequestSummary = {\n number: number;\n title: string;\n url: string;\n headRef: string;\n ciState: CiState;\n mergeable: PrMergeable;\n};\n\n/**\n * Reject a value before it's interpolated into a `gh api` URL path. The\n * `owner/repo` split methods already validate shape; this is the defense-in-depth\n * guard for the `branch` and file-`path` segments. An unexpected value (`..`, a\n * leading `/`, whitespace, or a URL-structural char like `?#%` or a backslash)\n * could otherwise retarget the endpoint (escape the intended path, smuggle a\n * query string, or traverse). Conservative by design — legit branch names like\n * `maint/self-updating-x` and paths like `.github/workflows/ci.yml` pass.\n *\n * Both branch refs (`maint/self-updating-x`) and file paths\n * (`.github/workflows/ci.yml`) legitimately contain `/`, so a single slash is\n * allowed; what's rejected is `..`, a leading `/`, whitespace, or a\n * URL-structural char (`?`, `#`, `%`, backslash) that could escape or retarget\n * the endpoint.\n */\nfunction assertUrlSegment(kind: \"branch\" | \"path\", value: string): void {\n const structural = /[\\s?#%\\\\]|\\.\\./;\n if (value.length === 0 || value.startsWith(\"/\") || structural.test(value)) {\n throw new Error(\n `unsafe ${kind} for gh api path (illegal characters or traversal): ${JSON.stringify(value)}`,\n );\n }\n}\n\n/** Map GitHub's `statusCheckRollup.state` enum to our normalized CiState. */\nfunction mapRollupState(state: string | null | undefined): CiState {\n switch (state) {\n case \"SUCCESS\":\n return \"passing\";\n case \"FAILURE\":\n case \"ERROR\":\n return \"failing\";\n case \"PENDING\":\n case \"EXPECTED\":\n return \"pending\";\n default:\n return \"none\"; // null/undefined = no checks reported\n }\n}\n\n/** Coerce GitHub's `mergeable` enum to our PrMergeable. Anything unexpected\n * (including the literal `UNKNOWN` GitHub returns while still computing) maps to\n * `UNKNOWN` — i.e. \"not known to conflict\". Only an explicit `CONFLICTING` is. */\nfunction mapMergeable(state: string | null | undefined): PrMergeable {\n return state === \"MERGEABLE\" || state === \"CONFLICTING\" ? state : \"UNKNOWN\";\n}\n\nexport type GitHub = {\n openPullRequest: (\n repo: string,\n pr: { head: string; base: string; title: string; body: string },\n ) => Promise<{ url: string }>;\n enableRepoAutoMerge: (repo: string) => Promise<void>;\n protectBranch: (repo: string, branch: string, requiredChecks: string[]) => Promise<void>;\n setRepoSecret: (repo: string, name: string, value: string) => Promise<void>;\n repoExists: (repo: string) => Promise<boolean>;\n defaultBranch: (repo: string) => Promise<string>;\n filesOnBranch: (repo: string, branch: string, paths: string[]) => Promise<string[]>;\n branchProtectionContexts: (repo: string, branch: string) => Promise<string[]>;\n secretExists: (repo: string, name: string) => Promise<boolean>;\n autoMergeEnabled: (repo: string) => Promise<boolean>;\n findOpenSelfUpdatingPR: (repo: string) => Promise<string | null>;\n /** All open PRs on a repo with each head commit's normalized CI rollup state. */\n openPullRequests: (repo: string) => Promise<PullRequestSummary[]>;\n /** The default branch's latest-commit date + normalized CI rollup, one query. */\n defaultBranchStatus: (repo: string) => Promise<{ ciState: CiState; lastCommitAt: string | null }>;\n /** Fire a `workflow_dispatch` for `<workflow>` (a filename like `renovate.yml`)\n * on `ref`. Requires the token's `actions:write` scope; a 404 (no such\n * workflow) or 403 (missing scope) surfaces as a thrown error. */\n dispatchWorkflow: (repo: string, workflow: string, ref: string) => Promise<void>;\n};\n\nexport function makeGitHub(deps: { token: string; spawn?: SpawnFn }): GitHub {\n const spawn = deps.spawn ?? defaultSpawn;\n const env = { ...process.env, GH_TOKEN: deps.token };\n\n async function gh(args: string[]): Promise<string> {\n const r = await spawn(\"gh\", args, { env, timeoutMs: 60_000 });\n if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);\n return r.stdout;\n }\n\n return {\n async openPullRequest(repo, pr) {\n const out = await gh([\n \"pr\",\n \"create\",\n \"--repo\",\n repo,\n \"--head\",\n pr.head,\n \"--base\",\n pr.base,\n \"--title\",\n pr.title,\n \"--body\",\n pr.body,\n ]);\n return { url: out.trim() };\n },\n async enableRepoAutoMerge(repo) {\n await gh([\"api\", \"-X\", \"PATCH\", `repos/${repo}`, \"-F\", \"allow_auto_merge=true\"]);\n },\n async protectBranch(repo, branch, requiredChecks) {\n assertUrlSegment(\"branch\", branch);\n const args = [\n \"api\",\n \"-X\",\n \"PUT\",\n `repos/${repo}/branches/${branch}/protection`,\n \"-H\",\n \"Accept: application/vnd.github+json\",\n \"-F\",\n \"required_status_checks[strict]=true\",\n ...requiredChecks.flatMap((c) => [\"-f\", `required_status_checks[contexts][]=${c}`]),\n \"-F\",\n \"enforce_admins=true\",\n \"-F\",\n \"required_pull_request_reviews=null\",\n \"-F\",\n \"restrictions=null\",\n ];\n await gh(args);\n },\n async setRepoSecret(repo, name, value) {\n await gh([\"secret\", \"set\", name, \"--repo\", repo, \"--body\", value]);\n },\n async repoExists(repo) {\n const r = await spawn(\"gh\", [\"api\", `repos/${repo}`], { env, timeoutMs: 60_000 });\n return r.code === 0;\n },\n async defaultBranch(repo) {\n const out = await gh([\"api\", `repos/${repo}`, \"--jq\", \".default_branch\"]);\n return out.trim();\n },\n // filesOnBranch and branchProtectionContexts call `spawn` directly (not the\n // throwing `gh()` helper) because a 404 is an expected, meaningful answer —\n // \"file/protection absent\" — not an error. The remaining readers use `gh()`\n // since a non-200 there is a genuine failure (e.g. missing token scope).\n async filesOnBranch(repo, branch, paths) {\n assertUrlSegment(\"branch\", branch);\n const present: string[] = [];\n for (const p of paths) {\n assertUrlSegment(\"path\", p);\n const r = await spawn(\"gh\", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {\n env,\n timeoutMs: 60_000,\n });\n if (r.code === 0) present.push(p);\n }\n return present;\n },\n async branchProtectionContexts(repo, branch) {\n assertUrlSegment(\"branch\", branch);\n const r = await spawn(\n \"gh\",\n [\n \"api\",\n `repos/${repo}/branches/${branch}/protection`,\n \"--jq\",\n \".required_status_checks.contexts[]?\",\n ],\n { env, timeoutMs: 60_000 },\n );\n if (r.code !== 0) return []; // 404 = no protection configured\n return r.stdout\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter((l) => l.length > 0);\n },\n async secretExists(repo, name) {\n // per_page=100: the REST default of 30 would false-negative on a repo with >30 secrets,\n // wrongly reporting an existing secret absent (→ a needless overwrite).\n const out = await gh([\n \"api\",\n `repos/${repo}/actions/secrets?per_page=100`,\n \"--jq\",\n \".secrets[].name\",\n ]);\n return out\n .split(\"\\n\")\n .map((l) => l.trim())\n .includes(name);\n },\n async autoMergeEnabled(repo) {\n const out = await gh([\"api\", `repos/${repo}`, \"--jq\", \".allow_auto_merge\"]);\n return out.trim() === \"true\";\n },\n async findOpenSelfUpdatingPR(repo) {\n // per_page=100: with the REST default of 30, a repo with >30 open PRs (plausible under\n // Renovate) could page past the existing self-updating PR and open a duplicate.\n const out = await gh([\n \"api\",\n `repos/${repo}/pulls?state=open&per_page=100`,\n \"--jq\",\n '.[] | select(.head.ref | startswith(\"maint/self-updating-\")) | .html_url',\n ]);\n const first = out\n .split(\"\\n\")\n .map((l) => l.trim())\n .find((l) => l.length > 0);\n return first ?? null;\n },\n async openPullRequests(repo) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`openPullRequests: expected \"owner/repo\", got \"${repo}\"`);\n }\n const query =\n \"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){\" +\n \"pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url headRefName mergeable \" +\n \"commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}\";\n const out = await gh([\n \"api\",\n \"graphql\",\n \"-f\",\n `query=${query}`,\n \"-F\",\n `owner=${owner}`,\n \"-F\",\n `name=${name}`,\n ]);\n const parsed = JSON.parse(out) as {\n data?: {\n repository?: {\n pullRequests?: {\n nodes?: Array<{\n number: number;\n title: string;\n url: string;\n headRefName: string;\n mergeable?: string;\n commits?: {\n nodes?: Array<{ commit?: { statusCheckRollup?: { state?: string } } }>;\n };\n }>;\n };\n };\n };\n };\n const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];\n return nodes.map((n) => ({\n number: n.number,\n title: n.title,\n url: n.url,\n headRef: n.headRefName,\n ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state),\n mergeable: mapMergeable(n.mergeable),\n }));\n },\n async defaultBranchStatus(repo) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`defaultBranchStatus: expected \"owner/repo\", got \"${repo}\"`);\n }\n const query =\n \"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){\" +\n \"defaultBranchRef{target{... on Commit{committedDate statusCheckRollup{state}}}}}}\";\n const out = await gh([\n \"api\",\n \"graphql\",\n \"-f\",\n `query=${query}`,\n \"-F\",\n `owner=${owner}`,\n \"-F\",\n `name=${name}`,\n ]);\n const parsed = JSON.parse(out) as {\n data?: {\n repository?: {\n defaultBranchRef?: {\n target?: { committedDate?: string; statusCheckRollup?: { state?: string } | null };\n } | null;\n };\n };\n };\n const target = parsed.data?.repository?.defaultBranchRef?.target;\n return {\n ciState: mapRollupState(target?.statusCheckRollup?.state),\n lastCommitAt: target?.committedDate ?? null,\n };\n },\n async dispatchWorkflow(repo, workflow, ref) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`dispatchWorkflow: expected \"owner/repo\", got \"${repo}\"`);\n }\n // Every segment interpolates into the API path, so guard them all like the\n // other write methods do (defense in depth). `owner`/`name` are the most\n // operator-controlled (typed into Airtable's \"Git repo\"); `workflow` is a\n // constant today; `ref` is repo-sourced. A junk value like `repo?x=1` would\n // otherwise smuggle a query string past the bare two-part shape check.\n assertUrlSegment(\"path\", owner);\n assertUrlSegment(\"path\", name);\n assertUrlSegment(\"path\", workflow);\n assertUrlSegment(\"branch\", ref);\n await gh([\n \"api\",\n \"-X\",\n \"POST\",\n `repos/${owner}/${name}/actions/workflows/${workflow}/dispatches`,\n \"-f\",\n `ref=${ref}`,\n ]);\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
makeGitHub
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-J4ECCFRT.js";
|
|
4
4
|
import {
|
|
5
5
|
templatesByName
|
|
6
6
|
} from "./chunk-CASCWQQX.js";
|
|
@@ -159,4 +159,4 @@ async function selfUpdating(site, deps = {}) {
|
|
|
159
159
|
export {
|
|
160
160
|
selfUpdating
|
|
161
161
|
};
|
|
162
|
-
//# sourceMappingURL=chunk-
|
|
162
|
+
//# sourceMappingURL=chunk-MKSZBODM.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/alerts/renovate.ts
|
|
2
|
+
var RENOVATE_HEAD_PREFIXES = ["renovate/", "renovate-"];
|
|
3
|
+
function isRenovatePR(pr) {
|
|
4
|
+
return RENOVATE_HEAD_PREFIXES.some((p) => pr.headRef.startsWith(p));
|
|
5
|
+
}
|
|
6
|
+
function isFailingRenovatePR(pr) {
|
|
7
|
+
return isRenovatePR(pr) && pr.ciState === "failing";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
isRenovatePR,
|
|
12
|
+
isFailingRenovatePR
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=chunk-PZQ3WED2.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/renovate.ts"],"sourcesContent":["import type { PullRequestSummary } from \"../github/gh.js\";\n\n/**\n * Renovate's default `branchPrefix` is `renovate/`, so every update branch on a\n * default-config repo is `renovate/<topic>` — grouped majors included (they're\n * `renovate/<group-slug>`, still slash-prefixed, not a separate shape). We also\n * match a bare `renovate-` prefix to defensively catch a repo that sets a custom\n * non-slash `branchPrefix`; the tradeoff is that a human branch named `renovate-foo`\n * is misclassified — an acceptable bias for an alerting tool that should prefer\n * over-matching to silently missing a broken update.\n */\nconst RENOVATE_HEAD_PREFIXES = [\"renovate/\", \"renovate-\"];\n\nexport function isRenovatePR(pr: Pick<PullRequestSummary, \"headRef\">): boolean {\n return RENOVATE_HEAD_PREFIXES.some((p) => pr.headRef.startsWith(p));\n}\n\nexport function isFailingRenovatePR(pr: PullRequestSummary): boolean {\n return isRenovatePR(pr) && pr.ciState === \"failing\";\n}\n"],"mappings":";AAWA,IAAM,yBAAyB,CAAC,aAAa,WAAW;AAEjD,SAAS,aAAa,IAAkD;AAC7E,SAAO,uBAAuB,KAAK,CAAC,MAAM,GAAG,QAAQ,WAAW,CAAC,CAAC;AACpE;AAEO,SAAS,oBAAoB,IAAiC;AACnE,SAAO,aAAa,EAAE,KAAK,GAAG,YAAY;AAC5C;","names":[]}
|
package/dist/cli/bin.js
CHANGED
|
@@ -122,7 +122,7 @@ cli.command(
|
|
|
122
122
|
"Bootstrap a repo to keep itself updated (CI + Renovate + auto-merge)."
|
|
123
123
|
).option("--dry", "List what would be enabled without writing or opening PRs").option("--fleet <inventory>", 'Inventory file (.json or .mjs/.js), or "airtable"').option("--workdir <path>", "Clone target for fleet mode (default ~/.reddoor-maint/sites)").action(
|
|
124
124
|
async (site, opts) => runOrExit(
|
|
125
|
-
async () => (await import("../self-updating-
|
|
125
|
+
async () => (await import("../self-updating-BYUBUNO4.js")).runSelfUpdatingCommand(site, opts),
|
|
126
126
|
opts
|
|
127
127
|
)
|
|
128
128
|
);
|
|
@@ -182,7 +182,7 @@ cli.command(
|
|
|
182
182
|
"Bootstrap + first-audit a site, then draft its launch email for approval."
|
|
183
183
|
).action(
|
|
184
184
|
async (site, opts) => runOrExit(
|
|
185
|
-
async () => (await import("../launch-
|
|
185
|
+
async () => (await import("../launch-XW6PEKUL.js")).runLaunchCommand(site, opts),
|
|
186
186
|
opts
|
|
187
187
|
)
|
|
188
188
|
);
|
|
@@ -206,7 +206,7 @@ cli.command("report [site]", "Draft or send maintenance/testing reports.").optio
|
|
|
206
206
|
"Email the operator one daily digest of reports ready for approval (skips when empty)."
|
|
207
207
|
).action(
|
|
208
208
|
async (site, opts) => runOrExit(
|
|
209
|
-
async () => (await import("../report-
|
|
209
|
+
async () => (await import("../report-WK3SKWJI.js")).runReportCommand(site, opts),
|
|
210
210
|
opts
|
|
211
211
|
)
|
|
212
212
|
);
|
|
@@ -215,7 +215,7 @@ cli.command(
|
|
|
215
215
|
"Sweep the fleet for GitHub signals (Renovate-failing/CI/last-commit) and write Airtable."
|
|
216
216
|
).option("--fleet", "Run across every site in the Airtable inventory.").option("--write-airtable", "Write each site's signals back to its Websites row.").action(
|
|
217
217
|
async (opts) => runOrExit(
|
|
218
|
-
async () => (await import("../github-signals-
|
|
218
|
+
async () => (await import("../github-signals-XLQXXH6Q.js")).runGitHubSignalsCommand({
|
|
219
219
|
fleet: opts.fleet,
|
|
220
220
|
writeAirtable: opts.writeAirtable
|
|
221
221
|
}),
|
|
@@ -233,7 +233,7 @@ cli.command(
|
|
|
233
233
|
"Trigger Renovate on fleet sites the security sweep flagged with critical/high vulns."
|
|
234
234
|
).option("--fleet", "Run across every active, repo-backed site in the Airtable inventory.").action(
|
|
235
235
|
async (opts) => runOrExit(
|
|
236
|
-
async () => (await import("../renovate-dispatch-
|
|
236
|
+
async () => (await import("../renovate-dispatch-YVBTBJJY.js")).runRenovateDispatchCommand({
|
|
237
237
|
fleet: opts.fleet
|
|
238
238
|
}),
|
|
239
239
|
opts
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isFailingRenovatePR
|
|
3
|
+
} from "./chunk-PZQ3WED2.js";
|
|
1
4
|
import {
|
|
2
5
|
formatFleetWriteSummary
|
|
3
6
|
} from "./chunk-5WL3ET24.js";
|
|
4
7
|
import "./chunk-IMIR7SC2.js";
|
|
5
8
|
import {
|
|
6
9
|
makeGitHub
|
|
7
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-J4ECCFRT.js";
|
|
8
11
|
import "./chunk-HVOOCK6L.js";
|
|
9
12
|
import {
|
|
10
13
|
listWebsites,
|
|
@@ -17,15 +20,6 @@ import {
|
|
|
17
20
|
} from "./chunk-HN6I2XWI.js";
|
|
18
21
|
import "./chunk-6VAL7XJT.js";
|
|
19
22
|
|
|
20
|
-
// src/alerts/renovate.ts
|
|
21
|
-
var RENOVATE_HEAD_PREFIXES = ["renovate/", "renovate-"];
|
|
22
|
-
function isRenovatePR(pr) {
|
|
23
|
-
return RENOVATE_HEAD_PREFIXES.some((p) => pr.headRef.startsWith(p));
|
|
24
|
-
}
|
|
25
|
-
function isFailingRenovatePR(pr) {
|
|
26
|
-
return isRenovatePR(pr) && pr.ciState === "failing";
|
|
27
|
-
}
|
|
28
|
-
|
|
29
23
|
// src/audits/github-signals.ts
|
|
30
24
|
async function collectGitHubSignals(sites, deps, onSkip = () => {
|
|
31
25
|
}) {
|
|
@@ -119,4 +113,4 @@ export {
|
|
|
119
113
|
githubSignalsExitCode,
|
|
120
114
|
runGitHubSignalsCommand
|
|
121
115
|
};
|
|
122
|
-
//# sourceMappingURL=github-signals-
|
|
116
|
+
//# sourceMappingURL=github-signals-XLQXXH6Q.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/audits/github-signals.ts","../src/cli/commands/github-signals.ts"],"sourcesContent":["import type { Site } from \"../types.js\";\nimport type { CiState, PullRequestSummary } from \"../github/gh.js\";\nimport { isFailingRenovatePR } from \"../alerts/renovate.js\";\n\n/** One swept row, ready for the Airtable writer (slug-keyed by `site`). */\nexport type GitHubSignalsRow = {\n site: string; // the site name/slug the writer matches on\n repo: string; // owner/repo\n renovateFailingCis: number;\n ciState: CiState;\n lastCommitAt: string | null;\n};\n\n/** Injected GitHub reads (so the sweep is pure + testable). */\nexport type GitHubSignalsDeps = {\n openPullRequests: (repo: string) => Promise<PullRequestSummary[]>;\n defaultBranchStatus: (repo: string) => Promise<{ ciState: CiState; lastCommitAt: string | null }>;\n};\n\n/** Per repo-backed site: count its failing Renovate PRs + read its default-branch\n * status. Sites without `gitRepo` are skipped (not errors). A repo whose probe\n * throws is reported via `onSkip` and produces no row — one GitHub hiccup never\n * sinks the sweep. PURE over `deps`. */\nexport async function collectGitHubSignals(\n sites: Site[],\n deps: GitHubSignalsDeps,\n onSkip: (s: { repo: string }) => void = () => {},\n): Promise<GitHubSignalsRow[]> {\n const rows: GitHubSignalsRow[] = [];\n for (const s of sites) {\n const repo = s.gitRepo;\n if (!repo) continue;\n const name = s.name;\n if (!name) continue;\n try {\n const prs = await deps.openPullRequests(repo);\n const status = await deps.defaultBranchStatus(repo);\n rows.push({\n site: name,\n repo,\n renovateFailingCis: prs.filter(isFailingRenovatePR).length,\n ciState: status.ciState,\n lastCommitAt: status.lastCommitAt,\n });\n } catch {\n onSkip({ repo });\n }\n }\n return rows;\n}\n","import { openBase, readAirtableConfig } from \"../../reports/airtable/client.js\";\nimport { listWebsites, siteSlug, updateGitHubSignals } from \"../../reports/airtable/websites.js\";\nimport type { Site } from \"../../types.js\";\nimport { collectGitHubSignals } from \"../../audits/github-signals.js\";\nimport { makeGitHub } from \"../../github/gh.js\";\nimport {\n formatFleetWriteSummary,\n type FleetWriteResult,\n} from \"../../audits/write-audits-to-airtable.js\";\n\n/** Exit code for a fleet github-signals run. Exit 1 when failures are the\n * MAJORITY of the fleet (`failed > written`), not only on a total wipeout —\n * a run where 11/12 repos failed but 1 wrote should still signal an outage.\n * All-success or a minority of flakes (e.g. 1/12 failed) stays exit 0. The\n * no-token clean-skip returns 0 separately (before any probe runs). */\nexport function githubSignalsExitCode(written: number, failed: number): number {\n return failed > written ? 1 : 0;\n}\n\n/** `github-signals --fleet --write-airtable`: sweep every repo-backed site for its\n * Renovate-failing count + default-branch CI state + last-commit date, write each\n * row serially (Airtable ~5 req/sec), and emit FLEET_WRITE_SUMMARY for CI. A\n * missing fleet token is a clean skip (local runs), not a failure. */\nexport async function runGitHubSignalsCommand(opts: {\n fleet?: boolean | undefined;\n writeAirtable?: boolean | undefined;\n}): Promise<{ output: string; code: number }> {\n if (!opts.fleet || !opts.writeAirtable) {\n return { output: \"github-signals currently supports only --fleet --write-airtable\", code: 2 };\n }\n const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();\n if (!token) {\n return {\n output: \"github-signals skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet read) configured.\",\n code: 0,\n };\n }\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const gh = makeGitHub({ token });\n const sites: Site[] = websites.map((w) => ({\n path: \"\",\n name: w.name,\n meta: {},\n ...(w.gitRepo ? { gitRepo: w.gitRepo } : {}),\n }));\n\n const skipped: string[] = [];\n const rows = await collectGitHubSignals(\n sites,\n {\n openPullRequests: (r) => gh.openPullRequests(r),\n defaultBranchStatus: (r) => gh.defaultBranchStatus(r),\n },\n ({ repo }) => skipped.push(repo),\n );\n\n const sweptAt = new Date().toISOString();\n const result: FleetWriteResult = { written: [], failed: [] };\n const byRepo = new Map(websites.filter((w) => w.gitRepo).map((w) => [w.gitRepo, w]));\n // Serial: Airtable's ~5 req/sec limit (matches writeFleetAuditsToAirtable).\n for (const row of rows) {\n const target = byRepo.get(row.repo);\n if (!target) {\n result.failed.push({ slug: siteSlug(row.site), error: \"no Websites row matched\" });\n continue;\n }\n try {\n await updateGitHubSignals(base, target.id, {\n renovateFailingCis: row.renovateFailingCis,\n ciState: row.ciState,\n lastCommitAt: row.lastCommitAt,\n sweptAt,\n });\n result.written.push({\n siteName: target.name,\n writes: [{ audit: \"github-signals\", counts: row }],\n });\n } catch (e) {\n result.failed.push({ slug: siteSlug(row.site), error: (e as Error).message });\n }\n }\n for (const repo of skipped) result.failed.push({ slug: repo, error: \"probe failed (skipped)\" });\n\n // Exit non-zero when failures are the MAJORITY of the fleet, not only on a\n // total wipeout. A run where 11/12 repos failed but 1 wrote used to return 0,\n // masking a large outage. The nightly cron step is `continue-on-error`, so a\n // non-zero here is an operator-visibility signal, not a red build.\n return {\n output: formatFleetWriteSummary(result),\n code: githubSignalsExitCode(result.written.length, result.failed.length),\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.js
CHANGED
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
} from "./chunk-IMIR7SC2.js";
|
|
8
8
|
import {
|
|
9
9
|
selfUpdating
|
|
10
|
-
} from "./chunk-
|
|
11
|
-
import "./chunk-
|
|
10
|
+
} from "./chunk-MKSZBODM.js";
|
|
11
|
+
import "./chunk-J4ECCFRT.js";
|
|
12
12
|
import {
|
|
13
13
|
resolveSites
|
|
14
14
|
} from "./chunk-J3OPWFZE.js";
|
|
@@ -204,4 +204,4 @@ async function runLaunchCommand(site, opts) {
|
|
|
204
204
|
export {
|
|
205
205
|
runLaunchCommand
|
|
206
206
|
};
|
|
207
|
-
//# sourceMappingURL=launch-
|
|
207
|
+
//# sourceMappingURL=launch-XW6PEKUL.js.map
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
|
+
GLOBAL_REPORT_CC,
|
|
2
3
|
isProbablyEmail,
|
|
3
4
|
parseAddresses,
|
|
4
|
-
sendApprovedReports
|
|
5
|
-
|
|
5
|
+
sendApprovedReports,
|
|
6
|
+
withGlobalCc
|
|
7
|
+
} from "./chunk-5L2QXUTU.js";
|
|
6
8
|
import "./chunk-OGGRFBL4.js";
|
|
7
9
|
import "./chunk-BWLUNY7L.js";
|
|
8
10
|
import "./chunk-OCS7SZUZ.js";
|
|
@@ -12,8 +14,10 @@ import "./chunk-HN6I2XWI.js";
|
|
|
12
14
|
import "./chunk-WEKL2HYJ.js";
|
|
13
15
|
import "./chunk-6VAL7XJT.js";
|
|
14
16
|
export {
|
|
17
|
+
GLOBAL_REPORT_CC,
|
|
15
18
|
isProbablyEmail,
|
|
16
19
|
parseAddresses,
|
|
17
|
-
sendApprovedReports
|
|
20
|
+
sendApprovedReports,
|
|
21
|
+
withGlobalCc
|
|
18
22
|
};
|
|
19
|
-
//# sourceMappingURL=orchestrate-
|
|
23
|
+
//# sourceMappingURL=orchestrate-LHH3LQNP.js.map
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isRenovatePR
|
|
3
|
+
} from "./chunk-PZQ3WED2.js";
|
|
1
4
|
import {
|
|
2
5
|
makeGitHub
|
|
3
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-J4ECCFRT.js";
|
|
4
7
|
import "./chunk-HVOOCK6L.js";
|
|
5
8
|
import {
|
|
6
9
|
isDashboardVisible,
|
|
@@ -14,8 +17,8 @@ import "./chunk-6VAL7XJT.js";
|
|
|
14
17
|
|
|
15
18
|
// src/github/renovate-dispatch.ts
|
|
16
19
|
var RENOVATE_WORKFLOW_FILE = "renovate.yml";
|
|
17
|
-
function
|
|
18
|
-
return
|
|
20
|
+
function hasHealthyRenovatePr(prs) {
|
|
21
|
+
return prs.some((pr) => isRenovatePR(pr) && pr.mergeable !== "CONFLICTING");
|
|
19
22
|
}
|
|
20
23
|
function selectRenovateTargets(sites) {
|
|
21
24
|
const targets = [];
|
|
@@ -36,7 +39,7 @@ async function dispatchRenovateAcross(targets, deps) {
|
|
|
36
39
|
const failed = [];
|
|
37
40
|
for (const t of targets) {
|
|
38
41
|
try {
|
|
39
|
-
if (await deps.
|
|
42
|
+
if (await deps.hasHealthyOpenRenovatePr(t.repo)) {
|
|
40
43
|
skipped.push(t.repo);
|
|
41
44
|
continue;
|
|
42
45
|
}
|
|
@@ -77,7 +80,7 @@ No active repo-backed sites with critical/high vulnerabilities \u2014 nothing to
|
|
|
77
80
|
}
|
|
78
81
|
const gh = makeGitHub({ token });
|
|
79
82
|
const result = await dispatchRenovateAcross(targets, {
|
|
80
|
-
|
|
83
|
+
hasHealthyOpenRenovatePr: async (repo) => hasHealthyRenovatePr(await gh.openPullRequests(repo)),
|
|
81
84
|
defaultBranch: (repo) => gh.defaultBranch(repo),
|
|
82
85
|
dispatch: (repo, workflow, ref) => gh.dispatchWorkflow(repo, workflow, ref)
|
|
83
86
|
});
|
|
@@ -94,4 +97,4 @@ No active repo-backed sites with critical/high vulnerabilities \u2014 nothing to
|
|
|
94
97
|
export {
|
|
95
98
|
runRenovateDispatchCommand
|
|
96
99
|
};
|
|
97
|
-
//# sourceMappingURL=renovate-dispatch-
|
|
100
|
+
//# sourceMappingURL=renovate-dispatch-YVBTBJJY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/github/renovate-dispatch.ts","../src/cli/commands/renovate-dispatch.ts"],"sourcesContent":["import { isDashboardVisible, type WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { isRenovatePR } from \"../alerts/renovate.js\";\nimport type { PullRequestSummary } from \"./gh.js\";\n\n/**\n * On-demand Renovate trigger for fleet sites.\n *\n * The nightly security sweep (`fleet-security.yml`) writes each active site's\n * vulnerability counts to Airtable. This module turns that into action: for the\n * sites the sweep just flagged with *actionable* (critical or high) vulns, it\n * dispatches that repo's `renovate.yml` `workflow_dispatch` so Renovate runs off\n * its weekly schedule. Renovate's OSV vulnerability alerts bypass the schedule,\n * so the remediation PR opens immediately (then auto-merges per the shared\n * preset). Moderate/low vulns are left to the normal weekly cadence — not urgent\n * enough to dispatch + churn CI nightly.\n */\n\n/** The per-repo workflow file the fleet uses for its self-hosted Renovate run. */\nexport const RENOVATE_WORKFLOW_FILE = \"renovate.yml\";\n\n/**\n * Does the repo have a HEALTHY open Renovate PR — one that is NOT conflicting?\n *\n * A healthy PR means remediation is genuinely in flight (moving toward merge), so\n * re-dispatching would just churn CI — skip it. A CONFLICTING Renovate PR is STUCK\n * (its branch fell behind the base after another PR merged the same lockfile), so\n * it does NOT count as healthy: re-dispatching triggers Renovate to rebase it.\n * `UNKNOWN` mergeability (GitHub still computing it) is treated as healthy — don't\n * churn on uncertainty. Reuses {@link isRenovatePR} for the branch-prefix check.\n */\nexport function hasHealthyRenovatePr(prs: PullRequestSummary[]): boolean {\n return prs.some((pr) => isRenovatePR(pr) && pr.mergeable !== \"CONFLICTING\");\n}\n\n/** A site selected for an on-demand Renovate run, with the reason (vuln counts). */\nexport type RenovateDispatchTarget = {\n repo: string; // owner/repo, from the Websites \"Git repo\" field\n siteName: string;\n critical: number;\n high: number;\n};\n\n/**\n * Pick the active, repo-backed sites whose latest security sweep found a\n * critical or high vulnerability. A `null` vuln count is treated as zero (a site\n * that was never swept, or had its counts cleared, is not dispatched). Inactive\n * sites and sites without a `Git repo` are excluded — there's nothing to kick.\n *\n * Counts are only guaranteed fresh for sites the sweep actually audited (active +\n * URL present); a site whose write-back failed/was skipped this run is selected on\n * its last-known counts. That's safe — a stale-high false dispatch is an idempotent\n * Renovate no-op, and the open-PR skip in `dispatchRenovateAcross` avoids re-firing\n * when remediation is already in flight.\n */\nexport function selectRenovateTargets(sites: WebsiteRow[]): RenovateDispatchTarget[] {\n const targets: RenovateDispatchTarget[] = [];\n for (const s of sites) {\n if (!isDashboardVisible(s)) continue; // active (maintenance / launch period) only\n const repo = s.gitRepo?.trim();\n if (!repo) continue; // repo-backed only — the dispatch target is its renovate.yml\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n if (critical + high <= 0) continue; // actionable (critical/high) vulns only\n targets.push({ repo, siteName: s.name, critical, high });\n }\n return targets;\n}\n\n/** Injected GitHub operations — real ones come from `makeGitHub`, fakes from tests. */\nexport type RenovateDispatchDeps = {\n /** True iff the repo has a HEALTHY (non-conflicting) open Renovate PR —\n * remediation in flight, so don't re-dispatch. A conflicting/stuck PR returns\n * false so the dispatch goes through and Renovate rebases it. */\n hasHealthyOpenRenovatePr: (repo: string) => Promise<boolean>;\n defaultBranch: (repo: string) => Promise<string>;\n dispatch: (repo: string, workflow: string, ref: string) => Promise<void>;\n};\n\nexport type RenovateDispatchResult = {\n dispatched: string[];\n /** Repos skipped because a Renovate PR is already open — no point re-firing. */\n skipped: string[];\n failed: { repo: string; error: string }[];\n};\n\n/**\n * Dispatch `renovate.yml` for each target on its default branch.\n *\n * Skips a repo with a HEALTHY (non-conflicting) open Renovate PR — remediation is\n * in flight, so re-dispatching would just churn CI (keeps a persistent vuln from\n * re-firing every nightly run while its fix PR waits, e.g. a human-gated major\n * bump). A repo whose open Renovate PR is CONFLICTING/stuck is NOT skipped:\n * dispatching triggers Renovate to rebase it. Best-effort and isolated: a repo\n * that lacks `renovate.yml`, or whose\n * dispatch/PR-check is refused (the token's `actions:write` scope), is recorded in\n * `failed` and the rest still run. Never throws — the caller (a follow-up step on\n * the security sweep) must not be able to fail the sweep over a dispatch hiccup.\n */\nexport async function dispatchRenovateAcross(\n targets: RenovateDispatchTarget[],\n deps: RenovateDispatchDeps,\n): Promise<RenovateDispatchResult> {\n const dispatched: string[] = [];\n const skipped: string[] = [];\n const failed: { repo: string; error: string }[] = [];\n for (const t of targets) {\n try {\n if (await deps.hasHealthyOpenRenovatePr(t.repo)) {\n skipped.push(t.repo);\n continue;\n }\n const ref = await deps.defaultBranch(t.repo);\n await deps.dispatch(t.repo, RENOVATE_WORKFLOW_FILE, ref);\n dispatched.push(t.repo);\n } catch (e) {\n failed.push({ repo: t.repo, error: e instanceof Error ? e.message : String(e) });\n }\n }\n return { dispatched, skipped, failed };\n}\n\n/** Machine-readable counts line the workflow greps to annotate partial failures. */\nexport function formatRenovateDispatchSummary(result: RenovateDispatchResult): string {\n return `RENOVATE_DISPATCH_SUMMARY dispatched=${result.dispatched.length} skipped=${result.skipped.length} failed=${result.failed.length}`;\n}\n","import { openBase, readAirtableConfig } from \"../../reports/airtable/client.js\";\nimport { listWebsites } from \"../../reports/airtable/websites.js\";\nimport { makeGitHub } from \"../../github/gh.js\";\nimport {\n selectRenovateTargets,\n dispatchRenovateAcross,\n formatRenovateDispatchSummary,\n hasHealthyRenovatePr,\n} from \"../../github/renovate-dispatch.js\";\n\n/**\n * `renovate-dispatch --fleet`: read the Websites table, pick the active,\n * repo-backed sites the latest security sweep flagged with critical/high vulns,\n * and fire each one's `renovate.yml` `workflow_dispatch` so Renovate's OSV\n * vulnerability alerts open the remediation PR now (instead of waiting for the\n * weekly schedule). Designed to run as a best-effort follow-up step on\n * `fleet-security.yml` AFTER the sweep has written fresh counts to Airtable.\n *\n * Always exits 0: a missing token is a clean skip; a partial dispatch failure\n * (a repo without `renovate.yml`, or a token lacking `actions:write`) is reported\n * in the summary for the workflow to annotate, never failing the security sweep.\n */\nexport async function runRenovateDispatchCommand(opts: {\n fleet?: boolean | undefined;\n}): Promise<{ output: string; code: number }> {\n if (!opts.fleet) {\n return { output: \"renovate-dispatch currently supports only --fleet\", code: 2 };\n }\n const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();\n if (!token) {\n return {\n output: \"renovate-dispatch skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet dispatch) configured.\",\n code: 0,\n };\n }\n\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const targets = selectRenovateTargets(websites);\n\n if (targets.length === 0) {\n return {\n output:\n `${formatRenovateDispatchSummary({ dispatched: [], skipped: [], failed: [] })}\\n` +\n \"No active repo-backed sites with critical/high vulnerabilities — nothing to dispatch.\",\n code: 0,\n };\n }\n\n const gh = makeGitHub({ token });\n const result = await dispatchRenovateAcross(targets, {\n hasHealthyOpenRenovatePr: async (repo) => hasHealthyRenovatePr(await gh.openPullRequests(repo)),\n defaultBranch: (repo) => gh.defaultBranch(repo),\n dispatch: (repo, workflow, ref) => gh.dispatchWorkflow(repo, workflow, ref),\n });\n\n const failedRepos = new Set(result.failed.map((f) => f.repo));\n const skippedRepos = new Set(result.skipped);\n const lines = targets.map((t) => {\n const status = failedRepos.has(t.repo)\n ? \"FAILED \"\n : skippedRepos.has(t.repo)\n ? \"skipped (open Renovate PR) \"\n : \"dispatched \";\n return `${status} ${t.repo} (critical=${t.critical} high=${t.high}) — ${t.siteName}`;\n });\n for (const f of result.failed) lines.push(` ↳ ${f.repo}: ${f.error}`);\n lines.push(formatRenovateDispatchSummary(result));\n\n return { output: lines.join(\"\\n\"), code: 0 };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -72,7 +72,7 @@ async function runReportCommand(slug, opts) {
|
|
|
72
72
|
return runDigest({ baseUrl: dashboardBaseUrl() });
|
|
73
73
|
}
|
|
74
74
|
if (opts.sendReady) {
|
|
75
|
-
const { sendApprovedReports } = await import("./orchestrate-
|
|
75
|
+
const { sendApprovedReports } = await import("./orchestrate-LHH3LQNP.js");
|
|
76
76
|
return sendApprovedReports();
|
|
77
77
|
}
|
|
78
78
|
if (opts.due) {
|
|
@@ -237,4 +237,4 @@ export {
|
|
|
237
237
|
parseSingleSiteReportType,
|
|
238
238
|
runReportCommand
|
|
239
239
|
};
|
|
240
|
-
//# sourceMappingURL=report-
|
|
240
|
+
//# sourceMappingURL=report-WK3SKWJI.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
selfUpdating
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-MKSZBODM.js";
|
|
4
|
+
import "./chunk-J4ECCFRT.js";
|
|
5
5
|
import {
|
|
6
6
|
appendSkipNotice,
|
|
7
7
|
fleetWorkdir,
|
|
@@ -58,4 +58,4 @@ async function runSelfUpdatingCommand(site, opts) {
|
|
|
58
58
|
export {
|
|
59
59
|
runSelfUpdatingCommand
|
|
60
60
|
};
|
|
61
|
-
//# sourceMappingURL=self-updating-
|
|
61
|
+
//# sourceMappingURL=self-updating-BYUBUNO4.js.map
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/github/gh.ts"],"sourcesContent":["import { defaultSpawn, type SpawnFn } from \"../audits/util/spawn.js\";\n\n/** Aggregate CI state of a PR's head commit, normalized from GitHub's rollup. */\nexport type CiState = \"passing\" | \"failing\" | \"pending\" | \"none\";\n\n/** A minimal open-PR summary with its head-commit CI rollup state. */\nexport type PullRequestSummary = {\n number: number;\n title: string;\n url: string;\n headRef: string;\n ciState: CiState;\n};\n\n/**\n * Reject a value before it's interpolated into a `gh api` URL path. The\n * `owner/repo` split methods already validate shape; this is the defense-in-depth\n * guard for the `branch` and file-`path` segments. An unexpected value (`..`, a\n * leading `/`, whitespace, or a URL-structural char like `?#%` or a backslash)\n * could otherwise retarget the endpoint (escape the intended path, smuggle a\n * query string, or traverse). Conservative by design — legit branch names like\n * `maint/self-updating-x` and paths like `.github/workflows/ci.yml` pass.\n *\n * Both branch refs (`maint/self-updating-x`) and file paths\n * (`.github/workflows/ci.yml`) legitimately contain `/`, so a single slash is\n * allowed; what's rejected is `..`, a leading `/`, whitespace, or a\n * URL-structural char (`?`, `#`, `%`, backslash) that could escape or retarget\n * the endpoint.\n */\nfunction assertUrlSegment(kind: \"branch\" | \"path\", value: string): void {\n const structural = /[\\s?#%\\\\]|\\.\\./;\n if (value.length === 0 || value.startsWith(\"/\") || structural.test(value)) {\n throw new Error(\n `unsafe ${kind} for gh api path (illegal characters or traversal): ${JSON.stringify(value)}`,\n );\n }\n}\n\n/** Map GitHub's `statusCheckRollup.state` enum to our normalized CiState. */\nfunction mapRollupState(state: string | null | undefined): CiState {\n switch (state) {\n case \"SUCCESS\":\n return \"passing\";\n case \"FAILURE\":\n case \"ERROR\":\n return \"failing\";\n case \"PENDING\":\n case \"EXPECTED\":\n return \"pending\";\n default:\n return \"none\"; // null/undefined = no checks reported\n }\n}\n\nexport type GitHub = {\n openPullRequest: (\n repo: string,\n pr: { head: string; base: string; title: string; body: string },\n ) => Promise<{ url: string }>;\n enableRepoAutoMerge: (repo: string) => Promise<void>;\n protectBranch: (repo: string, branch: string, requiredChecks: string[]) => Promise<void>;\n setRepoSecret: (repo: string, name: string, value: string) => Promise<void>;\n repoExists: (repo: string) => Promise<boolean>;\n defaultBranch: (repo: string) => Promise<string>;\n filesOnBranch: (repo: string, branch: string, paths: string[]) => Promise<string[]>;\n branchProtectionContexts: (repo: string, branch: string) => Promise<string[]>;\n secretExists: (repo: string, name: string) => Promise<boolean>;\n autoMergeEnabled: (repo: string) => Promise<boolean>;\n findOpenSelfUpdatingPR: (repo: string) => Promise<string | null>;\n /** All open PRs on a repo with each head commit's normalized CI rollup state. */\n openPullRequests: (repo: string) => Promise<PullRequestSummary[]>;\n /** The default branch's latest-commit date + normalized CI rollup, one query. */\n defaultBranchStatus: (repo: string) => Promise<{ ciState: CiState; lastCommitAt: string | null }>;\n /** Fire a `workflow_dispatch` for `<workflow>` (a filename like `renovate.yml`)\n * on `ref`. Requires the token's `actions:write` scope; a 404 (no such\n * workflow) or 403 (missing scope) surfaces as a thrown error. */\n dispatchWorkflow: (repo: string, workflow: string, ref: string) => Promise<void>;\n};\n\nexport function makeGitHub(deps: { token: string; spawn?: SpawnFn }): GitHub {\n const spawn = deps.spawn ?? defaultSpawn;\n const env = { ...process.env, GH_TOKEN: deps.token };\n\n async function gh(args: string[]): Promise<string> {\n const r = await spawn(\"gh\", args, { env, timeoutMs: 60_000 });\n if (r.code !== 0) throw new Error(`gh ${args[0]} failed (code ${r.code}): ${r.stderr.trim()}`);\n return r.stdout;\n }\n\n return {\n async openPullRequest(repo, pr) {\n const out = await gh([\n \"pr\",\n \"create\",\n \"--repo\",\n repo,\n \"--head\",\n pr.head,\n \"--base\",\n pr.base,\n \"--title\",\n pr.title,\n \"--body\",\n pr.body,\n ]);\n return { url: out.trim() };\n },\n async enableRepoAutoMerge(repo) {\n await gh([\"api\", \"-X\", \"PATCH\", `repos/${repo}`, \"-F\", \"allow_auto_merge=true\"]);\n },\n async protectBranch(repo, branch, requiredChecks) {\n assertUrlSegment(\"branch\", branch);\n const args = [\n \"api\",\n \"-X\",\n \"PUT\",\n `repos/${repo}/branches/${branch}/protection`,\n \"-H\",\n \"Accept: application/vnd.github+json\",\n \"-F\",\n \"required_status_checks[strict]=true\",\n ...requiredChecks.flatMap((c) => [\"-f\", `required_status_checks[contexts][]=${c}`]),\n \"-F\",\n \"enforce_admins=true\",\n \"-F\",\n \"required_pull_request_reviews=null\",\n \"-F\",\n \"restrictions=null\",\n ];\n await gh(args);\n },\n async setRepoSecret(repo, name, value) {\n await gh([\"secret\", \"set\", name, \"--repo\", repo, \"--body\", value]);\n },\n async repoExists(repo) {\n const r = await spawn(\"gh\", [\"api\", `repos/${repo}`], { env, timeoutMs: 60_000 });\n return r.code === 0;\n },\n async defaultBranch(repo) {\n const out = await gh([\"api\", `repos/${repo}`, \"--jq\", \".default_branch\"]);\n return out.trim();\n },\n // filesOnBranch and branchProtectionContexts call `spawn` directly (not the\n // throwing `gh()` helper) because a 404 is an expected, meaningful answer —\n // \"file/protection absent\" — not an error. The remaining readers use `gh()`\n // since a non-200 there is a genuine failure (e.g. missing token scope).\n async filesOnBranch(repo, branch, paths) {\n assertUrlSegment(\"branch\", branch);\n const present: string[] = [];\n for (const p of paths) {\n assertUrlSegment(\"path\", p);\n const r = await spawn(\"gh\", [`api`, `repos/${repo}/contents/${p}?ref=${branch}`], {\n env,\n timeoutMs: 60_000,\n });\n if (r.code === 0) present.push(p);\n }\n return present;\n },\n async branchProtectionContexts(repo, branch) {\n assertUrlSegment(\"branch\", branch);\n const r = await spawn(\n \"gh\",\n [\n \"api\",\n `repos/${repo}/branches/${branch}/protection`,\n \"--jq\",\n \".required_status_checks.contexts[]?\",\n ],\n { env, timeoutMs: 60_000 },\n );\n if (r.code !== 0) return []; // 404 = no protection configured\n return r.stdout\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter((l) => l.length > 0);\n },\n async secretExists(repo, name) {\n // per_page=100: the REST default of 30 would false-negative on a repo with >30 secrets,\n // wrongly reporting an existing secret absent (→ a needless overwrite).\n const out = await gh([\n \"api\",\n `repos/${repo}/actions/secrets?per_page=100`,\n \"--jq\",\n \".secrets[].name\",\n ]);\n return out\n .split(\"\\n\")\n .map((l) => l.trim())\n .includes(name);\n },\n async autoMergeEnabled(repo) {\n const out = await gh([\"api\", `repos/${repo}`, \"--jq\", \".allow_auto_merge\"]);\n return out.trim() === \"true\";\n },\n async findOpenSelfUpdatingPR(repo) {\n // per_page=100: with the REST default of 30, a repo with >30 open PRs (plausible under\n // Renovate) could page past the existing self-updating PR and open a duplicate.\n const out = await gh([\n \"api\",\n `repos/${repo}/pulls?state=open&per_page=100`,\n \"--jq\",\n '.[] | select(.head.ref | startswith(\"maint/self-updating-\")) | .html_url',\n ]);\n const first = out\n .split(\"\\n\")\n .map((l) => l.trim())\n .find((l) => l.length > 0);\n return first ?? null;\n },\n async openPullRequests(repo) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`openPullRequests: expected \"owner/repo\", got \"${repo}\"`);\n }\n const query =\n \"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){\" +\n \"pullRequests(states:OPEN,first:100,orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url headRefName \" +\n \"commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}}\";\n const out = await gh([\n \"api\",\n \"graphql\",\n \"-f\",\n `query=${query}`,\n \"-F\",\n `owner=${owner}`,\n \"-F\",\n `name=${name}`,\n ]);\n const parsed = JSON.parse(out) as {\n data?: {\n repository?: {\n pullRequests?: {\n nodes?: Array<{\n number: number;\n title: string;\n url: string;\n headRefName: string;\n commits?: {\n nodes?: Array<{ commit?: { statusCheckRollup?: { state?: string } } }>;\n };\n }>;\n };\n };\n };\n };\n const nodes = parsed.data?.repository?.pullRequests?.nodes ?? [];\n return nodes.map((n) => ({\n number: n.number,\n title: n.title,\n url: n.url,\n headRef: n.headRefName,\n ciState: mapRollupState(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state),\n }));\n },\n async defaultBranchStatus(repo) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`defaultBranchStatus: expected \"owner/repo\", got \"${repo}\"`);\n }\n const query =\n \"query($owner:String!,$name:String!){repository(owner:$owner,name:$name){\" +\n \"defaultBranchRef{target{... on Commit{committedDate statusCheckRollup{state}}}}}}\";\n const out = await gh([\n \"api\",\n \"graphql\",\n \"-f\",\n `query=${query}`,\n \"-F\",\n `owner=${owner}`,\n \"-F\",\n `name=${name}`,\n ]);\n const parsed = JSON.parse(out) as {\n data?: {\n repository?: {\n defaultBranchRef?: {\n target?: { committedDate?: string; statusCheckRollup?: { state?: string } | null };\n } | null;\n };\n };\n };\n const target = parsed.data?.repository?.defaultBranchRef?.target;\n return {\n ciState: mapRollupState(target?.statusCheckRollup?.state),\n lastCommitAt: target?.committedDate ?? null,\n };\n },\n async dispatchWorkflow(repo, workflow, ref) {\n const [owner, name, ...rest] = repo.split(\"/\");\n if (!owner || !name || rest.length > 0) {\n throw new Error(`dispatchWorkflow: expected \"owner/repo\", got \"${repo}\"`);\n }\n // Every segment interpolates into the API path, so guard them all like the\n // other write methods do (defense in depth). `owner`/`name` are the most\n // operator-controlled (typed into Airtable's \"Git repo\"); `workflow` is a\n // constant today; `ref` is repo-sourced. A junk value like `repo?x=1` would\n // otherwise smuggle a query string past the bare two-part shape check.\n assertUrlSegment(\"path\", owner);\n assertUrlSegment(\"path\", name);\n assertUrlSegment(\"path\", workflow);\n assertUrlSegment(\"branch\", ref);\n await gh([\n \"api\",\n \"-X\",\n \"POST\",\n `repos/${owner}/${name}/actions/workflows/${workflow}/dispatches`,\n \"-f\",\n `ref=${ref}`,\n ]);\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/reports/maintenance-email/header-image.ts","../src/reports/send/orchestrate.ts"],"sourcesContent":["import sharp from \"sharp\";\n\nexport type PreparedHeaderImage = {\n /** Resized JPEG bytes to attach inline (CID) in place of the Airtable original. */\n bytes: Uint8Array;\n /** Always \"image/jpeg\" — we re-encode for predictable size and a flat white background. */\n contentType: string;\n /** CSS display width in px (≤ requested, never wider than the source has pixels for). */\n displayWidth: number;\n /** CSS display height in px, source aspect ratio preserved (no distortion). */\n displayHeight: number;\n /** Dominant-color hex (e.g. \"#cfc3a8\"), used as the loading/blocked placeholder box. */\n placeholderColor: string;\n};\n\nexport type PrepareHeaderImageOptions = {\n /** Intended CSS display width. The email body is 600px, so that's the default. */\n displayWidth?: number;\n};\n\nconst DEFAULT_DISPLAY_WIDTH = 600;\n/** Encode the source at 2× display width so it stays crisp on retina screens. */\nconst RETINA_SCALE = 2;\n/** Quality is for *resized* pixels — at 1200px the texture/text read as sharp; bytes are tiny. */\nconst JPEG_QUALITY = 82;\n\nfunction channelToHex(value: number): string {\n return Math.max(0, Math.min(255, Math.round(value)))\n .toString(16)\n .padStart(2, \"0\");\n}\n\n/**\n * Downscale an oversized header image for email: 2× the display width (retina) at most,\n * never upscaled, re-encoded as JPEG on a flat white background. Also reports the display\n * dimensions (so the template can reserve the box and stop reflow) and a dominant color\n * (so the reserved box shows a matched placeholder while the image loads).\n *\n * Root cause this addresses: Airtable headers can be multi-MB / 2400px+ while the email\n * renders them at ~600px — shipping ~16× more pixels than the display can use.\n */\nexport async function prepareHeaderImage(\n bytes: Uint8Array,\n options: PrepareHeaderImageOptions = {},\n): Promise<PreparedHeaderImage> {\n const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;\n const input = Buffer.from(bytes);\n\n const meta = await sharp(input).metadata();\n const origWidth = meta.width;\n const origHeight = meta.height;\n if (!origWidth || !origHeight) {\n throw new Error(\"prepareHeaderImage: could not read source image dimensions\");\n }\n\n // Never claim a wider display than the source can fill at 1×.\n const displayWidth = Math.min(requestedDisplayWidth, origWidth);\n const displayHeight = Math.round((displayWidth * origHeight) / origWidth);\n\n // Encode at 2× display for retina, but never enlarge a smaller original.\n const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);\n\n const out = await sharp(input)\n .resize({ width: targetSourceWidth, withoutEnlargement: true })\n .flatten({ background: \"#ffffff\" })\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n\n const { dominant } = await sharp(out).stats();\n const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;\n\n return {\n bytes: new Uint8Array(out),\n contentType: \"image/jpeg\",\n displayWidth,\n displayHeight,\n placeholderColor,\n };\n}\n","import { openBase, readAirtableConfig } from \"../airtable/client.js\";\nimport { listSendableReports, stampSent } from \"../airtable/reports.js\";\nimport { listWebsites, siteSlug, updateLaunched } from \"../airtable/websites.js\";\nimport type { WebsiteRow } from \"../airtable/websites.js\";\nimport type { ReportRow } from \"../airtable/reports.js\";\nimport { fetchAttachmentBytes } from \"../airtable/attachments.js\";\nimport { renderReportHtml } from \"../render.js\";\nimport { resolveCopy } from \"../copy.js\";\nimport { announcementSiteExtras } from \"../announcement-email/template.js\";\nimport { loadBundledImages } from \"../maintenance-email/assets/index.js\";\nimport { prepareHeaderImage } from \"../maintenance-email/header-image.js\";\nimport { defaultResendClient, type ResendClient, type ResendSendInput } from \"./resend.js\";\nimport { isIdempotencyConflict } from \"./idempotency.js\";\nimport { checklistFor, isChecklistComplete } from \"../checklist.js\";\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\nconst REPLY_TO = \"info@reddoorla.com\";\n\nconst MONTHS = [\n \"January\",\n \"February\",\n \"March\",\n \"April\",\n \"May\",\n \"June\",\n \"July\",\n \"August\",\n \"September\",\n \"October\",\n \"November\",\n \"December\",\n];\n\n/** \"May 2026\" — UTC month/year, consistent with the rest of the reports pipeline's dates. */\nfunction monthYear(d: Date): string {\n return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;\n}\n\ntype InlineAttachment = NonNullable<ResendSendInput[\"attachments\"]>[number];\n\n/** Build a Resend inline (CID-referenced) attachment from raw bytes — the header\n * image and both bundled images share this exact shape. */\nfunction toInlineAttachment(a: {\n bytes: Uint8Array;\n filename: string;\n contentType: string;\n cid: string;\n}): InlineAttachment {\n return {\n filename: a.filename,\n content: Buffer.from(a.bytes).toString(\"base64\"),\n contentType: a.contentType,\n inlineContentId: a.cid,\n };\n}\n\nexport type OrchestrateOptions = {\n resend?: ResendClient;\n};\n\nexport async function sendApprovedReports(\n options: OrchestrateOptions = {},\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const client = options.resend ?? defaultResendClient();\n\n const sendable = await listSendableReports(base);\n if (sendable.length === 0) return { output: \"No reports ready to send.\", code: 0 };\n\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const lines: string[] = [];\n let anyFailed = false;\n for (const report of sendable) {\n const site = sites.get(report.siteId);\n if (!site) {\n lines.push(`✗ ${report.reportId} — Site row not found for id=${report.siteId}`);\n anyFailed = true;\n continue;\n }\n try {\n const messageId = await sendOne(client, base, site, report);\n lines.push(`✓ sent: ${report.reportId} (${messageId})`);\n if (report.reportType === \"Launch\") {\n try {\n await updateLaunched(base, site.id, new Date().toISOString());\n lines.push(` ↳ launched: ${site.name} flipped to maintenance`);\n } catch (e) {\n lines.push(` ⚠ launch flip failed for ${site.name}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n lines.push(`✗ ${report.reportId} — ${(e as Error).message}`);\n anyFailed = true;\n }\n }\n return { output: lines.join(\"\\n\"), code: anyFailed ? 1 : 0 };\n}\n\nasync function sendOne(\n client: ResendClient,\n base: ReturnType<typeof openBase>,\n site: WebsiteRow,\n report: ReportRow,\n): Promise<string> {\n // Hard checklist gate: a Maintenance/Testing report whose operator checklist isn't\n // fully checked must never go out — even if \"Approved to send\" was ticked directly in\n // Airtable, bypassing the dashboard's approve gate. Throw so the report is skipped and\n // `Sent at` stays null (at-least-once retry preserved), exactly like the other sendOne\n // guards. Launch/Announcement have an empty checklist → vacuously complete, never gated.\n if (!isChecklistComplete(report)) {\n const items = checklistFor(report.reportType);\n const done = items.filter((i) => report.checklist[i.field] === true).length;\n throw new Error(\n `Report ${report.reportId} checklist incomplete — ${done}/${items.length} items checked`,\n );\n }\n if (!site.headerImage) {\n throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);\n }\n if (!report.lighthouse) {\n throw new Error(\n `Report ${report.reportId} has no Lighthouse scores — all four cells ` +\n `(Lighthouse — Performance / Accessibility / Best Practices / SEO) must be numeric ` +\n `on the Reports row; one non-numeric or blank cell nulls all four`,\n );\n }\n\n // Resolve + validate recipients BEFORE the expensive work (header fetch + sharp\n // downscale + full MJML render). A misconfigured-recipients site is a guaranteed\n // failure, so fail fast here rather than after burning that work. Same checks +\n // messages as before — only the position moved.\n const explicitTo = parseAddresses(site.reportRecipientsTo);\n // Run pointOfContact through the parser too — operators sometimes paste\n // \"a@x, b@y\" into that single-line field.\n const fallbackTo = parseAddresses(site.pointOfContact);\n const to = explicitTo ?? fallbackTo ?? [];\n if (to.length === 0) {\n throw new Error(\n `Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`,\n );\n }\n for (const addr of to) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' recipient is malformed: ${addr} — use a bare address only ` +\n `(no \\`Name <addr>\\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`,\n );\n }\n }\n const cc = parseAddresses(site.reportRecipientsCc);\n if (cc) {\n for (const addr of cc) {\n if (!isProbablyEmail(addr)) {\n throw new Error(\n `Site '${site.name}' CC is malformed: ${addr} — fix Report recipients (CC) in Airtable`,\n );\n }\n }\n }\n\n const original = await fetchAttachmentBytes(site.headerImage.url);\n // Downscale the (often multi-MB / 2400px+) Airtable header to email display size, and get\n // back display dims + a placeholder color so the template can reserve the box.\n const header = await prepareHeaderImage(original.bytes);\n const bundled = await loadBundledImages();\n\n const slug = siteSlug(site.name);\n const cidName = `${slug}-header`;\n const { html } = await renderReportHtml({\n siteName: site.name,\n siteUrl: site.url,\n reportType: report.reportType,\n completedOn: report.completedOn ? new Date(report.completedOn) : new Date(),\n lighthouse: report.lighthouse,\n gaUsersCurrent: report.gaUsersCurrent ?? undefined,\n gaUsersPrevious: report.gaUsersPrevious ?? undefined,\n searchPosition:\n report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : undefined,\n lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,\n commentary: report.commentary,\n copy: resolveCopy(site),\n headerImageCid: cidName,\n headerWidth: header.displayWidth,\n headerHeight: header.displayHeight,\n headerBgColor: header.placeholderColor,\n // Announcement-only: re-derive cadence + improvements from the site row so the SENT email\n // keeps its cadence copy + improvement callouts. Without this the send-time re-render drops\n // them entirely (they're not stored on the Reports row). Ignored by the other templates.\n ...(report.reportType === \"Announcement\" ? announcementSiteExtras(site) : {}),\n });\n\n const reportDate = report.completedOn ? new Date(report.completedOn) : new Date();\n const subject =\n report.subjectOverride ?? `${site.name} — ${monthYear(reportDate)} ${report.reportType} Report`;\n\n // Inline attachments: the per-site header (every template renders it) plus only the bundled\n // images the rendered HTML actually references. The green check (cid:rd-check-png) appears in\n // every report's checklist/score rows EXCEPT Launch; the blurred-tests image\n // (cid:rd-blurred-tests-jpg) appears ONLY in the Maintenance email. Attaching a bundled image\n // the template doesn't reference leaves a dangling inline part that some mail clients surface as\n // a stray downloadable attachment — so gate each on its cid actually appearing in `html`. This\n // self-corrects if a template's image usage changes; there's no per-report-type list to keep in\n // sync. Self-contained inline (no external CDN, no image-blocked broken icons in webmail).\n const attachments: InlineAttachment[] = [\n toInlineAttachment({\n bytes: header.bytes,\n filename: `${cidName}.jpg`,\n contentType: header.contentType,\n cid: cidName,\n }),\n ];\n for (const img of [bundled.check, bundled.blurred]) {\n if (html.includes(`cid:${img.cid}`)) {\n attachments.push(\n toInlineAttachment({\n bytes: img.bytes,\n filename: img.filename,\n contentType: img.contentType,\n cid: img.cid,\n }),\n );\n }\n }\n\n const payload: Parameters<ResendClient[\"send\"]>[0] = {\n from: FROM_ADDRESS,\n to,\n replyTo: REPLY_TO,\n subject,\n html,\n attachments,\n // Stable across retries of the same row — if Airtable stamping fails after a\n // successful Resend, the next --send-ready replays with the same key and\n // Resend returns the original message id rather than sending a duplicate.\n idempotencyKey: `report:${report.id}`,\n };\n if (cc) payload.cc = cc;\n\n let result: Awaited<ReturnType<ResendClient[\"send\"]>>;\n try {\n result = await client.send(payload);\n } catch (err) {\n // The send path is at-least-once: client.send succeeds → stampSent writes\n // `Sent at` (the ONLY thing that removes the row from listSendableReports). If\n // stampSent threw on a PRIOR run (an Airtable blip), `Sent at` stayed null and\n // the row replays here. By replay time the rendered body has usually changed\n // (operator Commentary edit, `report --due` rewrote scores, or the header\n // re-encodes non-deterministically), so Resend rejects the same-key\n // (`report:<id>`) / different-body re-send with a 409 (`invalid_idempotent_request`).\n //\n // That 409 means the email ALREADY WENT OUT under this key on the prior run.\n // Do NOT re-throw and do NOT re-send (re-throwing leaves the row unstamped, and\n // after the 24h key TTL a SECOND real email would go out). Instead stamp the row\n // so it stops replaying, then return success so the caller runs the Launch flip —\n // which self-heals a launch that sent-but-never-flipped on the prior run.\n //\n // Any OTHER error (real network/Resend failure) re-throws, exactly as before, so\n // a genuine failure still fails loudly and the row replays next run.\n if (isIdempotencyConflict(err)) {\n // Stamp `Sent at` ONLY — the original send's messageId is unrecoverable on\n // the 409 path, so we leave `Resend message ID` null rather than writing a\n // sentinel that would masquerade as a real id and orphan webhook lookups.\n // Still return the sentinel string so the caller logs the already-sent path\n // and runs the Launch flip.\n await stampSent(base, report.id, new Date(), null);\n console.log(`↻ already sent (idempotency conflict), stamped: ${report.reportId}`);\n return \"idempotent-conflict\";\n }\n throw err;\n }\n await stampSent(base, report.id, new Date(), result.messageId);\n return result.messageId;\n}\n\n/**\n * Split a comma/newline-separated address field into a clean array.\n * Lowercases (case-insensitive dedupe) and removes empty entries. Returns\n * null if nothing survives. Does NOT understand `Display Name <email>` —\n * operators should put a bare address in the Airtable field, or use multiple\n * lines if needing multiple recipients.\n */\nexport function parseAddresses(field: string | null): string[] | null {\n if (!field) return null;\n const seen = new Set<string>();\n const list: string[] = [];\n for (const raw of field.split(/[,\\n]/)) {\n const trimmed = raw.trim().toLowerCase();\n if (!trimmed) continue;\n if (seen.has(trimmed)) continue;\n seen.add(trimmed);\n list.push(trimmed);\n }\n return list.length > 0 ? list : null;\n}\n\n/**\n * Cheap email shape check — must contain exactly one @, with non-empty\n * local and domain parts and at least one dot in the domain. We're not\n * trying to be a full RFC validator; we're trying to catch operator\n * mistakes like \"ops at acme dot com\" or a missing @ before they 422\n * at Resend.\n */\nexport function isProbablyEmail(s: string): boolean {\n const at = s.indexOf(\"@\");\n if (at < 1 || at !== s.lastIndexOf(\"@\")) return false;\n const local = s.slice(0, at);\n const domain = s.slice(at + 1);\n if (!local || !domain) return false;\n if (!domain.includes(\".\")) return false;\n if (/\\s/.test(s)) return false;\n return true;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/renovate.ts","../src/audits/github-signals.ts","../src/cli/commands/github-signals.ts"],"sourcesContent":["import type { PullRequestSummary } from \"../github/gh.js\";\n\n/**\n * Renovate's default `branchPrefix` is `renovate/`, so every update branch on a\n * default-config repo is `renovate/<topic>` — grouped majors included (they're\n * `renovate/<group-slug>`, still slash-prefixed, not a separate shape). We also\n * match a bare `renovate-` prefix to defensively catch a repo that sets a custom\n * non-slash `branchPrefix`; the tradeoff is that a human branch named `renovate-foo`\n * is misclassified — an acceptable bias for an alerting tool that should prefer\n * over-matching to silently missing a broken update.\n */\nconst RENOVATE_HEAD_PREFIXES = [\"renovate/\", \"renovate-\"];\n\nexport function isRenovatePR(pr: Pick<PullRequestSummary, \"headRef\">): boolean {\n return RENOVATE_HEAD_PREFIXES.some((p) => pr.headRef.startsWith(p));\n}\n\nexport function isFailingRenovatePR(pr: PullRequestSummary): boolean {\n return isRenovatePR(pr) && pr.ciState === \"failing\";\n}\n","import type { Site } from \"../types.js\";\nimport type { CiState, PullRequestSummary } from \"../github/gh.js\";\nimport { isFailingRenovatePR } from \"../alerts/renovate.js\";\n\n/** One swept row, ready for the Airtable writer (slug-keyed by `site`). */\nexport type GitHubSignalsRow = {\n site: string; // the site name/slug the writer matches on\n repo: string; // owner/repo\n renovateFailingCis: number;\n ciState: CiState;\n lastCommitAt: string | null;\n};\n\n/** Injected GitHub reads (so the sweep is pure + testable). */\nexport type GitHubSignalsDeps = {\n openPullRequests: (repo: string) => Promise<PullRequestSummary[]>;\n defaultBranchStatus: (repo: string) => Promise<{ ciState: CiState; lastCommitAt: string | null }>;\n};\n\n/** Per repo-backed site: count its failing Renovate PRs + read its default-branch\n * status. Sites without `gitRepo` are skipped (not errors). A repo whose probe\n * throws is reported via `onSkip` and produces no row — one GitHub hiccup never\n * sinks the sweep. PURE over `deps`. */\nexport async function collectGitHubSignals(\n sites: Site[],\n deps: GitHubSignalsDeps,\n onSkip: (s: { repo: string }) => void = () => {},\n): Promise<GitHubSignalsRow[]> {\n const rows: GitHubSignalsRow[] = [];\n for (const s of sites) {\n const repo = s.gitRepo;\n if (!repo) continue;\n const name = s.name;\n if (!name) continue;\n try {\n const prs = await deps.openPullRequests(repo);\n const status = await deps.defaultBranchStatus(repo);\n rows.push({\n site: name,\n repo,\n renovateFailingCis: prs.filter(isFailingRenovatePR).length,\n ciState: status.ciState,\n lastCommitAt: status.lastCommitAt,\n });\n } catch {\n onSkip({ repo });\n }\n }\n return rows;\n}\n","import { openBase, readAirtableConfig } from \"../../reports/airtable/client.js\";\nimport { listWebsites, siteSlug, updateGitHubSignals } from \"../../reports/airtable/websites.js\";\nimport type { Site } from \"../../types.js\";\nimport { collectGitHubSignals } from \"../../audits/github-signals.js\";\nimport { makeGitHub } from \"../../github/gh.js\";\nimport {\n formatFleetWriteSummary,\n type FleetWriteResult,\n} from \"../../audits/write-audits-to-airtable.js\";\n\n/** Exit code for a fleet github-signals run. Exit 1 when failures are the\n * MAJORITY of the fleet (`failed > written`), not only on a total wipeout —\n * a run where 11/12 repos failed but 1 wrote should still signal an outage.\n * All-success or a minority of flakes (e.g. 1/12 failed) stays exit 0. The\n * no-token clean-skip returns 0 separately (before any probe runs). */\nexport function githubSignalsExitCode(written: number, failed: number): number {\n return failed > written ? 1 : 0;\n}\n\n/** `github-signals --fleet --write-airtable`: sweep every repo-backed site for its\n * Renovate-failing count + default-branch CI state + last-commit date, write each\n * row serially (Airtable ~5 req/sec), and emit FLEET_WRITE_SUMMARY for CI. A\n * missing fleet token is a clean skip (local runs), not a failure. */\nexport async function runGitHubSignalsCommand(opts: {\n fleet?: boolean | undefined;\n writeAirtable?: boolean | undefined;\n}): Promise<{ output: string; code: number }> {\n if (!opts.fleet || !opts.writeAirtable) {\n return { output: \"github-signals currently supports only --fleet --write-airtable\", code: 2 };\n }\n const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();\n if (!token) {\n return {\n output: \"github-signals skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet read) configured.\",\n code: 0,\n };\n }\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const gh = makeGitHub({ token });\n const sites: Site[] = websites.map((w) => ({\n path: \"\",\n name: w.name,\n meta: {},\n ...(w.gitRepo ? { gitRepo: w.gitRepo } : {}),\n }));\n\n const skipped: string[] = [];\n const rows = await collectGitHubSignals(\n sites,\n {\n openPullRequests: (r) => gh.openPullRequests(r),\n defaultBranchStatus: (r) => gh.defaultBranchStatus(r),\n },\n ({ repo }) => skipped.push(repo),\n );\n\n const sweptAt = new Date().toISOString();\n const result: FleetWriteResult = { written: [], failed: [] };\n const byRepo = new Map(websites.filter((w) => w.gitRepo).map((w) => [w.gitRepo, w]));\n // Serial: Airtable's ~5 req/sec limit (matches writeFleetAuditsToAirtable).\n for (const row of rows) {\n const target = byRepo.get(row.repo);\n if (!target) {\n result.failed.push({ slug: siteSlug(row.site), error: \"no Websites row matched\" });\n continue;\n }\n try {\n await updateGitHubSignals(base, target.id, {\n renovateFailingCis: row.renovateFailingCis,\n ciState: row.ciState,\n lastCommitAt: row.lastCommitAt,\n sweptAt,\n });\n result.written.push({\n siteName: target.name,\n writes: [{ audit: \"github-signals\", counts: row }],\n });\n } catch (e) {\n result.failed.push({ slug: siteSlug(row.site), error: (e as Error).message });\n }\n }\n for (const repo of skipped) result.failed.push({ slug: repo, error: \"probe failed (skipped)\" });\n\n // Exit non-zero when failures are the MAJORITY of the fleet, not only on a\n // total wipeout. A run where 11/12 repos failed but 1 wrote used to return 0,\n // masking a large outage. The nightly cron step is `continue-on-error`, so a\n // non-zero here is an operator-visibility signal, not a red build.\n return {\n output: formatFleetWriteSummary(result),\n code: githubSignalsExitCode(result.written.length, result.failed.length),\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/github/renovate-dispatch.ts","../src/cli/commands/renovate-dispatch.ts"],"sourcesContent":["import { isDashboardVisible, type WebsiteRow } from \"../reports/airtable/websites.js\";\n\n/**\n * On-demand Renovate trigger for fleet sites.\n *\n * The nightly security sweep (`fleet-security.yml`) writes each active site's\n * vulnerability counts to Airtable. This module turns that into action: for the\n * sites the sweep just flagged with *actionable* (critical or high) vulns, it\n * dispatches that repo's `renovate.yml` `workflow_dispatch` so Renovate runs off\n * its weekly schedule. Renovate's OSV vulnerability alerts bypass the schedule,\n * so the remediation PR opens immediately (then auto-merges per the shared\n * preset). Moderate/low vulns are left to the normal weekly cadence — not urgent\n * enough to dispatch + churn CI nightly.\n */\n\n/** The per-repo workflow file the fleet uses for its self-hosted Renovate run. */\nexport const RENOVATE_WORKFLOW_FILE = \"renovate.yml\";\n\n/** A PR head-branch belongs to Renovate if it carries the default `renovate/`\n * branch prefix (the fleet preset doesn't override `branchPrefix`). Used to\n * detect \"remediation already in flight\" so a dispatch isn't re-fired. */\nexport function isRenovatePrBranch(headRef: string): boolean {\n return headRef.startsWith(\"renovate/\");\n}\n\n/** A site selected for an on-demand Renovate run, with the reason (vuln counts). */\nexport type RenovateDispatchTarget = {\n repo: string; // owner/repo, from the Websites \"Git repo\" field\n siteName: string;\n critical: number;\n high: number;\n};\n\n/**\n * Pick the active, repo-backed sites whose latest security sweep found a\n * critical or high vulnerability. A `null` vuln count is treated as zero (a site\n * that was never swept, or had its counts cleared, is not dispatched). Inactive\n * sites and sites without a `Git repo` are excluded — there's nothing to kick.\n *\n * Counts are only guaranteed fresh for sites the sweep actually audited (active +\n * URL present); a site whose write-back failed/was skipped this run is selected on\n * its last-known counts. That's safe — a stale-high false dispatch is an idempotent\n * Renovate no-op, and the open-PR skip in `dispatchRenovateAcross` avoids re-firing\n * when remediation is already in flight.\n */\nexport function selectRenovateTargets(sites: WebsiteRow[]): RenovateDispatchTarget[] {\n const targets: RenovateDispatchTarget[] = [];\n for (const s of sites) {\n if (!isDashboardVisible(s)) continue; // active (maintenance / launch period) only\n const repo = s.gitRepo?.trim();\n if (!repo) continue; // repo-backed only — the dispatch target is its renovate.yml\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n if (critical + high <= 0) continue; // actionable (critical/high) vulns only\n targets.push({ repo, siteName: s.name, critical, high });\n }\n return targets;\n}\n\n/** Injected GitHub operations — real ones come from `makeGitHub`, fakes from tests. */\nexport type RenovateDispatchDeps = {\n /** True iff the repo already has an open Renovate PR (remediation in flight). */\n hasOpenRenovatePr: (repo: string) => Promise<boolean>;\n defaultBranch: (repo: string) => Promise<string>;\n dispatch: (repo: string, workflow: string, ref: string) => Promise<void>;\n};\n\nexport type RenovateDispatchResult = {\n dispatched: string[];\n /** Repos skipped because a Renovate PR is already open — no point re-firing. */\n skipped: string[];\n failed: { repo: string; error: string }[];\n};\n\n/**\n * Dispatch `renovate.yml` for each target on its default branch.\n *\n * Skips a repo that already has an open Renovate PR: remediation is in flight, so\n * re-dispatching would just churn CI (this is what keeps a persistent vuln from\n * re-firing every nightly run while its fix PR waits — e.g. a human-gated major\n * bump). Best-effort and isolated: a repo that lacks `renovate.yml`, or whose\n * dispatch/PR-check is refused (the token's `actions:write` scope), is recorded in\n * `failed` and the rest still run. Never throws — the caller (a follow-up step on\n * the security sweep) must not be able to fail the sweep over a dispatch hiccup.\n */\nexport async function dispatchRenovateAcross(\n targets: RenovateDispatchTarget[],\n deps: RenovateDispatchDeps,\n): Promise<RenovateDispatchResult> {\n const dispatched: string[] = [];\n const skipped: string[] = [];\n const failed: { repo: string; error: string }[] = [];\n for (const t of targets) {\n try {\n if (await deps.hasOpenRenovatePr(t.repo)) {\n skipped.push(t.repo);\n continue;\n }\n const ref = await deps.defaultBranch(t.repo);\n await deps.dispatch(t.repo, RENOVATE_WORKFLOW_FILE, ref);\n dispatched.push(t.repo);\n } catch (e) {\n failed.push({ repo: t.repo, error: e instanceof Error ? e.message : String(e) });\n }\n }\n return { dispatched, skipped, failed };\n}\n\n/** Machine-readable counts line the workflow greps to annotate partial failures. */\nexport function formatRenovateDispatchSummary(result: RenovateDispatchResult): string {\n return `RENOVATE_DISPATCH_SUMMARY dispatched=${result.dispatched.length} skipped=${result.skipped.length} failed=${result.failed.length}`;\n}\n","import { openBase, readAirtableConfig } from \"../../reports/airtable/client.js\";\nimport { listWebsites } from \"../../reports/airtable/websites.js\";\nimport { makeGitHub } from \"../../github/gh.js\";\nimport {\n selectRenovateTargets,\n dispatchRenovateAcross,\n formatRenovateDispatchSummary,\n isRenovatePrBranch,\n} from \"../../github/renovate-dispatch.js\";\n\n/**\n * `renovate-dispatch --fleet`: read the Websites table, pick the active,\n * repo-backed sites the latest security sweep flagged with critical/high vulns,\n * and fire each one's `renovate.yml` `workflow_dispatch` so Renovate's OSV\n * vulnerability alerts open the remediation PR now (instead of waiting for the\n * weekly schedule). Designed to run as a best-effort follow-up step on\n * `fleet-security.yml` AFTER the sweep has written fresh counts to Airtable.\n *\n * Always exits 0: a missing token is a clean skip; a partial dispatch failure\n * (a repo without `renovate.yml`, or a token lacking `actions:write`) is reported\n * in the summary for the workflow to annotate, never failing the security sweep.\n */\nexport async function runRenovateDispatchCommand(opts: {\n fleet?: boolean | undefined;\n}): Promise<{ output: string; code: number }> {\n if (!opts.fleet) {\n return { output: \"renovate-dispatch currently supports only --fleet\", code: 2 };\n }\n const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();\n if (!token) {\n return {\n output: \"renovate-dispatch skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet dispatch) configured.\",\n code: 0,\n };\n }\n\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const targets = selectRenovateTargets(websites);\n\n if (targets.length === 0) {\n return {\n output:\n `${formatRenovateDispatchSummary({ dispatched: [], skipped: [], failed: [] })}\\n` +\n \"No active repo-backed sites with critical/high vulnerabilities — nothing to dispatch.\",\n code: 0,\n };\n }\n\n const gh = makeGitHub({ token });\n const result = await dispatchRenovateAcross(targets, {\n hasOpenRenovatePr: async (repo) =>\n (await gh.openPullRequests(repo)).some((pr) => isRenovatePrBranch(pr.headRef)),\n defaultBranch: (repo) => gh.defaultBranch(repo),\n dispatch: (repo, workflow, ref) => gh.dispatchWorkflow(repo, workflow, ref),\n });\n\n const failedRepos = new Set(result.failed.map((f) => f.repo));\n const skippedRepos = new Set(result.skipped);\n const lines = targets.map((t) => {\n const status = failedRepos.has(t.repo)\n ? \"FAILED \"\n : skippedRepos.has(t.repo)\n ? \"skipped (open Renovate PR) \"\n : \"dispatched \";\n return `${status} ${t.repo} (critical=${t.critical} high=${t.high}) — ${t.siteName}`;\n });\n for (const f of result.failed) lines.push(` ↳ ${f.repo}: ${f.error}`);\n lines.push(formatRenovateDispatchSummary(result));\n\n return { output: lines.join(\"\\n\"), code: 0 };\n}\n"],"mappings":"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","names":[]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|