@atolis-hq/wake 0.4.36 → 0.4.37
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/dist/src/bootstrap/composition-root.d.ts +1 -0
- package/dist/src/bootstrap/integration-runtime.d.ts +3 -0
- package/dist/src/bootstrap/integration-runtime.js +22 -0
- package/dist/src/bootstrap/surface-cli-applications.d.ts +1 -0
- package/dist/src/bootstrap/surface-cli-applications.js +18 -0
- package/dist/src/bootstrap/version.js +1 -1
- package/dist/src/integrations/application/poll-service.d.ts +3 -1
- package/dist/src/integrations/application/poll-service.js +2 -2
- package/dist/src/integrations/contracts/intake.d.ts +3 -1
- package/dist/src/integrations/contracts/provider.d.ts +7 -0
- package/dist/src/integrations/github/contracts/config.d.ts +3 -0
- package/dist/src/integrations/github/contracts/config.js +4 -0
- package/dist/src/integrations/github/index.d.ts +2 -0
- package/dist/src/integrations/github/index.js +2 -0
- package/dist/src/integrations/github/infrastructure/adapter-health-registry.d.ts +1 -1
- package/dist/src/integrations/github/infrastructure/adapter-health-registry.js +0 -0
- package/dist/src/integrations/github/infrastructure/client.d.ts +16 -0
- package/dist/src/integrations/github/infrastructure/client.js +29 -0
- package/dist/src/integrations/github/infrastructure/source.js +4 -2
- package/dist/src/integrations/github/infrastructure/webhook-state.d.ts +16 -0
- package/dist/src/integrations/github/infrastructure/webhook-state.js +70 -0
- package/dist/src/integrations/github/infrastructure/webhook.d.ts +24 -0
- package/dist/src/integrations/github/infrastructure/webhook.js +148 -0
- package/dist/src/integrations/github/provider.js +2 -0
- package/dist/src/surfaces/api/http-server.d.ts +1 -0
- package/dist/src/surfaces/api/http-server.js +11 -0
- package/package.json +1 -1
|
@@ -71,6 +71,7 @@ export interface CompositionRoot {
|
|
|
71
71
|
readonly delivery: DeliveryService;
|
|
72
72
|
readonly intakePipeline: IntakePipeline;
|
|
73
73
|
readonly runnerPipeline: RunnerPipeline;
|
|
74
|
+
readonly requestImmediatePoll: (adapter: string) => void;
|
|
74
75
|
readonly resolveResourceLink: ResourceLinkResolver;
|
|
75
76
|
}
|
|
76
77
|
export declare function createCompositionRoot(wakeRoot: string, options?: CompositionRootOptions): Promise<CompositionRoot>;
|
|
@@ -19,6 +19,7 @@ export interface IntegrationRuntime {
|
|
|
19
19
|
readonly delivery: DeliveryService;
|
|
20
20
|
readonly intakePipeline: IntakePipeline;
|
|
21
21
|
readonly runnerPipeline: RunnerPipeline;
|
|
22
|
+
readonly requestImmediatePoll: (adapter: string) => void;
|
|
22
23
|
}
|
|
23
24
|
export interface IntegrationRuntimeInput {
|
|
24
25
|
readonly config: ResolvedWakeModulesConfig;
|
|
@@ -46,3 +47,5 @@ export interface IntegrationRuntimeInput {
|
|
|
46
47
|
readonly providerDefinitions: readonly ProviderDefinition[];
|
|
47
48
|
}
|
|
48
49
|
export declare function composeIntegrationRuntime(input: IntegrationRuntimeInput): Promise<IntegrationRuntime>;
|
|
50
|
+
/** Coalesces provider webhook requests while retaining the source's durable poll semantics. */
|
|
51
|
+
export declare function createImmediatePollRequester(providers: readonly ProviderInstance[], journal: EventJournal, wakeRoot: string): (adapter: string) => void;
|
|
@@ -23,6 +23,7 @@ export async function composeIntegrationRuntime(input) {
|
|
|
23
23
|
registry.register(definition);
|
|
24
24
|
const { instances, failures: providerFailures } = registry.compose(await hydrateFakeProviderEvidence(input.wakeRoot, input.config.integrations), {
|
|
25
25
|
publicUiUrl: input.config.surfaces.web.publicUrl,
|
|
26
|
+
providerStateRoot: join(input.wakeRoot, '.wake'),
|
|
26
27
|
work: input.work,
|
|
27
28
|
conversations: input.conversations,
|
|
28
29
|
resources: input.resources,
|
|
@@ -155,6 +156,7 @@ export async function composeIntegrationRuntime(input) {
|
|
|
155
156
|
await delivery.deliverNext(signal);
|
|
156
157
|
}),
|
|
157
158
|
});
|
|
159
|
+
const requestImmediatePoll = createImmediatePollRequester(providers, input.journal, input.wakeRoot);
|
|
158
160
|
return {
|
|
159
161
|
projectionSubscriptions,
|
|
160
162
|
processors: [
|
|
@@ -172,6 +174,26 @@ export async function composeIntegrationRuntime(input) {
|
|
|
172
174
|
delivery,
|
|
173
175
|
intakePipeline,
|
|
174
176
|
runnerPipeline,
|
|
177
|
+
requestImmediatePoll,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** Coalesces provider webhook requests while retaining the source's durable poll semantics. */
|
|
181
|
+
export function createImmediatePollRequester(providers, journal, wakeRoot) {
|
|
182
|
+
const immediatePolls = new Map();
|
|
183
|
+
return (adapter) => {
|
|
184
|
+
if (immediatePolls.has(adapter))
|
|
185
|
+
return;
|
|
186
|
+
const provider = providers.find((candidate) => candidate.adapter === adapter);
|
|
187
|
+
if (provider === undefined)
|
|
188
|
+
return;
|
|
189
|
+
const run = withFileLock(join(wakeRoot, '.wake', 'locks', `poll-${provider.adapter}.lock`), async () => {
|
|
190
|
+
await new PollService(journal, provider).pollOnce(new AbortController().signal, {
|
|
191
|
+
bypassInterval: true,
|
|
192
|
+
});
|
|
193
|
+
})
|
|
194
|
+
.catch((error) => console.error(`Webhook polling ${adapter} failed`, error))
|
|
195
|
+
.finally(() => immediatePolls.delete(adapter));
|
|
196
|
+
immediatePolls.set(adapter, run);
|
|
175
197
|
};
|
|
176
198
|
}
|
|
177
199
|
async function observeMemory(profile, phase, operation) {
|
|
@@ -28,6 +28,7 @@ interface ExecutionShutdown {
|
|
|
28
28
|
shutdown(): Promise<void>;
|
|
29
29
|
}
|
|
30
30
|
export declare function closeResidentRuntime(execution: ExecutionShutdown, closeResources: () => Promise<void>): Promise<void>;
|
|
31
|
+
export declare function createProviderWebhookReceiver(providers: readonly CompositionRoot['providers'][number][], requestImmediatePoll: (adapter: string) => void): (body: Buffer, headers: Readonly<Record<string, string | string[] | undefined>>) => Promise<401 | 404 | 400 | 202>;
|
|
31
32
|
/** The production maintenance boundary shared by the CLI update application. */
|
|
32
33
|
export declare function createSelfUpdateQuiescePort(root: CompositionRoot): SelfUpdateQuiescePort;
|
|
33
34
|
export {};
|
|
@@ -55,10 +55,13 @@ export function createSurfaceCliApplications(root, api, now) {
|
|
|
55
55
|
},
|
|
56
56
|
start: {
|
|
57
57
|
async run(signal, budget) {
|
|
58
|
+
const listening = root.config.surfaces.web.enabled || root.config.surfaces.api.enabled;
|
|
58
59
|
if (root.config.surfaces.web.enabled)
|
|
59
60
|
await startHttp({}, true);
|
|
60
61
|
else if (root.config.surfaces.api.enabled)
|
|
61
62
|
await startHttp({}, false);
|
|
63
|
+
if (listening)
|
|
64
|
+
await Promise.all(root.providers.flatMap((provider) => provider.webhook === undefined ? [] : [provider.webhook.provision()]));
|
|
62
65
|
return runResidentLifecycle({
|
|
63
66
|
signal,
|
|
64
67
|
budget,
|
|
@@ -289,6 +292,7 @@ function createHttpStarter(root, api, servers) {
|
|
|
289
292
|
disabled: root.config.surfaces.web.auth.disabled,
|
|
290
293
|
redeemGrant: (grant) => redeemPairingGrant(root.paths.wakeRoot, grant, undefined, serialiseCredentialMutation),
|
|
291
294
|
},
|
|
295
|
+
webhookReceiver: createProviderWebhookReceiver(root.providers, root.requestImmediatePoll),
|
|
292
296
|
...(assets === undefined ? {} : { assets }),
|
|
293
297
|
});
|
|
294
298
|
servers.add(server);
|
|
@@ -304,6 +308,20 @@ function createHttpStarter(root, api, servers) {
|
|
|
304
308
|
}
|
|
305
309
|
};
|
|
306
310
|
}
|
|
311
|
+
export function createProviderWebhookReceiver(providers, requestImmediatePoll) {
|
|
312
|
+
return async (body, headers) => {
|
|
313
|
+
const statuses = await Promise.all(providers.flatMap((provider) => provider.webhook === undefined
|
|
314
|
+
? []
|
|
315
|
+
: [provider.webhook.receive(body, headers, () => requestImmediatePoll(provider.adapter))]));
|
|
316
|
+
if (statuses.includes(202))
|
|
317
|
+
return 202;
|
|
318
|
+
if (statuses.includes(401))
|
|
319
|
+
return 401;
|
|
320
|
+
if (statuses.includes(400))
|
|
321
|
+
return 400;
|
|
322
|
+
return 404;
|
|
323
|
+
};
|
|
324
|
+
}
|
|
307
325
|
function serialiseCredentialMutation(lockPath, operation) {
|
|
308
326
|
return withFileLock(lockPath, operation, { waitMs: 5_000, retryIntervalMs: 10 });
|
|
309
327
|
}
|
|
@@ -124,6 +124,6 @@ export function resolveWakeVersion(options = {}) {
|
|
|
124
124
|
return `g${headHash.slice(0, 7)}`;
|
|
125
125
|
return '0.1.0-dev';
|
|
126
126
|
}
|
|
127
|
-
export const wakeVersion = "v0.4.
|
|
127
|
+
export const wakeVersion = "v0.4.37";
|
|
128
128
|
/** The registry-installable version used by packaged sandbox images. */
|
|
129
129
|
export const wakePackageVersion = packageVersion(defaultRepoRoot) ?? wakeVersion;
|
|
@@ -9,5 +9,7 @@ export declare class PollService {
|
|
|
9
9
|
private readonly instance;
|
|
10
10
|
constructor(journal: EventJournal, instance: ProviderInstance);
|
|
11
11
|
/** Persists every independently durable draft and reports partial progress. */
|
|
12
|
-
pollOnce(signal: AbortSignal
|
|
12
|
+
pollOnce(signal: AbortSignal, options?: {
|
|
13
|
+
readonly bypassInterval?: boolean;
|
|
14
|
+
}): Promise<PollResult>;
|
|
13
15
|
}
|
|
@@ -7,9 +7,9 @@ export class PollService {
|
|
|
7
7
|
this.instance = instance;
|
|
8
8
|
}
|
|
9
9
|
/** Persists every independently durable draft and reports partial progress. */
|
|
10
|
-
async pollOnce(signal) {
|
|
10
|
+
async pollOnce(signal, options) {
|
|
11
11
|
const adapter = this.instance.adapter;
|
|
12
|
-
const drafts = await this.instance.source.poll(signal);
|
|
12
|
+
const drafts = await this.instance.source.poll(signal, options);
|
|
13
13
|
let appended = 0;
|
|
14
14
|
let failed = 0;
|
|
15
15
|
for (const draft of drafts) {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { EventData, EventProcessor } from '@atolis-hq/eventing';
|
|
2
2
|
export type ProviderEventData = EventData<string, unknown>;
|
|
3
3
|
export interface ExternalEventSource {
|
|
4
|
-
poll(signal: AbortSignal
|
|
4
|
+
poll(signal: AbortSignal, options?: {
|
|
5
|
+
readonly bypassInterval?: boolean;
|
|
6
|
+
}): Promise<readonly ProviderEventData[]>;
|
|
5
7
|
/** Commits provider cursor state after every draft from the poll is durable. */
|
|
6
8
|
markPollPersisted?(): Promise<void>;
|
|
7
9
|
}
|
|
@@ -22,6 +22,8 @@ export interface WorkConclusion {
|
|
|
22
22
|
export interface ProviderServices {
|
|
23
23
|
/** Stable operator-configured UI address included in provider notifications when present. */
|
|
24
24
|
readonly publicUiUrl?: string | undefined;
|
|
25
|
+
/** Durable provider-owned operational state, rooted at .wake/. */
|
|
26
|
+
readonly providerStateRoot: string;
|
|
25
27
|
readonly work: WorkService;
|
|
26
28
|
readonly conversations: ConversationService;
|
|
27
29
|
readonly resources: ResourceService;
|
|
@@ -73,6 +75,11 @@ export interface ProviderInstance {
|
|
|
73
75
|
readonly health?: () => readonly AdapterHealthCheck[];
|
|
74
76
|
readonly commands?: () => readonly AdapterCommand[];
|
|
75
77
|
readonly replyPublication?: ReplyPublicationConfig | undefined;
|
|
78
|
+
readonly webhook?: ProviderWebhook | undefined;
|
|
79
|
+
}
|
|
80
|
+
export interface ProviderWebhook {
|
|
81
|
+
provision(): Promise<void>;
|
|
82
|
+
receive(body: Buffer, headers: Readonly<Record<string, string | string[] | undefined>>, trigger: () => void): Promise<404 | 400 | 401 | 202>;
|
|
76
83
|
}
|
|
77
84
|
export type ComposedProviderInstance = Omit<ProviderInstance, 'provider'>;
|
|
78
85
|
export interface ProviderDefinition<Config = unknown> {
|
|
@@ -13,6 +13,9 @@ export declare const gitHubConfigSchema: z.ZodObject<{
|
|
|
13
13
|
lookbackMs: z.ZodDefault<z.ZodNumber>;
|
|
14
14
|
intervalMs: z.ZodDefault<z.ZodNumber>;
|
|
15
15
|
}, z.core.$strict>>;
|
|
16
|
+
webhooks: z.ZodDefault<z.ZodObject<{
|
|
17
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
18
|
+
}, z.core.$strict>>;
|
|
16
19
|
intake: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
17
20
|
where: z.ZodObject<{
|
|
18
21
|
kind: z.ZodOptional<z.ZodEnum<{
|
|
@@ -88,6 +88,10 @@ export const gitHubConfigSchema = z
|
|
|
88
88
|
lookbackMs: 60_000,
|
|
89
89
|
intervalMs: 30_000,
|
|
90
90
|
}),
|
|
91
|
+
webhooks: z
|
|
92
|
+
.object({ enabled: z.boolean().default(false) })
|
|
93
|
+
.strict()
|
|
94
|
+
.default({ enabled: false }),
|
|
91
95
|
intake: z.array(intakeRuleSchema).default([]),
|
|
92
96
|
publication: z
|
|
93
97
|
.object({
|
|
@@ -31,4 +31,6 @@ export * from './infrastructure/issue-source.js';
|
|
|
31
31
|
export * from './infrastructure/pr-source.js';
|
|
32
32
|
export * from './infrastructure/review-source.js';
|
|
33
33
|
export * from './infrastructure/source.js';
|
|
34
|
+
export * from './infrastructure/webhook.js';
|
|
35
|
+
export * from './infrastructure/webhook-state.js';
|
|
34
36
|
export * from './provider.js';
|
|
@@ -31,4 +31,6 @@ export * from './infrastructure/issue-source.js';
|
|
|
31
31
|
export * from './infrastructure/pr-source.js';
|
|
32
32
|
export * from './infrastructure/review-source.js';
|
|
33
33
|
export * from './infrastructure/source.js';
|
|
34
|
+
export * from './infrastructure/webhook.js';
|
|
35
|
+
export * from './infrastructure/webhook-state.js';
|
|
34
36
|
export * from './provider.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AdapterHealthCheck } from '../../contracts/provider.js';
|
|
2
|
-
export type GitHubHealthChannel = 'poll' | 'deliver';
|
|
2
|
+
export type GitHubHealthChannel = 'poll' | 'deliver' | 'webhook';
|
|
3
3
|
export interface GitHubAdapterHealthRegistry {
|
|
4
4
|
recordSuccess(repository: string, channel: GitHubHealthChannel): void;
|
|
5
5
|
recordFailure(repository: string, channel: GitHubHealthChannel, error: unknown): void;
|
|
Binary file
|
|
@@ -3,6 +3,22 @@ import type { GitHubIssueQueryFilters } from '../contracts/issue-query.js';
|
|
|
3
3
|
import { GitHubOutboundAction } from '../contracts/vocabulary.js';
|
|
4
4
|
export declare function redactGitHubRequestMessage(message: unknown): string;
|
|
5
5
|
export declare function createGitHubClient(token: string): {
|
|
6
|
+
getHook: (owner: string, repo: string, hookId: number) => Promise<void>;
|
|
7
|
+
createHook: (input: {
|
|
8
|
+
owner: string;
|
|
9
|
+
repo: string;
|
|
10
|
+
url: string;
|
|
11
|
+
secret: string;
|
|
12
|
+
events: readonly string[];
|
|
13
|
+
}) => Promise<number>;
|
|
14
|
+
updateHook: (input: {
|
|
15
|
+
owner: string;
|
|
16
|
+
repo: string;
|
|
17
|
+
hookId: number;
|
|
18
|
+
url: string;
|
|
19
|
+
secret: string;
|
|
20
|
+
events: readonly string[];
|
|
21
|
+
}) => Promise<void>;
|
|
6
22
|
authenticatedLogin: () => Promise<string>;
|
|
7
23
|
collaboratorPermission: (owner: string, repo: string, login: string) => Promise<ProviderPermission>;
|
|
8
24
|
getIssueLabelsFresh: (owner: string, repo: string, issueNumber: number) => Promise<string[]>;
|
|
@@ -62,6 +62,35 @@ export function createGitHubClient(token) {
|
|
|
62
62
|
});
|
|
63
63
|
},
|
|
64
64
|
deliver: (command) => deliver(octokit, command),
|
|
65
|
+
...createGitHubWebhookClient(octokit),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function createGitHubWebhookClient(octokit) {
|
|
69
|
+
return {
|
|
70
|
+
getHook: async (owner, repo, hookId) => {
|
|
71
|
+
await octokit.rest.repos.getWebhook({ owner, repo, hook_id: hookId });
|
|
72
|
+
},
|
|
73
|
+
createHook: async (input) => {
|
|
74
|
+
const response = await octokit.rest.repos.createWebhook({
|
|
75
|
+
owner: input.owner,
|
|
76
|
+
repo: input.repo,
|
|
77
|
+
name: 'web',
|
|
78
|
+
active: true,
|
|
79
|
+
events: [...input.events],
|
|
80
|
+
config: { url: input.url, content_type: 'json', secret: input.secret },
|
|
81
|
+
});
|
|
82
|
+
return response.data.id;
|
|
83
|
+
},
|
|
84
|
+
updateHook: async (input) => {
|
|
85
|
+
await octokit.rest.repos.updateWebhook({
|
|
86
|
+
owner: input.owner,
|
|
87
|
+
repo: input.repo,
|
|
88
|
+
hook_id: input.hookId,
|
|
89
|
+
active: true,
|
|
90
|
+
events: [...input.events],
|
|
91
|
+
config: { url: input.url, content_type: 'json', secret: input.secret },
|
|
92
|
+
});
|
|
93
|
+
},
|
|
65
94
|
};
|
|
66
95
|
}
|
|
67
96
|
function createGitHubReadClient(octokit, cache) {
|
|
@@ -20,9 +20,11 @@ export function createGitHubSource(config, client, adapter, requests = createGit
|
|
|
20
20
|
// the same tradeoff already made for lastEventIds above.
|
|
21
21
|
let lastPolledAt;
|
|
22
22
|
return {
|
|
23
|
-
async poll(signal) {
|
|
23
|
+
async poll(signal, options) {
|
|
24
24
|
const currentTime = now();
|
|
25
|
-
if (
|
|
25
|
+
if (options?.bypassInterval !== true &&
|
|
26
|
+
lastPolledAt !== undefined &&
|
|
27
|
+
currentTime - lastPolledAt < config.polling.intervalMs) {
|
|
26
28
|
return [];
|
|
27
29
|
}
|
|
28
30
|
lastPolledAt = currentTime;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AdapterId } from '../../contracts/identifiers.js';
|
|
2
|
+
export interface GitHubWebhookState {
|
|
3
|
+
readonly secret: string;
|
|
4
|
+
readonly hookId?: number;
|
|
5
|
+
}
|
|
6
|
+
/** Provider operational state: intentionally outside configuration and projections. */
|
|
7
|
+
export declare class GitHubWebhookStateStore {
|
|
8
|
+
private readonly root;
|
|
9
|
+
private readonly adapter;
|
|
10
|
+
constructor(root: string, adapter: AdapterId);
|
|
11
|
+
load(owner: string, repo: string): Promise<GitHubWebhookState>;
|
|
12
|
+
save(owner: string, repo: string, state: GitHubWebhookState): Promise<void>;
|
|
13
|
+
serialise<Value>(owner: string, repo: string, operation: () => Promise<Value>): Promise<Value>;
|
|
14
|
+
private path;
|
|
15
|
+
private lockPath;
|
|
16
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** Provider operational state: intentionally outside configuration and projections. */
|
|
5
|
+
export class GitHubWebhookStateStore {
|
|
6
|
+
root;
|
|
7
|
+
adapter;
|
|
8
|
+
constructor(root, adapter) {
|
|
9
|
+
this.root = root;
|
|
10
|
+
this.adapter = adapter;
|
|
11
|
+
}
|
|
12
|
+
async load(owner, repo) {
|
|
13
|
+
const path = this.path(owner, repo);
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
16
|
+
if (typeof parsed === 'object' &&
|
|
17
|
+
parsed !== null &&
|
|
18
|
+
'secret' in parsed &&
|
|
19
|
+
typeof parsed.secret === 'string' &&
|
|
20
|
+
('hookId' in parsed ? typeof parsed.hookId === 'number' : true))
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code !== 'ENOENT')
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
const state = { secret: randomBytes(32).toString('hex') };
|
|
28
|
+
await this.save(owner, repo, state);
|
|
29
|
+
return state;
|
|
30
|
+
}
|
|
31
|
+
async save(owner, repo, state) {
|
|
32
|
+
const path = this.path(owner, repo);
|
|
33
|
+
await mkdir(join(this.root, 'github-webhooks', this.adapter), { recursive: true });
|
|
34
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
35
|
+
await writeFile(temporary, JSON.stringify(state), { encoding: 'utf8', mode: 0o600 });
|
|
36
|
+
await rename(temporary, path);
|
|
37
|
+
}
|
|
38
|
+
async serialise(owner, repo, operation) {
|
|
39
|
+
await mkdir(join(this.root, 'locks'), { recursive: true });
|
|
40
|
+
const lock = this.lockPath(owner, repo);
|
|
41
|
+
await acquireLock(lock);
|
|
42
|
+
try {
|
|
43
|
+
return await operation();
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await unlink(lock);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
path(owner, repo) {
|
|
50
|
+
return join(this.root, 'github-webhooks', this.adapter, `${encodeURIComponent(owner)}--${encodeURIComponent(repo)}.json`);
|
|
51
|
+
}
|
|
52
|
+
lockPath(owner, repo) {
|
|
53
|
+
return join(this.root, 'locks', `github-webhook-${this.adapter}-${encodeURIComponent(owner)}--${encodeURIComponent(repo)}.lock`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function acquireLock(path) {
|
|
57
|
+
const deadline = Date.now() + 5_000;
|
|
58
|
+
while (true) {
|
|
59
|
+
try {
|
|
60
|
+
const handle = await open(path, 'wx', 0o600);
|
|
61
|
+
await handle.close();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code !== 'EEXIST' || Date.now() >= deadline)
|
|
66
|
+
throw error;
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AdapterId } from '../../contracts/identifiers.js';
|
|
2
|
+
import type { ProviderWebhook } from '../../contracts/provider.js';
|
|
3
|
+
import type { GitHubConfig } from '../contracts/config.js';
|
|
4
|
+
import type { GitHubAdapterHealthRegistry } from './adapter-health-registry.js';
|
|
5
|
+
interface HookClient {
|
|
6
|
+
getHook(owner: string, repo: string, hookId: number): Promise<void>;
|
|
7
|
+
createHook(input: {
|
|
8
|
+
owner: string;
|
|
9
|
+
repo: string;
|
|
10
|
+
url: string;
|
|
11
|
+
secret: string;
|
|
12
|
+
events: readonly string[];
|
|
13
|
+
}): Promise<number>;
|
|
14
|
+
updateHook(input: {
|
|
15
|
+
owner: string;
|
|
16
|
+
repo: string;
|
|
17
|
+
hookId: number;
|
|
18
|
+
url: string;
|
|
19
|
+
secret: string;
|
|
20
|
+
events: readonly string[];
|
|
21
|
+
}): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare function createGitHubWebhook(adapter: AdapterId, config: GitHubConfig, publicUrl: string | undefined, root: string, client: HookClient, health: GitHubAdapterHealthRegistry): ProviderWebhook;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { GitHubWebhookStateStore } from './webhook-state.js';
|
|
3
|
+
const events = new Set([
|
|
4
|
+
'issues',
|
|
5
|
+
'issue_comment',
|
|
6
|
+
'pull_request',
|
|
7
|
+
'pull_request_review',
|
|
8
|
+
'pull_request_review_comment',
|
|
9
|
+
'check_run',
|
|
10
|
+
'status',
|
|
11
|
+
]);
|
|
12
|
+
export function createGitHubWebhook(adapter, config, publicUrl, root, client, health) {
|
|
13
|
+
const state = new GitHubWebhookStateStore(root, adapter);
|
|
14
|
+
const endpoint = publicUrl === undefined ? undefined : webhookEndpoint(publicUrl);
|
|
15
|
+
return {
|
|
16
|
+
async provision() {
|
|
17
|
+
for (const repository of config.repositories) {
|
|
18
|
+
if (!config.webhooks.enabled)
|
|
19
|
+
continue;
|
|
20
|
+
if (endpoint === undefined) {
|
|
21
|
+
health.recordFailure(`${repository.owner}/${repository.repo}`, 'webhook', new Error('surfaces.web.publicUrl is not configured'));
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
await state.serialise(repository.owner, repository.repo, () => provisionRepository(repository, endpoint, state, client, health));
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
health.recordFailure(`${repository.owner}/${repository.repo}`, 'webhook', error);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
async receive(body, headers, trigger) {
|
|
33
|
+
if (!config.webhooks.enabled)
|
|
34
|
+
return 404;
|
|
35
|
+
const signature = header(headers, 'x-hub-signature-256');
|
|
36
|
+
if (signature === undefined)
|
|
37
|
+
return 401;
|
|
38
|
+
const event = header(headers, 'x-github-event');
|
|
39
|
+
if (event === undefined)
|
|
40
|
+
return 400;
|
|
41
|
+
const fullName = parseRepository(body);
|
|
42
|
+
if (fullName === null)
|
|
43
|
+
return 400;
|
|
44
|
+
if (fullName === undefined)
|
|
45
|
+
return 404;
|
|
46
|
+
const [owner, repo] = fullName.split('/');
|
|
47
|
+
if (owner === undefined ||
|
|
48
|
+
repo === undefined ||
|
|
49
|
+
!config.repositories.some((value) => value.owner === owner && value.repo === repo))
|
|
50
|
+
return 404;
|
|
51
|
+
return state.serialise(owner, repo, async () => {
|
|
52
|
+
const saved = await state.load(owner, repo);
|
|
53
|
+
const expected = `sha256=${createHmac('sha256', saved.secret).update(body).digest('hex')}`;
|
|
54
|
+
if (!safeEqual(signature, expected))
|
|
55
|
+
return 401;
|
|
56
|
+
if (!events.has(event))
|
|
57
|
+
return 404;
|
|
58
|
+
trigger();
|
|
59
|
+
return 202;
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function provisionRepository(repository, endpoint, state, client, health) {
|
|
65
|
+
const scope = `${repository.owner}/${repository.repo}`;
|
|
66
|
+
try {
|
|
67
|
+
const saved = await state.load(repository.owner, repository.repo);
|
|
68
|
+
if (saved.hookId !== undefined &&
|
|
69
|
+
(await updateManagedHook(repository, endpoint, saved, client))) {
|
|
70
|
+
health.recordSuccess(scope, 'webhook');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const hookId = await client.createHook({
|
|
74
|
+
...repository,
|
|
75
|
+
url: endpoint,
|
|
76
|
+
secret: saved.secret,
|
|
77
|
+
events: [...events],
|
|
78
|
+
});
|
|
79
|
+
await state.save(repository.owner, repository.repo, { ...saved, hookId });
|
|
80
|
+
health.recordSuccess(scope, 'webhook');
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
health.recordFailure(scope, 'webhook', error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function updateManagedHook(repository, endpoint, saved, client) {
|
|
87
|
+
if (saved.hookId === undefined)
|
|
88
|
+
return false;
|
|
89
|
+
try {
|
|
90
|
+
await client.getHook(repository.owner, repository.repo, saved.hookId);
|
|
91
|
+
await client.updateHook({
|
|
92
|
+
...repository,
|
|
93
|
+
hookId: saved.hookId,
|
|
94
|
+
url: endpoint,
|
|
95
|
+
secret: saved.secret,
|
|
96
|
+
events: [...events],
|
|
97
|
+
});
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (statusOf(error) === 404)
|
|
102
|
+
return false;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function header(headers, name) {
|
|
107
|
+
const value = headers[name] ?? headers[name.toLowerCase()];
|
|
108
|
+
return Array.isArray(value) ? value[0] : value;
|
|
109
|
+
}
|
|
110
|
+
function repositoryName(value) {
|
|
111
|
+
if (typeof value !== 'object' ||
|
|
112
|
+
value === null ||
|
|
113
|
+
!('full_name' in value) ||
|
|
114
|
+
typeof value.full_name !== 'string')
|
|
115
|
+
return undefined;
|
|
116
|
+
return value.full_name;
|
|
117
|
+
}
|
|
118
|
+
function parseRepository(body) {
|
|
119
|
+
try {
|
|
120
|
+
const payload = JSON.parse(body.toString('utf8'));
|
|
121
|
+
if (typeof payload !== 'object' || payload === null || !('repository' in payload))
|
|
122
|
+
return null;
|
|
123
|
+
return repositoryName(payload.repository);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function safeEqual(left, right) {
|
|
130
|
+
const a = Buffer.from(left);
|
|
131
|
+
const b = Buffer.from(right);
|
|
132
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
133
|
+
}
|
|
134
|
+
function statusOf(error) {
|
|
135
|
+
return typeof error === 'object' &&
|
|
136
|
+
error !== null &&
|
|
137
|
+
'status' in error &&
|
|
138
|
+
typeof error.status === 'number'
|
|
139
|
+
? error.status
|
|
140
|
+
: undefined;
|
|
141
|
+
}
|
|
142
|
+
function webhookEndpoint(publicUrl) {
|
|
143
|
+
const base = new URL(publicUrl);
|
|
144
|
+
base.pathname = `${base.pathname.replace(/\/$/, '')}/webhooks/github`;
|
|
145
|
+
base.search = '';
|
|
146
|
+
base.hash = '';
|
|
147
|
+
return base.href;
|
|
148
|
+
}
|
|
@@ -14,6 +14,7 @@ import { createGitHubDelivery } from './infrastructure/delivery.js';
|
|
|
14
14
|
import { resolveGitHubCliToken } from './infrastructure/gh-auth.js';
|
|
15
15
|
import { createGitHubRequestCoordinator } from './infrastructure/request-coordinator.js';
|
|
16
16
|
import { createGitHubSource } from './infrastructure/source.js';
|
|
17
|
+
import { createGitHubWebhook } from './infrastructure/webhook.js';
|
|
17
18
|
export const gitHubProviderDefinition = {
|
|
18
19
|
provider: 'github',
|
|
19
20
|
eventTypes: Object.values(GitHubEventType),
|
|
@@ -59,6 +60,7 @@ export const gitHubProviderDefinition = {
|
|
|
59
60
|
requests,
|
|
60
61
|
}),
|
|
61
62
|
health: () => health.snapshotAll(),
|
|
63
|
+
webhook: createGitHubWebhook(adapter, config, services.publicUiUrl, services.providerStateRoot, client, health),
|
|
62
64
|
commands: () => [
|
|
63
65
|
...Object.values(GitHubBuiltInCommand).map((syntax) => ({ syntax })),
|
|
64
66
|
...config.commands.map((syntax) => ({ syntax })),
|
|
@@ -24,6 +24,7 @@ export interface SurfaceHttpServerOptions {
|
|
|
24
24
|
readonly disabled: boolean;
|
|
25
25
|
readonly redeemGrant: (grant: string) => Promise<boolean>;
|
|
26
26
|
};
|
|
27
|
+
readonly webhookReceiver?: (body: Buffer, headers: Readonly<Record<string, string | string[] | undefined>>) => Promise<number>;
|
|
27
28
|
}
|
|
28
29
|
/**
|
|
29
30
|
* The production HTTP surface. Public login routes are declared separately;
|
|
@@ -41,6 +41,17 @@ export function createSurfaceHttpServer(options) {
|
|
|
41
41
|
return sendFastifyJson(reply, errorResult(new MalformedJsonError()), false);
|
|
42
42
|
return sendFastifyJson(reply, errorResult(error), false);
|
|
43
43
|
});
|
|
44
|
+
if (options.webhookReceiver !== undefined) {
|
|
45
|
+
app.register((webhooks, _pluginOptions, done) => {
|
|
46
|
+
webhooks.removeAllContentTypeParsers();
|
|
47
|
+
webhooks.addContentTypeParser('*', { parseAs: 'buffer' }, (_request, body, callback) => callback(null, body));
|
|
48
|
+
webhooks.post('/github', { bodyLimit: 64 * 1024 }, async (request, reply) => {
|
|
49
|
+
const status = await options.webhookReceiver(request.body, request.headers);
|
|
50
|
+
return reply.code(status).send();
|
|
51
|
+
});
|
|
52
|
+
done();
|
|
53
|
+
}, { prefix: '/webhooks' });
|
|
54
|
+
}
|
|
44
55
|
app.get('/api/v1/auth/session', async (request, reply) => {
|
|
45
56
|
if (authDisabled)
|
|
46
57
|
return { authenticated: true };
|