@akshatmittal/invoker 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -10
- package/dist/github.d.mts +22 -0
- package/dist/github.mjs +353 -0
- package/dist/index.d.mts +2 -8
- package/dist/index.mjs +93 -76
- package/dist/slack.d.mts +10 -0
- package/dist/slack.mjs +425 -0
- package/package.json +22 -1
package/README.md
CHANGED
|
@@ -10,7 +10,91 @@ validated JSON Output in Vitest metadata for reporters and later analysis.
|
|
|
10
10
|
pnpm add -D @akshatmittal/invoker vitest
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Invoker supports Node 24 and Vitest 4.1.10 or newer within Vitest 4.
|
|
13
|
+
Invoker supports Node 24.18.1 or newer within Node 24 and Vitest 4.1.10 or newer within Vitest 4.
|
|
14
|
+
|
|
15
|
+
## Schedule GitHub Actions
|
|
16
|
+
|
|
17
|
+
`@akshatmittal/invoker/github` runs code-defined GitHub Actions schedules from
|
|
18
|
+
a small, long-running Node process. It is independent from the Vitest SDK, so a
|
|
19
|
+
scheduler-only installation does not need Vitest.
|
|
20
|
+
|
|
21
|
+
Create a GitHub App with repository **Actions: read and write**, disable its
|
|
22
|
+
webhook, install it on the selected repositories, and generate a private key.
|
|
23
|
+
No other repository, organization, user, OAuth, or webhook permissions are
|
|
24
|
+
needed.
|
|
25
|
+
|
|
26
|
+
Install the scheduler with t3-env and Zod in a plain ESM application:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install @akshatmittal/invoker @t3-oss/env-core zod
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
// schedule.mjs
|
|
34
|
+
import { createEnv } from "@t3-oss/env-core";
|
|
35
|
+
import { defineGitHubSchedule } from "@akshatmittal/invoker/github";
|
|
36
|
+
import { z } from "zod";
|
|
37
|
+
|
|
38
|
+
const env = createEnv({
|
|
39
|
+
server: {
|
|
40
|
+
GITHUB_APP_ID: z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
41
|
+
GITHUB_APP_PRIVATE_KEY: z.string().min(1),
|
|
42
|
+
},
|
|
43
|
+
runtimeEnv: process.env,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await defineGitHubSchedule({
|
|
47
|
+
app: {
|
|
48
|
+
id: env.GITHUB_APP_ID,
|
|
49
|
+
privateKey: env.GITHUB_APP_PRIVATE_KEY,
|
|
50
|
+
},
|
|
51
|
+
schedules: [
|
|
52
|
+
{
|
|
53
|
+
cron: "0 9 * * 1",
|
|
54
|
+
timezone: "UTC",
|
|
55
|
+
repository: "acme/regressions",
|
|
56
|
+
workflow: "invoker.yml",
|
|
57
|
+
ref: "main",
|
|
58
|
+
inputs: { dataset: "weekly", publish: true },
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Schedules use five-field cron expressions and default to UTC when `timezone`
|
|
65
|
+
is omitted. Configuration is fixed at startup. Every repository installation
|
|
66
|
+
and active workflow is validated before timers begin; GitHub validates the ref,
|
|
67
|
+
`workflow_dispatch` declaration, and input schema when a Dispatch is due.
|
|
68
|
+
|
|
69
|
+
Run exactly one replica. Dispatches may overlap, are not persisted or retried,
|
|
70
|
+
and failures do not stop later occurrences. The process emits events through
|
|
71
|
+
evlog's shared logger, so the host's filtering, sampling, redaction, and drain
|
|
72
|
+
configuration applies. The module does not initialize or configure evlog.
|
|
73
|
+
`SIGINT` and `SIGTERM` stop new Dispatches, await in-flight requests, and
|
|
74
|
+
resolve the long-running promise.
|
|
75
|
+
|
|
76
|
+
### Docker
|
|
77
|
+
|
|
78
|
+
Keep `package.json`, `package-lock.json`, and `schedule.mjs` in a deployment
|
|
79
|
+
directory and build this image:
|
|
80
|
+
|
|
81
|
+
```dockerfile
|
|
82
|
+
FROM node:24-slim
|
|
83
|
+
|
|
84
|
+
WORKDIR /app
|
|
85
|
+
COPY package.json package-lock.json ./
|
|
86
|
+
RUN npm ci --omit=dev
|
|
87
|
+
COPY schedule.mjs ./
|
|
88
|
+
|
|
89
|
+
USER node
|
|
90
|
+
CMD ["node", "schedule.mjs"]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Exclude `.env`, PEM, and private-key files from the build context. Inject
|
|
94
|
+
`GITHUB_APP_ID` and the real multiline `GITHUB_APP_PRIVATE_KEY` through the
|
|
95
|
+
runtime platform's secret mechanism; never bake them into the image. Use the
|
|
96
|
+
platform's process-liveness check and restart policy with one replica. The
|
|
97
|
+
scheduler intentionally has no HTTP health endpoint.
|
|
14
98
|
|
|
15
99
|
## Define a Workflow
|
|
16
100
|
|
|
@@ -41,9 +125,9 @@ export const evaluateModels = defineTask({
|
|
|
41
125
|
```
|
|
42
126
|
|
|
43
127
|
```ts
|
|
44
|
-
// regressions/model
|
|
128
|
+
// regressions/workflows/model-regressions.test.ts
|
|
45
129
|
import { defineWorkflow } from "@akshatmittal/invoker";
|
|
46
|
-
import { evaluateModels } from "
|
|
130
|
+
import { evaluateModels } from "../tasks/evaluate-models.js";
|
|
47
131
|
|
|
48
132
|
defineWorkflow({
|
|
49
133
|
name: "model-regressions",
|
|
@@ -57,14 +141,15 @@ defineWorkflow({
|
|
|
57
141
|
|
|
58
142
|
Matrix literals determine the exact `matrix` type, `setup` determines the exact
|
|
59
143
|
shared setup type, and the exact JSON return type is retained on the Task.
|
|
144
|
+
Axis names must be non-empty, enumerable strings that are not array indexes.
|
|
60
145
|
Omitting `matrix` creates one Case with `{}`. Setup runs once per Task, Cases
|
|
61
146
|
within that Task run concurrently, and teardown runs once after successful
|
|
62
147
|
setup. Tasks run sequentially in their Workflow.
|
|
63
148
|
|
|
64
149
|
## Configure Vitest
|
|
65
150
|
|
|
66
|
-
|
|
67
|
-
|
|
151
|
+
The JSON reporter includes each Case's data at
|
|
152
|
+
`assertionResults[].meta.invoker`:
|
|
68
153
|
|
|
69
154
|
```ts
|
|
70
155
|
// vitest.config.ts
|
|
@@ -85,9 +170,13 @@ Run every Workflow or filter to one Task with ordinary Vitest commands:
|
|
|
85
170
|
|
|
86
171
|
```sh
|
|
87
172
|
pnpm vitest run
|
|
88
|
-
pnpm vitest run regressions/model
|
|
173
|
+
pnpm vitest run regressions/workflows/model-regressions.test.ts -t evaluate-models
|
|
89
174
|
```
|
|
90
175
|
|
|
176
|
+
Define additional Workflows in separate `*.test.ts` files. Vitest discovers
|
|
177
|
+
them automatically; Invoker does not scan directories or require a central
|
|
178
|
+
index.
|
|
179
|
+
|
|
91
180
|
The metadata envelope is stable and JSON-compatible:
|
|
92
181
|
|
|
93
182
|
```json
|
|
@@ -103,6 +192,48 @@ The metadata envelope is stable and JSON-compatible:
|
|
|
103
192
|
report remains authoritative for status, failures, timing, hierarchy, and
|
|
104
193
|
retries.
|
|
105
194
|
|
|
195
|
+
## Notify Slack
|
|
196
|
+
|
|
197
|
+
Invoker's optional Slack reporter posts one `Invoker Report` parent message per
|
|
198
|
+
Vitest run. It places additional Workflow cards in the message thread so every
|
|
199
|
+
Task table remains within Slack's row and character limits. Each card includes
|
|
200
|
+
aggregate results, Workflow metadata, and a table of Task counts and durations.
|
|
201
|
+
A shared footer contains the elapsed span from the first Case start to the final
|
|
202
|
+
Case completion, a localized timestamp, and the optional run link. Final
|
|
203
|
+
failures are posted in the same thread with one reply per failed Task in each
|
|
204
|
+
Workflow. Unhandled run errors are reported once. Delivery failures are
|
|
205
|
+
isolated to the affected reply. Ambiguous transport failures are not retried;
|
|
206
|
+
an explicit Slack rate-limit rejection is reattempted only after its required
|
|
207
|
+
delay.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { slackReporter } from "@akshatmittal/invoker/slack";
|
|
211
|
+
import { defineConfig } from "vitest/config";
|
|
212
|
+
|
|
213
|
+
const runUrl =
|
|
214
|
+
process.env.GITHUB_ACTIONS === "true"
|
|
215
|
+
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
216
|
+
: undefined;
|
|
217
|
+
|
|
218
|
+
export default defineConfig({
|
|
219
|
+
test: {
|
|
220
|
+
reporters: [
|
|
221
|
+
"tree",
|
|
222
|
+
slackReporter({
|
|
223
|
+
token: process.env.SLACK_BOT_TOKEN!,
|
|
224
|
+
channel: process.env.SLACK_CHANNEL_ID!,
|
|
225
|
+
runUrl,
|
|
226
|
+
}),
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Create a Slack app with the `chat:write` bot scope, install it to the workspace,
|
|
233
|
+
and expose its bot token and target channel ID as `SLACK_BOT_TOKEN` and
|
|
234
|
+
`SLACK_CHANNEL_ID`. Invite the bot to private target channels. Slack delivery
|
|
235
|
+
failures produce a warning but do not change Vitest's exit status.
|
|
236
|
+
|
|
106
237
|
## GitHub Actions artifacts
|
|
107
238
|
|
|
108
239
|
Create the output directory before Vitest and upload the report even when the
|
|
@@ -122,7 +253,7 @@ queries or reports. Invoker does not upload, index, or persist results itself.
|
|
|
122
253
|
|
|
123
254
|
## v1 scope
|
|
124
255
|
|
|
125
|
-
Invoker does not provide a CLI, directory discovery, custom runner,
|
|
126
|
-
reporter, configuration helper, Task-level parallelism, matrix
|
|
127
|
-
or hosted result storage. Use Vitest configuration and your CI
|
|
128
|
-
concerns.
|
|
256
|
+
Invoker does not provide a CLI, directory discovery, custom runner, general
|
|
257
|
+
reporter framework, configuration helper, Task-level parallelism, matrix
|
|
258
|
+
include/exclude, or hosted result storage. Use Vitest configuration and your CI
|
|
259
|
+
runner for those concerns.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/github/types.d.ts
|
|
2
|
+
type WorkflowInput = string | number | boolean;
|
|
3
|
+
type GitHubSchedule = {
|
|
4
|
+
readonly cron: string;
|
|
5
|
+
readonly timezone?: string;
|
|
6
|
+
readonly repository: string;
|
|
7
|
+
readonly workflow: string | number;
|
|
8
|
+
readonly ref: string;
|
|
9
|
+
readonly inputs?: Readonly<Record<string, WorkflowInput>>;
|
|
10
|
+
};
|
|
11
|
+
type GitHubScheduleDefinition = {
|
|
12
|
+
readonly app: {
|
|
13
|
+
readonly id: number;
|
|
14
|
+
readonly privateKey: string;
|
|
15
|
+
};
|
|
16
|
+
readonly schedules: readonly [GitHubSchedule, ...GitHubSchedule[]];
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/github.d.ts
|
|
20
|
+
declare function defineGitHubSchedule(definition: GitHubScheduleDefinition): Promise<void>;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { defineGitHubSchedule };
|
package/dist/github.mjs
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Cron } from "croner";
|
|
3
|
+
import { createAppAuth } from "@octokit/auth-app";
|
|
4
|
+
import { request } from "@octokit/request";
|
|
5
|
+
import { log } from "evlog";
|
|
6
|
+
//#region src/github/client.ts
|
|
7
|
+
const API_VERSION = "2026-03-10";
|
|
8
|
+
const dispatchResponseSchema = z.object({
|
|
9
|
+
workflow_run_id: z.int().positive(),
|
|
10
|
+
html_url: z.url()
|
|
11
|
+
});
|
|
12
|
+
const requestFailureSchema = z.object({
|
|
13
|
+
status: z.int().optional(),
|
|
14
|
+
response: z.object({ headers: z.object({ "x-github-request-id": z.string().optional() }) }).optional()
|
|
15
|
+
});
|
|
16
|
+
const githubFailures = /* @__PURE__ */ new WeakMap();
|
|
17
|
+
function createGitHubClient(app, signal) {
|
|
18
|
+
const apiRequest = request.defaults({
|
|
19
|
+
headers: {
|
|
20
|
+
accept: "application/vnd.github+json",
|
|
21
|
+
"x-github-api-version": API_VERSION
|
|
22
|
+
},
|
|
23
|
+
request: { signal }
|
|
24
|
+
});
|
|
25
|
+
const auth = createAppAuth({
|
|
26
|
+
appId: app.id,
|
|
27
|
+
privateKey: app.privateKey,
|
|
28
|
+
request: apiRequest,
|
|
29
|
+
log: { warn() {} }
|
|
30
|
+
});
|
|
31
|
+
const resolveInstallation = async (target) => {
|
|
32
|
+
try {
|
|
33
|
+
const authentication = await auth({ type: "app" });
|
|
34
|
+
return (await apiRequest("GET /repos/{owner}/{repo}/installation", {
|
|
35
|
+
owner: target.owner,
|
|
36
|
+
repo: target.repo,
|
|
37
|
+
headers: { authorization: `Bearer ${authentication.token}` }
|
|
38
|
+
})).data.id;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw githubError(githubFailure(error, "resolve installation", target));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const installationToken = async (target, installationId) => {
|
|
44
|
+
try {
|
|
45
|
+
return (await auth({
|
|
46
|
+
type: "installation",
|
|
47
|
+
installationId,
|
|
48
|
+
repositoryNames: [target.repo],
|
|
49
|
+
permissions: { actions: "write" }
|
|
50
|
+
})).token;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw githubError(githubFailure(error, "create installation token", target));
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const resolveWorkflow = async (target, installationId) => {
|
|
56
|
+
try {
|
|
57
|
+
const token = await installationToken(target, installationId);
|
|
58
|
+
const response = await apiRequest("GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}", {
|
|
59
|
+
owner: target.owner,
|
|
60
|
+
repo: target.repo,
|
|
61
|
+
workflow_id: target.workflow,
|
|
62
|
+
headers: { authorization: `Bearer ${token}` }
|
|
63
|
+
});
|
|
64
|
+
if (response.data.state !== "active") throw new Error("workflow is not active");
|
|
65
|
+
return response.data.id;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw githubError(githubFailure(error, "resolve workflow", target));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const dispatch = async (target) => {
|
|
71
|
+
try {
|
|
72
|
+
const token = await installationToken(target, target.installationId);
|
|
73
|
+
const response = await apiRequest("POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", {
|
|
74
|
+
owner: target.owner,
|
|
75
|
+
repo: target.repo,
|
|
76
|
+
workflow_id: target.workflowId,
|
|
77
|
+
ref: target.ref,
|
|
78
|
+
return_run_details: true,
|
|
79
|
+
inputs: target.inputs,
|
|
80
|
+
headers: { authorization: `Bearer ${token}` }
|
|
81
|
+
});
|
|
82
|
+
const data = dispatchResponseSchema.parse(response.data);
|
|
83
|
+
return {
|
|
84
|
+
runId: data.workflow_run_id,
|
|
85
|
+
webUrl: data.html_url
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw githubError(githubFailure(error, "dispatch workflow", target));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
dispatch,
|
|
93
|
+
resolveInstallation,
|
|
94
|
+
resolveWorkflow
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function githubFailure(cause, operation, target) {
|
|
98
|
+
if (cause instanceof Error) {
|
|
99
|
+
const known = githubFailures.get(cause);
|
|
100
|
+
if (known) return known;
|
|
101
|
+
}
|
|
102
|
+
const parsed = requestFailureSchema.safeParse(cause);
|
|
103
|
+
const status = parsed.success ? parsed.data.status : void 0;
|
|
104
|
+
const requestId = parsed.success ? parsed.data.response?.headers["x-github-request-id"] : void 0;
|
|
105
|
+
return {
|
|
106
|
+
message: `GitHub ${operation}${target ? ` for ${target.repository} workflow ${String(target.workflow)}` : ""}: ${status === 404 ? "not found or inaccessible" : "request failed"}${status === void 0 ? "" : ` (${status})`}${requestId === void 0 ? "" : ` [request ${requestId}]`}`,
|
|
107
|
+
operation,
|
|
108
|
+
repository: target?.repository,
|
|
109
|
+
workflow: target?.workflow,
|
|
110
|
+
status,
|
|
111
|
+
requestId
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function githubError(failure) {
|
|
115
|
+
const error = new Error(failure.message);
|
|
116
|
+
githubFailures.set(error, failure);
|
|
117
|
+
return error;
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/github/config.ts
|
|
121
|
+
const GITHUB_SCHEDULE_OWNER = "GitHub Schedule";
|
|
122
|
+
const inputSchema = z.union([
|
|
123
|
+
z.string(),
|
|
124
|
+
z.number().finite(),
|
|
125
|
+
z.boolean()
|
|
126
|
+
]);
|
|
127
|
+
const timezoneSchema = z.string().refine((timezone) => {
|
|
128
|
+
try {
|
|
129
|
+
new Intl.DateTimeFormat(void 0, { timeZone: timezone });
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}, "expected a valid IANA timezone");
|
|
135
|
+
const scheduleSchema = z.strictObject({
|
|
136
|
+
cron: z.string().min(1),
|
|
137
|
+
timezone: timezoneSchema.default("UTC"),
|
|
138
|
+
repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/),
|
|
139
|
+
workflow: z.union([z.int().positive(), z.string().min(1).refine((value) => value.trim() === value && !value.includes("/"))]),
|
|
140
|
+
ref: z.string().refine((value) => value.trim() !== ""),
|
|
141
|
+
inputs: z.record(z.string(), inputSchema).refine((inputs) => Object.keys(inputs).length <= 25).refine((inputs) => JSON.stringify(inputs).length <= 65535).optional()
|
|
142
|
+
});
|
|
143
|
+
const definitionSchema = z.strictObject({
|
|
144
|
+
app: z.strictObject({
|
|
145
|
+
id: z.int().positive(),
|
|
146
|
+
privateKey: z.string().min(1)
|
|
147
|
+
}),
|
|
148
|
+
schedules: z.array(scheduleSchema).min(1)
|
|
149
|
+
});
|
|
150
|
+
function normalizeDefinition(value) {
|
|
151
|
+
const result = definitionSchema.safeParse(value);
|
|
152
|
+
if (!result.success) throw new TypeError(`${GITHUB_SCHEDULE_OWNER}: ${z.prettifyError(result.error)}`);
|
|
153
|
+
const schedules = result.data.schedules.map((schedule, index) => {
|
|
154
|
+
let validationJob;
|
|
155
|
+
try {
|
|
156
|
+
validationJob = new Cron(schedule.cron, {
|
|
157
|
+
mode: "5-part",
|
|
158
|
+
paused: true,
|
|
159
|
+
timezone: schedule.timezone
|
|
160
|
+
});
|
|
161
|
+
validationJob.nextRun();
|
|
162
|
+
} catch {
|
|
163
|
+
throw new TypeError(`${GITHUB_SCHEDULE_OWNER}.schedules[${index}].cron: expected a valid five-field cron expression`);
|
|
164
|
+
} finally {
|
|
165
|
+
validationJob?.stop();
|
|
166
|
+
}
|
|
167
|
+
const separator = schedule.repository.indexOf("/");
|
|
168
|
+
const owner = schedule.repository.slice(0, separator);
|
|
169
|
+
const repo = schedule.repository.slice(separator + 1);
|
|
170
|
+
return {
|
|
171
|
+
...schedule,
|
|
172
|
+
owner,
|
|
173
|
+
repo
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
app: result.data.app,
|
|
178
|
+
schedules
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function workflowKey(schedule) {
|
|
182
|
+
return `${schedule.repository}\0${String(schedule.workflow)}`;
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/github/events.ts
|
|
186
|
+
const SERVICE = "github-schedule";
|
|
187
|
+
function logStartup(schedules, repositories, workflows, failure) {
|
|
188
|
+
log[failure ? "error" : "info"]({
|
|
189
|
+
service: SERVICE,
|
|
190
|
+
event: "github_schedule.startup",
|
|
191
|
+
schedules,
|
|
192
|
+
repositories,
|
|
193
|
+
workflows,
|
|
194
|
+
...failure ? {
|
|
195
|
+
outcome: "failure",
|
|
196
|
+
failure
|
|
197
|
+
} : { outcome: "success" }
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
function logDispatch(schedule, scheduledAt, result, failure) {
|
|
201
|
+
log[failure ? "error" : "info"]({
|
|
202
|
+
service: SERVICE,
|
|
203
|
+
event: "github_schedule.dispatch",
|
|
204
|
+
repository: schedule.repository,
|
|
205
|
+
workflow: schedule.workflow,
|
|
206
|
+
ref: schedule.ref,
|
|
207
|
+
cron: schedule.cron,
|
|
208
|
+
timezone: schedule.timezone,
|
|
209
|
+
scheduledAt,
|
|
210
|
+
...failure ? {
|
|
211
|
+
outcome: "failure",
|
|
212
|
+
failure
|
|
213
|
+
} : {
|
|
214
|
+
outcome: "success",
|
|
215
|
+
runId: result.runId,
|
|
216
|
+
runUrl: result.webUrl
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function logShutdown(signal, drainedDispatches) {
|
|
221
|
+
log.info({
|
|
222
|
+
service: SERVICE,
|
|
223
|
+
event: "github_schedule.shutdown",
|
|
224
|
+
signal,
|
|
225
|
+
drainedDispatches,
|
|
226
|
+
outcome: "success"
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/github/scheduler.ts
|
|
231
|
+
let schedulerActive = false;
|
|
232
|
+
async function runGitHubSchedule(definition) {
|
|
233
|
+
let normalized;
|
|
234
|
+
try {
|
|
235
|
+
normalized = normalizeDefinition(definition);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
logStartup(0, 0, 0, localFailure(error));
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
const repositoryCount = new Set(normalized.schedules.map(({ repository }) => repository)).size;
|
|
241
|
+
const workflowCount = new Set(normalized.schedules.map(workflowKey)).size;
|
|
242
|
+
if (schedulerActive) {
|
|
243
|
+
const error = /* @__PURE__ */ new TypeError(`${GITHUB_SCHEDULE_OWNER}: another scheduler is already active`);
|
|
244
|
+
logStartup(normalized.schedules.length, repositoryCount, workflowCount, localFailure(error));
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
schedulerActive = true;
|
|
248
|
+
const startupAbort = new AbortController();
|
|
249
|
+
const jobs = [];
|
|
250
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
251
|
+
let state = "starting";
|
|
252
|
+
let stopSignal;
|
|
253
|
+
let drainedCount = 0;
|
|
254
|
+
let resolveStop;
|
|
255
|
+
const stopRequested = new Promise((resolve) => {
|
|
256
|
+
resolveStop = resolve;
|
|
257
|
+
});
|
|
258
|
+
const isStopping = () => state === "stopping";
|
|
259
|
+
const removeSignalHandlers = () => {
|
|
260
|
+
process.removeListener("SIGINT", onSigint);
|
|
261
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
262
|
+
};
|
|
263
|
+
const requestStop = (signal) => {
|
|
264
|
+
if (isStopping()) return;
|
|
265
|
+
const duringStartup = state === "starting";
|
|
266
|
+
state = "stopping";
|
|
267
|
+
stopSignal = signal;
|
|
268
|
+
drainedCount = inFlight.size;
|
|
269
|
+
jobs.forEach((job) => job.stop());
|
|
270
|
+
removeSignalHandlers();
|
|
271
|
+
if (duringStartup) startupAbort.abort();
|
|
272
|
+
resolveStop();
|
|
273
|
+
};
|
|
274
|
+
function onSigint() {
|
|
275
|
+
requestStop("SIGINT");
|
|
276
|
+
}
|
|
277
|
+
function onSigterm() {
|
|
278
|
+
requestStop("SIGTERM");
|
|
279
|
+
}
|
|
280
|
+
process.on("SIGINT", onSigint);
|
|
281
|
+
process.on("SIGTERM", onSigterm);
|
|
282
|
+
try {
|
|
283
|
+
const github = createGitHubClient(normalized.app, startupAbort.signal);
|
|
284
|
+
const installations = /* @__PURE__ */ new Map();
|
|
285
|
+
for (const schedule of normalized.schedules) if (!installations.has(schedule.repository)) installations.set(schedule.repository, await github.resolveInstallation(schedule));
|
|
286
|
+
const workflows = /* @__PURE__ */ new Map();
|
|
287
|
+
for (const schedule of normalized.schedules) {
|
|
288
|
+
const key = workflowKey(schedule);
|
|
289
|
+
if (!workflows.has(key)) workflows.set(key, await github.resolveWorkflow(schedule, installations.get(schedule.repository)));
|
|
290
|
+
}
|
|
291
|
+
if (isStopping()) {
|
|
292
|
+
await finishShutdown(inFlight, stopSignal, drainedCount);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const schedules = normalized.schedules.map((schedule) => ({
|
|
296
|
+
...schedule,
|
|
297
|
+
installationId: installations.get(schedule.repository),
|
|
298
|
+
workflowId: workflows.get(workflowKey(schedule))
|
|
299
|
+
}));
|
|
300
|
+
for (const schedule of schedules) {
|
|
301
|
+
const job = new Cron(schedule.cron, {
|
|
302
|
+
catch: true,
|
|
303
|
+
mode: "5-part",
|
|
304
|
+
timezone: schedule.timezone
|
|
305
|
+
}, (currentJob) => {
|
|
306
|
+
if (state !== "running") return;
|
|
307
|
+
const scheduledAt = (currentJob.currentRun() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
308
|
+
const dispatch = github.dispatch(schedule).then((result) => logDispatch(schedule, scheduledAt, result), (cause) => {
|
|
309
|
+
const failure = githubFailure(cause, "dispatch workflow", schedule);
|
|
310
|
+
logDispatch(schedule, scheduledAt, void 0, failure);
|
|
311
|
+
throw new Error(failure.message);
|
|
312
|
+
});
|
|
313
|
+
inFlight.add(dispatch);
|
|
314
|
+
dispatch.then(() => inFlight.delete(dispatch), () => inFlight.delete(dispatch));
|
|
315
|
+
return dispatch;
|
|
316
|
+
});
|
|
317
|
+
jobs.push(job);
|
|
318
|
+
}
|
|
319
|
+
state = "running";
|
|
320
|
+
logStartup(normalized.schedules.length, repositoryCount, workflowCount);
|
|
321
|
+
await stopRequested;
|
|
322
|
+
await finishShutdown(inFlight, stopSignal, drainedCount);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (isStopping()) {
|
|
325
|
+
await finishShutdown(inFlight, stopSignal, drainedCount);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
jobs.forEach((job) => job.stop());
|
|
329
|
+
removeSignalHandlers();
|
|
330
|
+
schedulerActive = false;
|
|
331
|
+
const failure = githubFailure(error, "start scheduler");
|
|
332
|
+
logStartup(normalized.schedules.length, repositoryCount, workflowCount, failure);
|
|
333
|
+
throw githubError(failure);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function finishShutdown(inFlight, signal, drainedCount) {
|
|
337
|
+
await Promise.allSettled(inFlight);
|
|
338
|
+
schedulerActive = false;
|
|
339
|
+
logShutdown(signal, drainedCount);
|
|
340
|
+
}
|
|
341
|
+
function localFailure(cause) {
|
|
342
|
+
return {
|
|
343
|
+
message: cause instanceof Error ? cause.message : `${GITHUB_SCHEDULE_OWNER}: startup validation failed`,
|
|
344
|
+
operation: "validate configuration"
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/github.ts
|
|
349
|
+
function defineGitHubSchedule(definition) {
|
|
350
|
+
return runGitHubSchedule(definition);
|
|
351
|
+
}
|
|
352
|
+
//#endregion
|
|
353
|
+
export { defineGitHubSchedule };
|
package/dist/index.d.mts
CHANGED
|
@@ -35,10 +35,7 @@ declare const taskDefinitionBrand: unique symbol;
|
|
|
35
35
|
interface TaskDefinition<Name extends string = string, M extends Matrix = Matrix, Setup = unknown, Output extends JsonValue = JsonValue> {
|
|
36
36
|
readonly name: Name;
|
|
37
37
|
readonly matrix: M;
|
|
38
|
-
readonly [taskDefinitionBrand]:
|
|
39
|
-
readonly setup: Setup;
|
|
40
|
-
readonly output: Output;
|
|
41
|
-
};
|
|
38
|
+
readonly [taskDefinitionBrand]: true;
|
|
42
39
|
readonly setup?: (context: SetupContext<M>) => Awaitable<Setup>;
|
|
43
40
|
readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
|
|
44
41
|
readonly teardown?: (context: TeardownContext<M, Setup>) => Awaitable<void>;
|
|
@@ -62,10 +59,7 @@ declare function defineTask<const Name extends string, const M extends Matrix =
|
|
|
62
59
|
type AnyTaskDefinition = {
|
|
63
60
|
readonly name: string;
|
|
64
61
|
readonly matrix: Matrix;
|
|
65
|
-
readonly [taskDefinitionBrand]:
|
|
66
|
-
readonly setup: unknown;
|
|
67
|
-
readonly output: JsonValue;
|
|
68
|
-
};
|
|
62
|
+
readonly [taskDefinitionBrand]: true;
|
|
69
63
|
};
|
|
70
64
|
//#endregion
|
|
71
65
|
//#region src/workflow.d.ts
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { beforeAll, describe, test } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
//#region src/task.ts
|
|
3
4
|
const taskDefinitionBrand = Symbol("invoker.task");
|
|
4
5
|
function defineTask(definition) {
|
|
@@ -10,72 +11,91 @@ function defineTask(definition) {
|
|
|
10
11
|
}
|
|
11
12
|
//#endregion
|
|
12
13
|
//#region src/json.ts
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
const scalarSchema = z.union([
|
|
15
|
+
z.null(),
|
|
16
|
+
z.string(),
|
|
17
|
+
z.boolean(),
|
|
18
|
+
z.number()
|
|
19
|
+
]);
|
|
20
|
+
const nameSchema = z.string().trim().min(1);
|
|
21
|
+
function snapshotJson(value, owner, path, ancestors = /* @__PURE__ */ new Set()) {
|
|
22
|
+
return cloneJson(value, owner, path, ancestors);
|
|
23
|
+
}
|
|
24
|
+
function cloneJson(value, owner, path, ancestors) {
|
|
25
|
+
if (scalarSchema.safeParse(value).success) return value;
|
|
26
|
+
if (!isJsonContainer(value)) fail(owner, path, "expected JSON");
|
|
20
27
|
if (ancestors.has(value)) fail(owner, path, "cyclic values are not JSON");
|
|
21
28
|
ancestors.add(value);
|
|
22
|
-
if (Array.isArray(value))
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
const snapshot = [];
|
|
31
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
32
|
+
if (!(index in value)) fail(owner, `${path}[${index}]`, "sparse arrays are not JSON");
|
|
33
|
+
snapshot.push(cloneJson(value[index], owner, `${path}[${index}]`, ancestors));
|
|
34
|
+
}
|
|
35
|
+
ancestors.delete(value);
|
|
36
|
+
return snapshot;
|
|
37
|
+
} else {
|
|
27
38
|
assertPlainObject(value, owner, path);
|
|
28
39
|
if (Object.getOwnPropertySymbols(value).length > 0) fail(owner, path, "JSON objects cannot have symbol keys");
|
|
29
|
-
|
|
40
|
+
const snapshot = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneJson(child, owner, `${path}.${key}`, ancestors)]));
|
|
41
|
+
ancestors.delete(value);
|
|
42
|
+
return snapshot;
|
|
30
43
|
}
|
|
31
|
-
ancestors.delete(value);
|
|
32
44
|
}
|
|
33
45
|
function assertPlainObject(value, owner, path) {
|
|
34
|
-
if (value
|
|
46
|
+
if (Object(value) !== value || Array.isArray(value)) fail(owner, path, "expected a plain object");
|
|
35
47
|
const prototype = Object.getPrototypeOf(value);
|
|
36
48
|
if (prototype !== Object.prototype && prototype !== null) fail(owner, path, "expected a plain object");
|
|
37
49
|
}
|
|
38
50
|
function assertName(value, owner, path) {
|
|
39
|
-
if (
|
|
51
|
+
if (!nameSchema.safeParse(value).success) fail(owner, path, "expected a non-empty string");
|
|
40
52
|
}
|
|
41
53
|
function assertOnlyKeys(value, allowed, owner) {
|
|
42
54
|
for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(owner, `.${key}`, "unknown property");
|
|
43
55
|
}
|
|
44
56
|
function canonicalJson(value) {
|
|
45
57
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
46
|
-
if (value
|
|
47
|
-
const object = value;
|
|
48
|
-
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
|
|
49
|
-
}
|
|
58
|
+
if (isJsonObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
50
59
|
return JSON.stringify(value);
|
|
51
60
|
}
|
|
52
61
|
function fail(owner, path, message) {
|
|
53
62
|
throw new TypeError(`${owner}${path}: ${message}`);
|
|
54
63
|
}
|
|
64
|
+
function isJsonContainer(value) {
|
|
65
|
+
return value !== null && Object(value) === value;
|
|
66
|
+
}
|
|
67
|
+
function isJsonObject(value) {
|
|
68
|
+
return isJsonContainer(value) && !Array.isArray(value);
|
|
69
|
+
}
|
|
55
70
|
//#endregion
|
|
56
71
|
//#region src/matrix.ts
|
|
72
|
+
const axisSchema = z.string();
|
|
57
73
|
function expandMatrix(matrix, owner) {
|
|
58
74
|
if (matrix === void 0) return [{}];
|
|
59
75
|
assertPlainObject(matrix, owner, ".matrix");
|
|
60
76
|
let cases = [{}];
|
|
61
|
-
for (const
|
|
77
|
+
for (const candidate of Reflect.ownKeys(matrix)) {
|
|
78
|
+
const parsed = axisSchema.safeParse(candidate);
|
|
79
|
+
if (!parsed.success || !Object.prototype.propertyIsEnumerable.call(matrix, candidate)) fail(owner, ".matrix", "axis names must be enumerable strings");
|
|
80
|
+
const axis = parsed.data;
|
|
62
81
|
if (axis.trim() === "") fail(owner, ".matrix", "axis names must not be empty");
|
|
82
|
+
const index = Number(axis);
|
|
83
|
+
if (Number.isInteger(index) && index >= 0 && index < 2 ** 32 - 1 && String(index) === axis) fail(owner, `.matrix.${axis}`, "array-index axis names cannot preserve insertion order");
|
|
84
|
+
const values = matrix[axis];
|
|
63
85
|
if (!Array.isArray(values)) fail(owner, `.matrix.${axis}`, "expected an array");
|
|
64
86
|
if (values.length === 0) fail(owner, `.matrix.${axis}`, "expected at least one value");
|
|
65
|
-
values.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
const snapshots = values.map((value, index) => snapshotJson(value, owner, `.matrix.${axis}[${index}]`));
|
|
88
|
+
const axisValues = /* @__PURE__ */ new Set();
|
|
89
|
+
for (const [index, value] of snapshots.entries()) {
|
|
90
|
+
const key = canonicalJson(value);
|
|
91
|
+
if (axisValues.has(key)) fail(owner, `.matrix.${axis}[${index}]`, `duplicate axis value ${JSON.stringify(value)}`);
|
|
92
|
+
axisValues.add(key);
|
|
93
|
+
}
|
|
94
|
+
cases = cases.flatMap((coordinates) => snapshots.map((value) => ({
|
|
69
95
|
...coordinates,
|
|
70
96
|
[axis]: value
|
|
71
97
|
})));
|
|
72
98
|
}
|
|
73
|
-
const coordinates = /* @__PURE__ */ new Set();
|
|
74
|
-
for (const value of cases) {
|
|
75
|
-
const key = canonicalJson(value);
|
|
76
|
-
if (coordinates.has(key)) fail(owner, ".matrix", `duplicate coordinate ${JSON.stringify(value)}`);
|
|
77
|
-
coordinates.add(key);
|
|
78
|
-
}
|
|
79
99
|
return cases;
|
|
80
100
|
}
|
|
81
101
|
function caseName(matrix, index) {
|
|
@@ -109,8 +129,7 @@ function defineWorkflow(definition) {
|
|
|
109
129
|
setup,
|
|
110
130
|
vitest
|
|
111
131
|
});
|
|
112
|
-
|
|
113
|
-
meta.invoker.output = output;
|
|
132
|
+
meta.invoker.output = snapshotJson(output, `Task ${JSON.stringify(prepared.task.name)}`, ".output");
|
|
114
133
|
});
|
|
115
134
|
}
|
|
116
135
|
});
|
|
@@ -123,51 +142,49 @@ function prepareWorkflow(definition) {
|
|
|
123
142
|
"metadata",
|
|
124
143
|
"tasks"
|
|
125
144
|
], "Workflow");
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
if (!Array.isArray(definition.tasks) || definition.tasks.length === 0) fail("Workflow", ".tasks", "expected a non-empty Task tuple");
|
|
145
|
+
const { name, metadata: workflowMetadata, tasks: taskDefinitions } = definition;
|
|
146
|
+
assertName(name, "Workflow", ".name");
|
|
147
|
+
const metadata = workflowMetadata === void 0 ? void 0 : snapshotJson(workflowMetadata, `Workflow ${JSON.stringify(name)}`, ".metadata");
|
|
148
|
+
if (metadata !== void 0) assertPlainObject(metadata, `Workflow ${JSON.stringify(name)}`, ".metadata");
|
|
149
|
+
if (!Array.isArray(taskDefinitions) || taskDefinitions.length === 0) fail("Workflow", ".tasks", "expected a non-empty Task tuple");
|
|
133
150
|
const names = /* @__PURE__ */ new Set();
|
|
134
|
-
const tasks = definition.tasks.map((value, index) => {
|
|
135
|
-
const owner = `Workflow ${JSON.stringify(definition.name)} Task ${index + 1}`;
|
|
136
|
-
assertPlainObject(value, owner, "");
|
|
137
|
-
const task = value;
|
|
138
|
-
if (task[taskDefinitionBrand] !== true) fail(owner, "", "expected a Task created by defineTask");
|
|
139
|
-
assertOnlyKeys(task, [
|
|
140
|
-
"name",
|
|
141
|
-
"matrix",
|
|
142
|
-
"setup",
|
|
143
|
-
"run",
|
|
144
|
-
"teardown"
|
|
145
|
-
], owner);
|
|
146
|
-
assertName(task.name, owner, ".name");
|
|
147
|
-
if (names.has(task.name)) fail(owner, ".name", `duplicate Task name ${JSON.stringify(task.name)}`);
|
|
148
|
-
names.add(task.name);
|
|
149
|
-
if (typeof task.run !== "function") fail(owner, ".run", "expected a function");
|
|
150
|
-
if (task.setup !== void 0 && typeof task.setup !== "function") fail(owner, ".setup", "expected a function");
|
|
151
|
-
if (task.teardown !== void 0 && typeof task.teardown !== "function") fail(owner, ".teardown", "expected a function");
|
|
152
|
-
if (task.teardown && !task.setup) fail(owner, ".teardown", "requires setup");
|
|
153
|
-
const cases = expandMatrix(task.matrix, `Task ${JSON.stringify(task.name)}`);
|
|
154
|
-
return {
|
|
155
|
-
task,
|
|
156
|
-
cases,
|
|
157
|
-
names: cases.map(caseName),
|
|
158
|
-
metadata: cases.map((matrix) => metadata === void 0 ? {
|
|
159
|
-
schema: 1,
|
|
160
|
-
matrix
|
|
161
|
-
} : {
|
|
162
|
-
schema: 1,
|
|
163
|
-
matrix,
|
|
164
|
-
metadata
|
|
165
|
-
})
|
|
166
|
-
};
|
|
167
|
-
});
|
|
168
151
|
return {
|
|
169
|
-
name
|
|
170
|
-
tasks
|
|
152
|
+
name,
|
|
153
|
+
tasks: taskDefinitions.map((value, index) => {
|
|
154
|
+
const owner = `Workflow ${JSON.stringify(name)} Task ${index + 1}`;
|
|
155
|
+
assertPlainObject(value, owner, "");
|
|
156
|
+
if (value[taskDefinitionBrand] !== true) fail(owner, "", "expected a Task created by defineTask");
|
|
157
|
+
const task = value;
|
|
158
|
+
assertOnlyKeys(task, [
|
|
159
|
+
"name",
|
|
160
|
+
"matrix",
|
|
161
|
+
"setup",
|
|
162
|
+
"run",
|
|
163
|
+
"teardown"
|
|
164
|
+
], owner);
|
|
165
|
+
const taskSnapshot = { ...task };
|
|
166
|
+
assertName(taskSnapshot.name, owner, ".name");
|
|
167
|
+
if (names.has(taskSnapshot.name)) fail(owner, ".name", `duplicate Task name ${JSON.stringify(taskSnapshot.name)}`);
|
|
168
|
+
names.add(taskSnapshot.name);
|
|
169
|
+
if (!z.function().safeParse(taskSnapshot.run).success) fail(owner, ".run", "expected a function");
|
|
170
|
+
if (taskSnapshot.setup !== void 0 && !z.function().safeParse(taskSnapshot.setup).success) fail(owner, ".setup", "expected a function");
|
|
171
|
+
if (taskSnapshot.teardown !== void 0 && !z.function().safeParse(taskSnapshot.teardown).success) fail(owner, ".teardown", "expected a function");
|
|
172
|
+
if (taskSnapshot.teardown && !taskSnapshot.setup) fail(owner, ".teardown", "requires setup");
|
|
173
|
+
const cases = expandMatrix(taskSnapshot.matrix, `Task ${JSON.stringify(taskSnapshot.name)}`);
|
|
174
|
+
return {
|
|
175
|
+
task: taskSnapshot,
|
|
176
|
+
cases,
|
|
177
|
+
names: cases.map(caseName),
|
|
178
|
+
metadata: cases.map((matrix) => metadata === void 0 ? {
|
|
179
|
+
schema: 1,
|
|
180
|
+
matrix
|
|
181
|
+
} : {
|
|
182
|
+
schema: 1,
|
|
183
|
+
matrix,
|
|
184
|
+
metadata
|
|
185
|
+
})
|
|
186
|
+
};
|
|
187
|
+
})
|
|
171
188
|
};
|
|
172
189
|
}
|
|
173
190
|
//#endregion
|
package/dist/slack.d.mts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Reporter } from "vitest/reporters";
|
|
2
|
+
//#region src/slack/reporter.d.ts
|
|
3
|
+
type SlackReporterOptions = {
|
|
4
|
+
readonly token: string;
|
|
5
|
+
readonly channel: string;
|
|
6
|
+
readonly runUrl?: string;
|
|
7
|
+
};
|
|
8
|
+
declare function slackReporter(options: SlackReporterOptions): Reporter;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { type SlackReporterOptions, slackReporter };
|
package/dist/slack.mjs
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { WebAPIRateLimitedError, WebClient } from "@slack/web-api";
|
|
3
|
+
import { setTimeout } from "node:timers/promises";
|
|
4
|
+
//#region src/slack/report.ts
|
|
5
|
+
const stringSchema = z.string();
|
|
6
|
+
const jsonObjectSchema = z.record(stringSchema, z.json());
|
|
7
|
+
const invokerMetaSchema = z.strictObject({
|
|
8
|
+
schema: z.literal(1),
|
|
9
|
+
matrix: jsonObjectSchema,
|
|
10
|
+
metadata: jsonObjectSchema.optional(),
|
|
11
|
+
output: z.json().optional()
|
|
12
|
+
});
|
|
13
|
+
const testMetaSchema = z.object({ invoker: invokerMetaSchema.optional() });
|
|
14
|
+
const errorMessageSchema = z.object({ message: stringSchema });
|
|
15
|
+
const errorStackSchema = z.object({ stack: stringSchema });
|
|
16
|
+
const TABLE_ROW_LIMIT = 100;
|
|
17
|
+
const TABLE_CHARACTER_LIMIT = 1e4;
|
|
18
|
+
const SECTION_CHARACTER_LIMIT = 3e3;
|
|
19
|
+
const NAME_CHARACTER_LIMIT = 200;
|
|
20
|
+
const METADATA_CHARACTER_LIMIT = 1800;
|
|
21
|
+
function collectWorkflowReports(modules) {
|
|
22
|
+
const workflows = /* @__PURE__ */ new Map();
|
|
23
|
+
for (const module of modules) for (const testCase of module.children.allTests()) {
|
|
24
|
+
const meta = testMetaSchema.safeParse(testCase.meta());
|
|
25
|
+
const invoker = meta.success ? meta.data.invoker : void 0;
|
|
26
|
+
const taskSuite = testCase.parent;
|
|
27
|
+
const workflowSuite = taskSuite.type === "suite" ? taskSuite.parent : void 0;
|
|
28
|
+
if (!invoker || taskSuite.type !== "suite" || workflowSuite?.type !== "suite") continue;
|
|
29
|
+
let workflow = workflows.get(workflowSuite);
|
|
30
|
+
if (!workflow) {
|
|
31
|
+
workflow = {
|
|
32
|
+
name: workflowSuite.name,
|
|
33
|
+
module,
|
|
34
|
+
suite: workflowSuite,
|
|
35
|
+
metadata: invoker.metadata,
|
|
36
|
+
tasks: /* @__PURE__ */ new Map()
|
|
37
|
+
};
|
|
38
|
+
workflows.set(workflowSuite, workflow);
|
|
39
|
+
}
|
|
40
|
+
let task = workflow.tasks.get(taskSuite);
|
|
41
|
+
if (!task) {
|
|
42
|
+
task = {
|
|
43
|
+
name: taskSuite.name,
|
|
44
|
+
suite: taskSuite,
|
|
45
|
+
total: 0,
|
|
46
|
+
passed: 0,
|
|
47
|
+
retried: 0,
|
|
48
|
+
failed: 0,
|
|
49
|
+
skipped: 0,
|
|
50
|
+
incomplete: 0,
|
|
51
|
+
failures: []
|
|
52
|
+
};
|
|
53
|
+
workflow.tasks.set(taskSuite, task);
|
|
54
|
+
}
|
|
55
|
+
task.total += 1;
|
|
56
|
+
const result = testCase.result();
|
|
57
|
+
const diagnostic = testCase.diagnostic();
|
|
58
|
+
task[result.state === "pending" ? "incomplete" : result.state] += 1;
|
|
59
|
+
if ((diagnostic?.retryCount ?? 0) > 0) task.retried += 1;
|
|
60
|
+
if (diagnostic) {
|
|
61
|
+
task.startedAt = Math.min(task.startedAt ?? diagnostic.startTime, diagnostic.startTime);
|
|
62
|
+
task.endedAt = Math.max(task.endedAt ?? 0, diagnostic.startTime + diagnostic.duration);
|
|
63
|
+
}
|
|
64
|
+
if (result.state === "failed") task.failures.push({
|
|
65
|
+
task: task.name,
|
|
66
|
+
caseName: testCase.name,
|
|
67
|
+
matrix: invoker.matrix,
|
|
68
|
+
messages: result.errors.map(errorMessage)
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return [...workflows.values()].map((workflow) => {
|
|
72
|
+
const failures = [];
|
|
73
|
+
addErrors(failures, workflow.suite.errors());
|
|
74
|
+
addErrors(failures, workflow.module.errors());
|
|
75
|
+
const collectedTasks = [...workflow.tasks.values()];
|
|
76
|
+
for (const task of collectedTasks) {
|
|
77
|
+
failures.push(...task.failures);
|
|
78
|
+
addErrors(failures, task.suite.errors(), task.name);
|
|
79
|
+
}
|
|
80
|
+
const { startedAt, endedAt } = timeSpan(collectedTasks);
|
|
81
|
+
return {
|
|
82
|
+
name: workflow.name,
|
|
83
|
+
metadata: workflow.metadata,
|
|
84
|
+
tasks: collectedTasks.map(({ suite: _suite, failures: _failures, startedAt, endedAt, ...task }) => ({
|
|
85
|
+
...task,
|
|
86
|
+
duration: startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt
|
|
87
|
+
})),
|
|
88
|
+
failures: deduplicateFailures(failures),
|
|
89
|
+
startedAt,
|
|
90
|
+
endedAt
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function summaryMessages(reports, runUrl) {
|
|
95
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
96
|
+
const { startedAt, endedAt } = timeSpan(reports);
|
|
97
|
+
const footer = [
|
|
98
|
+
`Elapsed: ${formatDuration(startedAt === void 0 || endedAt === void 0 ? 0 : endedAt - startedAt)}`,
|
|
99
|
+
`<!date^${timestamp}^{date_short_pretty} at {time}|${(/* @__PURE__ */ new Date(timestamp * 1e3)).toISOString()}>`,
|
|
100
|
+
...runUrl ? [`<${escapeSlackControl(runUrl)}|View run>`] : []
|
|
101
|
+
].join(" • ");
|
|
102
|
+
return reports.flatMap(workflowAttachments).map((attachment, index) => ({
|
|
103
|
+
text: index === 0 ? "Invoker Report" : "Invoker Report (continued)",
|
|
104
|
+
attachments: [attachment, ...index === 0 ? [{ blocks: [{
|
|
105
|
+
type: "context",
|
|
106
|
+
elements: [{
|
|
107
|
+
type: "mrkdwn",
|
|
108
|
+
text: footer
|
|
109
|
+
}]
|
|
110
|
+
}] }] : []]
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
function workflowAttachments(report) {
|
|
114
|
+
const tables = taskTables(report.tasks);
|
|
115
|
+
return tables.map((rows, index) => workflowAttachment(report, rows, index, tables.length));
|
|
116
|
+
}
|
|
117
|
+
function workflowAttachment(report, rows, index, pages) {
|
|
118
|
+
const totals = taskTotals(report.tasks);
|
|
119
|
+
const status = workflowStatus(report);
|
|
120
|
+
const metadata = metadataText(report.metadata);
|
|
121
|
+
const page = pages > 1 ? ` (${index + 1}/${pages})` : "";
|
|
122
|
+
const headline = `${status.emoji} *${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))} — ${totals.passed}/${totals.total} passed${page}*`;
|
|
123
|
+
return {
|
|
124
|
+
color: status.color,
|
|
125
|
+
blocks: [
|
|
126
|
+
{
|
|
127
|
+
type: "section",
|
|
128
|
+
text: {
|
|
129
|
+
type: "mrkdwn",
|
|
130
|
+
text: headline
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
...metadata ? [{
|
|
134
|
+
type: "context",
|
|
135
|
+
elements: [{
|
|
136
|
+
type: "mrkdwn",
|
|
137
|
+
text: metadata
|
|
138
|
+
}]
|
|
139
|
+
}] : [],
|
|
140
|
+
{
|
|
141
|
+
type: "table",
|
|
142
|
+
column_settings: [
|
|
143
|
+
{ is_wrapped: true },
|
|
144
|
+
{ align: "right" },
|
|
145
|
+
{ align: "right" },
|
|
146
|
+
{ align: "right" },
|
|
147
|
+
{ align: "right" },
|
|
148
|
+
{ align: "right" }
|
|
149
|
+
],
|
|
150
|
+
rows
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function failureMessages(report) {
|
|
156
|
+
return [...Map.groupBy(report.failures, (failure) => failure.task)].flatMap(([task, failures]) => {
|
|
157
|
+
const title = escapeSlack(truncate(task ?? "Workflow errors", NAME_CHARACTER_LIMIT));
|
|
158
|
+
const workflow = `*Workflow:* ${escapeSlack(truncate(report.name, NAME_CHARACTER_LIMIT))}`;
|
|
159
|
+
const metadata = metadataText(report.metadata);
|
|
160
|
+
const context = metadata ? `${workflow} • ${metadata}` : workflow;
|
|
161
|
+
return chunk(failures.map((failure) => {
|
|
162
|
+
return `${failure.caseName ? `*${escapeSlack(failure.caseName)}*` : task ? "*Task error*" : "*Workflow error*"}${failure.matrix ? `\nMatrix: ${escapeSlack(JSON.stringify(failure.matrix))}` : ""}${failure.messages.map((message) => `\n• ${escapeSlack(message)}`).join("")}`;
|
|
163
|
+
}), 3e3).map((details, index, messages) => {
|
|
164
|
+
const part = messages.length > 1 ? ` (${index + 1}/${messages.length})` : "";
|
|
165
|
+
return {
|
|
166
|
+
text: `${truncate(report.name, NAME_CHARACTER_LIMIT)} › ${truncate(task ?? "Workflow", NAME_CHARACTER_LIMIT)} — ${failures.length} failed${part}`,
|
|
167
|
+
attachments: [{
|
|
168
|
+
color: "danger",
|
|
169
|
+
blocks: [
|
|
170
|
+
{
|
|
171
|
+
type: "section",
|
|
172
|
+
text: {
|
|
173
|
+
type: "mrkdwn",
|
|
174
|
+
text: `🔴 *${title} — ${failures.length} failed${part}*`
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
type: "context",
|
|
179
|
+
elements: [{
|
|
180
|
+
type: "mrkdwn",
|
|
181
|
+
text: context
|
|
182
|
+
}]
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
type: "section",
|
|
186
|
+
text: {
|
|
187
|
+
type: "mrkdwn",
|
|
188
|
+
text: details
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
}]
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function unhandledErrorMessages(errors) {
|
|
198
|
+
const messages = [...new Set(errors.map(errorMessage))];
|
|
199
|
+
return chunk(messages.map((message) => `• ${escapeSlack(message)}`), SECTION_CHARACTER_LIMIT).map((details, index, chunks) => {
|
|
200
|
+
const part = chunks.length > 1 ? ` (${index + 1}/${chunks.length})` : "";
|
|
201
|
+
return {
|
|
202
|
+
text: `Invoker run — ${messages.length} unhandled error${messages.length === 1 ? "" : "s"}${part}`,
|
|
203
|
+
attachments: [{
|
|
204
|
+
color: "danger",
|
|
205
|
+
blocks: [{
|
|
206
|
+
type: "section",
|
|
207
|
+
text: {
|
|
208
|
+
type: "mrkdwn",
|
|
209
|
+
text: `🔴 *Run errors${part}*`
|
|
210
|
+
}
|
|
211
|
+
}, {
|
|
212
|
+
type: "section",
|
|
213
|
+
text: {
|
|
214
|
+
type: "mrkdwn",
|
|
215
|
+
text: details
|
|
216
|
+
}
|
|
217
|
+
}]
|
|
218
|
+
}]
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function addErrors(failures, errors, task) {
|
|
223
|
+
const messages = errors.map(errorMessage);
|
|
224
|
+
if (messages.length > 0) failures.push({
|
|
225
|
+
task,
|
|
226
|
+
messages
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function errorMessage(cause) {
|
|
230
|
+
const message = errorMessageSchema.safeParse(cause);
|
|
231
|
+
if (message.success) return message.data.message;
|
|
232
|
+
const stack = errorStackSchema.safeParse(cause);
|
|
233
|
+
if (stack.success) return stack.data.stack.split("\n", 1)[0];
|
|
234
|
+
return String(cause);
|
|
235
|
+
}
|
|
236
|
+
function deduplicateFailures(failures) {
|
|
237
|
+
const seen = /* @__PURE__ */ new Set();
|
|
238
|
+
return failures.flatMap((failure) => {
|
|
239
|
+
const messages = failure.messages.filter((message) => {
|
|
240
|
+
const key = JSON.stringify([
|
|
241
|
+
failure.task,
|
|
242
|
+
failure.caseName,
|
|
243
|
+
failure.matrix,
|
|
244
|
+
message
|
|
245
|
+
]);
|
|
246
|
+
if (seen.has(key)) return false;
|
|
247
|
+
seen.add(key);
|
|
248
|
+
return true;
|
|
249
|
+
});
|
|
250
|
+
return messages.length > 0 ? [{
|
|
251
|
+
...failure,
|
|
252
|
+
messages
|
|
253
|
+
}] : [];
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function escapeSlack(value) {
|
|
257
|
+
return escapeSlackControl(value).replaceAll("*", "∗").replaceAll("_", "_").replaceAll("~", "∼").replaceAll("`", "ˋ");
|
|
258
|
+
}
|
|
259
|
+
function escapeSlackControl(value) {
|
|
260
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
261
|
+
}
|
|
262
|
+
function taskTotals(tasks) {
|
|
263
|
+
return tasks.reduce((totals, task) => ({
|
|
264
|
+
total: totals.total + task.total,
|
|
265
|
+
passed: totals.passed + task.passed
|
|
266
|
+
}), {
|
|
267
|
+
total: 0,
|
|
268
|
+
passed: 0
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function workflowStatus(report) {
|
|
272
|
+
if (report.failures.length > 0 || report.tasks.some((task) => task.failed > 0)) return {
|
|
273
|
+
emoji: "🔴",
|
|
274
|
+
color: "danger"
|
|
275
|
+
};
|
|
276
|
+
if (report.tasks.some((task) => task.retried > 0 || task.skipped > 0 || task.incomplete > 0)) return {
|
|
277
|
+
emoji: "🟡",
|
|
278
|
+
color: "warning"
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
emoji: "🟢",
|
|
282
|
+
color: "good"
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function metadataText(metadata) {
|
|
286
|
+
if (!metadata || Object.keys(metadata).length === 0) return void 0;
|
|
287
|
+
return truncate(Object.entries(metadata).map(([key, value]) => {
|
|
288
|
+
const string = stringSchema.safeParse(value);
|
|
289
|
+
return `*${escapeSlack(key)}:* ${escapeSlack(string.success ? string.data : JSON.stringify(value))}`;
|
|
290
|
+
}).join(" • "), METADATA_CHARACTER_LIMIT);
|
|
291
|
+
}
|
|
292
|
+
function rawCell(text) {
|
|
293
|
+
return {
|
|
294
|
+
type: "raw_text",
|
|
295
|
+
text
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function taskTables(tasks) {
|
|
299
|
+
const header = [
|
|
300
|
+
"Task",
|
|
301
|
+
"Passed",
|
|
302
|
+
"Retries",
|
|
303
|
+
"Failed",
|
|
304
|
+
"Skipped",
|
|
305
|
+
"Time"
|
|
306
|
+
].map(rawCell);
|
|
307
|
+
const headerCharacters = rowCharacters(header);
|
|
308
|
+
const tables = [];
|
|
309
|
+
let rows = [header];
|
|
310
|
+
let characters = headerCharacters;
|
|
311
|
+
for (const task of tasks) {
|
|
312
|
+
const values = [
|
|
313
|
+
String(task.passed),
|
|
314
|
+
String(task.retried),
|
|
315
|
+
String(task.failed),
|
|
316
|
+
String(task.skipped),
|
|
317
|
+
formatDuration(task.duration)
|
|
318
|
+
];
|
|
319
|
+
const taskNameLimit = TABLE_CHARACTER_LIMIT - headerCharacters - values.reduce((total, value) => total + value.length, 0);
|
|
320
|
+
const row = [truncate(task.name, taskNameLimit), ...values].map(rawCell);
|
|
321
|
+
const rowLength = rowCharacters(row);
|
|
322
|
+
if (rows.length === TABLE_ROW_LIMIT || characters + rowLength > TABLE_CHARACTER_LIMIT) {
|
|
323
|
+
tables.push(rows);
|
|
324
|
+
rows = [header];
|
|
325
|
+
characters = headerCharacters;
|
|
326
|
+
}
|
|
327
|
+
rows.push(row);
|
|
328
|
+
characters += rowLength;
|
|
329
|
+
}
|
|
330
|
+
tables.push(rows);
|
|
331
|
+
return tables;
|
|
332
|
+
}
|
|
333
|
+
function rowCharacters(row) {
|
|
334
|
+
return row.reduce((total, cell) => total + cell.text.length, 0);
|
|
335
|
+
}
|
|
336
|
+
function formatDuration(milliseconds) {
|
|
337
|
+
if (milliseconds < 1e3) return `${Math.round(milliseconds)}ms`;
|
|
338
|
+
return `${(milliseconds / 1e3).toFixed(1)}s`;
|
|
339
|
+
}
|
|
340
|
+
function chunk(entries, limit) {
|
|
341
|
+
const chunks = [];
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
const value = entry.length > limit ? `${entry.slice(0, limit - 1)}…` : entry;
|
|
344
|
+
const previous = chunks.at(-1);
|
|
345
|
+
if (previous && previous.length + value.length + 2 <= limit) chunks[chunks.length - 1] = `${previous}\n\n${value}`;
|
|
346
|
+
else chunks.push(value);
|
|
347
|
+
}
|
|
348
|
+
return chunks;
|
|
349
|
+
}
|
|
350
|
+
function truncate(value, limit) {
|
|
351
|
+
return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
|
|
352
|
+
}
|
|
353
|
+
function timeSpan(values) {
|
|
354
|
+
let startedAt;
|
|
355
|
+
let endedAt;
|
|
356
|
+
for (const value of values) {
|
|
357
|
+
if (value.startedAt !== void 0) startedAt = Math.min(startedAt ?? value.startedAt, value.startedAt);
|
|
358
|
+
if (value.endedAt !== void 0) endedAt = Math.max(endedAt ?? value.endedAt, value.endedAt);
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
startedAt,
|
|
362
|
+
endedAt
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region src/slack/reporter.ts
|
|
367
|
+
function slackReporter(options) {
|
|
368
|
+
const client = new WebClient(options.token, {
|
|
369
|
+
rejectRateLimitedCalls: true,
|
|
370
|
+
retryConfig: { retries: 0 },
|
|
371
|
+
timeout: 1e4
|
|
372
|
+
});
|
|
373
|
+
const postMessage = async (arguments_) => {
|
|
374
|
+
try {
|
|
375
|
+
return await client.chat.postMessage(arguments_);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (!(error instanceof WebAPIRateLimitedError)) throw error;
|
|
378
|
+
await setTimeout(error.retryAfter * 1e3);
|
|
379
|
+
return client.chat.postMessage(arguments_);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
return { async onTestRunEnd(modules, unhandledErrors) {
|
|
383
|
+
const reports = collectWorkflowReports(modules);
|
|
384
|
+
if (reports.length === 0) return;
|
|
385
|
+
const [parentMessage, ...continuations] = summaryMessages(reports, options.runUrl);
|
|
386
|
+
if (!parentMessage) return;
|
|
387
|
+
let parentTimestamp;
|
|
388
|
+
try {
|
|
389
|
+
const parentArguments = {
|
|
390
|
+
channel: options.channel,
|
|
391
|
+
...parentMessage,
|
|
392
|
+
mrkdwn: false,
|
|
393
|
+
unfurl_links: false
|
|
394
|
+
};
|
|
395
|
+
parentTimestamp = (await postMessage(parentArguments)).ts;
|
|
396
|
+
} catch {
|
|
397
|
+
console.warn("[invoker] Could not post the Slack report.");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const replies = [
|
|
401
|
+
...continuations,
|
|
402
|
+
...reports.flatMap(failureMessages),
|
|
403
|
+
...unhandledErrorMessages(unhandledErrors)
|
|
404
|
+
];
|
|
405
|
+
if (replies.length > 0 && !parentTimestamp) {
|
|
406
|
+
console.warn("[invoker] Slack did not return a timestamp for the report thread.");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
let failedMessages = 0;
|
|
410
|
+
for (const reply of replies) try {
|
|
411
|
+
await postMessage({
|
|
412
|
+
channel: options.channel,
|
|
413
|
+
...reply,
|
|
414
|
+
thread_ts: parentTimestamp,
|
|
415
|
+
mrkdwn: false,
|
|
416
|
+
unfurl_links: false
|
|
417
|
+
});
|
|
418
|
+
} catch {
|
|
419
|
+
failedMessages += 1;
|
|
420
|
+
}
|
|
421
|
+
if (failedMessages > 0) console.warn(`[invoker] Could not post ${failedMessages} Slack report message${failedMessages === 1 ? "" : "s"}.`);
|
|
422
|
+
} };
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
export { slackReporter };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akshatmittal/invoker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Typed matrix-driven regression workflows for Vitest.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"matrix",
|
|
@@ -27,11 +27,27 @@
|
|
|
27
27
|
".": {
|
|
28
28
|
"types": "./dist/index.d.mts",
|
|
29
29
|
"import": "./dist/index.mjs"
|
|
30
|
+
},
|
|
31
|
+
"./github": {
|
|
32
|
+
"types": "./dist/github.d.mts",
|
|
33
|
+
"import": "./dist/github.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./slack": {
|
|
36
|
+
"types": "./dist/slack.d.mts",
|
|
37
|
+
"import": "./dist/slack.mjs"
|
|
30
38
|
}
|
|
31
39
|
},
|
|
32
40
|
"publishConfig": {
|
|
33
41
|
"access": "public"
|
|
34
42
|
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@octokit/auth-app": "^8.3.0",
|
|
45
|
+
"@octokit/request": "^10.0.13",
|
|
46
|
+
"@slack/web-api": "^8.0.0",
|
|
47
|
+
"croner": "^10.0.1",
|
|
48
|
+
"evlog": "^2.26.0",
|
|
49
|
+
"zod": "^4.4.3"
|
|
50
|
+
},
|
|
35
51
|
"devDependencies": {
|
|
36
52
|
"@types/node": "^24.13.3",
|
|
37
53
|
"tsdown": "^0.22.14",
|
|
@@ -42,6 +58,11 @@
|
|
|
42
58
|
"peerDependencies": {
|
|
43
59
|
"vitest": "^4.1.10"
|
|
44
60
|
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"vitest": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
45
66
|
"engines": {
|
|
46
67
|
"node": "^24.18.1"
|
|
47
68
|
},
|