@decohere/ci-tools 0.0.1-dev.11 → 0.0.1-dev.111

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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Benchmark history: the results of each `pnpm bench` run, kept per machine so runs are only
3
+ * compared like for like (#660). Pure functions over plain data; bench.mjs does the I/O.
4
+ *
5
+ * Shape (version 2):
6
+ * { version: 2, contexts: { [contextId]: { context, runs: [{ date, sha, results }] } } }
7
+ * newest run first. A result is { id, label, unit, median, p90?, min, max, samples, files? }.
8
+ */
9
+
10
+ import crypto from "node:crypto";
11
+
12
+ export const HISTORY_VERSION = 2;
13
+ /** Nightly runs: a month per machine, enough for a trend and a regression baseline. */
14
+ export const MAX_RUNS = 30;
15
+ /** Printed by each suite before its JSON array of results (projects/flux/bench/harness.ts). */
16
+ export const RESULT_PREFIX = "BENCH_RESULT ";
17
+
18
+ /** The machine a run happened on. Runs are compared only within one context. */
19
+ export function getContext(os, env, nodeVersion, date = new Date()) {
20
+ const cpus = os.cpus();
21
+ const nodeMajor = nodeVersion.match(/^v(\d+)/)?.[1] ?? "?";
22
+ const rawEnv = env.BENCH_ENV ?? null;
23
+ return {
24
+ benchEnv: rawEnv ? `${rawEnv}-node${nodeMajor}` : "(local)",
25
+ date: date.toISOString(),
26
+ os: { platform: os.platform(), arch: os.arch() },
27
+ cpu: { model: cpus[0]?.model?.trim() ?? "unknown", count: cpus.length },
28
+ memoryGb: Number.parseFloat((os.totalmem() / 1024 ** 3).toFixed(1)),
29
+ node: nodeVersion,
30
+ };
31
+ }
32
+
33
+ /** 16-char SHA-256 prefix of platform|arch|cpu|cores|memGB|node. */
34
+ export function contextId(ctx) {
35
+ const key = [
36
+ ctx.os.platform,
37
+ ctx.os.arch,
38
+ ctx.cpu.model,
39
+ ctx.cpu.count,
40
+ ctx.memoryGb,
41
+ ctx.node,
42
+ ].join("|");
43
+ return crypto.createHash("sha256").update(key).digest("hex").slice(0, 16);
44
+ }
45
+
46
+ /** The results a suite printed, from its whole output. Throws if it printed none. */
47
+ export function parseResults(output, suite) {
48
+ const line = output
49
+ .split(/\r?\n/)
50
+ .reverse()
51
+ .find((candidate) => candidate.startsWith(RESULT_PREFIX));
52
+ if (line == null)
53
+ throw new Error(`suite "${suite}" printed no ${RESULT_PREFIX.trim()} line`);
54
+ return JSON.parse(line.slice(RESULT_PREFIX.length));
55
+ }
56
+
57
+ const FORGE_BENCHMARKS = [
58
+ ["forge build es6 + types", "forge.build.local"],
59
+ ["forge build es6 only", "forge.build.debug"],
60
+ ["forge build types only", "forge.build.types-only"],
61
+ ];
62
+
63
+ /**
64
+ * Results from `vitest bench --outputJson` for the forge build benchmark. Its names end in a
65
+ * file count, so they are mapped to stable ids; vitest reports no p90, so it is left out.
66
+ */
67
+ export function fromVitest(vitestOutput) {
68
+ return vitestOutput.files
69
+ .flatMap((file) => file.groups)
70
+ .flatMap((group) => group.benchmarks)
71
+ .map((benchmark) => {
72
+ const id = FORGE_BENCHMARKS.find(([prefix]) => benchmark.name.startsWith(prefix))?.[1];
73
+ if (id == null) throw new Error(`unknown forge benchmark "${benchmark.name}"`);
74
+ const files = Number(benchmark.name.match(/\((\d+) files\)/)?.[1]);
75
+ return {
76
+ id,
77
+ label: benchmark.name.replace(/\s*\(\d+ files\)$/, ""),
78
+ unit: "ms",
79
+ median: benchmark.median ?? benchmark.mean,
80
+ min: benchmark.min,
81
+ max: benchmark.max,
82
+ samples: benchmark.sampleCount,
83
+ ...(Number.isFinite(files) ? { files } : {}),
84
+ };
85
+ });
86
+ }
87
+
88
+ /** Throws when a result has no usable numbers: a suite that errored mid-run, not a slow one. */
89
+ export function assertValid(results) {
90
+ const invalid = results.filter(
91
+ (result) => !Number.isFinite(result.median) || !(result.samples > 0),
92
+ );
93
+ if (invalid.length > 0) {
94
+ throw new Error(
95
+ `${invalid.length} benchmark(s) have no valid samples: ${invalid.map((r) => r.id).join(", ")}`,
96
+ );
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Reads a stored history. Version 1 (forge build only, raw vitest output) is converted so its
102
+ * trend carries on; anything unrecognised starts empty rather than mixing formats.
103
+ */
104
+ export function normalize(raw) {
105
+ if (raw?.version === HISTORY_VERSION && raw.contexts != null) return raw;
106
+ const contexts = {};
107
+ for (const [id, entry] of Object.entries(raw?.runs ?? {})) {
108
+ const runs = (entry.runs ?? [])
109
+ .filter((run) => run.vitestOutput != null)
110
+ .map((run) => ({ date: run.date, sha: null, results: fromVitest(run.vitestOutput) }));
111
+ if (runs.length > 0) contexts[id] = { context: entry.context, runs };
112
+ }
113
+ return { version: HISTORY_VERSION, contexts };
114
+ }
115
+
116
+ /** The previous run for this context, if any: what a new run is compared against. */
117
+ export function previousRun(history, id) {
118
+ return history.contexts[id]?.runs[0];
119
+ }
120
+
121
+ /** A new history with `run` first in its context, trimmed to `max` runs. Does not mutate. */
122
+ export function appendRun(history, ctx, run, max = MAX_RUNS) {
123
+ const id = contextId(ctx);
124
+ const runs = [run, ...(history.contexts[id]?.runs ?? [])].slice(0, max);
125
+ return {
126
+ version: HISTORY_VERSION,
127
+ contexts: { ...history.contexts, [id]: { context: ctx, runs } },
128
+ };
129
+ }
130
+
131
+ /** One row per current result, with the change in median against the previous run. */
132
+ export function compare(results, previous) {
133
+ const before = new Map((previous?.results ?? []).map((result) => [result.id, result]));
134
+ return results.map((result) => {
135
+ const old = before.get(result.id);
136
+ if (old == null) return { id: result.id, current: result.median };
137
+ const delta = result.median - old.median;
138
+ return {
139
+ id: result.id,
140
+ current: result.median,
141
+ previous: old.median,
142
+ delta,
143
+ percent: old.median === 0 ? 0 : (delta / old.median) * 100,
144
+ };
145
+ });
146
+ }
147
+
148
+ /** The comparison as an aligned text table. */
149
+ export function formatComparison(rows) {
150
+ const ms = (value) => (value == null ? "n/a" : `${value.toFixed(1)} ms`);
151
+ const signed = (value, suffix) =>
152
+ value == null ? "n/a" : `${value >= 0 ? "+" : ""}${value.toFixed(1)}${suffix}`;
153
+ const table = [
154
+ ["Benchmark", "Median", "Previous", "Delta", "%"],
155
+ ...rows.map((row) => [
156
+ row.id,
157
+ ms(row.current),
158
+ ms(row.previous),
159
+ signed(row.delta, " ms"),
160
+ signed(row.percent, "%"),
161
+ ]),
162
+ ];
163
+ const widths = table[0].map((_, column) =>
164
+ Math.max(...table.map((row) => row[column].length)),
165
+ );
166
+ const line = (row) =>
167
+ row
168
+ .map((cell, column) =>
169
+ (column === 0 ? cell.padEnd : cell.padStart).call(cell, widths[column]),
170
+ )
171
+ .join(" | ");
172
+ return [
173
+ line(table[0]),
174
+ widths.map((w) => "-".repeat(w)).join("-+-"),
175
+ ...table.slice(1).map(line),
176
+ ].join("\n");
177
+ }
178
+
179
+ /**
180
+ * When a benchmark counts as slower (#660): its median beat the baseline by more than
181
+ * `threshold` on each of the last `confirmRuns` runs, where the baseline is the median of the
182
+ * `baselineRuns` runs before those. Two nights in a row, so one noisy runner files nothing.
183
+ */
184
+ export const REGRESSION = { threshold: 0.2, confirmRuns: 2, baselineRuns: 7, minBaseline: 3 };
185
+
186
+ function medianOf(values) {
187
+ const sorted = [...values].sort((a, b) => a - b);
188
+ const middle = Math.floor(sorted.length / 2);
189
+ return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
190
+ }
191
+
192
+ /** The benchmarks that regressed on one machine, given its runs newest first. */
193
+ export function findRegressions(runs, options = REGRESSION) {
194
+ const recent = runs.slice(0, options.confirmRuns);
195
+ const before = runs.slice(options.confirmRuns, options.confirmRuns + options.baselineRuns);
196
+ if (recent.length < options.confirmRuns) return [];
197
+ const medianIn = (run, id) => run.results.find((result) => result.id === id)?.median;
198
+ const regressions = [];
199
+ for (const latest of recent[0].results) {
200
+ const baselineValues = before
201
+ .map((run) => medianIn(run, latest.id))
202
+ .filter((value) => Number.isFinite(value));
203
+ if (baselineValues.length < options.minBaseline) continue;
204
+ const baseline = medianOf(baselineValues);
205
+ const recentValues = recent.map((run) => medianIn(run, latest.id));
206
+ const slower = recentValues.every(
207
+ (value) => Number.isFinite(value) && value > baseline * (1 + options.threshold),
208
+ );
209
+ if (!slower) continue;
210
+ regressions.push({
211
+ id: latest.id,
212
+ label: latest.label,
213
+ baseline,
214
+ percent: (recentValues[0] / baseline - 1) * 100,
215
+ recent: recent.map((run, index) => ({
216
+ date: run.date,
217
+ sha: run.sha,
218
+ median: recentValues[index],
219
+ })),
220
+ // The change landed between the newest baseline run and the oldest slow one.
221
+ lastGood: before[0] == null ? null : { date: before[0].date, sha: before[0].sha },
222
+ });
223
+ }
224
+ return regressions;
225
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The GitHub issue for a benchmark that got slower (#660): one open issue per benchmark,
3
+ * found again by its exact title and updated in place while the regression lasts.
4
+ */
5
+
6
+ const PAGES_URL = "https://typesmiths.github.io/decohere/benchmark.html";
7
+
8
+ export function issueTitle(regression) {
9
+ return `perf(bench): ${regression.id} is slower`;
10
+ }
11
+
12
+ function ms(value) {
13
+ return `${value.toFixed(1)} ms`;
14
+ }
15
+
16
+ function commitLink(repoUrl, sha) {
17
+ return sha == null ? "unknown" : `[\`${sha.slice(0, 7)}\`](${repoUrl}/commit/${sha})`;
18
+ }
19
+
20
+ export function issueBody(regression, { repoUrl, runUrl, machine, threshold }) {
21
+ const newest = regression.recent[0];
22
+ const oldestSlow = regression.recent.at(-1);
23
+ const range =
24
+ regression.lastGood?.sha != null && newest.sha != null
25
+ ? `[${regression.lastGood.sha.slice(0, 7)}...${newest.sha.slice(0, 7)}](${repoUrl}/compare/${regression.lastGood.sha}...${newest.sha})`
26
+ : "unknown (no baseline commit recorded)";
27
+ return [
28
+ "## Problem",
29
+ "",
30
+ `The nightly benchmark **${regression.label}** (\`${regression.id}\`) has a median ${regression.percent.toFixed(0)}% above its baseline, on each of the last ${regression.recent.length} nightly runs. A benchmark counts as slower above ${Math.round(threshold * 100)}%.`,
31
+ "",
32
+ `- Baseline (median of the runs before): **${ms(regression.baseline)}**`,
33
+ ...regression.recent.map(
34
+ (run) =>
35
+ `- ${run.date.slice(0, 10)}, ${commitLink(repoUrl, run.sha)}: **${ms(run.median)}**`,
36
+ ),
37
+ `- Machine: ${machine}`,
38
+ "",
39
+ `The change landed after the last normal run (${regression.lastGood?.date.slice(0, 10) ?? "unknown"}) and by ${oldestSlow.date.slice(0, 10)}: commits ${range}.`,
40
+ "",
41
+ "## What to do",
42
+ "",
43
+ "1. Check the trend on the [benchmark page](" +
44
+ PAGES_URL +
45
+ "). A step that holds is a real change; a single machine type jumping may be the runner.",
46
+ "2. Reproduce locally: check out the last normal commit, `pnpm bench:save`, then the newest commit, `pnpm bench`, and read the comparison.",
47
+ "3. Fix the cause, or if the slowdown is intended, say why here and close the issue.",
48
+ "",
49
+ `Updated by the nightly bench workflow: ${runUrl}`,
50
+ ].join("\n");
51
+ }
@@ -6,8 +6,8 @@ import { join } from "node:path";
6
6
 
7
7
  const WORKSPACE_ROOT = process.cwd();
8
8
  const VITEST_BIN = join(WORKSPACE_ROOT, "node_modules/.bin/vitest");
9
- const CONFIG = join(WORKSPACE_ROOT, "vitest.e2e.config.ts");
10
- const TRACE_SPEC = "projects/terminal/e2e/build.trace.e2e.spec.ts";
9
+ const CONFIG = join(WORKSPACE_ROOT, "vitest.config.ts");
10
+ const TRACE_SPEC = "projects/cli/e2e/build.trace.e2e.spec.ts";
11
11
  const TRACE_LOG_DIR = join(WORKSPACE_ROOT, ".decohere", "logs");
12
12
 
13
13
  function truthy(value, defaultValue = false) {
@@ -149,19 +149,23 @@ if (traceLogPath != null) {
149
149
  mkdirSync(TRACE_LOG_DIR, { recursive: true });
150
150
  }
151
151
 
152
- const result = spawnSync(VITEST_BIN, ["run", "--config", CONFIG, TRACE_SPEC], {
153
- cwd: WORKSPACE_ROOT,
154
- stdio: "pipe",
155
- encoding: "utf8",
156
- maxBuffer: 1024 * 1024 * 200,
157
- env: {
158
- ...process.env,
159
- NODE_OPTIONS: "",
160
- FORGE_TRACE: "1",
161
- FORGE_TRACE_STRUCTURED: "1",
162
- LOG_LEVEL: process.env.LOG_LEVEL ?? "trace",
152
+ const result = spawnSync(
153
+ VITEST_BIN,
154
+ ["run", "--config", CONFIG, "--project", "e2e", TRACE_SPEC],
155
+ {
156
+ cwd: WORKSPACE_ROOT,
157
+ stdio: "pipe",
158
+ encoding: "utf8",
159
+ maxBuffer: 1024 * 1024 * 200,
160
+ env: {
161
+ ...process.env,
162
+ NODE_OPTIONS: "",
163
+ FORGE_TRACE: "1",
164
+ FORGE_TRACE_STRUCTURED: "1",
165
+ LOG_LEVEL: process.env.LOG_LEVEL ?? "trace",
166
+ },
163
167
  },
164
- });
168
+ );
165
169
 
166
170
  const stdout = typeof result.stdout === "string" ? result.stdout : "";
167
171
  const stderr = typeof result.stderr === "string" ? result.stderr : "";
@@ -0,0 +1,320 @@
1
+ import { resolve } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ const LIST_ID = process.env.CLICKUP_LIST_ID ?? "901220171084";
5
+ const API = "https://api.clickup.com/api/v2";
6
+ const WARN_DAYS = 14;
7
+ const MS_PER_DAY = 86_400_000;
8
+ const CHORE_TYPE = 1004;
9
+ const DEFAULT_REPO = "typesmiths/decohere";
10
+
11
+ /**
12
+ * One profile per credential. Date math, ClickUp upsert, and fail-closed live below.
13
+ * `ghEnv` unset means repo secret / repo variable (not an Actions environment).
14
+ *
15
+ * @typedef {{
16
+ * id: string,
17
+ * secret: string,
18
+ * expiresVar: string,
19
+ * ghEnv?: string,
20
+ * mint: string,
21
+ * failImpact: string,
22
+ * rejectedWhy: string,
23
+ * revokeUrl: string,
24
+ * }} CredentialProfile
25
+ */
26
+
27
+ /** @type {Record<string, CredentialProfile>} */
28
+ export const CREDENTIALS = {
29
+ ghcr: {
30
+ id: "ghcr",
31
+ secret: "GHCR_PUSH_TOKEN",
32
+ expiresVar: "GHCR_TOKEN_EXPIRES_AT",
33
+ ghEnv: "ghcr-publish",
34
+ mint: "Create a **classic** GitHub PAT (fine-grained tokens cannot push to GHCR): https://github.com/settings/tokens/new?scopes=write:packages",
35
+ failImpact:
36
+ "npm may already have published. Docker login/push and the `v*` tag will **fail**. The SHA stays untagged, so the next successful Publish of that tip can finish Docker + tag after you rotate. Images are not pushed with `GITHUB_TOKEN`.",
37
+ rejectedWhy: "GHCR `docker login` returned 401 (expired, revoked, or wrong secret name).",
38
+ revokeUrl: "https://github.com/settings/tokens",
39
+ },
40
+ npm: {
41
+ id: "npm",
42
+ secret: "NODE_AUTH_TOKEN",
43
+ expiresVar: "NODE_AUTH_TOKEN_EXPIRES_AT",
44
+ mint: "Create an npm **read** token (granular/classic) at https://www.npmjs.com/settings/~/tokens — Publish PUTs are OIDC; this token is only for private `npm view` and create smoke.",
45
+ failImpact:
46
+ "Verify cannot see private packages, so skipIfPublished and create smoke are unsafe. Publish **fails closed** before OIDC/Docker. After you rotate, re-run the failed Publish jobs.",
47
+ rejectedWhy: "`npm whoami` failed (expired, revoked, or missing `NODE_AUTH_TOKEN`).",
48
+ revokeUrl: "https://www.npmjs.com/settings/~/tokens",
49
+ },
50
+ };
51
+
52
+ export function utcDayMs(date) {
53
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
54
+ }
55
+
56
+ /** @param {string | undefined} raw */
57
+ export function parseExpiresAt(raw) {
58
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(raw ?? "").trim());
59
+ if (!match) {
60
+ return null;
61
+ }
62
+ return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
63
+ }
64
+
65
+ /**
66
+ * @param {{ now?: number, expiresAtMs: number | null, warnDays?: number }} options
67
+ * @returns {{ kind: "missing" | "ok" | "warn" | "expired", daysLeft: number | null }}
68
+ */
69
+ export function assessTokenLifetime(options) {
70
+ const { expiresAtMs, warnDays = WARN_DAYS } = options;
71
+ if (expiresAtMs == null) {
72
+ return { kind: "missing", daysLeft: null };
73
+ }
74
+ const today = utcDayMs(new Date(options.now ?? Date.now()));
75
+ const daysLeft = Math.round((expiresAtMs - today) / MS_PER_DAY);
76
+ if (daysLeft <= 0) {
77
+ return { kind: "expired", daysLeft };
78
+ }
79
+ if (daysLeft <= warnDays) {
80
+ return { kind: "warn", daysLeft };
81
+ }
82
+ return { kind: "ok", daysLeft };
83
+ }
84
+
85
+ /** @param {CredentialProfile} credential */
86
+ export function resolveRepo() {
87
+ return process.env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
88
+ }
89
+
90
+ /** @param {CredentialProfile} credential */
91
+ export function ghScopeFlags(credential) {
92
+ return credential.ghEnv ? `--env ${credential.ghEnv} ` : "";
93
+ }
94
+
95
+ /** @param {string} id */
96
+ export function credentialProfile(id) {
97
+ const credential = CREDENTIALS[id];
98
+ if (!credential) {
99
+ throw new Error(
100
+ `Unknown NUDGE_CREDENTIAL=${id}. Expected one of: ${Object.keys(CREDENTIALS).join(", ")}`,
101
+ );
102
+ }
103
+ return credential;
104
+ }
105
+
106
+ /** @param {CredentialProfile} credential */
107
+ export function taskName(credential, expiresYmd) {
108
+ return `chore(security): rotate ${credential.secret} (expires ${expiresYmd})`;
109
+ }
110
+
111
+ /** @param {CredentialProfile} credential */
112
+ export function rotationCommands(credential, nextExpiresYmd, repo = resolveRepo()) {
113
+ const next = nextExpiresYmd ?? "YYYY-MM-DD";
114
+ const flags = ghScopeFlags(credential);
115
+ return [
116
+ credential.mint,
117
+ "",
118
+ "Then from a trusted machine (do not paste the token into Actions or this task):",
119
+ "",
120
+ "```bash",
121
+ `gh secret set ${credential.secret} ${flags}--repo ${repo}`,
122
+ `gh variable set ${credential.expiresVar} ${flags}--repo ${repo} --body ${next}`,
123
+ "```",
124
+ "",
125
+ `Revoke the old token: ${credential.revokeUrl}`,
126
+ "Mark this task complete, then retry Publish:",
127
+ "",
128
+ "```bash",
129
+ `gh run list --repo ${repo} --workflow Publish --limit 5`,
130
+ `gh run rerun --failed <run-id> --repo ${repo}`,
131
+ "```",
132
+ ].join("\n");
133
+ }
134
+
135
+ export function suggestedNextExpiry(fromMs, days = 90) {
136
+ const start = utcDayMs(new Date(fromMs));
137
+ const next = new Date(start + days * MS_PER_DAY);
138
+ const y = next.getUTCFullYear();
139
+ const m = String(next.getUTCMonth() + 1).padStart(2, "0");
140
+ const d = String(next.getUTCDate()).padStart(2, "0");
141
+ return `${y}-${m}-${d}`;
142
+ }
143
+
144
+ /** @param {CredentialProfile} credential */
145
+ export function buildDescription(credential, options) {
146
+ const { kind, expiresYmd, daysLeft, rejected, runUrl } = options;
147
+ const next = suggestedNextExpiry(Date.now());
148
+ const why = rejected
149
+ ? credential.rejectedWhy
150
+ : kind === "expired"
151
+ ? `${credential.expiresVar} (${expiresYmd}) is today or in the past. Rotate ${credential.secret} and this date before Publish can continue.`
152
+ : `${credential.secret} expires on ${expiresYmd} (${daysLeft} day(s) left). Rotate before that date so Publish does not fail closed.`;
153
+ return [
154
+ "## Summary",
155
+ "",
156
+ why,
157
+ "",
158
+ "## If this is not done in time",
159
+ "",
160
+ credential.failImpact,
161
+ "",
162
+ "## Commands",
163
+ "",
164
+ rotationCommands(credential, next),
165
+ "",
166
+ runUrl ? `- Workflow run: ${runUrl}` : null,
167
+ ]
168
+ .filter((line) => line != null)
169
+ .join("\n");
170
+ }
171
+
172
+ async function clickup(method, path, body) {
173
+ const key = process.env.CLICKUP_API_KEY;
174
+ if (key == null || key.length === 0) {
175
+ throw new Error("Missing CLICKUP_API_KEY");
176
+ }
177
+ const response = await fetch(`${API}${path}`, {
178
+ method,
179
+ headers: {
180
+ Authorization: key,
181
+ "Content-Type": "application/json",
182
+ },
183
+ body: body == null ? undefined : JSON.stringify(body),
184
+ });
185
+ const text = await response.text();
186
+ if (!response.ok) {
187
+ throw new Error(
188
+ `ClickUp ${method} ${path} failed (${response.status}): ${text.slice(0, 500)}`,
189
+ );
190
+ }
191
+ return text.length === 0 ? undefined : JSON.parse(text);
192
+ }
193
+
194
+ async function findTaskByName(name) {
195
+ for (let page = 0; page < 10; page += 1) {
196
+ const qs = new URLSearchParams({
197
+ include_closed: "true",
198
+ subtasks: "false",
199
+ page: String(page),
200
+ });
201
+ const data = await clickup("GET", `/list/${LIST_ID}/task?${qs}`);
202
+ const tasks = Array.isArray(data) ? data : (data?.tasks ?? []);
203
+ const match = tasks.find((task) => task.name === name);
204
+ if (match) {
205
+ return match;
206
+ }
207
+ if (tasks.length < 100) {
208
+ break;
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+
214
+ async function firstOpenStatus() {
215
+ const list = await clickup("GET", `/list/${LIST_ID}`);
216
+ return (list.statuses ?? []).find((status) => status.type === "open")?.status;
217
+ }
218
+
219
+ function runUrl() {
220
+ if (
221
+ process.env.GITHUB_SERVER_URL &&
222
+ process.env.GITHUB_REPOSITORY &&
223
+ process.env.GITHUB_RUN_ID
224
+ ) {
225
+ return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
226
+ }
227
+ return "";
228
+ }
229
+
230
+ /** @param {CredentialProfile} credential */
231
+ export async function upsertRotationTask(credential, options) {
232
+ const { expiresYmd, kind, daysLeft, rejected } = options;
233
+ const name = taskName(credential, expiresYmd ?? "unknown");
234
+ const existing = await findTaskByName(name);
235
+ const dueMs = parseExpiresAt(expiresYmd);
236
+ const payload = {
237
+ name,
238
+ markdown_description: buildDescription(credential, {
239
+ kind,
240
+ expiresYmd,
241
+ daysLeft,
242
+ rejected,
243
+ runUrl: runUrl(),
244
+ }),
245
+ custom_item_id: CHORE_TYPE,
246
+ priority: rejected || kind === "expired" ? 1 : 2,
247
+ due_date: dueMs == null ? undefined : dueMs,
248
+ due_date_time: false,
249
+ };
250
+ const assignee = process.env.CLICKUP_ASSIGNEE_ID?.trim();
251
+ if (assignee) {
252
+ payload.assignees = [Number(assignee)];
253
+ }
254
+
255
+ if (existing) {
256
+ const update = {
257
+ markdown_description: payload.markdown_description,
258
+ priority: payload.priority,
259
+ due_date: payload.due_date,
260
+ due_date_time: false,
261
+ };
262
+ if (existing.closed || existing.status?.type === "closed") {
263
+ const status = await firstOpenStatus();
264
+ if (status) {
265
+ update.status = status;
266
+ }
267
+ }
268
+ await clickup("PUT", `/task/${existing.id}`, update);
269
+ return { id: existing.id, url: existing.url, created: false };
270
+ }
271
+ const created = await clickup("POST", `/list/${LIST_ID}/task`, payload);
272
+ return { id: created.id, url: created.url, created: true };
273
+ }
274
+
275
+ export async function runNudge(env = process.env) {
276
+ const credential = credentialProfile(String(env.NUDGE_CREDENTIAL ?? "").trim());
277
+ const rejected = env.NUDGE_REJECTED === "1";
278
+ const expiresYmd = String(env[credential.expiresVar] ?? "").trim();
279
+ const expiresAtMs = parseExpiresAt(expiresYmd);
280
+ const assessment = assessTokenLifetime({ expiresAtMs });
281
+ const flags = ghScopeFlags(credential);
282
+ const repo = env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
283
+
284
+ if (assessment.kind === "missing" && !rejected) {
285
+ process.stderr.write(
286
+ `${credential.ghEnv ? `${credential.ghEnv} environment` : "repository"} variable ${credential.expiresVar}=YYYY-MM-DD is required\n` +
287
+ ` gh variable set ${credential.expiresVar} ${flags}--repo ${repo} --body YYYY-MM-DD\n`,
288
+ );
289
+ return 1;
290
+ }
291
+
292
+ if (rejected || assessment.kind === "warn" || assessment.kind === "expired") {
293
+ const result = await upsertRotationTask(credential, {
294
+ expiresYmd: expiresYmd || "unknown",
295
+ kind: assessment.kind,
296
+ daysLeft: assessment.daysLeft,
297
+ rejected,
298
+ });
299
+ process.stdout.write(
300
+ `${result.created ? "Created" : "Updated"} ClickUp ${result.id} ${result.url ?? ""}\n`,
301
+ );
302
+ }
303
+
304
+ if (rejected || assessment.kind === "expired" || assessment.kind === "missing") {
305
+ if (assessment.kind === "expired") {
306
+ process.stderr.write(
307
+ `${credential.secret} calendar expiry ${expiresYmd} has been reached — refusing to continue\n`,
308
+ );
309
+ }
310
+ return 1;
311
+ }
312
+ return 0;
313
+ }
314
+
315
+ const isCli =
316
+ process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
317
+
318
+ if (isCli) {
319
+ process.exit(await runNudge());
320
+ }