@reddoorla/maintenance 0.56.0 → 0.58.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/airtable-E5F5FLGD.js +9 -0
- package/dist/{announce-JEVCDDKL.js → announce-JO67XZLZ.js} +3 -3
- package/dist/{bump-deps-O3SOLQYY.js → bump-deps-6D4VCAFQ.js} +2 -2
- package/dist/{chunk-JQG222CI.js → chunk-5E3OW6YN.js} +2 -2
- package/dist/{chunk-VXSX2FHO.js → chunk-HPNGCRTK.js} +13 -3
- package/dist/chunk-HPNGCRTK.js.map +1 -0
- package/dist/{chunk-J3OPWFZE.js → chunk-IDRBAF2H.js} +2 -2
- package/dist/{chunk-5WL3ET24.js → chunk-IJW4DS2S.js} +3 -3
- 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-CLVTVDOU.js → chunk-PKIYAWA4.js} +8 -1
- package/dist/chunk-PKIYAWA4.js.map +1 -0
- package/dist/chunk-PZQ3WED2.js +14 -0
- package/dist/chunk-PZQ3WED2.js.map +1 -0
- package/dist/{chunk-IMIR7SC2.js → chunk-RYECSIEA.js} +2 -2
- package/dist/{chunk-W3G7XFBZ.js → chunk-S7WKPRID.js} +2 -2
- package/dist/cli/bin.js +13 -13
- package/dist/cli/commands/audit.js +5 -5
- package/dist/{convert-to-pnpm-UR64X3OX.js → convert-to-pnpm-JYUUUDDP.js} +2 -2
- package/dist/{digest-YTO73BEO.js → digest-RUQ42QXL.js} +10 -5
- package/dist/digest-RUQ42QXL.js.map +1 -0
- package/dist/{github-signals-YD64R2AF.js → github-signals-JXHRHDAG.js} +8 -14
- package/dist/github-signals-JXHRHDAG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -5
- package/dist/index.js.map +1 -1
- package/dist/{init-JITQXFVL.js → init-6LJU72F2.js} +2 -2
- package/dist/{launch-HXHEL6ID.js → launch-6ZB3QA2I.js} +7 -7
- package/dist/{lighthouse-airtable-4FKYKFH6.js → lighthouse-airtable-FSGOUVMQ.js} +3 -3
- package/dist/{onboard-TCQOP2W4.js → onboard-UM6UKX2V.js} +2 -2
- package/dist/{orchestrate-RMSBHLQK.js → orchestrate-GEHLKE64.js} +9 -5
- package/dist/{renovate-dispatch-ZGLXNPEK.js → renovate-dispatch-L5HWCNRY.js} +50 -8
- package/dist/renovate-dispatch-L5HWCNRY.js.map +1 -0
- package/dist/{report-SD3CNBOQ.js → report-3YVSMRFE.js} +5 -5
- package/dist/{self-updating-CIUMOMYI.js → self-updating-J7U7WIP3.js} +4 -4
- package/dist/{svelte-codemods-BYRJAU7I.js → svelte-codemods-BW4M4CDF.js} +2 -2
- package/dist/{sync-configs-JPRLAW5J.js → sync-configs-ZYKZCP4C.js} +2 -2
- package/dist/{upgrade-ERA45EVR.js → upgrade-UAXREMED.js} +2 -2
- package/dist/{websites-X5DLC2PC.js → websites-EEZVZXQT.js} +4 -2
- package/dist/{write-audits-to-airtable-CE4POBAF.js → write-audits-to-airtable-LUFY424L.js} +4 -4
- package/package.json +1 -1
- package/dist/airtable-2BJX4ATJ.js +0 -9
- package/dist/chunk-CLVTVDOU.js.map +0 -1
- package/dist/chunk-DIVB6ZF7.js.map +0 -1
- package/dist/chunk-VXSX2FHO.js.map +0 -1
- package/dist/digest-YTO73BEO.js.map +0 -1
- package/dist/github-signals-YD64R2AF.js.map +0 -1
- package/dist/renovate-dispatch-ZGLXNPEK.js.map +0 -1
- /package/dist/{airtable-2BJX4ATJ.js.map → airtable-E5F5FLGD.js.map} +0 -0
- /package/dist/{announce-JEVCDDKL.js.map → announce-JO67XZLZ.js.map} +0 -0
- /package/dist/{bump-deps-O3SOLQYY.js.map → bump-deps-6D4VCAFQ.js.map} +0 -0
- /package/dist/{chunk-JQG222CI.js.map → chunk-5E3OW6YN.js.map} +0 -0
- /package/dist/{chunk-J3OPWFZE.js.map → chunk-IDRBAF2H.js.map} +0 -0
- /package/dist/{chunk-5WL3ET24.js.map → chunk-IJW4DS2S.js.map} +0 -0
- /package/dist/{chunk-PF6BNYGE.js.map → chunk-MKSZBODM.js.map} +0 -0
- /package/dist/{chunk-IMIR7SC2.js.map → chunk-RYECSIEA.js.map} +0 -0
- /package/dist/{chunk-W3G7XFBZ.js.map → chunk-S7WKPRID.js.map} +0 -0
- /package/dist/{convert-to-pnpm-UR64X3OX.js.map → convert-to-pnpm-JYUUUDDP.js.map} +0 -0
- /package/dist/{init-JITQXFVL.js.map → init-6LJU72F2.js.map} +0 -0
- /package/dist/{launch-HXHEL6ID.js.map → launch-6ZB3QA2I.js.map} +0 -0
- /package/dist/{lighthouse-airtable-4FKYKFH6.js.map → lighthouse-airtable-FSGOUVMQ.js.map} +0 -0
- /package/dist/{onboard-TCQOP2W4.js.map → onboard-UM6UKX2V.js.map} +0 -0
- /package/dist/{orchestrate-RMSBHLQK.js.map → orchestrate-GEHLKE64.js.map} +0 -0
- /package/dist/{report-SD3CNBOQ.js.map → report-3YVSMRFE.js.map} +0 -0
- /package/dist/{self-updating-CIUMOMYI.js.map → self-updating-J7U7WIP3.js.map} +0 -0
- /package/dist/{svelte-codemods-BYRJAU7I.js.map → svelte-codemods-BW4M4CDF.js.map} +0 -0
- /package/dist/{sync-configs-JPRLAW5J.js.map → sync-configs-ZYKZCP4C.js.map} +0 -0
- /package/dist/{upgrade-ERA45EVR.js.map → upgrade-UAXREMED.js.map} +0 -0
- /package/dist/{websites-X5DLC2PC.js.map → websites-EEZVZXQT.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-CE4POBAF.js.map → write-audits-to-airtable-LUFY424L.js.map} +0 -0
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
} from "./chunk-4NAOR44V.js";
|
|
6
6
|
import {
|
|
7
7
|
resolveSites
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-IDRBAF2H.js";
|
|
9
9
|
import {
|
|
10
10
|
convertToPnpm
|
|
11
11
|
} from "./chunk-2X322ZES.js";
|
|
@@ -48,4 +48,4 @@ async function runConvertToPnpmCommand(site, opts) {
|
|
|
48
48
|
export {
|
|
49
49
|
runConvertToPnpmCommand
|
|
50
50
|
};
|
|
51
|
-
//# sourceMappingURL=convert-to-pnpm-
|
|
51
|
+
//# sourceMappingURL=convert-to-pnpm-JYUUUDDP.js.map
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
listWebsites,
|
|
11
11
|
siteSlug
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-PKIYAWA4.js";
|
|
13
13
|
import {
|
|
14
14
|
openBase,
|
|
15
15
|
readAirtableConfig
|
|
@@ -31,6 +31,7 @@ function gitHubSignalsStale(swept, now) {
|
|
|
31
31
|
if (!Number.isFinite(ageMs)) return true;
|
|
32
32
|
return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY;
|
|
33
33
|
}
|
|
34
|
+
var AUTO_FIX_EXHAUSTED_CYCLES = 3;
|
|
34
35
|
function collectVulnAlerts(sites, baseUrl) {
|
|
35
36
|
const items = [];
|
|
36
37
|
for (const s of sites) {
|
|
@@ -38,14 +39,18 @@ function collectVulnAlerts(sites, baseUrl) {
|
|
|
38
39
|
const high = s.securityVulnsHigh ?? 0;
|
|
39
40
|
const metric = critical + high;
|
|
40
41
|
if (metric <= 0) continue;
|
|
42
|
+
const attempts = s.securityAutoFixAttempts ?? 0;
|
|
43
|
+
const exhausted = attempts >= AUTO_FIX_EXHAUSTED_CYCLES;
|
|
44
|
+
const noun = metric === 1 ? "vuln" : "vulns";
|
|
41
45
|
items.push({
|
|
42
46
|
key: `vuln:${s.id}`,
|
|
43
47
|
kind: "vuln",
|
|
44
48
|
siteName: s.name,
|
|
45
|
-
title: `${metric} critical/high ${
|
|
49
|
+
title: exhausted ? `${metric} critical/high ${noun} \u2014 auto-fix failed (${attempts}\xD7)` : `${metric} critical/high ${noun}`,
|
|
46
50
|
url: dashboardUrl(baseUrl, s.name),
|
|
47
|
-
severity: critical > 0 ? "critical" : "warning",
|
|
48
|
-
metric
|
|
51
|
+
severity: exhausted || critical > 0 ? "critical" : "warning",
|
|
52
|
+
metric,
|
|
53
|
+
...exhausted ? { autoFixExhausted: true } : {}
|
|
49
54
|
});
|
|
50
55
|
}
|
|
51
56
|
return items;
|
|
@@ -405,4 +410,4 @@ export {
|
|
|
405
410
|
renderDigestHtml,
|
|
406
411
|
runDigest
|
|
407
412
|
};
|
|
408
|
-
//# sourceMappingURL=digest-
|
|
413
|
+
//# sourceMappingURL=digest-RUQ42QXL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/digest-collectors.ts","../src/alerts/digest-state.ts","../src/reports/digest.ts"],"sourcesContent":["// src/alerts/digest-collectors.ts\nimport type { AttentionItem } from \"./attention.js\";\nimport { siteSlug, type WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\n\n/** Build the same `/s/<slug>` dashboard link the M3 ready-section uses, trailing-slash-safe. */\nfunction dashboardUrl(baseUrl: string, siteName: string): string {\n return `${baseUrl.replace(/\\/$/, \"\")}/s/${siteSlug(siteName)}`;\n}\n\n/**\n * A GitHub-signals sweep older than this (or never run) is no longer trustworthy:\n * a repo whose nightly probe THREW stops being re-swept, so its persisted\n * `Default Branch CI` / `Renovate Failing CIs` freeze at their last value forever\n * — a phantom 🔴 that can never clear. 3 days ≈ 3× the daily sweep interval, so a\n * single missed/flaky run doesn't drop a real signal. The CI/Renovate collectors\n * (only) skip a site whose `githubSignalsAt` is staler than this. Vuln/Lighthouse/\n * delivery signals come from other sweeps and are unaffected.\n */\nconst GITHUB_SIGNALS_STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when a site's GitHub-signals sweep is too old (or never ran) to trust the\n * persisted CI/Renovate fields. A null `githubSignalsAt` (never swept) is stale —\n * there's no sweep to vouch for the values. A future timestamp (now - swept < 0)\n * is fresh. `now` is injected so the gate is testable. */\nfunction gitHubSignalsStale(swept: string | null, now: Date): boolean {\n if (swept === null) return true;\n const ageMs = now.getTime() - Date.parse(swept);\n if (!Number.isFinite(ageMs)) return true; // unparseable timestamp → don't trust it\n return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY;\n}\n\n/** Renovate auto-fix dispatches for one vuln episode before it's \"exhausted\" (manual fix needed). */\nconst AUTO_FIX_EXHAUSTED_CYCLES = 3;\n\n/**\n * One attention item per site carrying current critical+high vulns (medium/low omitted\n * per the locked threshold). PURE: takes already-fetched Websites rows. `metric` is the\n * critical+high count (so a rising count diffs as WORSE); `severity` is `critical` when\n * any critical exists, else `warning`. Null counts (never audited) read as 0 → skipped.\n * Once `securityAutoFixAttempts` reaches AUTO_FIX_EXHAUSTED_CYCLES the item is flagged\n * `autoFixExhausted` (forced-critical, escalated title) — Renovate tried and couldn't fix it.\n */\nexport function collectVulnAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n const metric = critical + high;\n if (metric <= 0) continue;\n const attempts = s.securityAutoFixAttempts ?? 0;\n const exhausted = attempts >= AUTO_FIX_EXHAUSTED_CYCLES;\n const noun = metric === 1 ? \"vuln\" : \"vulns\";\n items.push({\n key: `vuln:${s.id}`,\n kind: \"vuln\",\n siteName: s.name,\n title: exhausted\n ? `${metric} critical/high ${noun} — auto-fix failed (${attempts}×)`\n : `${metric} critical/high ${noun}`,\n url: dashboardUrl(baseUrl, s.name),\n severity: exhausted || critical > 0 ? \"critical\" : \"warning\",\n metric,\n ...(exhausted ? { autoFixExhausted: true } : {}),\n });\n }\n return items;\n}\n\n/** Absolute floor below which a Lighthouse category is \"Needs attention\" (Tucker's call). */\nconst LIGHTHOUSE_FLOOR = 75;\n\n/** The four Lighthouse categories, each mapped to its WebsiteRow score field, URL slug,\n * and the human label rendered in the digest title. Order is the operator's reading order. */\nconst LIGHTHOUSE_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n slug: string;\n label: string;\n}> = [\n { field: \"pScore\", slug: \"performance\", label: \"Performance\" },\n { field: \"rScore\", slug: \"accessibility\", label: \"Accessibility\" },\n { field: \"bpScore\", slug: \"best-practices\", label: \"Best Practices\" },\n { field: \"seoScore\", slug: \"seo\", label: \"SEO\" },\n];\n\n/**\n * One attention item per Lighthouse category below the absolute floor (75) for each site.\n * PURE: takes already-fetched Websites rows. Categories are Performance/Accessibility/\n * Best-Practices/SEO. A null score (never audited) or a score >= 75 is skipped. The\n * `metric` is the DEFICIT (`100 - score`): a lower score → higher metric, so a category\n * that drops further diffs as WORSE and one that first crosses below 75 diffs as NEW —\n * which is how `diffAttention`'s \"WORSE on increase\" rule reads an inverted score. `key`\n * is `lighthouse:<siteId>:<categorySlug>`, so the four categories stay distinct per site.\n */\nexport function collectLighthouseAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n for (const cat of LIGHTHOUSE_CATEGORIES) {\n const score = s[cat.field];\n if (score === null || score >= LIGHTHOUSE_FLOOR) continue;\n items.push({\n key: `lighthouse:${s.id}:${cat.slug}`,\n kind: \"lighthouse\",\n siteName: s.name,\n title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 100 - score,\n });\n }\n }\n return items;\n}\n\n/**\n * One attention item per report whose `deliveryStatus` is a failure (`bounced` or\n * `complained` — `delivered`/`pending` are ignored). PURE: takes already-fetched\n * Reports rows + a record-id→site map. A complaint ranks above a bounce (locked\n * threshold), so `severity` is `critical` for complained / `warning` for bounced.\n * `metric` is 1 (a binary event). Orphans (siteId not in the map) are skipped, as\n * the M3 ready-section does, so the digest never renders a broken link. The diff\n * key is the report RECORD id, so two failures on one site stay distinct.\n */\nexport function collectDeliveryFailures(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (r.deliveryStatus !== \"bounced\" && r.deliveryStatus !== \"complained\") continue;\n const site = sitesById.get(r.siteId);\n if (!site) continue; // orphan → skip rather than render a broken link\n const complained = r.deliveryStatus === \"complained\";\n items.push({\n key: `delivery:${r.id}`,\n kind: \"delivery\",\n siteName: site.name,\n title: complained ? \"Spam complaint on a sent report\" : \"A sent report bounced\",\n url: dashboardUrl(baseUrl, site.name),\n severity: complained ? \"critical\" : \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site carrying failing Renovate PRs, read from the\n * slice-2a-persisted `renovateFailingCis` field (the nightly github-signals sweep\n * populates it). PURE. Keyed `renovate:<siteId>` so the digest and the cockpit\n * share one diff key. `metric` is the count (a rising count diffs WORSE); severity\n * `warning`. Null/0 → skipped. A site whose `githubSignalsAt` is >3 days stale (or\n * null) is ALSO skipped — a repo that stopped being swept must not show a phantom\n * count forever (`now` injected, defaults to wall-clock).\n */\nexport function collectRenovateAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n const n = s.renovateFailingCis ?? 0;\n if (n <= 0) continue;\n items.push({\n key: `renovate:${s.id}`,\n kind: \"renovate\",\n siteName: s.name,\n title: `${n} Renovate ${n === 1 ? \"PR\" : \"PRs\"} failing CI`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: n,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site whose persisted default-branch CI rollup is\n * `failing` (slice 2a). PURE. `metric` 1 (binary); severity `warning`. Any other\n * state (passing/pending/none) or null is skipped. A site whose `githubSignalsAt`\n * is >3 days stale (or null) is ALSO skipped — a repo that stopped being swept must\n * not show a phantom 🔴 forever (`now` injected, defaults to wall-clock).\n */\nexport function collectCiAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n if (s.defaultBranchCi !== \"failing\") continue;\n items.push({\n key: `ci:${s.id}`,\n kind: \"ci\",\n siteName: s.name,\n title: \"Default-branch CI failing\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * A soft-fail older than this is no longer a trustworthy CURRENT signal. Drafting\n * self-clears `analyticsSoftFailAt` on the next clean enrichment, so a non-null\n * value normally means \"errored on the most recent draft and hasn't recovered\" —\n * but a site that stopped being drafted (freq→None, deprecated) would otherwise\n * show a phantom forever. 45 days covers a monthly cadence + margin; older drops.\n */\nconst ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;\n\n/**\n * One attention item per site whose last draft's GA/Search enrichment ERRORED, read\n * from the `analyticsSoftFailAt` timestamp (drafting sets it on a soft-fail, clears\n * it on a clean enrichment). PURE. Keyed `analytics:<siteId>` so the digest and the\n * cockpit share one diff key; `metric` 1 (binary), severity `warning`. A null\n * timestamp (clean, or the operator-added `Analytics soft-fail at` column absent) is\n * skipped, as is one staler than {@link ANALYTICS_SOFT_FAIL_STALE_DAYS}. On a\n * FLEET-WIDE subject outage many sites surface this at once — that breadth IS the\n * signal here; the report cron additionally emails a single concise fleet-wide alert\n * (see `assessAnalyticsAlert`). `now` injected, defaults to wall-clock.\n */\nexport function collectAnalyticsFailures(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const at = s.analyticsSoftFailAt;\n if (at === null) continue;\n const ageMs = now.getTime() - Date.parse(at);\n // Unparseable timestamp (NaN) → keep the item (don't silently drop a real failure\n // on a parse glitch); only a parseable, beyond-window age is skipped as stale.\n if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY) continue;\n items.push({\n key: `analytics:${s.id}`,\n kind: \"analytics\",\n siteName: s.name,\n title: \"GA/Search enrichment failing (analytics blank)\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n","// src/alerts/digest-state.ts\nimport type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { AttentionItem } from \"./attention.js\";\n\n/**\n * The persisted prior-run snapshot: stable item `key` → its last metric + the\n * date it was FIRST flagged. Lives as JSON in the single \"Digest State\" Airtable\n * row (the IO that loads/stores it — readDigestState/writeDigestState — is added\n * in component 2). `next` from diffAttention is what gets written back.\n */\nexport type DigestSnapshot = Record<string, { metric: number; firstFlaggedAt: string }>;\n\n/**\n * PURE diff — the testable core of the hybrid \"snapshot now, mark what's new\".\n * For each current item vs the prior snapshot:\n * - key absent from prior → NEW (firstFlaggedAt = today)\n * - present and metric > prior.metric → WORSE (keep the original firstFlaggedAt)\n * - otherwise (equal or dropped) → STANDING (keep the original firstFlaggedAt)\n * `next` contains EXACTLY the current items' keys: resolved keys drop out, so a\n * fixed-then-recurring problem re-news correctly. Neither input is mutated.\n */\nexport function diffAttention(\n items: AttentionItem[],\n prior: DigestSnapshot,\n today: string,\n): { tagged: AttentionItem[]; next: DigestSnapshot } {\n const tagged: AttentionItem[] = [];\n const next: DigestSnapshot = {};\n for (const it of items) {\n const was = prior[it.key];\n let status: AttentionItem[\"status\"];\n let firstFlaggedAt: string;\n if (!was) {\n status = \"new\";\n firstFlaggedAt = today;\n } else if (it.metric > was.metric) {\n status = \"worse\";\n firstFlaggedAt = was.firstFlaggedAt;\n } else {\n status = \"standing\";\n firstFlaggedAt = was.firstFlaggedAt;\n }\n tagged.push({ ...it, status });\n next[it.key] = { metric: it.metric, firstFlaggedAt };\n }\n return { tagged, next };\n}\n\n/** The single-row Airtable table that persists the prior digest snapshot. */\nexport const DIGEST_STATE_TABLE = \"Digest State\";\n\n/**\n * Read the persisted prior snapshot from the \"Digest State\" singleton.\n *\n * Reads the FIRST row of an unfiltered select (the table holds exactly one row;\n * the test fake does not evaluate filterByFormula, so we never rely on one). A\n * read miss (no row) OR a parse error (malformed Snapshot JSON) collapses to `{}`\n * — every key then reads as NEW once, which is safe degradation (never crashes\n * the digest).\n */\nexport async function readDigestState(base: AirtableBase): Promise<DigestSnapshot> {\n const rows: { id: string; fields: Record<string, unknown> }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n const first = rows[0];\n if (!first) return {};\n const raw = first.fields[\"Snapshot\"];\n if (typeof raw !== \"string\") return {};\n try {\n return JSON.parse(raw) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot to the \"Digest State\" singleton: get-or-create the\n * one row. If a row exists, UPDATE it (keyed by its record id); otherwise CREATE\n * one. `Snapshot` = JSON.stringify(snap); `Updated At` = the injected ISO\n * timestamp (or now). A caller that catches+logs a write failure keeps the\n * already-sent digest unaffected (next run re-news at worst).\n */\nexport async function writeDigestState(\n base: AirtableBase,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const rows: { id: string }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id });\n fetchNextPage();\n });\n const fields: FieldSet = {\n Snapshot: JSON.stringify(snap),\n \"Updated At\": updatedAt,\n };\n const existing = rows[0];\n if (existing) {\n await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);\n } else {\n await base(DIGEST_STATE_TABLE).create([{ fields }]);\n }\n}\n","// src/reports/digest.ts\nimport { openBase, readAirtableConfig, type AirtableBase } from \"./airtable/client.js\";\nimport { listAllReports, isPendingApproval } from \"./airtable/reports.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { listWebsites, siteSlug, type WebsiteRow } from \"./airtable/websites.js\";\nimport { defaultResendClient, type ResendClient } from \"./send/resend.js\";\nimport { isIdempotencyConflict } from \"./send/idempotency.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\n} from \"../alerts/digest-collectors.js\";\nimport { diffAttention, readDigestState, writeDigestState } from \"../alerts/digest-state.js\";\nimport { escapeHtml as esc } from \"../util/html.js\";\nimport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n} from \"../alerts/attention.js\";\n\n// The attention/digest contract lives in `../alerts/attention.ts` (a dependency-free\n// types module) so the `alerts/*` collectors can depend on it without importing back\n// from this renderer/IO module — see attention.ts for the cycle it breaks. Re-exported\n// here so existing `from \"./digest.js\"` type importers keep resolving.\nexport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n} from \"../alerts/attention.js\";\n\nconst GREY = \"#757575\";\nconst RED = \"#C00\";\n\n/** Shared anchor style — Gmail does not inherit font-family into <a> tags. */\nconst ANCHOR_STYLE = `color:${RED};font-family:helvetica,sans-serif`;\n\nfunction readySection(items: ReadyItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Ready for your yes</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">Nothing waiting on you.</p>`;\n }\n const rows = items\n .map((it) => {\n const safeUrl = it.dashboardUrl.startsWith(\"https://\") ? it.dashboardUrl : undefined;\n const link = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">review & approve</a>`\n : `review & approve`;\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(it.siteName)}</strong> — ${esc(it.reportType)} (${esc(it.period)})\n — ${link}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${rows}</table>`;\n}\n\nconst SEVERITY_ORDER: Record<AttentionSeverity, number> = { critical: 0, warning: 1 };\n\n/** Render the per-item status badge (\"NEW\"/\"WORSE\"); standing items get nothing. */\nfunction attentionBadge(status?: AttentionStatus): string {\n if (status === \"new\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">NEW</strong> `;\n if (status === \"worse\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">WORSE</strong> `;\n return \"\";\n}\n\nfunction attentionSection(items: AttentionItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Needs attention</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">All clear — nothing needs attention.</p>`;\n }\n\n // Group by siteName, preserving first-seen site order; sort within a site by\n // severity (critical first).\n const bySite = new Map<string, AttentionItem[]>();\n for (const it of items) {\n const bucket = bySite.get(it.siteName);\n if (bucket) bucket.push(it);\n else bySite.set(it.siteName, [it]);\n }\n\n const groups = [...bySite.entries()]\n .map(([siteName, siteItems]) => {\n const sorted = [...siteItems].sort(\n (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],\n );\n const rows = sorted\n .map((it) => {\n const safeUrl = it.url?.startsWith(\"https://\") ? it.url : undefined;\n const titleHtml = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">${esc(it.title)}</a>`\n : esc(it.title);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">${attentionBadge(it.status)}${titleHtml}</td>\n </tr>`;\n })\n .join(\"\");\n return `\n <tr>\n <td style=\"color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px\">${esc(siteName)}</td>\n </tr>\n ${rows}`;\n })\n .join(\"\");\n\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${groups}</table>`;\n}\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n/** Single-operator fleet — fallback when OPERATOR_EMAIL is unset. */\nconst DIGEST_OPERATOR_FALLBACK = \"info@reddoorla.com\";\n\n/** UTC \"YYYY-MM-DD\" — the Resend idempotency key suffix, so a same-day cron re-fire dedupes. */\nfunction digestDateKey(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * The gate for \"Ready for your yes\": Draft ready ∧ ¬Approved to send ∧ Sent at BLANK.\n *\n * Implemented as `listAllReports(base).filter(...)` (the authorized deviation from the plan's\n * draft, which pre-dated Slice 1's merged fix): `listAllReports` already calls `mapRow`, which\n * handles `Period` correctly, so no local `rawToReportRow` duplicate is needed. The JS filter\n * does real work — the test fake does NOT evaluate `filterByFormula` — so correctness is\n * test-provable here.\n *\n * Exported: the fleet homepage (Task 3.5b) reuses it for the pending-approval count.\n */\nexport async function listPendingApproval(base: AirtableBase): Promise<ReportRow[]> {\n return (await listAllReports(base)).filter(isPendingApproval);\n}\n\n// ── collectAttention (IO wrapper, sibling to runDigest) ──────────────────────\n\nexport type CollectAttentionDeps = {\n base: AirtableBase;\n /** Same baseUrl value runDigest threads; used for the /s/<slug> links. */\n baseUrl: string;\n /** Pre-fetched Websites rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listWebsites`. */\n websites?: WebsiteRow[];\n /** Pre-fetched Reports rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listAllReports`. */\n reports?: ReportRow[];\n /** Clock for the GitHub-signals staleness gate (collectCiAlerts /\n * collectRenovateAlerts skip a >3-day-stale sweep). Defaults to wall-clock;\n * runDigest threads its run-start `today` so a quiet repo's frozen CI/Renovate\n * signal stops badging once its sweep goes stale. */\n now?: Date;\n};\n\n/** Run a single collector under a try/catch: a thrown collector logs and yields []\n * so one broken signal never blanks the whole \"Needs attention\" section. */\nfunction runCollector(label: string, fn: () => AttentionItem[]): AttentionItem[] {\n try {\n return fn();\n } catch (e) {\n console.warn(`⚠ attention collector \"${label}\" failed: ${(e as Error).message}`);\n return [];\n }\n}\n\n/**\n * Fetch the free signals once (listAllReports + listWebsites) — or reuse the\n * `reports`/`websites` arrays runDigest already read, so a single run reads each\n * table once — build the sitesById map the delivery collector needs, and run each\n * pure collector isolated. Returns the union of items; diffing/badging happens in\n * runDigest.\n *\n * The Renovate + CI signals come from the SAME persisted collectors the operator\n * cockpit (`buildCockpitModel`) runs — `collectRenovateAlerts` (key\n * `renovate:<siteId>`) and `collectCiAlerts` (key `ci:<siteId>`), reading the\n * nightly-persisted `renovateFailingCis`/`defaultBranchCi` fields. This is the\n * key-space unification: because the digest writes the shared Digest State\n * snapshot with these same keys, the cockpit's NEW/WORSE diff finds them and the\n * two surfaces agree (the prior live per-PR `renovate:<repo>#<n>` sweep never\n * matched the cockpit's keys, so its cards badged NEW forever).\n */\nexport async function collectAttention(deps: CollectAttentionDeps): Promise<AttentionItem[]> {\n const reports = deps.reports ?? (await listAllReports(deps.base));\n const websites = deps.websites ?? (await listWebsites(deps.base));\n const now = deps.now ?? new Date();\n const sitesById = new Map<string, WebsiteRow>(websites.map((w) => [w.id, w]));\n return [\n ...runCollector(\"vuln\", () => collectVulnAlerts(websites, deps.baseUrl)),\n ...runCollector(\"delivery\", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"lighthouse\", () => collectLighthouseAlerts(websites, deps.baseUrl)),\n ...runCollector(\"renovate\", () => collectRenovateAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"ci\", () => collectCiAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"analytics\", () => collectAnalyticsFailures(websites, deps.baseUrl, now)),\n ];\n}\n\nexport type DigestRunOptions = {\n resend?: ResendClient;\n /** Dashboard origin for the /s/<slug> links, e.g. \"https://reddoor-maintenance.netlify.app\". */\n baseUrl: string;\n /**\n * Inject a pre-opened Airtable base (tests, server handlers).\n * When omitted, `openBase(readAirtableConfig())` is called from the environment.\n */\n base?: AirtableBase;\n};\n\nexport async function runDigest(\n options: DigestRunOptions,\n): Promise<{ output: string; code: number }> {\n // Capture clock BEFORE any await so the idempotency key can't roll past midnight mid-run.\n const today = new Date();\n try {\n const base = options.base ?? openBase(readAirtableConfig());\n // Read each table ONCE for the whole run, then thread the arrays into\n // collectAttention so it doesn't re-fetch (was: listWebsites ×2, listAllReports\n // ×2). Pending is derived in-line with listPendingApproval's exact predicate.\n const reports = await listAllReports(base);\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const pending = reports.filter(isPendingApproval);\n\n const readyForYourYes: ReadyItem[] = [];\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n for (const r of pending) {\n const site = sites.get(r.siteId);\n if (!site) continue; // orphan report → skip rather than render a broken link\n // An empty Name slugs to \"\" → `/s/` is a dead link (getWebsiteBySlug can't\n // match it). Fall back to the fleet homepage so the operator still lands\n // somewhere usable instead of a 404.\n const slug = siteSlug(site.name);\n readyForYourYes.push({\n siteName: site.name,\n reportType: r.reportType,\n period: r.period ?? \"—\",\n dashboardUrl: slug ? `${baseUrl}/s/${slug}` : baseUrl,\n });\n }\n\n // M5: collect the free signals (isolated), diff against yesterday's snapshot.\n // Renovate + CI come from the persisted collectors (the same ones the cockpit\n // runs), so the snapshot this digest writes carries the `renovate:<siteId>` /\n // `ci:<siteId>` keys the cockpit diffs against — no live GitHub sweep here.\n const collected = await collectAttention({\n base,\n baseUrl: options.baseUrl,\n websites,\n reports,\n now: today,\n });\n const prior = await readDigestState(base);\n const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));\n const needsAttention = tagged;\n\n // No-noise default: skip entirely when there's nothing to report.\n if (readyForYourYes.length === 0 && needsAttention.length === 0) {\n // On a skip, `collected` is [] so `next` is {} — still persist it so a key that\n // resolved on a quiet day clears and a later recurrence diffs as NEW (spec §10).\n // Wrapped: a write failure can't fail the skip.\n try {\n await writeDigestState(base, next);\n } catch (e) {\n console.warn(`⚠ digest state write failed: ${(e as Error).message}`);\n }\n return { output: \"Digest skipped (nothing ready, nothing needs attention).\", code: 0 };\n }\n\n const html = renderDigestHtml({ readyForYourYes, needsAttention });\n const client = options.resend ?? defaultResendClient();\n const to = [process.env.OPERATOR_EMAIL?.trim() || DIGEST_OPERATOR_FALLBACK];\n const n = readyForYourYes.length;\n const reportWord = n === 1 ? \"report\" : \"reports\";\n let result: Awaited<ReturnType<typeof client.send>>;\n try {\n result = await client.send({\n from: FROM_ADDRESS,\n to,\n subject: `Your fleet — ${digestDateKey(today)}: ${n} ${reportWord} ready for your yes`,\n html,\n idempotencyKey: `digest-${digestDateKey(today)}`,\n });\n } catch (err) {\n // A same-UTC-day re-run whose content changed re-sends with the same\n // `digest-<date>` idempotency key but a DIFFERENT body. Resend rejects that\n // with a 409 (`invalid_idempotent_request`) — \"This idempotency key has been\n // used ... but the request body was modified ...\". The operator already got\n // today's digest on the first send, so re-sending a changed version same-day\n // would just be a duplicate: treat it as an \"already sent today\" no-op.\n //\n // ResendClient (send/resend.ts) wraps the API error as a plain Error and only\n // preserves the *message* string (no name/statusCode), so the message\n // substring is the only reliable discriminator — match defensively on it.\n // Any OTHER send error re-throws to the outer catch → {code:1}, so a genuine\n // Resend/network failure still fails loudly.\n if (isIdempotencyConflict(err)) {\n // Do NOT write the snapshot: the first send already persisted it; writing\n // this run's `next` would diff against the first run's snapshot and mis-badge.\n return {\n output:\n \"Digest already sent today (content changed since the first send) — skipped to avoid a duplicate.\",\n code: 0,\n };\n }\n throw err;\n }\n // Persist the next snapshot AFTER a successful send. A write failure is caught +\n // logged: the digest already went out, tomorrow re-news at worst. (The send-FAILURE\n // path never reaches here — the outer catch returns code 1 with no write, preserving\n // the NEW badge for the retry.)\n try {\n await writeDigestState(base, next);\n } catch (e) {\n console.warn(`⚠ digest state write failed: ${(e as Error).message}`);\n }\n return { output: `Digest sent to ${to.join(\", \")} (${result.messageId})`, code: 0 };\n } catch (err) {\n // Re-throw config errors (exitCode=2: missing env vars, bad config) so runOrExit\n // surfaces them with the correct process exit code rather than collapsing to 1.\n if (typeof (err as { exitCode?: unknown }).exitCode === \"number\") {\n throw err;\n }\n const message = err instanceof Error ? err.message : String(err);\n return { output: `digest failed: ${message}`, code: 1 };\n }\n}\n\n/** Pure render of the unified daily operator digest. No IO — the caller (runDigest)\n * collects the rows and decides whether to send. */\nexport function renderDigestHtml(sections: DigestSections): string {\n return `<!doctype html>\n<html>\n <head><meta charset=\"utf-8\"></head>\n <body style=\"margin:0;padding:0;background:#ffffff\">\n <table width=\"100%\" style=\"border-collapse:collapse\">\n <tr>\n <td align=\"center\" style=\"padding:24px\">\n <table width=\"600\" style=\"border-collapse:collapse\">\n <tr>\n <td>\n <h1 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px\">Your fleet today</h1>\n ${readySection(sections.readyForYourYes)}\n ${attentionSection(sections.needsAttention)}\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </body>\n</html>`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,31 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isFailingRenovatePR
|
|
3
|
+
} from "./chunk-PZQ3WED2.js";
|
|
1
4
|
import {
|
|
2
5
|
formatFleetWriteSummary
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
6
|
+
} from "./chunk-IJW4DS2S.js";
|
|
7
|
+
import "./chunk-RYECSIEA.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,
|
|
11
14
|
siteSlug,
|
|
12
15
|
updateGitHubSignals
|
|
13
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-PKIYAWA4.js";
|
|
14
17
|
import {
|
|
15
18
|
openBase,
|
|
16
19
|
readAirtableConfig
|
|
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-JXHRHDAG.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.d.ts
CHANGED
|
@@ -309,6 +309,12 @@ type WebsiteRow = {
|
|
|
309
309
|
securityVulnsHigh: number | null;
|
|
310
310
|
securityVulnsModerate: number | null;
|
|
311
311
|
securityVulnsLow: number | null;
|
|
312
|
+
/** Count of consecutive nightly Renovate auto-fix dispatches for the CURRENT
|
|
313
|
+
* critical/high vuln episode that have NOT yet cleared it. Owned by
|
|
314
|
+
* `renovate-dispatch`: +1 per real dispatch, reset to 0 when vulns clear.
|
|
315
|
+
* Null = field absent / never dispatched → reads as 0. At/above
|
|
316
|
+
* AUTO_FIX_EXHAUSTED_CYCLES the vuln renders as "auto-fix failed". */
|
|
317
|
+
securityAutoFixAttempts: number | null;
|
|
312
318
|
/** ISO timestamp the security audit last ran — gates freshness of the Security Updates auto-tick
|
|
313
319
|
* (clean counts only auto-tick when recent). */
|
|
314
320
|
lastSecurityAuditAt: string | null;
|
|
@@ -709,6 +715,10 @@ type AttentionItem = {
|
|
|
709
715
|
metric: number;
|
|
710
716
|
/** Set by `diffAttention` before render. */
|
|
711
717
|
status?: AttentionStatus;
|
|
718
|
+
/** Set by collectVulnAlerts when Renovate's auto-fix has been retried past the
|
|
719
|
+
* exhaustion threshold without clearing the vuln. Drives a distinct "auto-fix
|
|
720
|
+
* failed" chip + filter token; absent on every other item and flavor. */
|
|
721
|
+
autoFixExhausted?: boolean;
|
|
712
722
|
};
|
|
713
723
|
|
|
714
724
|
type Tier = "attention" | "watch" | "healthy";
|
|
@@ -749,6 +759,8 @@ type CockpitSummary = {
|
|
|
749
759
|
deliveryFailures: number;
|
|
750
760
|
renovateFailing: number;
|
|
751
761
|
ciRed: number;
|
|
762
|
+
/** Count of sites whose vuln has exhausted the Renovate auto-fix (manual fix needed). */
|
|
763
|
+
autoFixStuck: number;
|
|
752
764
|
pending: number;
|
|
753
765
|
/** Count of NEW submissions across the fleet (optional for back-compat). */
|
|
754
766
|
newSubmissions?: number;
|
package/dist/index.js
CHANGED
|
@@ -16,11 +16,11 @@ import {
|
|
|
16
16
|
} from "./chunk-WVRPOXKH.js";
|
|
17
17
|
import {
|
|
18
18
|
draftReportForSite
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-S7WKPRID.js";
|
|
20
20
|
import "./chunk-6LIWDIXU.js";
|
|
21
21
|
import {
|
|
22
22
|
sendApprovedReports
|
|
23
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-HPNGCRTK.js";
|
|
24
24
|
import "./chunk-OGGRFBL4.js";
|
|
25
25
|
import {
|
|
26
26
|
BLURRED_CID,
|
|
@@ -71,12 +71,12 @@ import "./chunk-XXTZBPUY.js";
|
|
|
71
71
|
import "./chunk-XTK5VIQB.js";
|
|
72
72
|
import {
|
|
73
73
|
fromAirtableBase
|
|
74
|
-
} from "./chunk-
|
|
74
|
+
} from "./chunk-5E3OW6YN.js";
|
|
75
75
|
import "./chunk-U5SZ4FOP.js";
|
|
76
76
|
import {
|
|
77
77
|
SEVERITY_RANK,
|
|
78
78
|
siteSlug
|
|
79
|
-
} from "./chunk-
|
|
79
|
+
} from "./chunk-PKIYAWA4.js";
|
|
80
80
|
import "./chunk-HN6I2XWI.js";
|
|
81
81
|
import "./chunk-WEKL2HYJ.js";
|
|
82
82
|
import "./chunk-6VAL7XJT.js";
|
|
@@ -666,6 +666,7 @@ details.tier > summary { cursor:pointer; font-weight:700; font-size:1.05rem; pad
|
|
|
666
666
|
.chip { font-size:0.8rem; padding:0.1rem 0.5rem; border-radius:6px; background:#f0f0f0; }
|
|
667
667
|
@media (prefers-color-scheme: dark) { .chip { background:#222; } }
|
|
668
668
|
.chip.critical { background:#fdecea; color:#b00; }
|
|
669
|
+
.chip.stuck { border:1px solid #b00; font-weight:600; }
|
|
669
670
|
.badge { font-weight:700; color:#C00; font-size:0.72rem; margin-right:0.25rem; }
|
|
670
671
|
.all-clear { background:#e8f5e9; color:#1b7a2f; padding:0.6rem 1rem; border-radius:8px; margin-bottom:1.25rem; font-weight:600; }
|
|
671
672
|
@media (prefers-color-scheme: dark) { .all-clear { background:#10240f; color:#7fce85; } }
|
|
@@ -682,6 +683,7 @@ var FILTERS = [
|
|
|
682
683
|
"delivery",
|
|
683
684
|
"prs",
|
|
684
685
|
"ci",
|
|
686
|
+
"auto-fix-failed",
|
|
685
687
|
"stale",
|
|
686
688
|
"no-domain",
|
|
687
689
|
"pending",
|
|
@@ -695,6 +697,7 @@ function summaryBar(model) {
|
|
|
695
697
|
`${s.deliveryFailures} delivery`,
|
|
696
698
|
`${s.renovateFailing} PRs failing`,
|
|
697
699
|
`${s.ciRed} CI red`,
|
|
700
|
+
`${s.autoFixStuck} auto-fix stuck`,
|
|
698
701
|
`${s.pending} pending`,
|
|
699
702
|
`${s.newSubmissions ?? 0} new`
|
|
700
703
|
].join(" \xB7 ");
|
|
@@ -771,7 +774,7 @@ function attentionBadge(status) {
|
|
|
771
774
|
}
|
|
772
775
|
function chips(c) {
|
|
773
776
|
const items = c.items.map((it) => {
|
|
774
|
-
const cls = it.severity === "critical" ? "chip critical" : "chip";
|
|
777
|
+
const cls = it.autoFixExhausted ? "chip critical stuck" : it.severity === "critical" ? "chip critical" : "chip";
|
|
775
778
|
return `<span class="${cls}">${attentionBadge(it.status)}${escapeHtml(it.title)}</span>`;
|
|
776
779
|
});
|
|
777
780
|
for (const reason of c.watchReasons)
|
|
@@ -783,6 +786,7 @@ function signalsAttr(c) {
|
|
|
783
786
|
for (const it of c.items) {
|
|
784
787
|
kinds.add(it.kind === "vuln" ? "vulns" : it.kind === "renovate" ? "prs" : it.kind);
|
|
785
788
|
}
|
|
789
|
+
if (c.items.some((it) => it.autoFixExhausted)) kinds.add("auto-fix-failed");
|
|
786
790
|
for (const sig of c.watchSignals) kinds.add(sig);
|
|
787
791
|
return [...kinds].join(" ");
|
|
788
792
|
}
|