@midhunxavier/code-flow 0.0.0-stage → 0.1.0-preview.1
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/LICENSE +21 -0
- package/README.md +122 -2
- package/bin/codeflow.mjs +8 -0
- package/bin/launcher.mjs +127 -0
- package/dist/remote-host.json +1 -0
- package/licenses/inter.txt +92 -0
- package/licenses/preact.txt +21 -0
- package/licenses/signals-core.txt +21 -0
- package/licenses/signals.txt +21 -0
- package/licenses/typescript-notices.txt +193 -0
- package/licenses/typescript.txt +55 -0
- package/licenses/uqr.txt +22 -0
- package/licenses/xterm-fit.txt +19 -0
- package/licenses/xterm.txt +21 -0
- package/package.json +34 -4
- package/public/assets/browser-7XI52A6V.js +8 -0
- package/public/assets/changes-EYFPASEI.js +1 -0
- package/public/assets/chunk-7H3ZLTKA.js +2 -0
- package/public/assets/chunk-L7TUCIYF.js +1 -0
- package/public/assets/chunk-LLMQG23J.js +163 -0
- package/public/assets/main-KIRM2OEK.js +1 -0
- package/public/assets/settings-QJZVW22Y.js +9 -0
- package/public/assets/styles-615EBB20.css +32 -0
- package/public/assets/terminal-A2FI2NAX.js +62 -0
- package/public/assets.json +1 -0
- package/public/bundle-meta.json +1 -0
- package/public/docs/.nojekyll +0 -0
- package/public/docs/accessibility.html +19 -0
- package/public/docs/acp-agents.html +117 -0
- package/public/docs/agent-containers.html +79 -0
- package/public/docs/api.html +1130 -0
- package/public/docs/app.js +342 -0
- package/public/docs/application-improvements.html +275 -0
- package/public/docs/architecture-explorer.html +19 -0
- package/public/docs/architecture.html +149 -0
- package/public/docs/auto-team.html +142 -0
- package/public/docs/automations.html +109 -0
- package/public/docs/checkpoint-review.html +37 -0
- package/public/docs/code-hosts.html +35 -0
- package/public/docs/code-intelligence.html +35 -0
- package/public/docs/configuration.html +346 -0
- package/public/docs/context-inspection.html +23 -0
- package/public/docs/coordinated-changes.html +28 -0
- package/public/docs/deferred-runs.html +25 -0
- package/public/docs/dependency-discovery.html +19 -0
- package/public/docs/documentation.html +84 -0
- package/public/docs/dynamic-teams-evaluation.html +174 -0
- package/public/docs/dynamic-teams-implementation-plan.html +882 -0
- package/public/docs/dynamic-teams-implementation-review-1.html +141 -0
- package/public/docs/dynamic-teams-plan-review-0.html +204 -0
- package/public/docs/extensions.html +42 -0
- package/public/docs/external-sources.html +96 -0
- package/public/docs/filesystem-policy.html +26 -0
- package/public/docs/filtered-tasks.html +44 -0
- package/public/docs/font-license.txt +92 -0
- package/public/docs/getting-started.html +118 -0
- package/public/docs/github-review-threads.html +26 -0
- package/public/docs/github.html +157 -0
- package/public/docs/help.html +69 -0
- package/public/docs/index.html +77 -0
- package/public/docs/installation.html +74 -0
- package/public/docs/launch-profiles.html +48 -0
- package/public/docs/maintenance.html +60 -0
- package/public/docs/memory-claims.html +31 -0
- package/public/docs/memory-storage.html +58 -0
- package/public/docs/notifications.html +39 -0
- package/public/docs/patterns.html +195 -0
- package/public/docs/performance-results.html +210 -0
- package/public/docs/performance.html +166 -0
- package/public/docs/project-conventions.html +22 -0
- package/public/docs/project-first-navigation-plan.html +587 -0
- package/public/docs/project-teams-implementation-plan.html +772 -0
- package/public/docs/project-teams.html +99 -0
- package/public/docs/provider-compatibility.html +20 -0
- package/public/docs/releasing.html +61 -0
- package/public/docs/remote-workers.html +64 -0
- package/public/docs/review-inbox.html +26 -0
- package/public/docs/run-storage.html +23 -0
- package/public/docs/runs.html +141 -0
- package/public/docs/search.json +1 -0
- package/public/docs/security.html +133 -0
- package/public/docs/shared-projects.html +31 -0
- package/public/docs/ssh-projects.html +60 -0
- package/public/docs/style.css +768 -0
- package/public/docs/task-reruns.html +30 -0
- package/public/docs/team-collaboration.html +379 -0
- package/public/docs/verification.html +22 -0
- package/public/docs/vscode.html +53 -0
- package/public/docs/worker-permission-verification.html +122 -0
- package/public/index.html +17 -0
- package/scripts/check-node.mjs +7 -0
- package/scripts/codeflow.mjs +55 -0
- package/scripts/pty.py +273 -0
- package/src/acp-connections.js +110 -0
- package/src/adapters/acp-presets.js +37 -0
- package/src/adapters/acp-types.js +0 -0
- package/src/adapters/acp.js +328 -0
- package/src/adapters/agy-auth.js +67 -0
- package/src/adapters/agy-isolation.js +223 -0
- package/src/adapters/agy.js +297 -0
- package/src/adapters/claude.js +220 -0
- package/src/adapters/codex-app-server.js +518 -0
- package/src/adapters/codex.js +299 -0
- package/src/adapters/errors.js +57 -0
- package/src/adapters/index.js +91 -0
- package/src/adapters/managed-permissions.js +37 -0
- package/src/adapters/model-discovery.js +100 -0
- package/src/adapters/process.js +418 -0
- package/src/adapters/team-tools.js +102 -0
- package/src/adapters/types.js +0 -0
- package/src/agent-container.js +155 -0
- package/src/approvals.js +121 -0
- package/src/architecture.js +456 -0
- package/src/assets.js +44 -0
- package/src/automation.js +901 -0
- package/src/certificate-renewal.js +75 -0
- package/src/chat-handoff.js +55 -0
- package/src/checkpoint-review.js +72 -0
- package/src/checks.js +303 -0
- package/src/code-host-git.js +102 -0
- package/src/code-hosts-api.js +99 -0
- package/src/code-hosts.js +482 -0
- package/src/code-intelligence-worker.mjs +452 -0
- package/src/code-intelligence.js +141 -0
- package/src/collab/api.js +365 -0
- package/src/collab/checkpoints.js +198 -0
- package/src/collab/connections.js +139 -0
- package/src/collab/host-execution.js +347 -0
- package/src/collab/hub-client.js +210 -0
- package/src/collab/link-api.js +229 -0
- package/src/collab/link.js +595 -0
- package/src/collab/model.js +496 -0
- package/src/collab/runner.js +1014 -0
- package/src/collab/store.js +1452 -0
- package/src/collab/terminals.js +361 -0
- package/src/collaboration-reviews.js +116 -0
- package/src/config.js +48 -0
- package/src/context-budget.js +14 -0
- package/src/context-inspection.js +204 -0
- package/src/context.js +154 -0
- package/src/conventions.js +283 -0
- package/src/coordinated-api.js +247 -0
- package/src/deferred-launch.js +82 -0
- package/src/deferred-runs.js +515 -0
- package/src/dependency-health.js +35 -0
- package/src/diff.js +43 -0
- package/src/docs-help.js +316 -0
- package/src/doctor.js +232 -0
- package/src/engine.js +4366 -0
- package/src/env.js +44 -0
- package/src/experience.js +50 -0
- package/src/extensions.js +144 -0
- package/src/filesystem-access.js +107 -0
- package/src/filesystem-policy.js +238 -0
- package/src/filtered-task-limits.js +93 -0
- package/src/filtered-tasks-api.js +53 -0
- package/src/filtered-tasks.js +576 -0
- package/src/fs.js +57 -0
- package/src/git.js +626 -0
- package/src/github-review-threads.js +181 -0
- package/src/github.js +538 -0
- package/src/http.js +69 -0
- package/src/improve-settings.js +38 -0
- package/src/improve.js +187 -0
- package/src/integrations.js +373 -0
- package/src/integrity.js +259 -0
- package/src/journey-api.js +77 -0
- package/src/journey-replay.js +65 -0
- package/src/journey-store.js +97 -0
- package/src/journeys.js +60 -0
- package/src/json.js +81 -0
- package/src/launch-profiles.js +292 -0
- package/src/learnings.js +80 -0
- package/src/log.js +39 -0
- package/src/maintenance.js +335 -0
- package/src/memory-analysis.js +182 -0
- package/src/memory-claims.js +153 -0
- package/src/memory-sqlite.js +236 -0
- package/src/memory.js +552 -0
- package/src/merge-validation.js +58 -0
- package/src/model-catalog.js +238 -0
- package/src/model-research.js +91 -0
- package/src/notes.js +176 -0
- package/src/notifications.js +413 -0
- package/src/patterns/auto-team-model.js +212 -0
- package/src/patterns/auto-team.js +659 -0
- package/src/patterns/council.js +199 -0
- package/src/patterns/debate.js +188 -0
- package/src/patterns/findings.js +141 -0
- package/src/patterns/index.js +19 -0
- package/src/patterns/loop.js +158 -0
- package/src/patterns/pipeline.js +125 -0
- package/src/patterns/prompts.js +181 -0
- package/src/patterns/race.js +147 -0
- package/src/patterns/schemas.js +40 -0
- package/src/patterns/solo.js +49 -0
- package/src/patterns/supervisor.js +235 -0
- package/src/patterns/task-draft.js +29 -0
- package/src/patterns/tdd.js +231 -0
- package/src/patterns/team-strategies.js +67 -0
- package/src/performance.js +43 -0
- package/src/persistence-status.js +35 -0
- package/src/phone-sessions.js +96 -0
- package/src/preview-overlay.js +383 -0
- package/src/preview.js +219 -0
- package/src/privacy.js +23 -0
- package/src/project-report.js +22 -0
- package/src/project-team-model.js +178 -0
- package/src/project-team-policy.js +42 -0
- package/src/project-team-store.js +213 -0
- package/src/project-teams-api.js +36 -0
- package/src/project-teams.js +551 -0
- package/src/projects.js +286 -0
- package/src/provider-eligibility.js +9 -0
- package/src/readiness.js +98 -0
- package/src/remote-tls.js +66 -0
- package/src/remote-workers/bundles.js +198 -0
- package/src/remote-workers/client.js +332 -0
- package/src/remote-workers/durability.js +37 -0
- package/src/remote-workers/protocol.js +68 -0
- package/src/remote-workers/stdio.js +138 -0
- package/src/remote-workers/worker.js +304 -0
- package/src/remote.js +204 -0
- package/src/repo-config.js +201 -0
- package/src/repo-coordination.js +732 -0
- package/src/repo-tree.js +106 -0
- package/src/retention-policy.js +466 -0
- package/src/review-inbox.js +38 -0
- package/src/routing.js +49 -0
- package/src/run-analysis.js +111 -0
- package/src/run-cold-store.js +231 -0
- package/src/run-environment.js +15 -0
- package/src/run-history.js +91 -0
- package/src/run-log.js +160 -0
- package/src/run-permissions.js +110 -0
- package/src/run-projection.js +75 -0
- package/src/run-recovery.js +53 -0
- package/src/run-status.js +54 -0
- package/src/serve.js +138 -0
- package/src/server.js +2738 -0
- package/src/shared-access.js +103 -0
- package/src/shared-accounts.js +301 -0
- package/src/shared-api.js +239 -0
- package/src/shell.js +346 -0
- package/src/ship.js +344 -0
- package/src/slots.js +74 -0
- package/src/snapshot.js +388 -0
- package/src/source-context.js +17 -0
- package/src/sse-writer.js +70 -0
- package/src/ssh-projects/bootstrap.mjs +95 -0
- package/src/ssh-projects/config.js +45 -0
- package/src/ssh-projects/gateway.js +143 -0
- package/src/ssh-projects/manager.js +320 -0
- package/src/ssh-projects/team-autostart.mjs +93 -0
- package/src/ssh-projects/team-remove.mjs +69 -0
- package/src/ssh-projects/team-status.mjs +39 -0
- package/src/ssh-projects/transport.js +116 -0
- package/src/stats.js +139 -0
- package/src/store.js +544 -0
- package/src/stream.js +91 -0
- package/src/sweeps.js +241 -0
- package/src/tailscale.js +101 -0
- package/src/task-draft.js +23 -0
- package/src/team-evaluation.js +242 -0
- package/src/team-mailbox.js +160 -0
- package/src/team-memory.js +84 -0
- package/src/team-rerun-graph.js +15 -0
- package/src/team-reruns.js +130 -0
- package/src/team-scheduler.js +121 -0
- package/src/team-template-store.js +98 -0
- package/src/team-templates.js +117 -0
- package/src/team-tool-client.mjs +79 -0
- package/src/team-tools.js +176 -0
- package/src/teams.js +0 -0
- package/src/templates.js +62 -0
- package/src/transcript.js +134 -0
- package/src/verification-integrity.js +27 -0
- package/src/verification.js +183 -0
- package/src/watch.js +195 -0
- package/src/work-queue.js +47 -0
- package/src/workflow-control.js +83 -0
- package/src/workspace-api.js +207 -0
- package/src/workspace-changes.js +61 -0
- package/src/workspace.js +146 -0
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, rename } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { CODEFLOW_HOME } from "./config.js";
|
|
5
|
+
import { untrustedBlock } from "./github.js";
|
|
6
|
+
import { logError, logInfo, logWarn } from "./log.js";
|
|
7
|
+
import { readRepoConfig } from "./repo-config.js";
|
|
8
|
+
import { findSweepItem, nextSweepItems, sourceKeys, sweepTakes, upgradeLegacyKeys } from "./sweeps.js";
|
|
9
|
+
const MAX_HANDLED = 2e3;
|
|
10
|
+
const MAX_DELIVERIES = 500;
|
|
11
|
+
const MAX_QUEUED = 10;
|
|
12
|
+
const MAX_EVENT_HISTORY = 100;
|
|
13
|
+
const WEBHOOK_RUNS_PER_HOUR = 10;
|
|
14
|
+
const HOUR = 36e5;
|
|
15
|
+
const MISSED_GRACE_MS = HOUR;
|
|
16
|
+
const FILE = join(CODEFLOW_HOME, "triggers.json");
|
|
17
|
+
const MIN_EVERY_MINUTES = 5;
|
|
18
|
+
const SWEEP_KINDS = ["issues", "dependabot", "alerts"];
|
|
19
|
+
function validateSchedule(input) {
|
|
20
|
+
if (!input || typeof input !== "object") return void 0;
|
|
21
|
+
const raw = input;
|
|
22
|
+
if (raw.every !== void 0) {
|
|
23
|
+
const every = Math.round(Number(raw.every));
|
|
24
|
+
if (!Number.isFinite(every) || every < MIN_EVERY_MINUTES) throw new Error(`Repeat at most every ${MIN_EVERY_MINUTES} minutes`);
|
|
25
|
+
return { every: Math.min(every, 7 * 24 * 60) };
|
|
26
|
+
}
|
|
27
|
+
if (typeof raw.at === "string") {
|
|
28
|
+
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(raw.at)) throw new Error('Time must look like "07:30"');
|
|
29
|
+
if (raw.days !== void 0 && (!Array.isArray(raw.days) || raw.days.length > 7 || !raw.days.length || raw.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) throw new Error("Choose at least one valid day of the week");
|
|
30
|
+
const days = Array.isArray(raw.days) ? [...new Set(raw.days)].sort() : void 0;
|
|
31
|
+
let timezone;
|
|
32
|
+
if (raw.timezone !== void 0) {
|
|
33
|
+
if (typeof raw.timezone !== "string" || !raw.timezone.trim() || raw.timezone.length > 100) throw new Error("Choose a valid IANA time zone, such as Europe/Stockholm");
|
|
34
|
+
try {
|
|
35
|
+
timezone = new Intl.DateTimeFormat("en", { timeZone: raw.timezone.trim() }).resolvedOptions().timeZone;
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error("Choose a valid IANA time zone, such as Europe/Stockholm");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { at: raw.at, days: days?.length && days.length < 7 ? days : void 0, ...timezone ? { timezone } : {} };
|
|
41
|
+
}
|
|
42
|
+
throw new Error("A schedule needs every (minutes) or at (HH:MM)");
|
|
43
|
+
}
|
|
44
|
+
const zoneFormats = /* @__PURE__ */ new Map();
|
|
45
|
+
function zonedParts(at, timezone) {
|
|
46
|
+
let format = zoneFormats.get(timezone);
|
|
47
|
+
if (!format) {
|
|
48
|
+
format = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, calendar: "gregory", numberingSystem: "latn", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
49
|
+
if (zoneFormats.size >= 64) zoneFormats.clear();
|
|
50
|
+
zoneFormats.set(timezone, format);
|
|
51
|
+
}
|
|
52
|
+
const parts = Object.fromEntries(format.formatToParts(at).map((p) => [p.type, p.value]));
|
|
53
|
+
return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day), hour: Number(parts.hour), minute: Number(parts.minute) };
|
|
54
|
+
}
|
|
55
|
+
const wallTime = (p) => Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute);
|
|
56
|
+
function zonedTime(day, hour, minute, timezone) {
|
|
57
|
+
const desired = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), hour, minute);
|
|
58
|
+
const offsets = new Set([-36, -12, 0, 12, 36].map((h) => {
|
|
59
|
+
const sample = desired + h * HOUR;
|
|
60
|
+
return wallTime(zonedParts(sample, timezone)) - sample;
|
|
61
|
+
}));
|
|
62
|
+
const candidates = [...offsets].map((offset) => desired - offset).sort((a, b) => a - b);
|
|
63
|
+
const exact = candidates.find((at) => wallTime(zonedParts(at, timezone)) === desired);
|
|
64
|
+
if (exact !== void 0) return exact;
|
|
65
|
+
return candidates.filter((at) => {
|
|
66
|
+
const p = zonedParts(at, timezone);
|
|
67
|
+
return p.year === day.getUTCFullYear() && p.month === day.getUTCMonth() + 1 && p.day === day.getUTCDate() && wallTime(p) > desired;
|
|
68
|
+
}).sort((a, b) => wallTime(zonedParts(a, timezone)) - wallTime(zonedParts(b, timezone)))[0];
|
|
69
|
+
}
|
|
70
|
+
function nextFire(schedule, after) {
|
|
71
|
+
if ("every" in schedule) return after + schedule.every * 6e4;
|
|
72
|
+
const [h, m] = schedule.at.split(":").map(Number);
|
|
73
|
+
if (schedule.timezone) {
|
|
74
|
+
const local = zonedParts(after, schedule.timezone);
|
|
75
|
+
const day = new Date(Date.UTC(local.year, local.month - 1, local.day));
|
|
76
|
+
for (let i = 0; i < 14; i++) {
|
|
77
|
+
if (!schedule.days?.length || schedule.days.includes(day.getUTCDay())) {
|
|
78
|
+
const candidate = zonedTime(day, h, m, schedule.timezone);
|
|
79
|
+
if (candidate !== void 0 && candidate > after) return candidate;
|
|
80
|
+
}
|
|
81
|
+
day.setUTCDate(day.getUTCDate() + 1);
|
|
82
|
+
}
|
|
83
|
+
throw new Error("Could not find the next scheduled time in this time zone");
|
|
84
|
+
}
|
|
85
|
+
const d = new Date(after);
|
|
86
|
+
const nextDay = () => {
|
|
87
|
+
d.setDate(d.getDate() + 1);
|
|
88
|
+
d.setHours(h, m, 0, 0);
|
|
89
|
+
};
|
|
90
|
+
d.setHours(h, m, 0, 0);
|
|
91
|
+
if (d.getTime() <= after) nextDay();
|
|
92
|
+
for (let i = 0; i < 7 && schedule.days?.length && !schedule.days.includes(d.getDay()); i++) nextDay();
|
|
93
|
+
return d.getTime();
|
|
94
|
+
}
|
|
95
|
+
function previewSchedule(input, after = Date.now(), count = 5) {
|
|
96
|
+
const schedule = validateSchedule(input);
|
|
97
|
+
if (!schedule) return [];
|
|
98
|
+
if (!Number.isFinite(after) || !Number.isInteger(count) || count < 1 || count > 10) throw new Error("Schedule preview needs a valid date and 1\u201310 occurrences");
|
|
99
|
+
const times = [];
|
|
100
|
+
for (let i = 0; i < count; i++) {
|
|
101
|
+
after = nextFire(schedule, after);
|
|
102
|
+
times.push(after);
|
|
103
|
+
}
|
|
104
|
+
return times;
|
|
105
|
+
}
|
|
106
|
+
function stillDue(schedule, due, now) {
|
|
107
|
+
if (now - due > MISSED_GRACE_MS) return false;
|
|
108
|
+
if ("every" in schedule || !schedule.days?.length) return true;
|
|
109
|
+
const day = schedule.timezone ? zonedParts(due, schedule.timezone) : void 0;
|
|
110
|
+
const weekday = day ? new Date(Date.UTC(day.year, day.month - 1, day.day)).getUTCDay() : new Date(due).getDay();
|
|
111
|
+
return schedule.days.includes(weekday);
|
|
112
|
+
}
|
|
113
|
+
function resumeSchedule(schedule, saved, now) {
|
|
114
|
+
if (!saved || !Number.isFinite(saved)) return nextFire(schedule, now);
|
|
115
|
+
if (saved <= now) return stillDue(schedule, saved, now) ? saved : nextFire(schedule, now);
|
|
116
|
+
const longest = "every" in schedule ? schedule.every * 6e4 : 8 * 24 * HOUR;
|
|
117
|
+
return saved - now > longest ? nextFire(schedule, now) : saved;
|
|
118
|
+
}
|
|
119
|
+
function describeSchedule(schedule) {
|
|
120
|
+
if (!schedule) return "only when triggered";
|
|
121
|
+
if ("every" in schedule) return schedule.every % 60 === 0 ? `every ${schedule.every / 60} h` : `every ${schedule.every} min`;
|
|
122
|
+
const names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
123
|
+
const days = schedule.days?.length ? schedule.days.map((d) => names[d]).join(", ") : "every day";
|
|
124
|
+
return `${days} at ${schedule.at}${schedule.timezone ? ` (${schedule.timezone})` : " (server local time)"}`;
|
|
125
|
+
}
|
|
126
|
+
class Automations {
|
|
127
|
+
constructor(manager, startRun, listSweep, file = FILE) {
|
|
128
|
+
this.manager = manager;
|
|
129
|
+
this.startRun = startRun;
|
|
130
|
+
this.listSweep = listSweep;
|
|
131
|
+
this.file = file;
|
|
132
|
+
}
|
|
133
|
+
triggers = [];
|
|
134
|
+
managed;
|
|
135
|
+
setProjectTeams(hooks) {
|
|
136
|
+
this.managed = hooks;
|
|
137
|
+
}
|
|
138
|
+
/** Internal projection: execution settings remain owned by the team's versioned mission. */
|
|
139
|
+
async syncProjectTeam(team) {
|
|
140
|
+
this.requireWritable();
|
|
141
|
+
const id = `t-${team.id}`, previous = this.get(id);
|
|
142
|
+
if (previous && previous.target?.teamId !== team.id) throw new Error("Managed automation identity collision");
|
|
143
|
+
const enabled = team.status === "active", schedule = team.mission.schedule;
|
|
144
|
+
const next = { ...previous, id, target: { kind: "project-team", teamId: team.id }, name: team.name, run: { repo: team.repo, project: team.projectId, pattern: "auto-team", task: `Standing team: ${team.name}` }, schedule, enabled, webhook: false, isolate: false, token: previous?.token ?? randomBytes(24).toString("hex"), createdAt: previous?.createdAt ?? Date.now(), nextAt: enabled ? previous?.enabled && JSON.stringify(previous.schedule) === JSON.stringify(schedule) ? previous.nextAt : nextFire(schedule, Date.now()) : void 0 };
|
|
145
|
+
if (previous && JSON.stringify(previous) === JSON.stringify(next)) return id;
|
|
146
|
+
this.triggers = [...this.triggers.filter((t) => t.id !== id), next];
|
|
147
|
+
try {
|
|
148
|
+
await this.save(true);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
this.triggers = this.triggers.filter((t) => t.id !== id);
|
|
151
|
+
if (previous) this.triggers.push(previous);
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
return id;
|
|
155
|
+
}
|
|
156
|
+
timer;
|
|
157
|
+
unsubscribe;
|
|
158
|
+
saving = Promise.resolve();
|
|
159
|
+
stopped = false;
|
|
160
|
+
firing = /* @__PURE__ */ new Set();
|
|
161
|
+
/**
|
|
162
|
+
* False until triggers.json was read cleanly (or there was none). A file that couldn't be read
|
|
163
|
+
* or set aside is never written over: that would lose every automation and webhook secret.
|
|
164
|
+
*/
|
|
165
|
+
writable = false;
|
|
166
|
+
/** Automations starting their run right now; a second fire meanwhile doesn't start another. */
|
|
167
|
+
starting = /* @__PURE__ */ new Set();
|
|
168
|
+
/** Sweeps listing their work right now. */
|
|
169
|
+
sweeping = /* @__PURE__ */ new Set();
|
|
170
|
+
/** Per sweep: the items a run is being started for right now. */
|
|
171
|
+
claimed = /* @__PURE__ */ new Map();
|
|
172
|
+
/**
|
|
173
|
+
* Read triggers.json. A file that isn't valid JSON, or isn't a list, is moved aside to
|
|
174
|
+
* `triggers.json.corrupt-<time>` (and logged), and code-flow starts with no automations; so
|
|
175
|
+
* is one with entries that can't be used, keeping the usable ones. Saved next times are kept.
|
|
176
|
+
*/
|
|
177
|
+
async load(now = Date.now()) {
|
|
178
|
+
this.writable = false;
|
|
179
|
+
this.triggers = [];
|
|
180
|
+
let text;
|
|
181
|
+
try {
|
|
182
|
+
text = await readFile(this.file, "utf8");
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err.code !== "ENOENT") {
|
|
185
|
+
logError(`Couldn't read ${this.file}; automations are off and it won't be written over until it can be read`, err);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const entries = [];
|
|
190
|
+
if (text !== void 0) {
|
|
191
|
+
let parsed;
|
|
192
|
+
let why = "isn't a list of automations";
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(text);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
why = `isn't valid JSON (${err.message})`;
|
|
197
|
+
}
|
|
198
|
+
if (Array.isArray(parsed)) entries.push(...parsed);
|
|
199
|
+
else if (!await this.setAside(why)) return;
|
|
200
|
+
}
|
|
201
|
+
let rejected = 0;
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
const t = readTrigger(entry);
|
|
204
|
+
if (t) this.triggers.push(t);
|
|
205
|
+
else rejected++;
|
|
206
|
+
}
|
|
207
|
+
if (rejected && !await this.setAside(`has ${rejected} automation(s) code-flow can't read`)) {
|
|
208
|
+
this.triggers = [];
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.writable = true;
|
|
212
|
+
for (const t of this.triggers) t.nextAt = t.enabled && t.schedule ? resumeSchedule(t.schedule, t.nextAt, now) : void 0;
|
|
213
|
+
if (rejected) await this.save();
|
|
214
|
+
}
|
|
215
|
+
/** Move triggers.json aside so it isn't lost when the usable automations are saved. */
|
|
216
|
+
async setAside(why) {
|
|
217
|
+
const aside = `${this.file}.corrupt-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
218
|
+
try {
|
|
219
|
+
await rename(this.file, aside);
|
|
220
|
+
logError(`${this.file} ${why}: moved it to ${aside}`);
|
|
221
|
+
return true;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
logError(`${this.file} ${why}, and it couldn't be moved aside; automations are off until it's fixed`, err);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
list() {
|
|
228
|
+
return this.triggers.map((t) => ({ ...t }));
|
|
229
|
+
}
|
|
230
|
+
get(id) {
|
|
231
|
+
return this.triggers.find((t) => t.id === id);
|
|
232
|
+
}
|
|
233
|
+
async create(input) {
|
|
234
|
+
this.requireWritable();
|
|
235
|
+
const trigger = { ...input, id: `t-${randomBytes(4).toString("hex")}`, token: randomBytes(24).toString("hex"), createdAt: Date.now() };
|
|
236
|
+
trigger.nextAt = trigger.enabled && trigger.schedule ? nextFire(trigger.schedule, Date.now()) : void 0;
|
|
237
|
+
this.triggers.push(trigger);
|
|
238
|
+
await this.save();
|
|
239
|
+
return { ...trigger };
|
|
240
|
+
}
|
|
241
|
+
async update(id, patch) {
|
|
242
|
+
this.requireWritable();
|
|
243
|
+
const t = this.require(id);
|
|
244
|
+
if (t.target) throw new Error("Edit this automation through its standing project team");
|
|
245
|
+
const { rotateToken, ...rest } = patch;
|
|
246
|
+
const timing = JSON.stringify([t.enabled, t.schedule]);
|
|
247
|
+
Object.assign(t, rest);
|
|
248
|
+
if ("schedule" in patch && !patch.schedule) t.schedule = void 0;
|
|
249
|
+
if (rotateToken) t.token = randomBytes(24).toString("hex");
|
|
250
|
+
if (JSON.stringify([t.enabled, t.schedule]) !== timing || t.enabled && t.schedule && !t.nextAt) {
|
|
251
|
+
t.nextAt = t.enabled && t.schedule ? nextFire(t.schedule, Date.now()) : void 0;
|
|
252
|
+
}
|
|
253
|
+
if (!t.enabled) {
|
|
254
|
+
for (const queued of t.queued ?? []) this.updateEvent(t, queued.eventId, "dropped", "Automation was disabled before this event could run");
|
|
255
|
+
t.queued = void 0;
|
|
256
|
+
}
|
|
257
|
+
await this.save();
|
|
258
|
+
return { ...t };
|
|
259
|
+
}
|
|
260
|
+
/** A sweep started runs for these items itself (when you created it): don't start them again. */
|
|
261
|
+
async markHandled(id, keys) {
|
|
262
|
+
const t = this.require(id);
|
|
263
|
+
t.handled = [.../* @__PURE__ */ new Set([...t.handled ?? [], ...keys])].slice(-MAX_HANDLED);
|
|
264
|
+
await this.save();
|
|
265
|
+
}
|
|
266
|
+
async remove(id) {
|
|
267
|
+
this.requireWritable();
|
|
268
|
+
if (this.require(id).target) throw new Error("Archive this automation through its standing project team");
|
|
269
|
+
this.triggers = this.triggers.filter((t) => t.id !== id);
|
|
270
|
+
await this.save();
|
|
271
|
+
}
|
|
272
|
+
start(tickMs = 3e4) {
|
|
273
|
+
if (this.timer) return;
|
|
274
|
+
this.stopped = false;
|
|
275
|
+
this.timer = setInterval(() => {
|
|
276
|
+
void this.tick().catch((error) => logError("Automation tick failed", error));
|
|
277
|
+
}, tickMs);
|
|
278
|
+
this.timer.unref?.();
|
|
279
|
+
this.unsubscribe = this.manager.subscribeAll((run) => this.runChanged(run));
|
|
280
|
+
}
|
|
281
|
+
/** Stop firing; resolves once the last save is on disk. */
|
|
282
|
+
async stop() {
|
|
283
|
+
this.stopped = true;
|
|
284
|
+
if (this.timer) clearInterval(this.timer);
|
|
285
|
+
this.timer = void 0;
|
|
286
|
+
this.unsubscribe?.();
|
|
287
|
+
this.unsubscribe = void 0;
|
|
288
|
+
await Promise.allSettled([...this.firing]);
|
|
289
|
+
await this.saving;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Fire every enabled schedule that is due. A time missed while code-flow was closed or the
|
|
293
|
+
* computer asleep fires once if it's at most an hour late (and on one of its days), and is
|
|
294
|
+
* skipped otherwise; it never fires on a day the schedule leaves out.
|
|
295
|
+
*/
|
|
296
|
+
async tick(now = Date.now()) {
|
|
297
|
+
if (this.stopped) return;
|
|
298
|
+
await this.managed?.tick().catch((error) => logError("Project team recovery failed", error));
|
|
299
|
+
let changed = false;
|
|
300
|
+
for (const t of this.triggers) {
|
|
301
|
+
if (this.stopped) return;
|
|
302
|
+
if (t.queued?.length) void this.drain(t);
|
|
303
|
+
if (!t.enabled || !t.schedule) continue;
|
|
304
|
+
if (!t.nextAt) {
|
|
305
|
+
t.nextAt = nextFire(t.schedule, now);
|
|
306
|
+
changed = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (t.nextAt > now) continue;
|
|
310
|
+
const due = t.nextAt;
|
|
311
|
+
if (t.target) {
|
|
312
|
+
if (stillDue(t.schedule, due, now)) {
|
|
313
|
+
try {
|
|
314
|
+
await this.fire(t.id, { via: "schedule", occurrence: `schedule:${due}` });
|
|
315
|
+
} catch (error) {
|
|
316
|
+
logWarn("Managed cycle admission failed; retaining the scheduled occurrence", { error: error.message });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
t.nextAt = nextFire(t.schedule, now);
|
|
321
|
+
await this.save(true);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
t.nextAt = nextFire(t.schedule, now);
|
|
325
|
+
changed = true;
|
|
326
|
+
if (!stillDue(t.schedule, due, now)) {
|
|
327
|
+
logInfo(`Automation "${t.name}" skipped its ${new Date(due).toLocaleString()} run: code-flow was closed or asleep then`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
await this.save();
|
|
331
|
+
await this.fire(t.id, { via: "schedule" }).catch((err) => logWarn(`Automation "${t.name}" couldn't start its scheduled run`, { error: err.message }));
|
|
332
|
+
}
|
|
333
|
+
if (changed) await this.save();
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* A webhook call, answered at once: GitHub gives up on a delivery after 10 seconds, and
|
|
337
|
+
* starting a run takes longer than that. The run starts in the background; a failure is
|
|
338
|
+
* recorded on the automation (lastError). A delivery GitHub sends again is ignored.
|
|
339
|
+
*
|
|
340
|
+
* GitHub's signature covers the body but not the X-GitHub-Delivery id, so anyone who saw one
|
|
341
|
+
* signed delivery (a tunnel's log, plain HTTP on the Wi-Fi) could send it again with the id
|
|
342
|
+
* changed or left out. A signed body (`signedBody`) that came before is a replay whatever id it
|
|
343
|
+
* carries, or none.
|
|
344
|
+
*/
|
|
345
|
+
accept(id, input, delivery, signedBody) {
|
|
346
|
+
if (this.stopped) return { ignored: "Automation dispatch is paused" };
|
|
347
|
+
const t = this.require(id);
|
|
348
|
+
const validated = validateFireInput({ via: "webhook", ...input });
|
|
349
|
+
const body = signedBody && `sha256:${createHash("sha256").update(signedBody).digest("hex")}`;
|
|
350
|
+
const duplicateReason = delivery && t.deliveries?.includes(delivery) ? `Delivery ${delivery} was already handled` : body && t.deliveries?.includes(body) ? "This delivery was already handled: the same signed body came before" : void 0;
|
|
351
|
+
if (duplicateReason) {
|
|
352
|
+
const duplicate = this.recordEvent(t, validated, delivery);
|
|
353
|
+
this.updateEvent(t, duplicate.id, "deduplicated", duplicateReason);
|
|
354
|
+
void this.save();
|
|
355
|
+
return { ignored: duplicateReason };
|
|
356
|
+
}
|
|
357
|
+
const seen = [delivery, body].filter((key) => !!key);
|
|
358
|
+
if (seen.length) {
|
|
359
|
+
t.deliveries = [...t.deliveries ?? [], ...seen].slice(-MAX_DELIVERIES);
|
|
360
|
+
void this.save();
|
|
361
|
+
}
|
|
362
|
+
const event = this.recordEvent(t, validated, delivery);
|
|
363
|
+
void this.fire(id, { ...validated, eventId: event.id }).then(
|
|
364
|
+
(result) => result.skipped && logInfo(`Webhook for automation "${t.name}": ${result.skipped}`),
|
|
365
|
+
(err) => logWarn(`Webhook for automation "${t.name}" couldn't start a run`, { error: err.message })
|
|
366
|
+
);
|
|
367
|
+
return { accepted: true };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Start the automation's run. If its previous run is still going (or still starting), a
|
|
371
|
+
* schedule or "Run now" is skipped, so automations never pile up; a webhook event waits and
|
|
372
|
+
* fires once that run ends, so a push made meanwhile isn't lost.
|
|
373
|
+
*/
|
|
374
|
+
fire(id, input) {
|
|
375
|
+
if (this.stopped) return Promise.resolve({ skipped: "Automation dispatch is paused" });
|
|
376
|
+
const work = this.fireTracked(id, input);
|
|
377
|
+
this.firing.add(work);
|
|
378
|
+
void work.finally(() => this.firing.delete(work)).catch(() => void 0);
|
|
379
|
+
return work;
|
|
380
|
+
}
|
|
381
|
+
async fireTracked(id, input) {
|
|
382
|
+
const t = this.require(id);
|
|
383
|
+
const validated = validateFireInput(input);
|
|
384
|
+
const event = input.eventId ? t.eventHistory?.find((e) => e.id === input.eventId) : void 0;
|
|
385
|
+
const tracked = event ?? this.recordEvent(t, validated);
|
|
386
|
+
try {
|
|
387
|
+
const result = await this.fireOne(t, { ...validated, occurrence: input.occurrence, eventId: tracked.id });
|
|
388
|
+
const runs = result.runs ?? (result.run ? [result.run] : []);
|
|
389
|
+
tracked.runIds = runs.map((run) => run.id);
|
|
390
|
+
const state = result.queued ? "queued" : result.skipped ? /already started \d+ runs from webhooks/.test(result.skipped) ? "dropped" : "skipped" : "started";
|
|
391
|
+
if (!(result.queued && (tracked.state === "deduplicated" || tracked.state === "dropped"))) this.updateEvent(t, tracked.id, state, result.skipped);
|
|
392
|
+
return result;
|
|
393
|
+
} catch (err) {
|
|
394
|
+
this.updateEvent(t, tracked.id, "failed", err.message);
|
|
395
|
+
throw err;
|
|
396
|
+
} finally {
|
|
397
|
+
await this.save();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** Recent queue decisions, start failures and dropped deliveries, newest first. */
|
|
401
|
+
events(id) {
|
|
402
|
+
return structuredClone([...this.require(id).eventHistory ?? []].reverse());
|
|
403
|
+
}
|
|
404
|
+
async retryEvent(id, eventId) {
|
|
405
|
+
const t = this.require(id);
|
|
406
|
+
this.requireWritable();
|
|
407
|
+
if (!t.enabled) throw new Error("Enable this automation before retrying an event");
|
|
408
|
+
const event = t.eventHistory?.find((e) => e.id === eventId);
|
|
409
|
+
if (!event) throw new Error("Event is no longer in the bounded history");
|
|
410
|
+
if (event.state !== "failed" && event.state !== "dropped") throw new Error("Only failed or dropped events can be retried");
|
|
411
|
+
const input = validateFireInput({ via: event.via, ...event.input });
|
|
412
|
+
event.retries++;
|
|
413
|
+
this.updateEvent(t, event.id, "received", "Explicit retry requested");
|
|
414
|
+
return this.fire(id, { ...input, eventId: event.id });
|
|
415
|
+
}
|
|
416
|
+
/** List the same candidates and execution settings without claiming items or starting agents. */
|
|
417
|
+
async dryRun(id, raw = { via: "manual" }) {
|
|
418
|
+
const t = this.require(id);
|
|
419
|
+
if (t.target) {
|
|
420
|
+
if (!this.managed) throw new Error("Project teams are unavailable");
|
|
421
|
+
return this.managed.preview(t.target.teamId);
|
|
422
|
+
}
|
|
423
|
+
const input = validateFireInput(raw);
|
|
424
|
+
const { config, error: configError } = await readRepoConfig(t.run.repo);
|
|
425
|
+
const busy = this.busy(t);
|
|
426
|
+
const warnings = [];
|
|
427
|
+
if (configError) warnings.push(`Repository configuration: ${configError}`);
|
|
428
|
+
const selected = [];
|
|
429
|
+
const skipped = [];
|
|
430
|
+
if (t.sweep) {
|
|
431
|
+
if (!this.listSweep) throw new Error("Sweeps aren't available here");
|
|
432
|
+
const list = await this.listSweep(t.run.repo, t.sweep.kind);
|
|
433
|
+
warnings.push(...list.notices);
|
|
434
|
+
const handled = new Set(t.handled ?? []);
|
|
435
|
+
upgradeLegacyKeys(list, handled);
|
|
436
|
+
const claims = this.claimed.get(t.id) ?? /* @__PURE__ */ new Set();
|
|
437
|
+
const items = input.source ? sweepTakes(t.sweep.kind, input.source) ? [findSweepItem(list, input.source)].filter((i) => i !== void 0) : [] : nextSweepItems(list, /* @__PURE__ */ new Set([...handled, ...claims]), t.sweep.max);
|
|
438
|
+
for (const item of items) {
|
|
439
|
+
if (item.source && item.task && (!("selected" in item) || item.selected) && (!("manual" in item) || !item.manual) && !sourceKeys(item.source).every((k) => handled.has(k)) && !sourceKeys(item.source).some((k) => claims.has(k))) selected.push({ key: item.key, task: item.task, source: item.source });
|
|
440
|
+
}
|
|
441
|
+
for (const item of list.items.slice(0, 100)) if (!selected.some((chosen) => chosen.key === item.key)) skipped.push({ key: item.key, reason: item.note ?? (item.manual ? "Requires manual action" : handled.has(item.key) ? "Already handled" : "Outside the current selection or limit") });
|
|
442
|
+
if (input.source && !items.length) warnings.push("The event's source is absent from the sweep's current list");
|
|
443
|
+
} else selected.push({ key: "saved-task", task: input.details?.trim() ? `${t.run.task}
|
|
444
|
+
|
|
445
|
+
## Details from the ${input.via}
|
|
446
|
+
${input.details}` : t.run.task, source: input.source ?? t.run.source });
|
|
447
|
+
const startsPerHour = t.sweep?.max ?? WEBHOOK_RUNS_PER_HOUR;
|
|
448
|
+
const startsUsed = (t.webhookStarts ?? []).filter((at) => Date.now() - at < HOUR).length;
|
|
449
|
+
if (!t.enabled && input.via !== "manual") warnings.push("This automation is disabled");
|
|
450
|
+
if (input.via === "webhook" && startsUsed >= startsPerHour) warnings.push("The webhook run limit has been reached for this hour");
|
|
451
|
+
if (!(t.run.loop?.check ?? config.check) && !t.run.loop?.verification && !t.run.verificationProfile) warnings.push("No independent verification command or profile is configured");
|
|
452
|
+
if (selected.some((item) => item.source)) warnings.push("Source permissions and isolation are rechecked against current GitHub authorship when the run starts");
|
|
453
|
+
const configuredTrust = t.run.trust ?? config.trust ?? "standard";
|
|
454
|
+
const effectiveTrust = configuredTrust === "full" ? "trusted" : configuredTrust;
|
|
455
|
+
return {
|
|
456
|
+
automation: id,
|
|
457
|
+
ready: !!selected.length && (t.enabled || input.via === "manual") && !(busy && input.via !== "webhook") && !(input.via === "webhook" && startsUsed >= startsPerHour),
|
|
458
|
+
busy,
|
|
459
|
+
wouldQueue: !!busy && input.via === "webhook" && !t.sweep,
|
|
460
|
+
nextTimes: t.schedule ? previewSchedule(t.schedule) : [],
|
|
461
|
+
selected,
|
|
462
|
+
skipped,
|
|
463
|
+
warnings,
|
|
464
|
+
permissions: { trust: effectiveTrust, isolate: t.isolate, ...t.isolate ? { image: t.image || "node:22" } : {} },
|
|
465
|
+
limits: { maxSteps: t.run.loop?.maxSteps ?? config.limits?.maxSteps ?? 40, maxInvocations: t.run.loop?.maxInvocations ?? t.run.loop?.maxSteps ?? config.limits?.maxSteps ?? 40, maxMinutes: t.run.loop?.maxMinutes ?? config.limits?.maxMinutes ?? 60, startsPerHour, startsUsed },
|
|
466
|
+
commands: { setup: t.run.loop?.setup ?? config.setup, check: t.run.loop?.check ?? config.check }
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
recordEvent(t, input, delivery) {
|
|
470
|
+
const event = { id: `event-${randomBytes(6).toString("hex")}`, at: Date.now(), updatedAt: Date.now(), via: input.via, state: "received", input: { details: input.details, source: input.source }, delivery, retries: 0 };
|
|
471
|
+
const pinned = new Set((t.queued ?? []).map((q) => q.eventId));
|
|
472
|
+
const history = [...t.eventHistory ?? [], event];
|
|
473
|
+
while (history.length > MAX_EVENT_HISTORY) {
|
|
474
|
+
const completed = history.findIndex((e) => !pinned.has(e.id) && e.state !== "received");
|
|
475
|
+
const index = completed >= 0 ? completed : history.findIndex((e) => !pinned.has(e.id));
|
|
476
|
+
history.splice(index < 0 ? 0 : index, 1);
|
|
477
|
+
}
|
|
478
|
+
t.eventHistory = history;
|
|
479
|
+
return event;
|
|
480
|
+
}
|
|
481
|
+
updateEvent(t, id, state, reason) {
|
|
482
|
+
const event = id && t.eventHistory?.find((e) => e.id === id);
|
|
483
|
+
if (!event) return;
|
|
484
|
+
event.state = state;
|
|
485
|
+
event.updatedAt = Date.now();
|
|
486
|
+
event.reason = reason?.slice(0, 1e3);
|
|
487
|
+
}
|
|
488
|
+
async fireOne(t, input) {
|
|
489
|
+
if (!t.enabled && input.via !== "manual") return { skipped: "This automation is disabled" };
|
|
490
|
+
if (t.target) {
|
|
491
|
+
if (!this.managed || input.via === "webhook") throw new Error("Project team automations require the project team service");
|
|
492
|
+
const result = await this.managed.fire(t.target.teamId, input.occurrence ?? `manual:${input.eventId}`);
|
|
493
|
+
t.lastFiredAt = Date.now();
|
|
494
|
+
t.lastRunId = result.run?.id ?? t.lastRunId;
|
|
495
|
+
t.lastError = result.skipped;
|
|
496
|
+
return result;
|
|
497
|
+
}
|
|
498
|
+
if (t.sweep) return this.fireSweep(t, input);
|
|
499
|
+
const busy = this.busy(t);
|
|
500
|
+
if (busy) return input.via === "webhook" ? this.enqueue(t, input, busy) : { skipped: busy };
|
|
501
|
+
const startedAt = Date.now();
|
|
502
|
+
if (input.via === "webhook" && !this.takeWebhookSlot(t, WEBHOOK_RUNS_PER_HOUR, startedAt)) {
|
|
503
|
+
await this.save();
|
|
504
|
+
return { skipped: t.lastError };
|
|
505
|
+
}
|
|
506
|
+
this.starting.add(t.id);
|
|
507
|
+
const body = {
|
|
508
|
+
...t.run,
|
|
509
|
+
task: withDetails(t.run.task, input),
|
|
510
|
+
source: input.source ?? t.run.source,
|
|
511
|
+
loop: { ...t.run.loop ?? {}, container: t.isolate ? { image: t.image || "node:22", network: true } : void 0 }
|
|
512
|
+
};
|
|
513
|
+
t.lastFiredAt = startedAt;
|
|
514
|
+
try {
|
|
515
|
+
const run = await this.startRun(body, { trigger: { id: t.id, name: t.name, via: input.via }, ...hasDetails(input) && { untrustedText: true } });
|
|
516
|
+
t.lastRunId = run.id;
|
|
517
|
+
t.lastError = void 0;
|
|
518
|
+
return { run };
|
|
519
|
+
} catch (err) {
|
|
520
|
+
t.lastError = err.message;
|
|
521
|
+
if (input.via === "webhook") this.releaseWebhookSlot(t, startedAt);
|
|
522
|
+
throw err;
|
|
523
|
+
} finally {
|
|
524
|
+
this.starting.delete(t.id);
|
|
525
|
+
await this.save();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/** Why the automation can't start a run right now, if it can't. */
|
|
529
|
+
busy(t) {
|
|
530
|
+
if (this.starting.has(t.id)) return "it's starting a run right now";
|
|
531
|
+
const previous = t.lastRunId ? this.manager.get(t.lastRunId) : void 0;
|
|
532
|
+
if (previous?.status === "running") return `the previous run (${previous.id}) is still running`;
|
|
533
|
+
return void 0;
|
|
534
|
+
}
|
|
535
|
+
/** Keep a webhook event for when the running run ends; the same event twice is kept once. */
|
|
536
|
+
async enqueue(t, input, busy) {
|
|
537
|
+
const { via: _via, ...event } = input;
|
|
538
|
+
const key = eventKey(event);
|
|
539
|
+
const unique = (t.queued ?? []).filter((q) => {
|
|
540
|
+
if (eventKey(q) !== key) return true;
|
|
541
|
+
this.updateEvent(t, q.eventId, "deduplicated", "A newer delivery describes the same queued work");
|
|
542
|
+
return false;
|
|
543
|
+
});
|
|
544
|
+
const queue = [...unique, event];
|
|
545
|
+
for (const dropped of queue.slice(0, -MAX_QUEUED)) this.updateEvent(t, dropped.eventId, "dropped", `The queue limit of ${MAX_QUEUED} events was reached`);
|
|
546
|
+
t.queued = queue.slice(-MAX_QUEUED);
|
|
547
|
+
await this.save();
|
|
548
|
+
return { skipped: `${busy}; this event fires when it ends`, queued: true };
|
|
549
|
+
}
|
|
550
|
+
runChanged(run) {
|
|
551
|
+
if (run.deleted) return;
|
|
552
|
+
const t = run.trigger && run.status !== "running" ? this.get(run.trigger.id) : void 0;
|
|
553
|
+
if (t?.queued?.length && t.lastRunId === run.id) void this.drain(t);
|
|
554
|
+
}
|
|
555
|
+
/** Fire the oldest webhook event that waited, once nothing of this automation is running. */
|
|
556
|
+
async drain(t) {
|
|
557
|
+
if (this.stopped) return;
|
|
558
|
+
if (!t.queued?.length || this.busy(t)) return;
|
|
559
|
+
const [next, ...rest] = t.queued;
|
|
560
|
+
t.queued = rest.length ? rest : void 0;
|
|
561
|
+
if (!t.enabled || !this.get(t.id)) return void this.save();
|
|
562
|
+
await this.fire(t.id, { via: "webhook", ...next }).catch(
|
|
563
|
+
(err) => logWarn(`Automation "${t.name}" couldn't start a webhook run that waited`, { error: err.message })
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Room for one more webhook-started run this hour (`perHour` of them), taken at once so
|
|
568
|
+
* concurrent deliveries count too. Without room the event is dropped and that's recorded:
|
|
569
|
+
* an outsider opening 50 issues mustn't start 50 runs.
|
|
570
|
+
*/
|
|
571
|
+
takeWebhookSlot(t, perHour, now) {
|
|
572
|
+
const recent = (t.webhookStarts ?? []).filter((at) => now - at < HOUR);
|
|
573
|
+
if (recent.length >= perHour) {
|
|
574
|
+
t.webhookStarts = recent;
|
|
575
|
+
t.lastError = `Ignored a webhook event: this automation already started ${recent.length} runs from webhooks in the last hour (at most ${perHour})`;
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
t.webhookStarts = [...recent, now];
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
releaseWebhookSlot(t, at) {
|
|
582
|
+
const i = t.webhookStarts?.lastIndexOf(at) ?? -1;
|
|
583
|
+
if (i >= 0) t.webhookStarts.splice(i, 1);
|
|
584
|
+
}
|
|
585
|
+
claims(t) {
|
|
586
|
+
let set = this.claimed.get(t.id);
|
|
587
|
+
if (!set) this.claimed.set(t.id, set = /* @__PURE__ */ new Set());
|
|
588
|
+
return set;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* A sweep fires: a webhook names one item (an issue, PR or alert); a schedule or "Run now" lists
|
|
592
|
+
* the work and starts a run for each new item that would be picked by default, up to `max`.
|
|
593
|
+
* Items already started (or covered by another run) are left alone.
|
|
594
|
+
*/
|
|
595
|
+
async fireSweep(t, input) {
|
|
596
|
+
if (input.source) return this.fireSweepItem(t, input, input.source);
|
|
597
|
+
if (!this.listSweep) throw new Error("Sweeps aren't available here");
|
|
598
|
+
if (this.sweeping.has(t.id)) return { runs: [], skipped: "it's already sweeping" };
|
|
599
|
+
this.sweeping.add(t.id);
|
|
600
|
+
const sweep = t.sweep;
|
|
601
|
+
const claimed = this.claims(t);
|
|
602
|
+
const mine = [];
|
|
603
|
+
t.lastFiredAt = Date.now();
|
|
604
|
+
try {
|
|
605
|
+
const list = await this.listSweep(t.run.repo, sweep.kind);
|
|
606
|
+
const handled = new Set(t.handled ?? []);
|
|
607
|
+
if (upgradeLegacyKeys(list, handled)) t.handled = [...handled].slice(-MAX_HANDLED);
|
|
608
|
+
const items = nextSweepItems(list, /* @__PURE__ */ new Set([...handled, ...claimed]), sweep.max);
|
|
609
|
+
for (const item of items) for (const key of sourceKeys(item.source)) claimed.add(key), mine.push(key);
|
|
610
|
+
if (!items.length) {
|
|
611
|
+
t.lastError = void 0;
|
|
612
|
+
return { runs: [], skipped: "nothing new to sweep" };
|
|
613
|
+
}
|
|
614
|
+
const runs = [];
|
|
615
|
+
const errors = [];
|
|
616
|
+
for (const item of items) {
|
|
617
|
+
try {
|
|
618
|
+
runs.push(await this.startRun(this.sweepRun(t, item.task, item.source), { trigger: { id: t.id, name: t.name, via: input.via } }));
|
|
619
|
+
this.handle(t, sourceKeys(item.source));
|
|
620
|
+
} catch (err) {
|
|
621
|
+
errors.push(`${item.key}: ${err.message}`);
|
|
622
|
+
const failed = this.recordEvent(t, { ...input, source: item.source });
|
|
623
|
+
this.updateEvent(t, failed.id, "failed", `${item.key}: ${err.message}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
t.lastRunId = runs.at(-1)?.id ?? t.lastRunId;
|
|
627
|
+
t.lastError = errors.length ? errors.join("; ").slice(0, 1e3) : void 0;
|
|
628
|
+
if (!runs.length && errors.length) throw new Error(t.lastError);
|
|
629
|
+
return { runs };
|
|
630
|
+
} finally {
|
|
631
|
+
for (const key of mine) claimed.delete(key);
|
|
632
|
+
this.sweeping.delete(t.id);
|
|
633
|
+
await this.save();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* A webhook names one item. It goes through the same rules as the sweep's list: the event
|
|
638
|
+
* must be the sweep's kind, and the item must be one the list would pick (not labelled
|
|
639
|
+
* wontfix or question, not a green Dependabot PR, not a low-severity or unpatched alert, not
|
|
640
|
+
* already covered by a run) — at most `max` runs an hour.
|
|
641
|
+
*/
|
|
642
|
+
async fireSweepItem(t, input, source) {
|
|
643
|
+
const sweep = t.sweep;
|
|
644
|
+
if (!sweepTakes(sweep.kind, source)) return { runs: [], skipped: `a ${sweep.kind} sweep doesn't start from ${source.kind === "pr" ? "a pull request" : `an ${source.kind}`}` };
|
|
645
|
+
const claimed = this.claims(t);
|
|
646
|
+
const keys = sourceKeys(source);
|
|
647
|
+
if (keys.every((k) => (t.handled ?? []).includes(k) || claimed.has(k))) return { runs: [], skipped: "nothing new to sweep" };
|
|
648
|
+
const startedAt = Date.now();
|
|
649
|
+
if (input.via === "webhook" && !this.takeWebhookSlot(t, sweep.max, startedAt)) {
|
|
650
|
+
await this.save();
|
|
651
|
+
return { runs: [], skipped: t.lastError };
|
|
652
|
+
}
|
|
653
|
+
const mine = keys.filter((k) => !claimed.has(k));
|
|
654
|
+
for (const key of mine) claimed.add(key);
|
|
655
|
+
let started = false;
|
|
656
|
+
t.lastFiredAt = startedAt;
|
|
657
|
+
try {
|
|
658
|
+
if (!this.listSweep) throw new Error("Sweeps aren't available here");
|
|
659
|
+
const list = await this.listSweep(t.run.repo, sweep.kind);
|
|
660
|
+
const handled = new Set(t.handled ?? []);
|
|
661
|
+
if (upgradeLegacyKeys(list, handled)) t.handled = [...handled].slice(-MAX_HANDLED);
|
|
662
|
+
const item = findSweepItem(list, source);
|
|
663
|
+
if (!item?.source || !item.task || item.manual) return { runs: [], skipped: "it isn't in the sweep's list (closed, or not the kind this sweep fixes)" };
|
|
664
|
+
if (!item.selected) return { runs: [], skipped: `the sweep wouldn't pick it: ${item.note ?? "not picked by default"}` };
|
|
665
|
+
const itemKeys = sourceKeys(item.source);
|
|
666
|
+
if (itemKeys.every((k) => handled.has(k))) return { runs: [], skipped: "nothing new to sweep" };
|
|
667
|
+
if (itemKeys.some((k) => claimed.has(k) && !mine.includes(k))) return { runs: [], skipped: "a run for it is starting right now" };
|
|
668
|
+
for (const key of itemKeys) if (!claimed.has(key)) claimed.add(key), mine.push(key);
|
|
669
|
+
const run = await this.startRun(this.sweepRun(t, withDetails(item.task, input), item.source), {
|
|
670
|
+
trigger: { id: t.id, name: t.name, via: input.via },
|
|
671
|
+
...hasDetails(input) && { untrustedText: true }
|
|
672
|
+
});
|
|
673
|
+
started = true;
|
|
674
|
+
this.handle(t, itemKeys);
|
|
675
|
+
t.lastRunId = run.id;
|
|
676
|
+
t.lastError = void 0;
|
|
677
|
+
return { runs: [run] };
|
|
678
|
+
} catch (err) {
|
|
679
|
+
t.lastError = err.message.slice(0, 1e3);
|
|
680
|
+
throw err;
|
|
681
|
+
} finally {
|
|
682
|
+
if (!started && input.via === "webhook") this.releaseWebhookSlot(t, startedAt);
|
|
683
|
+
for (const key of mine) claimed.delete(key);
|
|
684
|
+
await this.save();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
sweepRun(t, task, source) {
|
|
688
|
+
return { ...t.run, task, source, loop: { ...t.run.loop ?? {}, container: t.isolate ? { image: t.image || "node:22", network: true } : void 0 } };
|
|
689
|
+
}
|
|
690
|
+
handle(t, keys) {
|
|
691
|
+
t.handled = [.../* @__PURE__ */ new Set([...t.handled ?? [], ...keys])].slice(-MAX_HANDLED);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Check a webhook call: `Authorization: Bearer <token>`, or GitHub's `X-Hub-Signature-256`
|
|
695
|
+
* (HMAC of the body with the token as the secret). Says which, since a signed body can be sent
|
|
696
|
+
* again by anyone who saw it, and the secret can't.
|
|
697
|
+
*/
|
|
698
|
+
authorize(id, headers, body) {
|
|
699
|
+
const t = this.get(id);
|
|
700
|
+
if (!t || !t.webhook) throw Object.assign(new Error("Not found"), { status: 404 });
|
|
701
|
+
if (!t.enabled) throw Object.assign(new Error("This automation is turned off"), { status: 409 });
|
|
702
|
+
const bearer = String(headers.authorization ?? "").replace(/^Bearer\s+/i, "");
|
|
703
|
+
if (bearer && same(bearer, t.token)) return { trigger: t, signed: false };
|
|
704
|
+
const signature = String(headers["x-hub-signature-256"] ?? "");
|
|
705
|
+
const expected = `sha256=${createHmac("sha256", t.token).update(body).digest("hex")}`;
|
|
706
|
+
if (signature && same(signature, expected)) return { trigger: t, signed: true };
|
|
707
|
+
throw Object.assign(new Error("Bad token or signature"), { status: 401 });
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Changes you make must be saved, or a webhook secret you copied would be gone after a restart:
|
|
711
|
+
* refuse them while triggers.json couldn't be read (and so isn't written).
|
|
712
|
+
*/
|
|
713
|
+
requireWritable() {
|
|
714
|
+
if (!this.writable) throw new Error(`Automations can't be changed: ${this.file} couldn't be read when code-flow started (see server.log). Fix or remove it, then restart code-flow.`);
|
|
715
|
+
}
|
|
716
|
+
require(id) {
|
|
717
|
+
const t = this.get(id);
|
|
718
|
+
if (!t) throw new Error("Automation not found");
|
|
719
|
+
return t;
|
|
720
|
+
}
|
|
721
|
+
save(strict = false) {
|
|
722
|
+
if (!this.writable) {
|
|
723
|
+
if (strict) return Promise.reject(new Error("Automations storage is unavailable"));
|
|
724
|
+
return this.saving;
|
|
725
|
+
}
|
|
726
|
+
const snapshot = JSON.stringify(this.triggers, null, 2);
|
|
727
|
+
const saving = this.saving.then(async () => {
|
|
728
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
729
|
+
const tmp = `${this.file}.tmp`;
|
|
730
|
+
const handle = await open(tmp, "w", 384);
|
|
731
|
+
try {
|
|
732
|
+
await handle.writeFile(snapshot);
|
|
733
|
+
await handle.sync();
|
|
734
|
+
} finally {
|
|
735
|
+
await handle.close();
|
|
736
|
+
}
|
|
737
|
+
await rename(tmp, this.file);
|
|
738
|
+
if (process.platform !== "win32") {
|
|
739
|
+
const dir = await open(dirname(this.file), "r");
|
|
740
|
+
try {
|
|
741
|
+
await dir.sync();
|
|
742
|
+
} finally {
|
|
743
|
+
await dir.close();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
this.saving = saving.catch((err) => logError(`Couldn't save automations to ${this.file}`, err));
|
|
748
|
+
return strict ? saving : this.saving;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function validateFireInput(raw) {
|
|
752
|
+
if (!raw || typeof raw !== "object") throw new Error("Invalid automation event");
|
|
753
|
+
const input = raw;
|
|
754
|
+
if (!["manual", "schedule", "webhook"].includes(input.via)) throw new Error("Invalid automation event origin");
|
|
755
|
+
if (input.details !== void 0 && typeof input.details !== "string") throw new Error("Automation event details must be text");
|
|
756
|
+
if (input.event !== void 0 && typeof input.event !== "string") throw new Error("Automation event description must be text");
|
|
757
|
+
let source;
|
|
758
|
+
if (input.source !== void 0) {
|
|
759
|
+
const s = input.source;
|
|
760
|
+
if (!s || typeof s !== "object" || !["issue", "pr", "alert"].includes(s.kind) || !Number.isSafeInteger(s.number) || s.number < 1) throw new Error("Invalid automation event source");
|
|
761
|
+
if (s.kind === "alert" && !["dependabot", "code-scanning"].includes(s.tool)) throw new Error("Invalid automation alert tool");
|
|
762
|
+
if (s.numbers !== void 0 && (!Array.isArray(s.numbers) || s.numbers.length > 20 || s.numbers.some((n) => !Number.isSafeInteger(n) || n < 1))) throw new Error("Invalid automation alert numbers");
|
|
763
|
+
source = { kind: s.kind, number: s.number, ...s.kind === "alert" ? { tool: s.tool, ...s.numbers ? { numbers: [...new Set(s.numbers)] } : {} } : { ciLogs: s.ciLogs === true } };
|
|
764
|
+
}
|
|
765
|
+
return { via: input.via, details: input.details?.slice(0, 4e3), event: input.event?.slice(0, 1e3), source };
|
|
766
|
+
}
|
|
767
|
+
function eventKey(event) {
|
|
768
|
+
return event.source ? sourceKeys(event.source).join(",") : `details:${event.details ?? ""}`;
|
|
769
|
+
}
|
|
770
|
+
const hasDetails = (input) => !!input.details?.trim();
|
|
771
|
+
function withDetails(task, input) {
|
|
772
|
+
const details = input.details?.trim().slice(0, 4e3);
|
|
773
|
+
const parts = [input.event?.trim(), details && untrustedBlock(`text sent with the ${input.via}`, details)].filter(Boolean);
|
|
774
|
+
return parts.length ? `${task}
|
|
775
|
+
|
|
776
|
+
## Details from the ${input.via}
|
|
777
|
+
${parts.join("\n\n")}` : task;
|
|
778
|
+
}
|
|
779
|
+
function readTrigger(raw) {
|
|
780
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
781
|
+
if (typeof raw.id !== "string" || !raw.id || typeof raw.token !== "string" || raw.token.length < 16) return void 0;
|
|
782
|
+
const run = raw.run;
|
|
783
|
+
if (!run || typeof run !== "object" || typeof run.repo !== "string" || typeof run.pattern !== "string" || typeof run.task !== "string") return void 0;
|
|
784
|
+
if (raw.target && (raw.target.kind !== "project-team" || !/^pt-[a-f0-9]{24}$/.test(raw.target.teamId))) return void 0;
|
|
785
|
+
const problems = [];
|
|
786
|
+
let schedule;
|
|
787
|
+
try {
|
|
788
|
+
schedule = validateSchedule(raw.schedule);
|
|
789
|
+
} catch {
|
|
790
|
+
problems.push("its schedule couldn't be read; set it again");
|
|
791
|
+
}
|
|
792
|
+
let sweep;
|
|
793
|
+
if (raw.sweep !== void 0) {
|
|
794
|
+
if (raw.sweep && SWEEP_KINDS.includes(raw.sweep.kind)) sweep = { kind: raw.sweep.kind, max: Math.min(10, Math.max(1, Math.round(Number(raw.sweep.max)) || 5)) };
|
|
795
|
+
else problems.push("its sweep couldn't be read");
|
|
796
|
+
}
|
|
797
|
+
const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : void 0;
|
|
798
|
+
const time = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
799
|
+
const queued = Array.isArray(raw.queued) ? raw.queued.slice(-MAX_QUEUED).flatMap((q) => {
|
|
800
|
+
try {
|
|
801
|
+
const { via: _via, ...input } = validateFireInput({ ...q, via: "webhook" });
|
|
802
|
+
return [{ ...input, ...typeof q.eventId === "string" ? { eventId: q.eventId.slice(0, 100) } : {} }];
|
|
803
|
+
} catch {
|
|
804
|
+
return [];
|
|
805
|
+
}
|
|
806
|
+
}) : void 0;
|
|
807
|
+
const eventHistory = Array.isArray(raw.eventHistory) ? raw.eventHistory.slice(-MAX_EVENT_HISTORY).flatMap((event) => {
|
|
808
|
+
try {
|
|
809
|
+
if (!event || typeof event.id !== "string" || !["received", "queued", "started", "failed", "dropped", "deduplicated", "skipped"].includes(event.state)) return [];
|
|
810
|
+
const { via, ...input } = validateFireInput({ ...event.input, via: event.via });
|
|
811
|
+
return [{
|
|
812
|
+
id: event.id.slice(0, 100),
|
|
813
|
+
at: time(event.at) ?? Date.now(),
|
|
814
|
+
updatedAt: time(event.updatedAt) ?? Date.now(),
|
|
815
|
+
via,
|
|
816
|
+
input,
|
|
817
|
+
state: event.state === "received" ? "failed" : event.state,
|
|
818
|
+
reason: event.state === "received" ? "code-flow restarted before this event finished starting" : typeof event.reason === "string" ? event.reason.slice(0, 1e3) : void 0,
|
|
819
|
+
delivery: typeof event.delivery === "string" ? event.delivery.slice(0, 200) : void 0,
|
|
820
|
+
runIds: strings(event.runIds)?.slice(0, 10),
|
|
821
|
+
retries: Number.isSafeInteger(event.retries) && event.retries > 0 ? event.retries : 0
|
|
822
|
+
}];
|
|
823
|
+
} catch {
|
|
824
|
+
return [];
|
|
825
|
+
}
|
|
826
|
+
}) : void 0;
|
|
827
|
+
if (problems.length) logWarn(`Automation "${raw.name ?? raw.id}" was turned off: ${problems.join("; ")}`);
|
|
828
|
+
return {
|
|
829
|
+
id: raw.id,
|
|
830
|
+
target: raw.target,
|
|
831
|
+
name: typeof raw.name === "string" && raw.name.trim() ? raw.name.slice(0, 80) : "Automation",
|
|
832
|
+
enabled: raw.enabled === true && !problems.length,
|
|
833
|
+
run,
|
|
834
|
+
schedule,
|
|
835
|
+
webhook: raw.webhook === true,
|
|
836
|
+
token: raw.token,
|
|
837
|
+
isolate: raw.isolate === true,
|
|
838
|
+
image: typeof raw.image === "string" ? raw.image : void 0,
|
|
839
|
+
createdAt: time(raw.createdAt) ?? Date.now(),
|
|
840
|
+
nextAt: time(raw.nextAt),
|
|
841
|
+
lastFiredAt: time(raw.lastFiredAt),
|
|
842
|
+
lastRunId: typeof raw.lastRunId === "string" ? raw.lastRunId : void 0,
|
|
843
|
+
lastError: problems.length ? `Turned off: ${problems.join("; ")}` : typeof raw.lastError === "string" ? raw.lastError : void 0,
|
|
844
|
+
sweep,
|
|
845
|
+
handled: strings(raw.handled)?.slice(-MAX_HANDLED),
|
|
846
|
+
deliveries: strings(raw.deliveries)?.slice(-MAX_DELIVERIES),
|
|
847
|
+
queued: queued?.length ? queued : void 0,
|
|
848
|
+
eventHistory,
|
|
849
|
+
webhookStarts: Array.isArray(raw.webhookStarts) ? raw.webhookStarts.filter((n) => time(n) !== void 0) : void 0
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
function readWebhook(headers, payload) {
|
|
853
|
+
const event = String(headers["x-github-event"] ?? "");
|
|
854
|
+
if (!event) {
|
|
855
|
+
const source = payload?.source;
|
|
856
|
+
const number2 = Number(source?.number);
|
|
857
|
+
const tool = source?.tool === "dependabot" || source?.tool === "code-scanning" ? source.tool : void 0;
|
|
858
|
+
const valid = source && (source.kind === "issue" || source.kind === "pr" || source.kind === "alert" && tool) && Number.isInteger(number2) && number2 > 0;
|
|
859
|
+
return {
|
|
860
|
+
details: typeof payload?.details === "string" ? payload.details : typeof payload?.task === "string" ? payload.task : void 0,
|
|
861
|
+
source: valid ? source.kind === "alert" ? { kind: "alert", tool, number: number2 } : { kind: source.kind, number: number2, ciLogs: source.ciLogs === true } : void 0
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
const number = (n) => Number.isInteger(n) && n > 0 ? n : void 0;
|
|
865
|
+
const login = (u) => typeof u?.login === "string" && /^[\w-]{1,39}(\[bot\])?$/.test(u.login) ? `@${u.login}` : "someone";
|
|
866
|
+
if (event === "dependabot_alert" && ["created", "reopened", "reintroduced"].includes(payload?.action) && number(payload.alert?.number)) {
|
|
867
|
+
return { source: { kind: "alert", tool: "dependabot", number: payload.alert.number }, event: `GitHub: Dependabot alert ${payload.action}` };
|
|
868
|
+
}
|
|
869
|
+
if (event === "code_scanning_alert" && ["created", "reopened", "appeared_in_branch"].includes(payload?.action) && number(payload.alert?.number)) {
|
|
870
|
+
return { source: { kind: "alert", tool: "code-scanning", number: payload.alert.number }, event: `GitHub: code scanning alert ${payload.action}` };
|
|
871
|
+
}
|
|
872
|
+
if (event === "issues" && ["opened", "labeled", "reopened"].includes(payload?.action) && number(payload.issue?.number)) {
|
|
873
|
+
return { source: { kind: "issue", number: payload.issue.number }, event: `GitHub: issue ${payload.action} by ${login(payload.sender)}` };
|
|
874
|
+
}
|
|
875
|
+
if (event === "pull_request" && ["opened", "synchronize", "reopened", "ready_for_review", "labeled"].includes(payload?.action) && number(payload.pull_request?.number)) {
|
|
876
|
+
return { source: { kind: "pr", number: payload.pull_request.number }, event: `GitHub: pull request ${payload.action}` };
|
|
877
|
+
}
|
|
878
|
+
if (event === "workflow_run" && payload?.action === "completed" && payload.workflow_run?.conclusion === "failure") {
|
|
879
|
+
const pr = number(payload.workflow_run.pull_requests?.[0]?.number);
|
|
880
|
+
const name = String(payload.workflow_run.name ?? "").replace(/[^\w .()/-]/g, "").slice(0, 80);
|
|
881
|
+
return pr ? { source: { kind: "pr", number: pr, ciLogs: true }, event: `GitHub: workflow "${name}" failed` } : null;
|
|
882
|
+
}
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
function same(a, b) {
|
|
886
|
+
const x = Buffer.from(a);
|
|
887
|
+
const y = Buffer.from(b);
|
|
888
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
889
|
+
}
|
|
890
|
+
export {
|
|
891
|
+
Automations,
|
|
892
|
+
MISSED_GRACE_MS,
|
|
893
|
+
describeSchedule,
|
|
894
|
+
nextFire,
|
|
895
|
+
previewSchedule,
|
|
896
|
+
readWebhook,
|
|
897
|
+
resumeSchedule,
|
|
898
|
+
stillDue,
|
|
899
|
+
validateFireInput,
|
|
900
|
+
validateSchedule
|
|
901
|
+
};
|