@skippercorp/skipper 1.0.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.
@@ -0,0 +1,324 @@
1
+ type WorkerProvider = "github";
2
+ type WorkerAgent = "claude" | "opencode";
3
+ type WorkerMode = "comment-only" | "apply";
4
+
5
+ export type WorkerMetadata = {
6
+ id: string;
7
+ type: string;
8
+ description?: string;
9
+ enabled?: boolean;
10
+ version?: string;
11
+ };
12
+
13
+ export type WorkerTriggerFilter = {
14
+ repository?: string[];
15
+ baseBranches?: string[];
16
+ headBranches?: string[];
17
+ draft?: boolean;
18
+ };
19
+
20
+ export type WorkerTrigger = {
21
+ provider: WorkerProvider;
22
+ event: string;
23
+ actions?: string[];
24
+ if?: WorkerTriggerFilter;
25
+ };
26
+
27
+ export type WorkerRuntime = {
28
+ agent?: WorkerAgent;
29
+ mode?: WorkerMode;
30
+ prompt: string;
31
+ allowPush?: boolean;
32
+ maxDurationMinutes?: number;
33
+ env?: Record<string, string>;
34
+ };
35
+
36
+ export type WorkerDefinition = {
37
+ metadata: WorkerMetadata;
38
+ triggers: WorkerTrigger[];
39
+ runtime: WorkerRuntime;
40
+ };
41
+
42
+ export type WorkerManifest = {
43
+ workers: WorkerDefinition[];
44
+ };
45
+
46
+ /**
47
+ * Parse worker module export into typed worker definition.
48
+ *
49
+ * @since 1.0.0
50
+ * @category Shared
51
+ */
52
+ export function parseWorkerDefinition(value: unknown, label: string): WorkerDefinition {
53
+ if (!isRecord(value)) {
54
+ throw new Error(`invalid worker definition in ${label}`);
55
+ }
56
+ const metadata = parseMetadata(value.metadata, label);
57
+ const triggers = parseTriggers(value.triggers, label);
58
+ const runtime = parseRuntime(value.runtime, label);
59
+ return {
60
+ metadata: {
61
+ ...metadata,
62
+ enabled: metadata.enabled ?? true,
63
+ },
64
+ triggers,
65
+ runtime,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Parse worker metadata section.
71
+ *
72
+ * @since 1.0.0
73
+ * @category Shared
74
+ */
75
+ function parseMetadata(value: unknown, label: string): WorkerMetadata {
76
+ if (!isRecord(value)) throw new Error(`missing metadata in ${label}`);
77
+ const id = parseWorkerId(value.id, label);
78
+ const type = readNonEmptyString(value.type, `metadata.type in ${label}`);
79
+ const description = readOptionalString(value.description, `metadata.description in ${label}`);
80
+ const enabled = readOptionalBoolean(value.enabled, `metadata.enabled in ${label}`);
81
+ const version = readOptionalString(value.version, `metadata.version in ${label}`);
82
+ return { id, type, description, enabled, version };
83
+ }
84
+
85
+ /**
86
+ * Parse worker trigger list.
87
+ *
88
+ * @since 1.0.0
89
+ * @category Shared
90
+ */
91
+ function parseTriggers(value: unknown, label: string): WorkerTrigger[] {
92
+ if (!Array.isArray(value) || value.length === 0) {
93
+ throw new Error(`missing triggers in ${label}`);
94
+ }
95
+ return value.map((entry, index) => parseTrigger(entry, `${label} triggers[${index}]`));
96
+ }
97
+
98
+ /**
99
+ * Parse one worker trigger.
100
+ *
101
+ * @since 1.0.0
102
+ * @category Shared
103
+ */
104
+ function parseTrigger(value: unknown, label: string): WorkerTrigger {
105
+ if (!isRecord(value)) {
106
+ throw new Error(`invalid ${label}`);
107
+ }
108
+ const provider = readNonEmptyString(value.provider, `${label} provider`);
109
+ if (provider !== "github") {
110
+ throw new Error(`invalid provider in ${label}`);
111
+ }
112
+ const event = readNonEmptyString(value.event, `${label} event`);
113
+ const actions = readOptionalStringArray(value.actions, `${label} actions`);
114
+ const filter = parseTriggerFilter(value.if, label);
115
+ return {
116
+ provider,
117
+ event,
118
+ actions,
119
+ if: filter,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Parse trigger filter block.
125
+ *
126
+ * @since 1.0.0
127
+ * @category Shared
128
+ */
129
+ function parseTriggerFilter(value: unknown, label: string): WorkerTriggerFilter | undefined {
130
+ if (value === undefined) return undefined;
131
+ if (!isRecord(value)) {
132
+ throw new Error(`invalid if block in ${label}`);
133
+ }
134
+ const repository = readOptionalStringArray(value.repository, `${label} if.repository`);
135
+ const baseBranches = readOptionalStringArray(value.baseBranches, `${label} if.baseBranches`);
136
+ const headBranches = readOptionalStringArray(value.headBranches, `${label} if.headBranches`);
137
+ const draft = readOptionalBoolean(value.draft, `${label} if.draft`);
138
+ return {
139
+ repository,
140
+ baseBranches,
141
+ headBranches,
142
+ draft,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Parse worker runtime section.
148
+ *
149
+ * @since 1.0.0
150
+ * @category Shared
151
+ */
152
+ function parseRuntime(value: unknown, label: string): WorkerRuntime {
153
+ if (!isRecord(value)) throw new Error(`missing runtime in ${label}`);
154
+ const prompt = readNonEmptyString(value.prompt, `runtime.prompt in ${label}`);
155
+ const agentValue = readOptionalString(value.agent, `runtime.agent in ${label}`);
156
+ const modeValue = readOptionalString(value.mode, `runtime.mode in ${label}`);
157
+ const maxDurationMinutes = readOptionalNumber(
158
+ value.maxDurationMinutes,
159
+ `runtime.maxDurationMinutes in ${label}`,
160
+ );
161
+ const env = readOptionalStringRecord(value.env, `runtime.env in ${label}`);
162
+ const allowPush = readOptionalBoolean(value.allowPush, `runtime.allowPush in ${label}`);
163
+ const agent = parseRuntimeAgent(agentValue, label);
164
+ const mode = parseRuntimeMode(modeValue, label);
165
+ return {
166
+ prompt,
167
+ agent,
168
+ mode,
169
+ allowPush,
170
+ maxDurationMinutes,
171
+ env,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Parse runtime agent value.
177
+ *
178
+ * @since 1.0.0
179
+ * @category Shared
180
+ */
181
+ function parseRuntimeAgent(value: string | undefined, label: string): WorkerAgent | undefined {
182
+ if (value === undefined) return undefined;
183
+ if (value !== "claude" && value !== "opencode") {
184
+ throw new Error(`invalid runtime.agent in ${label}`);
185
+ }
186
+ return value;
187
+ }
188
+
189
+ /**
190
+ * Parse runtime mode value.
191
+ *
192
+ * @since 1.0.0
193
+ * @category Shared
194
+ */
195
+ function parseRuntimeMode(value: string | undefined, label: string): WorkerMode | undefined {
196
+ if (value === undefined) return undefined;
197
+ if (value !== "comment-only" && value !== "apply") {
198
+ throw new Error(`invalid runtime.mode in ${label}`);
199
+ }
200
+ return value;
201
+ }
202
+
203
+ /**
204
+ * Parse worker id.
205
+ *
206
+ * @since 1.0.0
207
+ * @category Shared
208
+ */
209
+ function parseWorkerId(value: unknown, label: string): string {
210
+ const workerId = readNonEmptyString(value, `metadata.id in ${label}`);
211
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(workerId)) {
212
+ throw new Error(`invalid metadata.id in ${label}`);
213
+ }
214
+ return workerId;
215
+ }
216
+
217
+ /**
218
+ * Read optional string value.
219
+ *
220
+ * @since 1.0.0
221
+ * @category Shared
222
+ */
223
+ function readOptionalString(value: unknown, label: string): string | undefined {
224
+ if (value === undefined) return undefined;
225
+ if (typeof value !== "string") {
226
+ throw new Error(`${label} must be string`);
227
+ }
228
+ const normalized = value.trim();
229
+ if (normalized.length === 0) {
230
+ throw new Error(`${label} must be non-empty`);
231
+ }
232
+ return normalized;
233
+ }
234
+
235
+ /**
236
+ * Read required non-empty string value.
237
+ *
238
+ * @since 1.0.0
239
+ * @category Shared
240
+ */
241
+ function readNonEmptyString(value: unknown, label: string): string {
242
+ const normalized = readOptionalString(value, label);
243
+ if (!normalized) {
244
+ throw new Error(`${label} is required`);
245
+ }
246
+ return normalized;
247
+ }
248
+
249
+ /**
250
+ * Read optional number value.
251
+ *
252
+ * @since 1.0.0
253
+ * @category Shared
254
+ */
255
+ function readOptionalNumber(value: unknown, label: string): number | undefined {
256
+ if (value === undefined) return undefined;
257
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
258
+ throw new Error(`${label} must be a positive number`);
259
+ }
260
+ return value;
261
+ }
262
+
263
+ /**
264
+ * Read optional boolean value.
265
+ *
266
+ * @since 1.0.0
267
+ * @category Shared
268
+ */
269
+ function readOptionalBoolean(value: unknown, label: string): boolean | undefined {
270
+ if (value === undefined) return undefined;
271
+ if (typeof value !== "boolean") {
272
+ throw new Error(`${label} must be boolean`);
273
+ }
274
+ return value;
275
+ }
276
+
277
+ /**
278
+ * Read optional string array value.
279
+ *
280
+ * @since 1.0.0
281
+ * @category Shared
282
+ */
283
+ function readOptionalStringArray(value: unknown, label: string): string[] | undefined {
284
+ if (value === undefined) return undefined;
285
+ if (!Array.isArray(value)) {
286
+ throw new Error(`${label} must be string[]`);
287
+ }
288
+ const result = value.map((entry, index) =>
289
+ readNonEmptyString(entry, `${label}[${index}]`),
290
+ );
291
+ return result;
292
+ }
293
+
294
+ /**
295
+ * Read optional string record value.
296
+ *
297
+ * @since 1.0.0
298
+ * @category Shared
299
+ */
300
+ function readOptionalStringRecord(
301
+ value: unknown,
302
+ label: string,
303
+ ): Record<string, string> | undefined {
304
+ if (value === undefined) return undefined;
305
+ if (!isRecord(value)) throw new Error(`${label} must be object`);
306
+ const result: Record<string, string> = {};
307
+ for (const [key, entry] of Object.entries(value)) {
308
+ if (typeof entry !== "string") {
309
+ throw new Error(`${label}.${key} must be string`);
310
+ }
311
+ result[key] = entry;
312
+ }
313
+ return result;
314
+ }
315
+
316
+ /**
317
+ * Check plain object shape.
318
+ *
319
+ * @since 1.0.0
320
+ * @category Shared
321
+ */
322
+ function isRecord(value: unknown): value is Record<string, unknown> {
323
+ return typeof value === "object" && value !== null;
324
+ }
@@ -0,0 +1,57 @@
1
+ import type { WorkerDefinition } from "./contract.js";
2
+
3
+ export type WorkerGithubEventSubscription = {
4
+ workerId: string;
5
+ events: string[];
6
+ };
7
+
8
+ /**
9
+ * Collect unique GitHub webhook events from enabled workers.
10
+ *
11
+ * @since 1.0.0
12
+ * @category Shared
13
+ */
14
+ export function collectGithubEventsFromWorkers(workers: WorkerDefinition[]): string[] {
15
+ const events = new Set<string>();
16
+ for (const subscription of collectGithubEventSubscriptions(workers)) {
17
+ for (const event of subscription.events) {
18
+ events.add(event);
19
+ }
20
+ }
21
+ return [...events].sort((left, right) => left.localeCompare(right));
22
+ }
23
+
24
+ /**
25
+ * Collect per-worker GitHub event subscriptions for enabled workers.
26
+ *
27
+ * @since 1.0.0
28
+ * @category Shared
29
+ */
30
+ export function collectGithubEventSubscriptions(
31
+ workers: WorkerDefinition[],
32
+ ): WorkerGithubEventSubscription[] {
33
+ const subscriptions: WorkerGithubEventSubscription[] = [];
34
+ for (const worker of workers) {
35
+ if (worker.metadata.enabled === false) {
36
+ continue;
37
+ }
38
+ const events = new Set<string>();
39
+ for (const trigger of worker.triggers) {
40
+ if (trigger.provider !== "github") {
41
+ continue;
42
+ }
43
+ const event = trigger.event.trim();
44
+ if (event.length > 0) {
45
+ events.add(event);
46
+ }
47
+ }
48
+ if (events.size === 0) {
49
+ continue;
50
+ }
51
+ subscriptions.push({
52
+ workerId: worker.metadata.id,
53
+ events: [...events].sort((left, right) => left.localeCompare(right)),
54
+ });
55
+ }
56
+ return subscriptions.sort((left, right) => left.workerId.localeCompare(right.workerId));
57
+ }
@@ -0,0 +1,86 @@
1
+ import { Glob } from "bun";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { parseWorkerDefinition, type WorkerDefinition } from "./contract.js";
5
+
6
+ const WORKER_GLOB = new Glob(".skipper/worker/*.ts");
7
+
8
+ type WorkerModuleExport = {
9
+ default?: unknown;
10
+ metadata?: unknown;
11
+ triggers?: unknown;
12
+ runtime?: unknown;
13
+ };
14
+
15
+ /**
16
+ * Discover and load worker definitions from repository.
17
+ *
18
+ * @since 1.0.0
19
+ * @category Shared
20
+ */
21
+ export async function loadWorkers(rootDir: string): Promise<WorkerDefinition[]> {
22
+ const files = await discoverWorkerFiles(rootDir);
23
+ const workers: WorkerDefinition[] = [];
24
+ const seenIds = new Set<string>();
25
+ for (const file of files) {
26
+ const worker = await loadWorkerFile(rootDir, file);
27
+ if (seenIds.has(worker.metadata.id)) {
28
+ throw new Error(`duplicate worker id: ${worker.metadata.id}`);
29
+ }
30
+ seenIds.add(worker.metadata.id);
31
+ workers.push(worker);
32
+ }
33
+ return workers;
34
+ }
35
+
36
+ /**
37
+ * Discover worker file paths from root directory.
38
+ *
39
+ * @since 1.0.0
40
+ * @category Shared
41
+ */
42
+ export async function discoverWorkerFiles(rootDir: string): Promise<string[]> {
43
+ const files: string[] = [];
44
+ for await (const file of WORKER_GLOB.scan({ cwd: rootDir, dot: true })) {
45
+ files.push(file);
46
+ }
47
+ return files.sort((left, right) => left.localeCompare(right));
48
+ }
49
+
50
+ /**
51
+ * Load and validate one worker module file.
52
+ *
53
+ * @since 1.0.0
54
+ * @category Shared
55
+ */
56
+ async function loadWorkerFile(rootDir: string, relativeFile: string): Promise<WorkerDefinition> {
57
+ const absoluteFile = resolve(rootDir, relativeFile);
58
+ const url = pathToFileURL(absoluteFile).href;
59
+ const module = (await import(url)) as WorkerModuleExport;
60
+ const value = readWorkerValue(module);
61
+ return parseWorkerDefinition(value, relativeFile);
62
+ }
63
+
64
+ /**
65
+ * Resolve worker data from module shape.
66
+ *
67
+ * @since 1.0.0
68
+ * @category Shared
69
+ */
70
+ function readWorkerValue(module: WorkerModuleExport): unknown {
71
+ if (module.default !== undefined) {
72
+ return module.default;
73
+ }
74
+ if (
75
+ module.metadata !== undefined ||
76
+ module.triggers !== undefined ||
77
+ module.runtime !== undefined
78
+ ) {
79
+ return {
80
+ metadata: module.metadata,
81
+ triggers: module.triggers,
82
+ runtime: module.runtime,
83
+ };
84
+ }
85
+ throw new Error("worker module must export default or metadata/triggers/runtime");
86
+ }
@@ -0,0 +1,91 @@
1
+ import type { WorkerDefinition, WorkerManifest, WorkerTrigger } from "./contract.js";
2
+
3
+ export type WorkerRouteContext = {
4
+ provider: "github";
5
+ event: string;
6
+ action?: string;
7
+ repository?: string;
8
+ baseBranch?: string;
9
+ headBranch?: string;
10
+ draft?: boolean;
11
+ };
12
+
13
+ /**
14
+ * Route workers matching incoming event context.
15
+ *
16
+ * @since 1.0.0
17
+ * @category Shared
18
+ */
19
+ export function routeWorkers(
20
+ manifest: WorkerManifest,
21
+ context: WorkerRouteContext,
22
+ ): WorkerDefinition[] {
23
+ const matched: WorkerDefinition[] = [];
24
+ for (const worker of manifest.workers) {
25
+ if (worker.metadata.enabled === false) continue;
26
+ if (matchesWorker(worker, context)) {
27
+ matched.push(worker);
28
+ }
29
+ }
30
+ return matched;
31
+ }
32
+
33
+ /**
34
+ * Check whether worker has at least one matching trigger.
35
+ *
36
+ * @since 1.0.0
37
+ * @category Shared
38
+ */
39
+ function matchesWorker(worker: WorkerDefinition, context: WorkerRouteContext): boolean {
40
+ for (const trigger of worker.triggers) {
41
+ if (matchesTrigger(trigger, context)) return true;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ /**
47
+ * Check whether trigger matches context values.
48
+ *
49
+ * @since 1.0.0
50
+ * @category Shared
51
+ */
52
+ function matchesTrigger(trigger: WorkerTrigger, context: WorkerRouteContext): boolean {
53
+ if (trigger.provider !== context.provider) return false;
54
+ if (trigger.event !== context.event) return false;
55
+ if (trigger.actions && !matchesAction(trigger.actions, context.action)) return false;
56
+ if (!trigger.if) return true;
57
+ if (!matchesOptionalFilter(trigger.if.repository, context.repository)) return false;
58
+ if (!matchesOptionalFilter(trigger.if.baseBranches, context.baseBranch)) return false;
59
+ if (!matchesOptionalFilter(trigger.if.headBranches, context.headBranch)) return false;
60
+ if (
61
+ trigger.if.draft !== undefined &&
62
+ context.draft !== undefined &&
63
+ trigger.if.draft !== context.draft
64
+ ) {
65
+ return false;
66
+ }
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * Check action value against trigger actions list.
72
+ *
73
+ * @since 1.0.0
74
+ * @category Shared
75
+ */
76
+ function matchesAction(actions: string[], action: string | undefined): boolean {
77
+ if (!action) return false;
78
+ return actions.some((candidate) => candidate === action);
79
+ }
80
+
81
+ /**
82
+ * Match optional string filter list.
83
+ *
84
+ * @since 1.0.0
85
+ * @category Shared
86
+ */
87
+ function matchesOptionalFilter(filters: string[] | undefined, value: string | undefined): boolean {
88
+ if (!filters || filters.length === 0) return true;
89
+ if (!value) return false;
90
+ return filters.some((candidate) => candidate === value);
91
+ }