@reddoorla/maintenance 0.72.0 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/airtable-6L3JD7FY.js +9 -0
  2. package/dist/{announce-HJCGEVJA.js → announce-V6EYPPUR.js} +4 -4
  3. package/dist/{blux-FKZYMKLF.js → blux-C24OVDDW.js} +1044 -69
  4. package/dist/blux-C24OVDDW.js.map +1 -0
  5. package/dist/{bump-deps-GZEJAUKI.js → bump-deps-OQR3IXLN.js} +2 -2
  6. package/dist/chunk-2OP4JKIQ.js +19 -0
  7. package/dist/chunk-2OP4JKIQ.js.map +1 -0
  8. package/dist/{chunk-BJCA7O4A.js → chunk-3PU2QWOJ.js} +2 -2
  9. package/dist/{chunk-GI4M2AMO.js → chunk-4BHNP6Y2.js} +2 -2
  10. package/dist/{chunk-KRHPS3R4.js → chunk-6OYICQSJ.js} +47 -3
  11. package/dist/chunk-6OYICQSJ.js.map +1 -0
  12. package/dist/{chunk-BWKK2ZXS.js → chunk-GXVX4HUH.js} +32 -2
  13. package/dist/chunk-GXVX4HUH.js.map +1 -0
  14. package/dist/{chunk-KLB2OLPW.js → chunk-HBFMTM2E.js} +3 -3
  15. package/dist/{chunk-SQJ53Z7R.js → chunk-I55YWV6T.js} +3 -3
  16. package/dist/{chunk-7P7YETHG.js → chunk-IDBMYDVH.js} +2 -2
  17. package/dist/{chunk-JGLAWXUC.js → chunk-JR67FZVC.js} +7 -3
  18. package/dist/chunk-JR67FZVC.js.map +1 -0
  19. package/dist/{chunk-APTAITXZ.js → chunk-KKL3RYFQ.js} +2 -2
  20. package/dist/{chunk-NRISG4XD.js → chunk-NUIBQPB3.js} +42 -11
  21. package/dist/chunk-NUIBQPB3.js.map +1 -0
  22. package/dist/{chunk-BHE5CUWI.js → chunk-PERP7LSS.js} +269 -18
  23. package/dist/chunk-PERP7LSS.js.map +1 -0
  24. package/dist/chunk-RHPDQW6D.js +29 -0
  25. package/dist/chunk-RHPDQW6D.js.map +1 -0
  26. package/dist/{chunk-HR5SONJC.js → chunk-SK44IFZN.js} +2 -2
  27. package/dist/cli/bin.js +31 -19
  28. package/dist/cli/bin.js.map +1 -1
  29. package/dist/cli/commands/audit.js +6 -6
  30. package/dist/{convert-to-pnpm-SVFDLXZS.js → convert-to-pnpm-OVMASATZ.js} +2 -2
  31. package/dist/{digest-VX7Q4MNF.js → digest-LEMZS5ZK.js} +99 -7
  32. package/dist/digest-LEMZS5ZK.js.map +1 -0
  33. package/dist/{ensure-site-O7G5ZJS7.js → ensure-site-3SOAWTZF.js} +2 -2
  34. package/dist/forms/index.js +1 -1
  35. package/dist/{github-signals-QVW2LB7N.js → github-signals-TF3VSNIN.js} +4 -4
  36. package/dist/{health-endpoint-IYR5VBUT.js → health-endpoint-JZGYCMTV.js} +2 -2
  37. package/dist/index.d.ts +104 -19
  38. package/dist/index.js +122 -30
  39. package/dist/index.js.map +1 -1
  40. package/dist/{init-CU6PVZRG.js → init-7PTP4O2K.js} +4 -4
  41. package/dist/{launch-JWXCTJRG.js → launch-W33FWWOZ.js} +6 -6
  42. package/dist/{lighthouse-airtable-J5TLASJC.js → lighthouse-airtable-ZXNMDEPE.js} +3 -3
  43. package/dist/{onboard-6UQE5QIO.js → onboard-PX36YDZA.js} +2 -2
  44. package/dist/{orchestrate-ZDH24Z6Y.js → orchestrate-NYRYEKQM.js} +3 -3
  45. package/dist/{preflight-BNTMWTPG.js → preflight-JCQFKB6M.js} +4 -4
  46. package/dist/{renovate-dispatch-C3ALLACX.js → renovate-dispatch-6II2JVOX.js} +29 -27
  47. package/dist/renovate-dispatch-6II2JVOX.js.map +1 -0
  48. package/dist/{report-YLFSGNWR.js → report-POHQEV6F.js} +21 -5
  49. package/dist/report-POHQEV6F.js.map +1 -0
  50. package/dist/screenouts-7P5GOZMV.js +57 -0
  51. package/dist/screenouts-7P5GOZMV.js.map +1 -0
  52. package/dist/{self-updating-64FNRQB5.js → self-updating-2YSVNMPP.js} +2 -2
  53. package/dist/{selftest-7DPNGL6R.js → selftest-OO2WDSDM.js} +5 -5
  54. package/dist/{smoke-suite-TWILWDSF.js → smoke-suite-CSP23NZM.js} +2 -2
  55. package/dist/spam-classifier-VG4ICIWS.js +217 -0
  56. package/dist/spam-classifier-VG4ICIWS.js.map +1 -0
  57. package/dist/submissions-BNRGY3JQ.js +122 -0
  58. package/dist/submissions-BNRGY3JQ.js.map +1 -0
  59. package/dist/submissions-MDKWJYMM.js +281 -0
  60. package/dist/submissions-MDKWJYMM.js.map +1 -0
  61. package/dist/{svelte-codemods-RVDTTXFD.js → svelte-codemods-V54KOBWU.js} +2 -2
  62. package/dist/{sync-configs-BUUD6L5Q.js → sync-configs-UBCJRASZ.js} +2 -2
  63. package/dist/{upgrade-HHKWYJRN.js → upgrade-WR6ICPAG.js} +2 -2
  64. package/dist/{websites-EH2ULELZ.js → websites-U6642XSE.js} +10 -2
  65. package/dist/{write-audits-to-airtable-LZDYTNX5.js → write-audits-to-airtable-22T6D733.js} +4 -4
  66. package/package.json +1 -1
  67. package/dist/airtable-U2GTDTK5.js +0 -9
  68. package/dist/blux-FKZYMKLF.js.map +0 -1
  69. package/dist/chunk-BHE5CUWI.js.map +0 -1
  70. package/dist/chunk-BWKK2ZXS.js.map +0 -1
  71. package/dist/chunk-JGLAWXUC.js.map +0 -1
  72. package/dist/chunk-KRHPS3R4.js.map +0 -1
  73. package/dist/chunk-NRISG4XD.js.map +0 -1
  74. package/dist/chunk-QTHKOIFJ.js +0 -13
  75. package/dist/chunk-QTHKOIFJ.js.map +0 -1
  76. package/dist/digest-VX7Q4MNF.js.map +0 -1
  77. package/dist/renovate-dispatch-C3ALLACX.js.map +0 -1
  78. package/dist/report-YLFSGNWR.js.map +0 -1
  79. /package/dist/{airtable-U2GTDTK5.js.map → airtable-6L3JD7FY.js.map} +0 -0
  80. /package/dist/{announce-HJCGEVJA.js.map → announce-V6EYPPUR.js.map} +0 -0
  81. /package/dist/{bump-deps-GZEJAUKI.js.map → bump-deps-OQR3IXLN.js.map} +0 -0
  82. /package/dist/{chunk-BJCA7O4A.js.map → chunk-3PU2QWOJ.js.map} +0 -0
  83. /package/dist/{chunk-GI4M2AMO.js.map → chunk-4BHNP6Y2.js.map} +0 -0
  84. /package/dist/{chunk-KLB2OLPW.js.map → chunk-HBFMTM2E.js.map} +0 -0
  85. /package/dist/{chunk-SQJ53Z7R.js.map → chunk-I55YWV6T.js.map} +0 -0
  86. /package/dist/{chunk-7P7YETHG.js.map → chunk-IDBMYDVH.js.map} +0 -0
  87. /package/dist/{chunk-APTAITXZ.js.map → chunk-KKL3RYFQ.js.map} +0 -0
  88. /package/dist/{chunk-HR5SONJC.js.map → chunk-SK44IFZN.js.map} +0 -0
  89. /package/dist/{convert-to-pnpm-SVFDLXZS.js.map → convert-to-pnpm-OVMASATZ.js.map} +0 -0
  90. /package/dist/{ensure-site-O7G5ZJS7.js.map → ensure-site-3SOAWTZF.js.map} +0 -0
  91. /package/dist/{github-signals-QVW2LB7N.js.map → github-signals-TF3VSNIN.js.map} +0 -0
  92. /package/dist/{health-endpoint-IYR5VBUT.js.map → health-endpoint-JZGYCMTV.js.map} +0 -0
  93. /package/dist/{init-CU6PVZRG.js.map → init-7PTP4O2K.js.map} +0 -0
  94. /package/dist/{launch-JWXCTJRG.js.map → launch-W33FWWOZ.js.map} +0 -0
  95. /package/dist/{lighthouse-airtable-J5TLASJC.js.map → lighthouse-airtable-ZXNMDEPE.js.map} +0 -0
  96. /package/dist/{onboard-6UQE5QIO.js.map → onboard-PX36YDZA.js.map} +0 -0
  97. /package/dist/{orchestrate-ZDH24Z6Y.js.map → orchestrate-NYRYEKQM.js.map} +0 -0
  98. /package/dist/{preflight-BNTMWTPG.js.map → preflight-JCQFKB6M.js.map} +0 -0
  99. /package/dist/{self-updating-64FNRQB5.js.map → self-updating-2YSVNMPP.js.map} +0 -0
  100. /package/dist/{selftest-7DPNGL6R.js.map → selftest-OO2WDSDM.js.map} +0 -0
  101. /package/dist/{smoke-suite-TWILWDSF.js.map → smoke-suite-CSP23NZM.js.map} +0 -0
  102. /package/dist/{svelte-codemods-RVDTTXFD.js.map → svelte-codemods-V54KOBWU.js.map} +0 -0
  103. /package/dist/{sync-configs-BUUD6L5Q.js.map → sync-configs-UBCJRASZ.js.map} +0 -0
  104. /package/dist/{upgrade-HHKWYJRN.js.map → upgrade-WR6ICPAG.js.map} +0 -0
  105. /package/dist/{websites-EH2ULELZ.js.map → websites-U6642XSE.js.map} +0 -0
  106. /package/dist/{write-audits-to-airtable-LZDYTNX5.js.map → write-audits-to-airtable-22T6D733.js.map} +0 -0
@@ -5,10 +5,10 @@ import {
5
5
  } from "./chunk-4NAOR44V.js";
6
6
  import {
7
7
  resolveSites
8
- } from "./chunk-GI4M2AMO.js";
8
+ } from "./chunk-4BHNP6Y2.js";
9
9
  import {
10
10
  init
11
- } from "./chunk-APTAITXZ.js";
11
+ } from "./chunk-KKL3RYFQ.js";
12
12
  import "./chunk-2X322ZES.js";
13
13
  import "./chunk-BTEBRTW4.js";
14
14
  import "./chunk-J7SZQUCW.js";
@@ -20,7 +20,7 @@ import "./chunk-JNXCDUYB.js";
20
20
  import "./chunk-CASCWQQX.js";
21
21
  import "./chunk-XKOZUVK2.js";
22
22
  import "./chunk-OTGB6YQO.js";
23
- import "./chunk-BHE5CUWI.js";
23
+ import "./chunk-PERP7LSS.js";
24
24
  import "./chunk-GASBX52O.js";
25
25
  import "./chunk-YEGHUODU.js";
26
26
  import "./chunk-LKGVSM2O.js";
@@ -105,4 +105,4 @@ async function runInitCommand(site, opts) {
105
105
  export {
106
106
  runInitCommand
107
107
  };
108
- //# sourceMappingURL=init-CU6PVZRG.js.map
108
+ //# sourceMappingURL=init-7PTP4O2K.js.map
@@ -3,15 +3,15 @@ import {
3
3
  } from "./chunk-IRRZG66U.js";
4
4
  import {
5
5
  writeAuditsToAirtable
6
- } from "./chunk-JGLAWXUC.js";
6
+ } from "./chunk-JR67FZVC.js";
7
7
  import {
8
8
  hasRealScores,
9
9
  lighthouseScoresFromResult
10
- } from "./chunk-BJCA7O4A.js";
10
+ } from "./chunk-3PU2QWOJ.js";
11
11
  import "./chunk-5L3FPQIJ.js";
12
12
  import {
13
13
  resolveSites
14
- } from "./chunk-GI4M2AMO.js";
14
+ } from "./chunk-4BHNP6Y2.js";
15
15
  import {
16
16
  queueDraft
17
17
  } from "./chunk-KLVMJYZZ.js";
@@ -26,7 +26,7 @@ import {
26
26
  import "./chunk-CASCWQQX.js";
27
27
  import {
28
28
  runAudits
29
- } from "./chunk-BHE5CUWI.js";
29
+ } from "./chunk-PERP7LSS.js";
30
30
  import "./chunk-GASBX52O.js";
31
31
  import "./chunk-YEGHUODU.js";
32
32
  import "./chunk-LKGVSM2O.js";
@@ -40,7 +40,7 @@ import "./chunk-U5SZ4FOP.js";
40
40
  import {
41
41
  listWebsites,
42
42
  siteSlug
43
- } from "./chunk-BWKK2ZXS.js";
43
+ } from "./chunk-GXVX4HUH.js";
44
44
  import {
45
45
  openBase,
46
46
  readAirtableConfig
@@ -210,4 +210,4 @@ async function runLaunchCommand(site, opts) {
210
210
  export {
211
211
  runLaunchCommand
212
212
  };
213
- //# sourceMappingURL=launch-JWXCTJRG.js.map
213
+ //# sourceMappingURL=launch-W33FWWOZ.js.map
@@ -2,11 +2,11 @@ import {
2
2
  hasRealScores,
3
3
  lighthouseScoresFromResult,
4
4
  resolveSlugFromCwd
5
- } from "./chunk-BJCA7O4A.js";
6
- import "./chunk-BWKK2ZXS.js";
5
+ } from "./chunk-3PU2QWOJ.js";
6
+ import "./chunk-GXVX4HUH.js";
7
7
  export {
8
8
  hasRealScores,
9
9
  lighthouseScoresFromResult,
10
10
  resolveSlugFromCwd
11
11
  };
12
- //# sourceMappingURL=lighthouse-airtable-J5TLASJC.js.map
12
+ //# sourceMappingURL=lighthouse-airtable-ZXNMDEPE.js.map
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-4NAOR44V.js";
9
9
  import {
10
10
  resolveSites
11
- } from "./chunk-GI4M2AMO.js";
11
+ } from "./chunk-4BHNP6Y2.js";
12
12
  import {
13
13
  onboard
14
14
  } from "./chunk-ZYBOT5PM.js";
@@ -70,4 +70,4 @@ async function runOnboardCommand(site, opts) {
70
70
  export {
71
71
  runOnboardCommand
72
72
  };
73
- //# sourceMappingURL=onboard-6UQE5QIO.js.map
73
+ //# sourceMappingURL=onboard-PX36YDZA.js.map
@@ -4,7 +4,7 @@ import {
4
4
  parseAddresses,
5
5
  sendApprovedReports,
6
6
  withGlobalCc
7
- } from "./chunk-7P7YETHG.js";
7
+ } from "./chunk-IDBMYDVH.js";
8
8
  import "./chunk-GECESMVS.js";
9
9
  import "./chunk-JCITWZQR.js";
10
10
  import "./chunk-WEKL2HYJ.js";
@@ -12,7 +12,7 @@ import "./chunk-GYNRZCM6.js";
12
12
  import "./chunk-PH3AGF4Z.js";
13
13
  import "./chunk-HIN3P4ZN.js";
14
14
  import "./chunk-U5SZ4FOP.js";
15
- import "./chunk-BWKK2ZXS.js";
15
+ import "./chunk-GXVX4HUH.js";
16
16
  import "./chunk-HN6I2XWI.js";
17
17
  import "./chunk-6VAL7XJT.js";
18
18
  export {
@@ -22,4 +22,4 @@ export {
22
22
  sendApprovedReports,
23
23
  withGlobalCc
24
24
  };
25
- //# sourceMappingURL=orchestrate-ZDH24Z6Y.js.map
25
+ //# sourceMappingURL=orchestrate-NYRYEKQM.js.map
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  preflight
3
- } from "./chunk-KLB2OLPW.js";
3
+ } from "./chunk-HBFMTM2E.js";
4
4
  import "./chunk-D4FVAIEJ.js";
5
- import "./chunk-7P7YETHG.js";
5
+ import "./chunk-IDBMYDVH.js";
6
6
  import "./chunk-GECESMVS.js";
7
7
  import "./chunk-JCITWZQR.js";
8
8
  import "./chunk-WEKL2HYJ.js";
@@ -10,7 +10,7 @@ import "./chunk-GYNRZCM6.js";
10
10
  import "./chunk-PH3AGF4Z.js";
11
11
  import "./chunk-HIN3P4ZN.js";
12
12
  import "./chunk-U5SZ4FOP.js";
13
- import "./chunk-BWKK2ZXS.js";
13
+ import "./chunk-GXVX4HUH.js";
14
14
  import "./chunk-HN6I2XWI.js";
15
15
  import "./chunk-6VAL7XJT.js";
16
16
 
@@ -61,4 +61,4 @@ async function runPreflightCommand(site, opts) {
61
61
  export {
62
62
  runPreflightCommand
63
63
  };
64
- //# sourceMappingURL=preflight-BNTMWTPG.js.map
64
+ //# sourceMappingURL=preflight-JCQFKB6M.js.map
@@ -9,7 +9,7 @@ import {
9
9
  isDashboardVisible,
10
10
  listWebsites,
11
11
  updateAutoFixAttempts
12
- } from "./chunk-BWKK2ZXS.js";
12
+ } from "./chunk-GXVX4HUH.js";
13
13
  import {
14
14
  openBase,
15
15
  readAirtableConfig
@@ -60,15 +60,16 @@ function computeAutoFixAttemptUpdates(sites, result) {
60
60
  const dispatched = new Set(result.dispatched);
61
61
  const updates = [];
62
62
  for (const s of sites) {
63
+ const current = s.securityAutoFixAttempts ?? 0;
64
+ const vulns = (s.securityVulnsCritical ?? 0) + (s.securityVulnsHigh ?? 0);
65
+ if (vulns === 0) {
66
+ if (current !== 0) updates.push({ id: s.id, attempts: 0 });
67
+ continue;
68
+ }
63
69
  if (!isDashboardVisible(s)) continue;
64
70
  const repo = s.gitRepo?.trim();
65
71
  if (!repo) continue;
66
- const current = s.securityAutoFixAttempts ?? 0;
67
- const vulns = (s.securityVulnsCritical ?? 0) + (s.securityVulnsHigh ?? 0);
68
- let next = current;
69
- if (vulns === 0) next = 0;
70
- else if (dispatched.has(repo)) next = current + 1;
71
- if (next !== current) updates.push({ id: s.id, attempts: next });
72
+ if (dispatched.has(repo)) updates.push({ id: s.id, attempts: current + 1 });
72
73
  }
73
74
  return updates;
74
75
  }
@@ -101,29 +102,30 @@ async function runRenovateDispatchCommand(opts) {
101
102
  code: 0
102
103
  };
103
104
  }
104
- const base = openBase(readAirtableConfig());
105
+ const base = opts.base ?? openBase(readAirtableConfig());
105
106
  const websites = await listWebsites(base);
106
107
  const targets = selectRenovateTargets(websites);
108
+ const lines = [];
109
+ let result = { dispatched: [], skipped: [], failed: [] };
107
110
  if (targets.length === 0) {
108
- return {
109
- output: `${formatRenovateDispatchSummary({ dispatched: [], skipped: [], failed: [] })}
110
- No active repo-backed sites with critical/high vulnerabilities \u2014 nothing to dispatch.`,
111
- code: 0
112
- };
111
+ lines.push(
112
+ "No active repo-backed sites with critical/high vulnerabilities \u2014 nothing to dispatch."
113
+ );
114
+ } else {
115
+ const gh = makeGitHub({ token });
116
+ result = await dispatchRenovateAcross(targets, {
117
+ hasHealthyOpenRenovatePr: async (repo) => hasHealthyRenovatePr(await gh.openPullRequests(repo)),
118
+ defaultBranch: (repo) => gh.defaultBranch(repo),
119
+ dispatch: (repo, workflow, ref) => gh.dispatchWorkflow(repo, workflow, ref)
120
+ });
121
+ const failedRepos = new Set(result.failed.map((f) => f.repo));
122
+ const skippedRepos = new Set(result.skipped);
123
+ for (const t of targets) {
124
+ const status = failedRepos.has(t.repo) ? "FAILED " : skippedRepos.has(t.repo) ? "skipped (open Renovate PR) " : "dispatched ";
125
+ lines.push(`${status} ${t.repo} (critical=${t.critical} high=${t.high}) \u2014 ${t.siteName}`);
126
+ }
127
+ for (const f of result.failed) lines.push(` \u21B3 ${f.repo}: ${f.error}`);
113
128
  }
114
- const gh = makeGitHub({ token });
115
- const result = await dispatchRenovateAcross(targets, {
116
- hasHealthyOpenRenovatePr: async (repo) => hasHealthyRenovatePr(await gh.openPullRequests(repo)),
117
- defaultBranch: (repo) => gh.defaultBranch(repo),
118
- dispatch: (repo, workflow, ref) => gh.dispatchWorkflow(repo, workflow, ref)
119
- });
120
- const failedRepos = new Set(result.failed.map((f) => f.repo));
121
- const skippedRepos = new Set(result.skipped);
122
- const lines = targets.map((t) => {
123
- const status = failedRepos.has(t.repo) ? "FAILED " : skippedRepos.has(t.repo) ? "skipped (open Renovate PR) " : "dispatched ";
124
- return `${status} ${t.repo} (critical=${t.critical} high=${t.high}) \u2014 ${t.siteName}`;
125
- });
126
- for (const f of result.failed) lines.push(` \u21B3 ${f.repo}: ${f.error}`);
127
129
  const attemptUpdates = computeAutoFixAttemptUpdates(websites, result);
128
130
  const attemptTally = await applyAutoFixAttemptUpdates(
129
131
  attemptUpdates,
@@ -136,4 +138,4 @@ No active repo-backed sites with critical/high vulnerabilities \u2014 nothing to
136
138
  export {
137
139
  runRenovateDispatchCommand
138
140
  };
139
- //# sourceMappingURL=renovate-dispatch-C3ALLACX.js.map
141
+ //# sourceMappingURL=renovate-dispatch-6II2JVOX.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\n/**\n * Plan the per-site auto-fix-attempt counter writes from a dispatch result. PURE.\n * For each site:\n * - vulns now 0 (or never counted) → reset to 0 (episode resolved)\n * - else dispatched this run → attempts + 1 (a fresh failed-so-far attempt)\n * - else (skipped / failed / untouched) → unchanged\n * The RESET applies fleet-wide — regardless of Status or Git repo — so a site\n * archived or un-repo'd after its episode can't carry a stale counter into its\n * next vuln (Alamo sat at 7 from a long-closed episode). A null vuln count\n * (never-audited) reads as zero, consistent with the existing null-as-zero\n * semantics, so a lingering counter on such a row also clears. The INCREMENT\n * keeps its active + repo-backed filters (only those sites are dispatched).\n * Returns only the rows whose value CHANGES (a steady fleet writes nothing —\n * a 0→0 site emits no write). A skipped repo (healthy Renovate PR in flight) is\n * NOT a failed attempt — a fix is genuinely moving toward merge — so its counter\n * holds.\n */\nexport function computeAutoFixAttemptUpdates(\n sites: WebsiteRow[],\n result: RenovateDispatchResult,\n): { id: string; attempts: number }[] {\n const dispatched = new Set(result.dispatched);\n const updates: { id: string; attempts: number }[] = [];\n for (const s of sites) {\n const current = s.securityAutoFixAttempts ?? 0;\n const vulns = (s.securityVulnsCritical ?? 0) + (s.securityVulnsHigh ?? 0);\n if (vulns === 0) {\n // Episode resolved (or never confirmed): reset REGARDLESS of status/repo —\n // a site archived or un-repo'd after its episode must not carry a stale\n // counter into its next vuln (Alamo sat at 7 from a long-closed episode).\n if (current !== 0) updates.push({ id: s.id, attempts: 0 });\n continue;\n }\n if (!isDashboardVisible(s)) continue; // increments stay active-only\n const repo = s.gitRepo?.trim();\n if (!repo) continue; // …and repo-backed-only\n if (dispatched.has(repo)) updates.push({ id: s.id, attempts: current + 1 });\n }\n return updates;\n}\n\n/**\n * Apply the planned counter updates with an injected writer, BEST-EFFORT: a writer\n * that throws (e.g. the Airtable field not yet created, or a transient error) is\n * counted in `failed` and never propagates — the security sweep must not fail over\n * counter bookkeeping. Returns the applied/failed tallies for the summary line.\n */\nexport async function applyAutoFixAttemptUpdates(\n updates: { id: string; attempts: number }[],\n write: (id: string, attempts: number) => Promise<void>,\n): Promise<{ written: number; failed: number }> {\n let written = 0;\n let failed = 0;\n for (const u of updates) {\n try {\n await write(u.id, u.attempts);\n written++;\n } catch {\n failed++;\n }\n }\n return { written, failed };\n}\n\n/** Machine-readable counts line the workflow can grep, mirroring RENOVATE_DISPATCH_SUMMARY. */\nexport function formatAutoFixAttemptsSummary(tally: { written: number; failed: number }): string {\n return `AUTO_FIX_ATTEMPTS_SUMMARY written=${tally.written} failed=${tally.failed}`;\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport { listWebsites, updateAutoFixAttempts } from \"../../reports/airtable/websites.js\";\nimport { makeGitHub } from \"../../github/gh.js\";\nimport {\n selectRenovateTargets,\n dispatchRenovateAcross,\n formatRenovateDispatchSummary,\n hasHealthyRenovatePr,\n computeAutoFixAttemptUpdates,\n applyAutoFixAttemptUpdates,\n formatAutoFixAttemptsSummary,\n type RenovateDispatchResult,\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 /** Inject a pre-opened Airtable base (tests). Defaults to env config. */\n base?: AirtableBase;\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 = opts.base ?? openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const targets = selectRenovateTargets(websites);\n\n const lines: string[] = [];\n let result: RenovateDispatchResult = { dispatched: [], skipped: [], failed: [] };\n if (targets.length === 0) {\n lines.push(\n \"No active repo-backed sites with critical/high vulnerabilities — nothing to dispatch.\",\n );\n } else {\n const gh = makeGitHub({ token });\n result = await dispatchRenovateAcross(targets, {\n hasHealthyOpenRenovatePr: async (repo) =>\n 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 for (const t of targets) {\n const status = failedRepos.has(t.repo)\n ? \"FAILED \"\n : skippedRepos.has(t.repo)\n ? \"skipped (open Renovate PR) \"\n : \"dispatched \";\n lines.push(`${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 }\n\n // Counter bookkeeping runs on EVERY fleet run — the reset-on-clean branch was\n // previously unreachable behind the zero-targets early return, so a fully-clean\n // fleet never cleared stale counters (Alamo stuck at 7, 2026-07). Best-effort:\n // a failed write is tallied, never thrown. Uses the full `websites` list so\n // 0-vuln sites reset.\n const attemptUpdates = computeAutoFixAttemptUpdates(websites, result);\n const attemptTally = await applyAutoFixAttemptUpdates(attemptUpdates, (id, attempts) =>\n updateAutoFixAttempts(base, id, attempts),\n );\n\n lines.push(formatRenovateDispatchSummary(result));\n lines.push(formatAutoFixAttemptsSummary(attemptTally));\n\n return { output: lines.join(\"\\n\"), code: 0 };\n}\n"],"mappings":"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","names":[]}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  draftReportForSite,
3
3
  readGaConfig
4
- } from "./chunk-NRISG4XD.js";
4
+ } from "./chunk-NUIBQPB3.js";
5
5
  import {
6
6
  reportTier
7
7
  } from "./chunk-KLVMJYZZ.js";
@@ -19,7 +19,7 @@ import {
19
19
  listWebsites,
20
20
  siteSlug,
21
21
  updateNextDueDates
22
- } from "./chunk-BWKK2ZXS.js";
22
+ } from "./chunk-GXVX4HUH.js";
23
23
  import {
24
24
  openBase,
25
25
  readAirtableConfig
@@ -69,11 +69,11 @@ function dashboardBaseUrl() {
69
69
  }
70
70
  async function runReportCommand(slug, opts) {
71
71
  if (opts.digest) {
72
- const { runDigest } = await import("./digest-VX7Q4MNF.js");
72
+ const { runDigest } = await import("./digest-LEMZS5ZK.js");
73
73
  return runDigest({ baseUrl: dashboardBaseUrl() });
74
74
  }
75
75
  if (opts.sendReady) {
76
- const { sendApprovedReports } = await import("./orchestrate-ZDH24Z6Y.js");
76
+ const { sendApprovedReports } = await import("./orchestrate-NYRYEKQM.js");
77
77
  return sendApprovedReports();
78
78
  }
79
79
  if (opts.due) {
@@ -145,6 +145,8 @@ async function draftDueReports(base, today) {
145
145
  }
146
146
  const lines = [];
147
147
  let softFailedSites = 0;
148
+ let searchDefaultMisses = 0;
149
+ let searchPropertiesMissing = 0;
148
150
  let gaConfiguredSites = 0;
149
151
  let skipped = 0;
150
152
  for (const item of due) {
@@ -185,6 +187,8 @@ async function draftDueReports(base, today) {
185
187
  );
186
188
  if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;
187
189
  if (result.softFailures.length > 0) softFailedSites++;
190
+ if (result.searchDefaultMissed) searchDefaultMisses++;
191
+ if (result.searchPropertyMissing) searchPropertiesMissing++;
188
192
  } catch (e) {
189
193
  lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
190
194
  }
@@ -206,6 +210,8 @@ async function draftDueReports(base, today) {
206
210
  if (result.reportRow) reports.push(result.reportRow);
207
211
  if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;
208
212
  if (result.softFailures.length > 0) softFailedSites++;
213
+ if (result.searchDefaultMissed) searchDefaultMisses++;
214
+ if (result.searchPropertyMissing) searchPropertiesMissing++;
209
215
  } catch (e) {
210
216
  lines.push(`\u2717 failed: ${item.site.name} ${item.reportType} \u2014 ${e.message}`);
211
217
  }
@@ -218,6 +224,16 @@ async function draftDueReports(base, today) {
218
224
  `\u26A0 ${softFailedSites} site${softFailedSites === 1 ? "" : "s"} had GA/Search enrichment fail \u2014 drafted with blank analytics; check the logs above`
219
225
  );
220
226
  }
227
+ if (searchDefaultMisses > 0) {
228
+ lines.push(
229
+ `\u2691 ${searchDefaultMisses} site${searchDefaultMisses === 1 ? "" : "s"} returned no Search Console data for their name \u2014 set an explicit "Search query" in Airtable to track brand presence.`
230
+ );
231
+ }
232
+ if (searchPropertiesMissing > 0) {
233
+ lines.push(
234
+ `\u2691 ${searchPropertiesMissing} site${searchPropertiesMissing === 1 ? "" : "s"} matched NO Search Console property \u2014 verify the domain property exists and the service account has access (a "Search query" change cannot fix this).`
235
+ );
236
+ }
221
237
  return {
222
238
  output: lines.join("\n"),
223
239
  code: lines.some((l) => l.startsWith("\u2717")) ? 1 : 0,
@@ -252,4 +268,4 @@ export {
252
268
  parseSingleSiteReportType,
253
269
  runReportCommand
254
270
  };
255
- //# sourceMappingURL=report-YLFSGNWR.js.map
271
+ //# sourceMappingURL=report-POHQEV6F.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/alerts/analytics-health.ts","../src/cli/commands/report.ts"],"sourcesContent":["import { escapeHtml } from \"../util/html.js\";\n\n/**\n * The GA/Search enrichment outcome of one `report --due` draft run, aggregated\n * across the sites it drafted. `softFailedSites` is how many drafted sites had GA\n * OR Search enrichment ERROR (not \"not configured\" — that's a legitimate skip);\n * `configuredSites` is how many drafted sites had analytics configured at all\n * (the subject is set AND the site has a GA4 property or a search query). The ratio\n * is what distinguishes a fleet-wide subject outage from a single transient blip.\n */\nexport type AnalyticsRunHealth = {\n softFailedSites: number;\n configuredSites: number;\n};\n\nexport type AnalyticsAlert = { fire: boolean; reason: string };\n\n/** A lone failure never alerts — that's a per-site config issue, not the shared-\n * subject SPOF. Two-plus AND a majority of configured sites is the fleet signature. */\nconst MIN_FAILED_SITES = 2;\n\n/**\n * Decide whether a draft run's GA/Search soft-failures look FLEET-WIDE — i.e. the\n * one impersonated subject (`GA_SUBJECT`) lost access — rather than one site's\n * transient blip. PURE. Fires when at least {@link MIN_FAILED_SITES} configured\n * sites failed AND they are a majority (≥ half) of the analytics-configured sites\n * this run. With <2 configured sites it can't distinguish a SPOF from a one-off, so\n * it never fires (at fleet scale there are always many configured sites).\n */\nexport function assessAnalyticsAlert(h: AnalyticsRunHealth): AnalyticsAlert {\n const { softFailedSites, configuredSites } = h;\n const fire =\n configuredSites >= 2 &&\n softFailedSites >= MIN_FAILED_SITES &&\n softFailedSites * 2 >= configuredSites;\n const reason = fire\n ? `${softFailedSites} of ${configuredSites} analytics-configured sites had GA/Search enrichment fail this run — the shared GA_SUBJECT likely lost access (an offboarded user, revoked property access, or a botched role-account cutover). Reports were drafted with BLANK analytics.`\n : \"\";\n return { fire, reason };\n}\n\n/**\n * Compose the operator alert email for a fleet-wide analytics failure. PURE — the\n * caller (the `report --due` cron) sends it best-effort via Resend only when\n * {@link assessAnalyticsAlert} fires. `dashboardUrl` is the fleet homepage link.\n */\nexport function composeAnalyticsAlertEmail(\n h: AnalyticsRunHealth,\n dashboardUrl: string,\n): { subject: string; html: string } {\n const subject = `⚠ Fleet analytics enrichment failing — ${h.softFailedSites}/${h.configuredSites} sites`;\n const { reason } = assessAnalyticsAlert(h);\n const html = `<p><strong>${escapeHtml(reason)}</strong></p>\n<p>This usually means the Google Workspace user the service account impersonates can no longer read the GA4 / Search Console properties. Reports still send — but with blank analytics — until the subject is restored.</p>\n<p>Next step: follow the GA/Search subject runbook (<code>docs/runbooks/ga-search-role-account-cutover.md</code>) to restore or move the subject, then re-run <code>reddoor-maint report --due</code> and confirm the warning clears.</p>\n<p><a href=\"${escapeHtml(dashboardUrl)}\">Open the fleet dashboard →</a></p>`;\n return { subject, html };\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateNextDueDates,\n type WebsiteRow,\n} from \"../../reports/airtable/websites.js\";\nimport { listAllReports, type ReportRow } from \"../../reports/airtable/reports.js\";\nimport { findDueReports, nextDueDate, reportPeriodKey } from \"../../reports/due.js\";\nimport { draftReportForSite } from \"../../reports/draft.js\";\nimport { reportTier } from \"../../reports/queue.js\";\nimport { readGaConfig } from \"../../reports/ga/config.js\";\nimport {\n assessAnalyticsAlert,\n composeAnalyticsAlertEmail,\n type AnalyticsRunHealth,\n} from \"../../alerts/analytics-health.js\";\nimport type { ReportType } from \"../../reports/types.js\";\n\nexport type ReportCommandOptions = {\n due?: boolean;\n preview?: boolean;\n sendReady?: boolean;\n digest?: boolean;\n type?: string;\n cwd?: string;\n};\n\n/**\n * Summary line for a drafted report, reflecting the single-queue outcome. `queued === false`\n * means a higher-or-equal-tier report was already pending for the site, so this draft was\n * created but deliberately left OUT of the approve queue. A non-empty `supersededIds` means it\n * un-queued that many lower-tier drafts. `null` is the previewOnly path (no Airtable queue).\n */\nfunction draftLine(\n reportId: string | undefined,\n queued: boolean | null,\n supersededIds: string[],\n verb = \"drafted\",\n): string {\n const id = reportId ?? \"(unknown)\";\n if (queued === false) {\n return `• ${verb} but NOT queued: ${id} — a higher-or-equal-tier report is already pending approval`;\n }\n const sup =\n supersededIds.length > 0\n ? ` (superseded ${supersededIds.length} lower-tier draft${supersededIds.length > 1 ? \"s\" : \"\"})`\n : \"\";\n return `✓ ${verb}: ${id}${sup}`;\n}\n\n/**\n * Parse the single-site `--type` flag. Only Maintenance and Testing are draftable\n * this way — Launch has the `launch <site>` command and Announcement has\n * `announce <site>`, each with its own purpose-built flow. Case-insensitive;\n * defaults to Maintenance (the historical single-site behaviour). Throws an\n * exitCode-2 usage error on anything else. PURE.\n */\nexport function parseSingleSiteReportType(raw: string | undefined): ReportType {\n if (raw === undefined || raw.trim() === \"\") return \"Maintenance\";\n const norm = raw.trim().toLowerCase();\n if (norm === \"maintenance\") return \"Maintenance\";\n if (norm === \"testing\") return \"Testing\";\n const hint =\n norm === \"launch\"\n ? \" — use the `launch <site>` command\"\n : norm === \"announcement\"\n ? \" — use the `announce <site>` command\"\n : \"\";\n throw Object.assign(\n new Error(`--type must be Maintenance or Testing (got ${JSON.stringify(raw)})${hint}`),\n { exitCode: 2 },\n );\n}\n\n/** Dashboard origin for digest /s/<slug> links. DASHBOARD_BASE_URL overrides the\n * production default; the trailing slash (if any) is trimmed by runDigest. */\nfunction dashboardBaseUrl(): string {\n return process.env.DASHBOARD_BASE_URL?.trim() || \"https://reddoor-maintenance.netlify.app\";\n}\n\nexport async function runReportCommand(\n slug: string | undefined,\n opts: ReportCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (opts.digest) {\n const { runDigest } = await import(\"../../reports/digest.js\");\n return runDigest({ baseUrl: dashboardBaseUrl() });\n }\n\n if (opts.sendReady) {\n const { sendApprovedReports } = await import(\"../../reports/send/orchestrate.js\");\n return sendApprovedReports();\n }\n\n if (opts.due) {\n return runDueDraft();\n }\n\n if (slug) {\n // Validate the type BEFORE any Airtable access so a bad --type fails fast (and\n // without needing credentials).\n const reportType = parseSingleSiteReportType(opts.type);\n return runSingleSiteDraft(slug, { previewOnly: Boolean(opts.preview), reportType });\n }\n\n throw Object.assign(\n new Error(\n \"Usage: reddoor-maint report [<slug>] [--type <Maintenance|Testing>] [--due] [--preview] [--send-ready] [--digest]\",\n ),\n {\n exitCode: 2,\n },\n );\n}\n\nasync function runDueDraft(): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const result = await draftDueReports(base, new Date());\n await alertOnFleetAnalyticsFailure(result.health);\n return { output: result.output, code: result.code };\n}\n\n/** Best-effort: when a draft run's GA/Search soft-failures look FLEET-WIDE (the shared\n * GA_SUBJECT lost access — see assessAnalyticsAlert), email the operator one alert.\n * NEVER throws — a Resend or config hiccup must not fail the nightly draft cron; the\n * per-site `⚠ GA skipped` warnings + the run-output line still carry the signal. The\n * daily idempotency key dedupes multiple runs in one day (Resend dedupes 24h). */\nasync function alertOnFleetAnalyticsFailure(health: AnalyticsRunHealth): Promise<void> {\n if (!assessAnalyticsAlert(health).fire) return;\n try {\n const to = process.env.OPERATOR_EMAIL?.trim() || \"info@reddoorla.com\";\n const { subject, html } = composeAnalyticsAlertEmail(health, dashboardBaseUrl());\n const { defaultResendClient } = await import(\"../../reports/send/resend.js\");\n await defaultResendClient().send({\n from: \"Reddoor Reports <reports@reddoorla.com>\",\n to: [to],\n subject,\n html,\n idempotencyKey: `analytics-alert-${new Date().toISOString().slice(0, 10)}`,\n });\n console.warn(`⚠ ${subject} — operator alerted (${to})`);\n } catch (e) {\n console.warn(`⚠ analytics-failure alert send failed: ${(e as Error).message}`);\n }\n}\n\n/**\n * Write each site's code-computed next-maintenance / next-testing date back to Airtable\n * (date-only, or null when there's no schedule), so the \"next\" dates shown there derive\n * from the SAME `nextDueDate` the scheduler uses — replacing the old Airtable formula +\n * automation. Best-effort and per-site isolated: a missing `Next … at` column or one bad\n * row warns and is skipped, never aborting the nightly draft run.\n */\nasync function writeNextDueDates(\n base: AirtableBase,\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n): Promise<void> {\n const ymd = (d: Date | null): string | null => (d ? d.toISOString().slice(0, 10) : null);\n for (const site of websites) {\n try {\n await updateNextDueDates(base, site.id, {\n maintenanceAt: ymd(nextDueDate(site, reports, \"Maintenance\", today)),\n testingAt: ymd(nextDueDate(site, reports, \"Testing\", today)),\n });\n } catch (e) {\n console.warn(`⚠ next-due write skipped for ${site.name}: ${(e as Error).message}`);\n }\n }\n}\n\nexport async function draftDueReports(\n base: AirtableBase,\n today: Date,\n): Promise<{ output: string; code: number; health: AnalyticsRunHealth }> {\n const websites = await listWebsites(base);\n // ONE unfiltered fetch for the whole fleet. Per-site queries can't be pushed to\n // Airtable anyway (linked-record fields aren't formula-filterable by record id),\n // and findDueReports + the period guard below match on siteId in memory.\n const reports = await listAllReports(base);\n\n // Refresh every site's code-owned next-due dates first, so they stay current even on\n // a run where nothing is due (the early return below).\n await writeNextDueDates(base, websites, reports, today);\n\n const due = findDueReports(websites, reports, today);\n\n // GA/Search enrichment is configured globally (the impersonation subject) AND\n // per-site (a GA4 property or a search query). `gaConfigured` is the global half;\n // the per-site half is checked as each draft runs, to build the fleet-wide\n // analytics-failure alert's denominator (see alertOnFleetAnalyticsFailure).\n const gaConfigured = readGaConfig() !== null;\n // Truthy (not `!== null`) to mirror fetchGaUsers/fetchSearch's own gate exactly\n // (`!siteRow.ga4PropertyId`), so an empty-string cell counts as not-configured in\n // BOTH places and can't inflate the alert denominator.\n const isAnalyticsConfigured = (s: WebsiteRow): boolean =>\n gaConfigured && Boolean(s.ga4PropertyId || s.searchQuery);\n\n if (due.length === 0) {\n return {\n output: \"No reports due.\",\n code: 0,\n health: { softFailedSites: 0, configuredSites: 0 },\n };\n }\n\n const lines: string[] = [];\n let softFailedSites = 0;\n let searchDefaultMisses = 0;\n let searchPropertiesMissing = 0;\n let gaConfiguredSites = 0;\n let skipped = 0;\n for (const item of due) {\n // Idempotency: a re-run must not re-draft a (site, type) already drafted this\n // recurrence. The dueDate's YYYY-MM is the stable per-cycle key. Match against the\n // reports we already fetched — no extra query on the hot path.\n const period = reportPeriodKey(item.dueDate);\n const existing = reports.find(\n (r) => r.siteId === item.site.id && r.reportType === item.reportType && r.period === period,\n );\n\n // A row already exists for THIS period. Two cases:\n // - Draft ready → truly done, skip (the idempotent re-run path).\n // - NOT ready → a crash between createDraft and setDraftReady wedged it: the\n // row exists (so we never re-draft) yet it's never sendable (listSendable\n // needs Draft ready). COMPLETE it in place instead of skipping forever —\n // re-render → re-upload the HTML → flip Draft ready on the EXISTING row.\n if (existing) {\n if (existing.draftReady) {\n skipped++;\n lines.push(`• skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);\n continue;\n }\n // A not-ready row is normally a crash between createDraft and setDraftReady — re-complete\n // it in place. BUT queueDraft also clears Draft ready on rows it supersedes/blocks, and\n // those must NOT be re-completed: doing so would re-render and APPEND a duplicate HTML\n // attachment every nightly run, only to be re-blocked. Distinguish the two: if a\n // higher-or-equal-tier report is still pending for this site, this row was intentionally\n // un-queued (not crashed) — skip it until the blocker is sent/approved or the month rolls.\n const blockedByPending = reports.some(\n (r) =>\n r.siteId === item.site.id &&\n r.id !== existing.id &&\n r.sentAt === null &&\n r.draftReady &&\n reportTier(r.reportType) >= reportTier(item.reportType),\n );\n if (blockedByPending) {\n skipped++;\n lines.push(\n `• skipped (superseded — a higher-or-equal-tier report is pending): ${item.site.name} ${item.reportType}`,\n );\n continue;\n }\n try {\n const result = await draftReportForSite(base, item.site, item.reportType, {\n period,\n completeRowId: existing.id,\n existingRow: existing,\n });\n existing.draftReady = result.queued === true;\n lines.push(\n draftLine(\n result.reportRow?.reportId ?? existing.reportId,\n result.queued,\n result.supersededIds,\n \"completed half-made draft\",\n ),\n );\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n continue;\n }\n\n // Pile-up guard: don't accrue a fresh new-period draft every recurrence for a\n // site nobody ever approves. The period key follows the DUE month, so each\n // recurrence wants a new (later-period) draft — but if a PRIOR draft is still\n // pending approval, a new one just stacks. Skip creating the new one while an\n // earlier-period draft for this (site, type) sits ready-but-unsent.\n //\n // `r.draftReady` is load-bearing: a draft a higher tier SUPERSEDED has\n // draftReady=false and never gets a Sent at, so without this clause it would\n // match (sentAt null + earlier period) and block EVERY future draft for the\n // site forever. Pending-approval means draftReady=true AND sentAt=null.\n const pendingEarlier = reports.find(\n (r) =>\n r.siteId === item.site.id &&\n r.reportType === item.reportType &&\n r.draftReady &&\n r.sentAt === null &&\n r.period !== null &&\n r.period < period,\n );\n if (pendingEarlier) {\n skipped++;\n lines.push(\n `• skipped: ${item.site.name} ${item.reportType} already has an unsent ${pendingEarlier.period} draft pending approval`,\n );\n continue;\n }\n\n try {\n // Pass the SAME key the guard searches by, so the stamped Period always\n // matches a future run's reportPeriodKey(dueDate) — even if this run lags\n // into a later month than the dueDate.\n const result = await draftReportForSite(base, item.site, item.reportType, { period });\n lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));\n // Keep the in-memory snapshot current so the guard's `.some()` check on the\n // NEXT iteration of this same run catches a row we JUST created — rather than\n // relying on findDueReports never emitting two items for the same (site, type).\n if (result.reportRow) reports.push(result.reportRow);\n // Count sites (not individual GA/Search failures) so a fleet-wide enrichment\n // outage is one obvious line at the bottom, not 200 buried console.warns.\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n }\n if (skipped > 0) {\n lines.push(`• ${skipped} already drafted or pending this period`);\n }\n if (softFailedSites > 0) {\n lines.push(\n `⚠ ${softFailedSites} site${softFailedSites === 1 ? \"\" : \"s\"} had GA/Search enrichment fail — drafted with blank analytics; check the logs above`,\n );\n }\n if (searchDefaultMisses > 0) {\n lines.push(\n `⚑ ${searchDefaultMisses} site${searchDefaultMisses === 1 ? \"\" : \"s\"} returned no Search Console data for their name — set an explicit \"Search query\" in Airtable to track brand presence.`,\n );\n }\n if (searchPropertiesMissing > 0) {\n lines.push(\n `⚑ ${searchPropertiesMissing} site${searchPropertiesMissing === 1 ? \"\" : \"s\"} matched NO Search Console property — verify the domain property exists and the service account has access (a \"Search query\" change cannot fix this).`,\n );\n }\n return {\n output: lines.join(\"\\n\"),\n code: lines.some((l) => l.startsWith(\"✗\")) ? 1 : 0,\n health: { softFailedSites, configuredSites: gaConfiguredSites },\n };\n}\n\nasync function runSingleSiteDraft(\n slug: string,\n opts: { previewOnly: boolean; reportType: ReportType },\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const site = websites.find((w) => siteSlug(w.name) === slug);\n if (!site) {\n throw Object.assign(new Error(`No Websites row matched slug \"${slug}\"`), { exitCode: 2 });\n }\n const result = await draftReportForSite(opts.previewOnly ? null : base, site, opts.reportType, {\n previewOnly: opts.previewOnly,\n });\n if (opts.previewOnly) {\n return { output: `Preview written to ${result.htmlPath}`, code: 0 };\n }\n return {\n output: draftLine(\n result.reportRow?.reportId,\n result.queued,\n result.supersededIds,\n \"Draft created\",\n ),\n code: 0,\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,57 @@
1
+ // src/db/screenouts.ts
2
+ import { sql } from "kysely";
3
+ var COLUMN = {
4
+ honeypot: "honeypot",
5
+ "too-fast": "too_fast"
6
+ };
7
+ async function recordScreenOut(db, siteId, reason, date) {
8
+ const col = COLUMN[reason];
9
+ await sql`
10
+ INSERT INTO spam_screenouts (site_id, date, ${sql.ref(col)})
11
+ VALUES (${siteId}, ${date}, 1)
12
+ ON CONFLICT (site_id, date) DO UPDATE SET ${sql.ref(col)} = ${sql.ref(col)} + 1
13
+ `.execute(db);
14
+ }
15
+ async function listScreenOutsSince(db, sinceDate) {
16
+ const screenoutRows = await db.selectFrom("spam_screenouts").select((eb) => [
17
+ "site_id",
18
+ eb.fn.sum("honeypot").as("honeypot"),
19
+ eb.fn.sum("too_fast").as("too_fast")
20
+ ]).where("date", ">=", sinceDate).groupBy("site_id").execute();
21
+ const markedSpamRows = await db.selectFrom("submissions").select((eb) => ["site_id", eb.fn.countAll().as("marked_spam")]).where("status", "=", "spam").where("submitted_at", ">=", sinceDate).groupBy("site_id").execute();
22
+ const out = /* @__PURE__ */ new Map();
23
+ for (const r of screenoutRows) {
24
+ out.set(r.site_id, {
25
+ honeypot: Number(r.honeypot) || 0,
26
+ tooFast: Number(r.too_fast) || 0,
27
+ markedSpam: 0
28
+ });
29
+ }
30
+ for (const r of markedSpamRows) {
31
+ const totals = out.get(r.site_id) ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };
32
+ totals.markedSpam = Number(r.marked_spam) || 0;
33
+ out.set(r.site_id, totals);
34
+ }
35
+ return out;
36
+ }
37
+ function screenOutsSince(now, days) {
38
+ const d = new Date(now.getTime() - days * 24 * 60 * 60 * 1e3);
39
+ return d.toISOString().slice(0, 10);
40
+ }
41
+ async function backfillScreenoutBucket(db, b) {
42
+ await sql`
43
+ INSERT INTO spam_screenouts (site_id, date, honeypot, too_fast, marked_spam)
44
+ VALUES (${b.siteId}, ${b.date}, ${b.honeypot}, ${b.tooFast}, ${b.markedSpam})
45
+ ON CONFLICT (site_id, date) DO UPDATE SET
46
+ honeypot = excluded.honeypot,
47
+ too_fast = excluded.too_fast,
48
+ marked_spam = excluded.marked_spam
49
+ `.execute(db);
50
+ }
51
+ export {
52
+ backfillScreenoutBucket,
53
+ listScreenOutsSince,
54
+ recordScreenOut,
55
+ screenOutsSince
56
+ };
57
+ //# sourceMappingURL=screenouts-7P5GOZMV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/screenouts.ts"],"sourcesContent":["import { sql } from \"kysely\";\nimport type { Db } from \"./client.js\";\n\nexport type ScreenOutReason = \"honeypot\" | \"too-fast\";\nexport type ScreenOutTotals = { honeypot: number; tooFast: number; markedSpam: number };\n\nconst COLUMN: Record<ScreenOutReason, \"honeypot\" | \"too_fast\"> = {\n honeypot: \"honeypot\",\n \"too-fast\": \"too_fast\",\n};\n\n/** Atomically increment the caught counter for a reason on the (site, date) bucket.\n * ON CONFLICT keeps the count exact — no read-modify-write race, no duplicate\n * buckets. A swallowed failure must never error a screened bot (caller's job). */\nexport async function recordScreenOut(\n db: Db,\n siteId: string,\n reason: ScreenOutReason,\n date: string,\n): Promise<void> {\n const col = COLUMN[reason];\n await sql`\n INSERT INTO spam_screenouts (site_id, date, ${sql.ref(col)})\n VALUES (${siteId}, ${date}, 1)\n ON CONFLICT (site_id, date) DO UPDATE SET ${sql.ref(col)} = ${sql.ref(col)} + 1\n `.execute(db);\n}\n\n/** Per-site spam totals over the window `>= sinceDate`.\n *\n * `honeypot`/`tooFast` are summed from the ingest-time `spam_screenouts` buckets\n * (bots screened BEFORE storage — there's no row to count, so an atomic counter\n * is the right source).\n *\n * `markedSpam` is DERIVED from the submissions table — a live `COUNT(*) WHERE\n * status = 'spam'` — NOT a mutable counter. A counter incremented on every\n * →spam transition double-counts when an operator re-marks a submission\n * (spam → new → spam) and can never self-correct an un-mark; counting the rows\n * themselves is exact and idempotent. Windowed by `submitted_at` so it's\n * arrival-dated, matching the honeypot/too-fast buckets (which key on the\n * screen = arrival day). The legacy `marked_spam` column is no longer read. */\nexport async function listScreenOutsSince(\n db: Db,\n sinceDate: string,\n): Promise<Map<string, ScreenOutTotals>> {\n const screenoutRows = await db\n .selectFrom(\"spam_screenouts\")\n .select((eb) => [\n \"site_id\",\n eb.fn.sum<number>(\"honeypot\").as(\"honeypot\"),\n eb.fn.sum<number>(\"too_fast\").as(\"too_fast\"),\n ])\n .where(\"date\", \">=\", sinceDate)\n .groupBy(\"site_id\")\n .execute();\n\n const markedSpamRows = await db\n .selectFrom(\"submissions\")\n .select((eb) => [\"site_id\", eb.fn.countAll<number>().as(\"marked_spam\")])\n .where(\"status\", \"=\", \"spam\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .groupBy(\"site_id\")\n .execute();\n\n const out = new Map<string, ScreenOutTotals>();\n for (const r of screenoutRows) {\n out.set(r.site_id, {\n honeypot: Number(r.honeypot) || 0,\n tooFast: Number(r.too_fast) || 0,\n markedSpam: 0,\n });\n }\n for (const r of markedSpamRows) {\n const totals = out.get(r.site_id) ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };\n totals.markedSpam = Number(r.marked_spam) || 0;\n out.set(r.site_id, totals);\n }\n return out;\n}\n\n/** The ISO date (YYYY-MM-DD) `days` before `now`, for the window queries.\n * Verbatim from the Airtable module so the windows match exactly. */\nexport function screenOutsSince(now: Date, days: number): string {\n const d = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);\n return d.toISOString().slice(0, 10);\n}\n\n/** Set the (site, date) bucket to exact totals. Replace-upsert (DO UPDATE SET col\n * = excluded.col) so re-running the backfill is idempotent. The caller pre-sums\n * duplicate same-day Airtable buckets in JS before calling this. */\nexport async function backfillScreenoutBucket(\n db: Db,\n b: { siteId: string; date: string; honeypot: number; tooFast: number; markedSpam: number },\n): Promise<void> {\n await sql`\n INSERT INTO spam_screenouts (site_id, date, honeypot, too_fast, marked_spam)\n VALUES (${b.siteId}, ${b.date}, ${b.honeypot}, ${b.tooFast}, ${b.markedSpam})\n ON CONFLICT (site_id, date) DO UPDATE SET\n honeypot = excluded.honeypot,\n too_fast = excluded.too_fast,\n marked_spam = excluded.marked_spam\n `.execute(db);\n}\n"],"mappings":";AAAA,SAAS,WAAW;AAMpB,IAAM,SAA2D;AAAA,EAC/D,UAAU;AAAA,EACV,YAAY;AACd;AAKA,eAAsB,gBACpB,IACA,QACA,QACA,MACe;AACf,QAAM,MAAM,OAAO,MAAM;AACzB,QAAM;AAAA,kDAC0C,IAAI,IAAI,GAAG,CAAC;AAAA,cAChD,MAAM,KAAK,IAAI;AAAA,gDACmB,IAAI,IAAI,GAAG,CAAC,MAAM,IAAI,IAAI,GAAG,CAAC;AAAA,IAC1E,QAAQ,EAAE;AACd;AAeA,eAAsB,oBACpB,IACA,WACuC;AACvC,QAAM,gBAAgB,MAAM,GACzB,WAAW,iBAAiB,EAC5B,OAAO,CAAC,OAAO;AAAA,IACd;AAAA,IACA,GAAG,GAAG,IAAY,UAAU,EAAE,GAAG,UAAU;AAAA,IAC3C,GAAG,GAAG,IAAY,UAAU,EAAE,GAAG,UAAU;AAAA,EAC7C,CAAC,EACA,MAAM,QAAQ,MAAM,SAAS,EAC7B,QAAQ,SAAS,EACjB,QAAQ;AAEX,QAAM,iBAAiB,MAAM,GAC1B,WAAW,aAAa,EACxB,OAAO,CAAC,OAAO,CAAC,WAAW,GAAG,GAAG,SAAiB,EAAE,GAAG,aAAa,CAAC,CAAC,EACtE,MAAM,UAAU,KAAK,MAAM,EAC3B,MAAM,gBAAgB,MAAM,SAAS,EACrC,QAAQ,SAAS,EACjB,QAAQ;AAEX,QAAM,MAAM,oBAAI,IAA6B;AAC7C,aAAW,KAAK,eAAe;AAC7B,QAAI,IAAI,EAAE,SAAS;AAAA,MACjB,UAAU,OAAO,EAAE,QAAQ,KAAK;AAAA,MAChC,SAAS,OAAO,EAAE,QAAQ,KAAK;AAAA,MAC/B,YAAY;AAAA,IACd,CAAC;AAAA,EACH;AACA,aAAW,KAAK,gBAAgB;AAC9B,UAAM,SAAS,IAAI,IAAI,EAAE,OAAO,KAAK,EAAE,UAAU,GAAG,SAAS,GAAG,YAAY,EAAE;AAC9E,WAAO,aAAa,OAAO,EAAE,WAAW,KAAK;AAC7C,QAAI,IAAI,EAAE,SAAS,MAAM;AAAA,EAC3B;AACA,SAAO;AACT;AAIO,SAAS,gBAAgB,KAAW,MAAsB;AAC/D,QAAM,IAAI,IAAI,KAAK,IAAI,QAAQ,IAAI,OAAO,KAAK,KAAK,KAAK,GAAI;AAC7D,SAAO,EAAE,YAAY,EAAE,MAAM,GAAG,EAAE;AACpC;AAKA,eAAsB,wBACpB,IACA,GACe;AACf,QAAM;AAAA;AAAA,cAEM,EAAE,MAAM,KAAK,EAAE,IAAI,KAAK,EAAE,QAAQ,KAAK,EAAE,OAAO,KAAK,EAAE,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA,IAK3E,QAAQ,EAAE;AACd;","names":[]}
@@ -11,7 +11,7 @@ import {
11
11
  } from "./chunk-4NAOR44V.js";
12
12
  import {
13
13
  resolveSites
14
- } from "./chunk-GI4M2AMO.js";
14
+ } from "./chunk-4BHNP6Y2.js";
15
15
  import "./chunk-CASCWQQX.js";
16
16
  import "./chunk-GASBX52O.js";
17
17
  import "./chunk-YEGHUODU.js";
@@ -61,4 +61,4 @@ async function runSelfUpdatingCommand(site, opts) {
61
61
  export {
62
62
  runSelfUpdatingCommand
63
63
  };
64
- //# sourceMappingURL=self-updating-64FNRQB5.js.map
64
+ //# sourceMappingURL=self-updating-2YSVNMPP.js.map
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  buildReportDataForSite,
3
3
  scoresFromRow
4
- } from "./chunk-SQJ53Z7R.js";
5
- import "./chunk-NRISG4XD.js";
4
+ } from "./chunk-I55YWV6T.js";
5
+ import "./chunk-NUIBQPB3.js";
6
6
  import "./chunk-KLVMJYZZ.js";
7
7
  import {
8
8
  ELIGIBLE_STATUSES
@@ -12,7 +12,7 @@ import {
12
12
  parseAddresses,
13
13
  prepareHeaderImage,
14
14
  renderReportEmail
15
- } from "./chunk-7P7YETHG.js";
15
+ } from "./chunk-IDBMYDVH.js";
16
16
  import "./chunk-GECESMVS.js";
17
17
  import {
18
18
  fetchAttachmentBytes
@@ -27,7 +27,7 @@ import "./chunk-U5SZ4FOP.js";
27
27
  import {
28
28
  listWebsites,
29
29
  siteSlug
30
- } from "./chunk-BWKK2ZXS.js";
30
+ } from "./chunk-GXVX4HUH.js";
31
31
  import {
32
32
  openBase,
33
33
  readAirtableConfig
@@ -159,4 +159,4 @@ async function runSelftestCommand(kind, site, opts) {
159
159
  export {
160
160
  runSelftestCommand
161
161
  };
162
- //# sourceMappingURL=selftest-7DPNGL6R.js.map
162
+ //# sourceMappingURL=selftest-OO2WDSDM.js.map
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-4NAOR44V.js";
9
9
  import {
10
10
  resolveSites
11
- } from "./chunk-GI4M2AMO.js";
11
+ } from "./chunk-4BHNP6Y2.js";
12
12
  import {
13
13
  smokeSuite
14
14
  } from "./chunk-PKVZMFEB.js";
@@ -51,4 +51,4 @@ async function runSmokeSuiteCommand(site, opts) {
51
51
  export {
52
52
  runSmokeSuiteCommand
53
53
  };
54
- //# sourceMappingURL=smoke-suite-TWILWDSF.js.map
54
+ //# sourceMappingURL=smoke-suite-CSP23NZM.js.map