@volter/twin-github 0.1.2 → 2.0.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 +88 -36
- package/client/github-mirror.css +277 -319
- package/client/github-mirror.d.ts +418 -0
- package/client/github-mirror.js +485 -0
- package/client/github-mirror.tsx +153 -357
- package/client/pulls-rest.ts +159 -0
- package/client/pulls-workspace.tsx +841 -0
- package/dist/client/github-mirror.bundle.js +239 -0
- package/dist/client/github-mirror.css +916 -0
- package/dist/client/github-mirror.d.ts +418 -0
- package/dist/client/github-mirror.js +485 -0
- package/dist/client/github-mirror.tsx +1315 -0
- package/dist/client/pulls-rest.d.ts +42 -0
- package/dist/client/pulls-rest.js +140 -0
- package/dist/client/pulls-rest.ts +159 -0
- package/dist/client/pulls-workspace.bundle.js +22 -0
- package/dist/client/pulls-workspace.d.ts +114 -0
- package/dist/client/pulls-workspace.js +418 -0
- package/dist/client/pulls-workspace.tsx +841 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/generated/graphql-sdl.gen.json +1 -0
- package/dist/src/generated/graphql.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/github-budget.d.ts +69 -0
- package/dist/src/github-budget.js +172 -0
- package/dist/src/github-capabilities.d.ts +5 -0
- package/dist/src/github-capabilities.js +4468 -0
- package/dist/src/github-conformance.d.ts +43 -0
- package/dist/src/github-conformance.js +76 -0
- package/dist/src/github-connector.d.ts +307 -0
- package/dist/src/github-connector.js +1398 -0
- package/dist/src/github-events.d.ts +41 -0
- package/dist/src/github-events.js +230 -0
- package/dist/src/github-git-http.d.ts +49 -0
- package/dist/src/github-git-http.js +185 -0
- package/dist/src/github-git-plane.d.ts +114 -0
- package/dist/src/github-git-plane.js +407 -0
- package/dist/src/github-mirror-state.d.ts +2 -0
- package/dist/src/github-mirror-state.js +335 -0
- package/dist/src/github-mirror-ui.d.ts +20 -0
- package/dist/src/github-mirror-ui.js +101 -0
- package/dist/src/github-server.d.ts +14 -0
- package/dist/src/github-server.js +240 -0
- package/dist/src/github-shared.d.ts +12 -0
- package/dist/src/github-shared.js +21 -0
- package/dist/src/github-twin.d.ts +1411 -0
- package/dist/src/github-twin.js +4084 -0
- package/dist/src/github-ui-conformance.d.ts +4 -0
- package/dist/src/github-ui-conformance.js +105 -0
- package/dist/src/github-ui-structure.d.ts +18 -0
- package/dist/src/github-ui-structure.js +251 -0
- package/dist/src/graphql-wire.d.ts +22 -0
- package/dist/src/graphql-wire.js +89 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +101 -0
- package/dist/src/manifest.d.ts +8 -0
- package/dist/src/manifest.js +597 -0
- package/dist/src/npm-registry.d.ts +7 -0
- package/dist/src/npm-registry.js +47 -0
- package/dist/src/screens/app-installation.d.ts +3 -0
- package/dist/src/screens/app-installation.js +166 -0
- package/dist/src/screens/app-manifest.d.ts +3 -0
- package/dist/src/screens/app-manifest.js +81 -0
- package/dist/src/screens/oauth.d.ts +15 -0
- package/dist/src/screens/oauth.js +257 -0
- package/dist/src/screens/session.d.ts +8 -0
- package/dist/src/screens/session.js +159 -0
- package/dist/src/semantics/actions.d.ts +2 -0
- package/dist/src/semantics/actions.js +413 -0
- package/dist/src/semantics/activity.d.ts +4 -0
- package/dist/src/semantics/activity.js +161 -0
- package/dist/src/semantics/apps.d.ts +2 -0
- package/dist/src/semantics/apps.js +144 -0
- package/dist/src/semantics/branches.d.ts +2 -0
- package/dist/src/semantics/branches.js +136 -0
- package/dist/src/semantics/checks.d.ts +2 -0
- package/dist/src/semantics/checks.js +176 -0
- package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
- package/dist/src/semantics/code-scanning-upload.js +97 -0
- package/dist/src/semantics/codespaces.d.ts +2 -0
- package/dist/src/semantics/codespaces.js +58 -0
- package/dist/src/semantics/commits.d.ts +2 -0
- package/dist/src/semantics/commits.js +109 -0
- package/dist/src/semantics/contents.d.ts +2 -0
- package/dist/src/semantics/contents.js +131 -0
- package/dist/src/semantics/deployments.d.ts +2 -0
- package/dist/src/semantics/deployments.js +127 -0
- package/dist/src/semantics/gists.d.ts +2 -0
- package/dist/src/semantics/gists.js +86 -0
- package/dist/src/semantics/git.d.ts +2 -0
- package/dist/src/semantics/git.js +235 -0
- package/dist/src/semantics/graphql.d.ts +7 -0
- package/dist/src/semantics/graphql.js +512 -0
- package/dist/src/semantics/index.d.ts +5 -0
- package/dist/src/semantics/index.js +62 -0
- package/dist/src/semantics/issues.d.ts +2 -0
- package/dist/src/semantics/issues.js +456 -0
- package/dist/src/semantics/keys.d.ts +2 -0
- package/dist/src/semantics/keys.js +66 -0
- package/dist/src/semantics/labels.d.ts +2 -0
- package/dist/src/semantics/labels.js +100 -0
- package/dist/src/semantics/meta.d.ts +12 -0
- package/dist/src/semantics/meta.js +144 -0
- package/dist/src/semantics/notifications.d.ts +4 -0
- package/dist/src/semantics/notifications.js +62 -0
- package/dist/src/semantics/orgs.d.ts +2 -0
- package/dist/src/semantics/orgs.js +420 -0
- package/dist/src/semantics/packages.d.ts +2 -0
- package/dist/src/semantics/packages.js +55 -0
- package/dist/src/semantics/pages.d.ts +2 -0
- package/dist/src/semantics/pages.js +176 -0
- package/dist/src/semantics/projects.d.ts +2 -0
- package/dist/src/semantics/projects.js +239 -0
- package/dist/src/semantics/pulls.d.ts +2 -0
- package/dist/src/semantics/pulls.js +462 -0
- package/dist/src/semantics/push-reactions.d.ts +101 -0
- package/dist/src/semantics/push-reactions.js +513 -0
- package/dist/src/semantics/releases.d.ts +19 -0
- package/dist/src/semantics/releases.js +231 -0
- package/dist/src/semantics/repo-invitations.d.ts +2 -0
- package/dist/src/semantics/repo-invitations.js +98 -0
- package/dist/src/semantics/repos.d.ts +17 -0
- package/dist/src/semantics/repos.js +390 -0
- package/dist/src/semantics/rulesets.d.ts +2 -0
- package/dist/src/semantics/rulesets.js +111 -0
- package/dist/src/semantics/search.d.ts +2 -0
- package/dist/src/semantics/search.js +84 -0
- package/dist/src/semantics/security.d.ts +2 -0
- package/dist/src/semantics/security.js +177 -0
- package/dist/src/semantics/shared.d.ts +61 -0
- package/dist/src/semantics/shared.js +151 -0
- package/dist/src/semantics/users.d.ts +2 -0
- package/dist/src/semantics/users.js +255 -0
- package/dist/src/semantics/webhooks.d.ts +2 -0
- package/dist/src/semantics/webhooks.js +107 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
- package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
- package/dist/test-fixtures/github-comment-schema.json +702 -0
- package/dist/test-fixtures/github-known-deviations.json +94 -0
- package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
- package/dist/test-fixtures/github-openapi-operations.json +362 -0
- package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
- package/dist/test-fixtures/github-pull-schema.json +3601 -0
- package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
- package/dist/test-fixtures/github-review-schema.json +236 -0
- package/package.json +20 -11
- package/src/cli.ts +7 -6
- package/src/generated/graphql-sdl.gen.json +1 -0
- package/src/generated/graphql.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/github-a11y-snapshot.uitest.ts +5 -5
- package/src/github-budget.ts +4 -4
- package/src/github-capabilities.ts +1993 -556
- package/src/github-conformance.ts +12 -7
- package/src/github-connector.ts +108 -96
- package/src/github-events.ts +223 -95
- package/src/github-git-http.ts +47 -83
- package/src/github-git-plane.ts +247 -385
- package/src/github-journey.uitest.ts +28 -44
- package/src/github-mirror-state.ts +23 -61
- package/src/github-mirror-ui.ts +16 -10
- package/src/github-server.ts +158 -60
- package/src/github-shared.ts +1 -1
- package/src/github-twin.ts +1525 -4494
- package/src/github-ui-conformance.ts +7 -8
- package/src/github-ui-structure.ts +19 -18
- package/src/graphql-wire.ts +115 -0
- package/src/index.ts +47 -7
- package/src/manifest.ts +605 -0
- package/src/npm-registry.ts +43 -0
- package/src/screens/app-installation.tsx +217 -0
- package/src/screens/app-manifest.tsx +97 -0
- package/src/screens/oauth.tsx +258 -0
- package/src/screens/session.tsx +186 -0
- package/src/semantics/actions.ts +395 -0
- package/src/semantics/activity.ts +171 -0
- package/src/semantics/apps.ts +133 -0
- package/src/semantics/branches.ts +133 -0
- package/src/semantics/checks.ts +163 -0
- package/src/semantics/code-scanning-upload.ts +92 -0
- package/src/semantics/codespaces.ts +58 -0
- package/src/semantics/commits.ts +109 -0
- package/src/semantics/contents.ts +112 -0
- package/src/semantics/deployments.ts +116 -0
- package/src/semantics/gists.ts +85 -0
- package/src/semantics/git.ts +226 -0
- package/src/semantics/graphql.ts +505 -0
- package/src/semantics/index.ts +68 -0
- package/src/semantics/issues.ts +434 -0
- package/src/semantics/keys.ts +66 -0
- package/src/semantics/labels.ts +91 -0
- package/src/semantics/meta.ts +139 -0
- package/src/semantics/notifications.ts +58 -0
- package/src/semantics/orgs.ts +383 -0
- package/src/semantics/packages.ts +59 -0
- package/src/semantics/pages.ts +154 -0
- package/src/semantics/projects.ts +246 -0
- package/src/semantics/pulls.ts +421 -0
- package/src/semantics/push-reactions.ts +471 -0
- package/src/semantics/releases.ts +213 -0
- package/src/semantics/repo-invitations.ts +112 -0
- package/src/semantics/repos.ts +373 -0
- package/src/semantics/rulesets.ts +107 -0
- package/src/semantics/search.ts +83 -0
- package/src/semantics/security.ts +153 -0
- package/src/semantics/shared.ts +181 -0
- package/src/semantics/users.ts +252 -0
- package/src/semantics/webhooks.ts +101 -0
- package/test-fixtures/github-known-deviations.json +7 -8
- package/test-fixtures/github-openapi-operations.json +46 -227
- package/src/github-graphql.ts +0 -398
package/src/github-events.ts
CHANGED
|
@@ -1,105 +1,233 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// A registered endpoint + its (optional) signing secret.
|
|
19
|
-
type Endpoint = { url: string; secret?: string };
|
|
20
|
-
const registry: Endpoint[] = [];
|
|
21
|
-
export function registerGithubWebhook(url: string, secret?: string): void {
|
|
22
|
-
const existing = registry.find((e) => e.url === url);
|
|
23
|
-
if (existing) existing.secret = secret;
|
|
24
|
-
else registry.push({ url, secret });
|
|
25
|
-
}
|
|
26
|
-
export function clearGithubWebhooks(): void { registry.length = 0; deliveries.length = 0; deliverySeq = 0; }
|
|
27
|
-
export function listGithubWebhooks(): string[] { return registry.map((e) => e.url); }
|
|
28
|
-
|
|
29
|
-
// The deliveries log — one entry per (endpoint, event) delivery, with the exact payload
|
|
30
|
-
// JSON so a redelivery replays it byte-for-byte (real GitHub keeps deliveries for redelivery).
|
|
31
|
-
export type GithubDelivery = { id: string; url: string; event: string; action: string; payloadJson: string; deliveredAt?: string; secret?: string };
|
|
32
|
-
const deliveries: GithubDelivery[] = [];
|
|
33
|
-
let deliverySeq = 0;
|
|
34
|
-
export function listGithubDeliveries(): GithubDelivery[] { return deliveries.map((d) => ({ ...d })); }
|
|
35
|
-
|
|
36
|
-
/** The HMAC-SHA256 signature GitHub sends in X-Hub-Signature-256 (sha256=<hex>). */
|
|
37
|
-
export function signGithubPayload(secret: string, payloadJson: string): string {
|
|
38
|
-
return `sha256=${createHmac('sha256', secret).update(payloadJson).digest('hex')}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const httpDelivery: GithubWebhookDelivery = async (url, _headerEvent, payload, headers) => {
|
|
42
|
-
try { await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(payload) }); }
|
|
43
|
-
catch { /* fire-and-forget, like real GitHub */ }
|
|
1
|
+
// Native repository hooks and delivery history live in the World's existing state.
|
|
2
|
+
// Callback HTTP is the caller's webhook sink, never a credentialed GitHub API call.
|
|
3
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
4
|
+
import { applyTwinWrite, applyTwinWriteAtomic, ownFields, twinResources, worldNow } from '@volter/world-core';
|
|
5
|
+
import { GITHUB_WORLD_USER, githubApps, githubState, handleGithubRequest, toGithubWebhook } from './github-twin.ts';
|
|
6
|
+
import type { GithubWebhook as Hook, GithubWriteEvent } from './github-twin.ts';
|
|
7
|
+
import { worldEgressRefusal } from '@volter/world-core/network-policy';
|
|
8
|
+
|
|
9
|
+
export type GithubWebhook = Record<string, unknown>;
|
|
10
|
+
export type GithubWebhookResponse = { status: number; headers?: Record<string, string>; body?: string };
|
|
11
|
+
export type GithubWebhookDelivery = (url: string, event: string, payload: GithubWebhook, headers: Record<string, string>, body: string) => Promise<GithubWebhookResponse> | GithubWebhookResponse;
|
|
12
|
+
type Options = { root?: string; occurredAt?: string; deliver?: GithubWebhookDelivery };
|
|
13
|
+
type Delivery = {
|
|
14
|
+
id: number; guid: string; repository: string; hookId: number; event: string; action?: string;
|
|
15
|
+
url: string; payloadJson: string; requestBody: string; requestHeaders: Record<string, string>;
|
|
16
|
+
deliveredAt: string; redelivery: boolean; statusCode: number | null; error?: string;
|
|
17
|
+
responseHeaders?: Record<string, string>; responseBody?: string;
|
|
44
18
|
};
|
|
19
|
+
const TYPE = 'webhook_delivery';
|
|
45
20
|
|
|
46
|
-
function
|
|
47
|
-
return {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
21
|
+
export function signGithubPayload(secret: string, body: string): string {
|
|
22
|
+
return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const httpDelivery: GithubWebhookDelivery = async (url, _event, _payload, headers, body) => {
|
|
26
|
+
// the World's egress rule: a refused hook URL fails as a transport failure, recorded on the delivery
|
|
27
|
+
const refusal = worldEgressRefusal(url);
|
|
28
|
+
if (refusal !== null) throw new Error(refusal);
|
|
29
|
+
const response = await fetch(url, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(10_000) });
|
|
30
|
+
return { status: response.status, headers: Object.fromEntries(response.headers), body: await response.text() };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async function nativeObject(path: string, root?: string): Promise<Record<string, unknown> | undefined> {
|
|
34
|
+
const response = await handleGithubRequest({ method: 'GET', path, root });
|
|
35
|
+
return response.status === 200 && response.body && !Array.isArray(response.body)
|
|
36
|
+
? response.body as Record<string, unknown> : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function buildPayload(write: GithubWriteEvent, root?: string): Promise<GithubWebhook> {
|
|
40
|
+
const payload: GithubWebhook = { action: write.action, repository: { full_name: write.repository }, sender: { login: GITHUB_WORLD_USER, type: 'User' } };
|
|
41
|
+
const path = `/repos/${write.repository}`;
|
|
42
|
+
if (['pull_request', 'pull_request_review', 'pull_request_review_comment'].includes(write.event)) {
|
|
43
|
+
payload.pull_request = write.pr ?? await nativeObject(`${path}/pulls/${write.number}`, root) ?? { number: write.number };
|
|
44
|
+
}
|
|
45
|
+
if (write.event === 'pull_request') payload.number = write.number;
|
|
46
|
+
if (write.event === 'issues' || write.event === 'issue_comment') payload.issue = await issuePayload(write, path, root);
|
|
47
|
+
if (write.event === 'pull_request_review') payload.review = reviewPayload(write);
|
|
48
|
+
if (write.event === 'issue_comment' || write.event === 'pull_request_review_comment') payload.comment = write.comment ?? { body: write.body ?? '' };
|
|
49
|
+
if (write.event === 'discussion' || write.event === 'discussion_comment') payload.number = write.number;
|
|
50
|
+
if (write.event === 'push' && write.ref !== undefined) pushPayload(payload, write);
|
|
51
|
+
return payload;
|
|
52
|
+
}
|
|
53
|
+
/** An issue event's issue: the one the write carried, or as the API reads it. */
|
|
54
|
+
async function issuePayload(write: GithubWriteEvent, path: string, root?: string): Promise<unknown> {
|
|
55
|
+
return write.issue ?? await nativeObject(`${path}/issues/${write.number}`, root) ?? { number: write.number };
|
|
56
|
+
}
|
|
57
|
+
/** A review event's review, its state lower-cased as webhooks send it. */
|
|
58
|
+
function reviewPayload(write: GithubWriteEvent): Record<string, unknown> {
|
|
59
|
+
const review = write.review ?? { body: write.body ?? '' };
|
|
60
|
+
return { ...review, ...(typeof review.state === 'string' ? { state: review.state.toLowerCase() } : {}) };
|
|
61
|
+
}
|
|
62
|
+
/** A push event names its ref and the commits before and after it, and carries no action. */
|
|
63
|
+
function pushPayload(payload: GithubWebhook, write: GithubWriteEvent): void {
|
|
64
|
+
delete payload.action;
|
|
65
|
+
Object.assign(payload, { ref: write.ref, before: write.before ?? '', after: write.after ?? '', created: write.before === '0'.repeat(40), deleted: write.after === '0'.repeat(40) });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function records(root?: string): Delivery[] {
|
|
69
|
+
return twinResources('github', root).filter(r => r.type === TYPE).map(r => ownFields(r) as unknown as Delivery);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hookFor(repository: string, id: number, root?: string): Hook | undefined {
|
|
73
|
+
return githubState(root).webhooks.find(h => h.repository === repository && h.id === id);
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
function headersFor(
|
|
70
|
-
const headers: Record<string, string> = {
|
|
71
|
-
|
|
76
|
+
function headersFor(hook: Hook, event: string, guid: string, body: string): Record<string, string> {
|
|
77
|
+
const headers: Record<string, string> = {
|
|
78
|
+
'content-type': hook.content_type !== 'json' ? 'application/x-www-form-urlencoded' : 'application/json',
|
|
79
|
+
'x-github-event': event, 'x-github-delivery': guid, 'x-github-hook-id': String(hook.id),
|
|
80
|
+
};
|
|
81
|
+
if (hook.secret) headers['x-hub-signature-256'] = signGithubPayload(hook.secret, body);
|
|
72
82
|
return headers;
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
async function dispatch(hook: Hook, event: string, payload: GithubWebhook, opts: Options, original?: Delivery): Promise<Delivery> {
|
|
86
|
+
const payloadJson = original?.payloadJson ?? JSON.stringify(payload);
|
|
87
|
+
const requestBody = hook.content_type !== 'json' ? new URLSearchParams({ payload: payloadJson }).toString() : payloadJson;
|
|
88
|
+
const occurredAt = opts.occurredAt ?? worldNow();
|
|
89
|
+
// Allocation and append share the kernel lock; two callbacks cannot claim the same id.
|
|
90
|
+
const { value: pending } = await applyTwinWriteAtomic<Delivery>('github', resources => {
|
|
91
|
+
const id = resources.filter(r => r.type === TYPE).reduce((max, r) => Math.max(max, Number(ownFields(r).id) || 0), 0) + 1;
|
|
92
|
+
// from the World's history alone (never its directory): a replay of the same history delivers the same ids
|
|
93
|
+
const hex = createHash('sha256').update(`${occurredAt}\0${hook.repository}\0${hook.id}\0${id}\0${payloadJson}`).digest('hex');
|
|
94
|
+
const guid = original?.guid ?? `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
95
|
+
const value: Delivery = {
|
|
96
|
+
id, guid, repository: hook.repository, hookId: hook.id, event,
|
|
97
|
+
...(typeof payload.action === 'string' ? { action: payload.action } : {}),
|
|
98
|
+
url: hook.url!, payloadJson, requestBody, requestHeaders: headersFor(hook, event, guid, requestBody),
|
|
99
|
+
deliveredAt: occurredAt, redelivery: original !== undefined, statusCode: null,
|
|
100
|
+
};
|
|
101
|
+
return { kind: 'write', value, write: { operation: 'webhook_delivery.create', subjectType: TYPE, subjectId: `webhook-delivery:${id}`, fields: value, occurredAt, actor: { kind: 'system' } } };
|
|
102
|
+
}, opts.root);
|
|
103
|
+
let result: Partial<Delivery>;
|
|
104
|
+
try {
|
|
105
|
+
const response = await (opts.deliver ?? httpDelivery)(pending.url, event, JSON.parse(payloadJson), pending.requestHeaders, requestBody);
|
|
106
|
+
result = { statusCode: response.status, responseHeaders: response.headers ?? {}, responseBody: response.body ?? '' };
|
|
107
|
+
} catch {
|
|
108
|
+
// Persist failure without exposing signing keys, URL credentials or a thrown transport stack.
|
|
109
|
+
result = { statusCode: 0, error: 'Webhook transport failed' };
|
|
110
|
+
}
|
|
111
|
+
await applyTwinWrite('github', { operation: 'webhook_delivery.update', subjectType: TYPE, subjectId: `webhook-delivery:${pending.id}`, fields: result, occurredAt, actor: { kind: 'system' } }, opts.root);
|
|
112
|
+
return { ...pending, ...result };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Current persisted hook settings are resolved per event and World, including after restart. */
|
|
116
|
+
export async function emitGithubEvent(write: GithubWriteEvent, opts: Options = {}): Promise<GithubWebhook[]> {
|
|
117
|
+
const hooks = githubState(opts.root).webhooks.filter(h => h.repository === write.repository && h.active && h.url && (h.events.includes('*') || h.events.includes(write.event)));
|
|
118
|
+
return hooks.length ? deliverToHooks(hooks, write, opts) : [];
|
|
119
|
+
}
|
|
120
|
+
/** Each subscribed repository hook gets the event's payload. */
|
|
121
|
+
async function deliverToHooks(hooks: Hook[], write: GithubWriteEvent, opts: Options): Promise<GithubWebhook[]> {
|
|
122
|
+
const payload = await buildPayload(write, opts.root);
|
|
123
|
+
for (const hook of hooks) await dispatch(hook, write.event, payload, opts);
|
|
124
|
+
return hooks.map(() => payload);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A repository's event, delivered as well to each GitHub App installed on the repository whose app subscribes to it,
|
|
128
|
+
* with the installation it is for, so the app knows which installation token to mint
|
|
129
|
+
* (docs.github.com/apps/creating-github-apps/registering-a-github-app/using-webhooks-with-github-apps,
|
|
130
|
+
* docs.github.com/webhooks/webhook-events-and-payloads: `installation` is present when the event is for an app's
|
|
131
|
+
* installation). Answers how many apps it was delivered to. */
|
|
132
|
+
export async function emitGithubInstallationEvents(write: GithubWriteEvent, opts: Options = {}): Promise<number> {
|
|
133
|
+
const st = githubState(opts.root);
|
|
134
|
+
const [owner] = write.repository.split('/');
|
|
135
|
+
const covering = st.appInstallations.filter((i) => i.account_login === owner && (i.repository_selection === 'all' || (i.repositories ?? []).includes(write.repository)));
|
|
136
|
+
let delivered = 0;
|
|
137
|
+
for (const installation of covering) {
|
|
138
|
+
const app = githubApps(opts.root).find((a) => a.slug === installation.app_slug);
|
|
139
|
+
if (!app?.hook_url || !app.events.includes(write.event)) continue;
|
|
140
|
+
const payload = { ...(await buildPayload(write, opts.root)), installation: { id: installation.id, node_id: nodeIdOf('Integration::Installation', installation.id) } };
|
|
141
|
+
if (await emitGithubAppEvent(app, write.event, payload, opts)) delivered += 1;
|
|
91
142
|
}
|
|
92
|
-
return
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
const headers = headersFor(d.event, newId, d.payloadJson, d.secret);
|
|
103
|
-
await deliver(d.url, d.event, JSON.parse(d.payloadJson) as GithubWebhook, headers);
|
|
143
|
+
return delivered;
|
|
144
|
+
}
|
|
145
|
+
const nodeIdOf = (type: string, id: number): string => Buffer.from(`${String(type.length).padStart(2, '0')}:${type}${id}`).toString('base64');
|
|
146
|
+
|
|
147
|
+
/** An event for a GitHub App's own webhook (installation, …), delivered to the URL its registration names
|
|
148
|
+
* and recorded as any delivery is; an app with no webhook URL gets none, as on GitHub. */
|
|
149
|
+
export async function emitGithubAppEvent(app: { id: number; hook_url?: string; webhook_secret?: string }, event: string, payload: GithubWebhook, opts: Options = {}): Promise<boolean> {
|
|
150
|
+
if (!app.hook_url) return false;
|
|
151
|
+
const hook = { id: app.id, repository: '', name: 'web', active: true, url: app.hook_url, content_type: 'json', secret: app.webhook_secret ?? '', events: [event] } as unknown as Hook;
|
|
152
|
+
await dispatch(hook, event, payload, opts);
|
|
104
153
|
return true;
|
|
105
154
|
}
|
|
155
|
+
|
|
156
|
+
export async function pingGithubHook(repository: string, hookId: number, opts: Options = {}): Promise<boolean> {
|
|
157
|
+
const hook = hookFor(repository, hookId, opts.root);
|
|
158
|
+
if (!hook?.url) return false;
|
|
159
|
+
await dispatch(hook, 'ping', { hook_id: hook.id, hook: toGithubWebhook(hook), repository: { full_name: repository } }, opts);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A hook's last delivery as GitHub reports it on the hook (`last_response`): unused until one is made. */
|
|
164
|
+
export function hookLastResponse(repository: string, hookId: number, root?: string): Record<string, unknown> {
|
|
165
|
+
const last = records(root).filter((d) => d.repository === repository && d.hookId === hookId).sort((a, b) => b.id - a.id)[0];
|
|
166
|
+
if (!last || last.statusCode === null) return { code: null, status: 'unused', message: null };
|
|
167
|
+
const ok = last.statusCode >= 200 && last.statusCode < 300;
|
|
168
|
+
return { code: last.statusCode || null, status: ok ? 'active' : 'failed', message: ok ? 'OK' : (last.error ?? `Invalid HTTP Response: ${last.statusCode}`) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// a delivery takes no World time, so its duration is 0; a repository's hook belongs to no installation
|
|
172
|
+
function deliveryJson(d: Delivery, detail: boolean, repositoryId: number | null): Record<string, unknown> {
|
|
173
|
+
return {
|
|
174
|
+
id: d.id, guid: d.guid, delivered_at: d.deliveredAt, redelivery: d.redelivery, duration: 0, installation_id: null, repository_id: repositoryId,
|
|
175
|
+
status: d.statusCode === null ? 'Pending' : d.statusCode >= 200 && d.statusCode < 300 ? 'OK' : d.error ?? 'Failed',
|
|
176
|
+
status_code: d.statusCode, event: d.event, action: d.action ?? null,
|
|
177
|
+
...(detail ? { url: d.url, request: { headers: d.requestHeaders, payload: JSON.parse(d.payloadJson) }, response: { headers: d.responseHeaders ?? {}, payload: d.responseBody ?? null } } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A GitHub App's own webhook deliveries, newest first (docs.github.com/rest/apps/webhooks#list-deliveries-for-an-app-webhook):
|
|
182
|
+
* each names the installation its payload was for; one delivery in detail carries its request and response. */
|
|
183
|
+
export function githubAppDeliveries(appId: number, root?: string): Record<string, unknown>[] {
|
|
184
|
+
return records(root).filter((d) => d.repository === '' && d.hookId === appId).sort((a, b) => b.id - a.id).map((d) => appDeliveryJson(d, false));
|
|
185
|
+
}
|
|
186
|
+
export function githubAppDelivery(appId: number, id: number, root?: string): Record<string, unknown> | undefined {
|
|
187
|
+
const d = records(root).find((x) => x.repository === '' && x.hookId === appId && x.id === id);
|
|
188
|
+
return d && appDeliveryJson(d, true);
|
|
189
|
+
}
|
|
190
|
+
function appDeliveryJson(d: Delivery, detail: boolean): Record<string, unknown> {
|
|
191
|
+
const payload = JSON.parse(d.payloadJson) as { installation?: { id?: number }; repository?: { id?: number } };
|
|
192
|
+
return { ...deliveryJson(d, detail, typeof payload.repository?.id === 'number' ? payload.repository.id : null), installation_id: payload.installation?.id ?? null };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Native delivery/ping routes, shared by CLI serve and in-process callers. */
|
|
196
|
+
export async function handleGithubWebhookRequest(request: Request, opts: Options & { readOnly?: boolean }): Promise<Response | undefined> {
|
|
197
|
+
const url = new URL(request.url);
|
|
198
|
+
const match = /^\/repos\/([^/]+)\/([^/]+)\/hooks\/(\d+)\/(deliveries(?:\/(\d+)(?:\/(attempts))?)?|pings)\/?$/.exec(url.pathname);
|
|
199
|
+
if (!match) return;
|
|
200
|
+
const repository = `${decodeURIComponent(match[1]!)}/${decodeURIComponent(match[2]!)}`;
|
|
201
|
+
const hook = hookFor(repository, Number(match[3]), opts.root);
|
|
202
|
+
const missing = () => Response.json({ message: 'Not Found' }, { status: 404 });
|
|
203
|
+
if (!hook) return missing();
|
|
204
|
+
if (request.method === 'POST' && opts.readOnly) return Response.json({ message: 'Read-only mirror' }, { status: 403 });
|
|
205
|
+
if (match[4] === 'pings') {
|
|
206
|
+
if (request.method !== 'POST') return missing();
|
|
207
|
+
await pingGithubHook(repository, hook.id, opts);
|
|
208
|
+
return new Response(null, { status: 204 });
|
|
209
|
+
}
|
|
210
|
+
const all = records(opts.root).filter(d => d.repository === repository && d.hookId === hook.id).sort((a, b) => b.id - a.id);
|
|
211
|
+
const repoId = githubState(opts.root).repos.find((r) => r.full_name === repository)?.id ?? null;
|
|
212
|
+
if (!match[5]) {
|
|
213
|
+
if (request.method !== 'GET') return missing();
|
|
214
|
+
const perPage = Number(url.searchParams.get('per_page') ?? 30);
|
|
215
|
+
const cursor = url.searchParams.get('cursor');
|
|
216
|
+
const status = url.searchParams.get('status');
|
|
217
|
+
if (!Number.isInteger(perPage) || perPage < 1 || perPage > 100 || cursor !== null && !/^\d+$/.test(cursor) || status !== null && !['success', 'failure'].includes(status)) return Response.json({ message: 'Validation Failed' }, { status: 422 });
|
|
218
|
+
const eligible = all.filter(d => (cursor === null || d.id < Number(cursor)) && (status === null || (status === 'success'
|
|
219
|
+
? d.statusCode !== null && d.statusCode >= 200 && d.statusCode <= 399
|
|
220
|
+
: d.statusCode !== null && d.statusCode >= 400 && d.statusCode <= 599)));
|
|
221
|
+
const page = eligible.slice(0, perPage);
|
|
222
|
+
const headers: Record<string, string> = {};
|
|
223
|
+
if (eligible.length > page.length) { url.searchParams.set('cursor', String(page.at(-1)!.id)); headers.link = `<${url.href}>; rel="next"`; }
|
|
224
|
+
return Response.json(page.map(d => deliveryJson(d, false, repoId)), { headers });
|
|
225
|
+
}
|
|
226
|
+
const delivery = all.find(d => d.id === Number(match[5]));
|
|
227
|
+
if (!delivery) return missing();
|
|
228
|
+
if (match[6] === 'attempts' && request.method === 'POST') {
|
|
229
|
+
await dispatch(hook, delivery.event, JSON.parse(delivery.payloadJson), opts, delivery);
|
|
230
|
+
return new Response(null, { status: 202 });
|
|
231
|
+
}
|
|
232
|
+
return !match[6] && request.method === 'GET' ? Response.json(deliveryJson(delivery, true, repoId)) : missing();
|
|
233
|
+
}
|
package/src/github-git-http.ts
CHANGED
|
@@ -26,17 +26,15 @@
|
|
|
26
26
|
// Data REST reads fall back to it (github-twin.ts).
|
|
27
27
|
//
|
|
28
28
|
// Each branch-ref change also yields a `push` webhook event (ref/before/after), which the
|
|
29
|
-
// server
|
|
30
|
-
// same webhook machinery a REST contents write does.
|
|
29
|
+
// server announces as a REST contents write's is — GitHub's push reactions and the webhook.
|
|
31
30
|
//
|
|
32
|
-
// Offline + deterministic: everything here is
|
|
33
|
-
// network, no rate budget
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
import {
|
|
37
|
-
|
|
38
|
-
} from './
|
|
39
|
-
import { closePrsForDeletedBranch, githubState, syncPrHeadsToBranchTip, type GithubWriteEvent } from './github-twin.ts';
|
|
31
|
+
// Offline + deterministic: everything here is the kernel's git library over the World's
|
|
32
|
+
// blob store (no network, no rate budget, no `git` binary).
|
|
33
|
+
import { applyTwinWrite, git } from '@volter/world-core';
|
|
34
|
+
const { serveSmartHttp, serveSmartHttpPost } = git;
|
|
35
|
+
import { ensureBareRepo, hasBareRepo, listGitPlaneRefs, planeRepo, type PlaneRef } from './github-git-plane.ts';
|
|
36
|
+
import { closePrsForDeletedBranch, githubState, kernelAtomic, syncPrHeadsToBranchTip, type GithubWriteEvent } from './github-twin.ts';
|
|
37
|
+
import { directPushRules } from './semantics/push-reactions.ts';
|
|
40
38
|
|
|
41
39
|
const SERVICE = 'github';
|
|
42
40
|
|
|
@@ -65,17 +63,10 @@ export type GitSmartHttpResponse = {
|
|
|
65
63
|
status: number;
|
|
66
64
|
contentType: string;
|
|
67
65
|
body: Uint8Array;
|
|
68
|
-
/** push webhook events (receive-pack only) — the
|
|
66
|
+
/** push webhook events (receive-pack only) — the server announces them. */
|
|
69
67
|
webhooks: GithubWriteEvent[];
|
|
70
68
|
};
|
|
71
69
|
|
|
72
|
-
// One pkt-line: 4-byte hex length (including the length itself) + payload (gitprotocol-common(5)).
|
|
73
|
-
function pktLine(payload: string): Buffer {
|
|
74
|
-
const data = Buffer.from(payload, 'utf8');
|
|
75
|
-
return Buffer.concat([Buffer.from((data.length + 4).toString(16).padStart(4, '0'), 'utf8'), data]);
|
|
76
|
-
}
|
|
77
|
-
const FLUSH_PKT = Buffer.from('0000', 'utf8');
|
|
78
|
-
|
|
79
70
|
function plain(status: number, message: string): GitSmartHttpResponse {
|
|
80
71
|
return { status, contentType: 'text/plain; charset=utf-8', body: Buffer.from(`${message}\n`, 'utf8'), webhooks: [] };
|
|
81
72
|
}
|
|
@@ -88,22 +79,19 @@ function repoNotFound(): GitSmartHttpResponse {
|
|
|
88
79
|
// The bare repo must exist to be served. A repo the CONTROL plane knows (created via REST
|
|
89
80
|
// before the git plane existed, or pulled from real GitHub) is materialized on first
|
|
90
81
|
// protocol contact — creating a repo via REST initializes its bare repo either way.
|
|
91
|
-
|
|
92
|
-
|
|
82
|
+
/** The repository's refs exist, or are created for a repository the twin knows (its default branch). */
|
|
83
|
+
function resolvePlaneRepo(repo: string, root?: string): boolean {
|
|
84
|
+
if (hasBareRepo(repo, root)) return true;
|
|
93
85
|
const known = githubState(root).repos.find((r) => r.full_name === repo);
|
|
94
|
-
if (!known) return
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
function serviceEnv(gitProtocol?: string): Record<string, string> | undefined {
|
|
99
|
-
// Protocol v2 negotiation rides the Git-Protocol header end-to-end (gitprotocol-http(5)).
|
|
100
|
-
return gitProtocol ? { GIT_PROTOCOL: gitProtocol } : undefined;
|
|
86
|
+
if (!known) return false;
|
|
87
|
+
ensureBareRepo(repo, root, known.default_branch ?? 'main');
|
|
88
|
+
return true;
|
|
101
89
|
}
|
|
102
90
|
|
|
103
91
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
92
|
+
* Smart HTTP over the kernel library (`serveSmartHttp`): the advertisement, upload-pack and
|
|
93
|
+
* receive-pack — no `git` binary. A push's applied ref commands reach the twin through the
|
|
94
|
+
* reconciliation below (git_ref / branch rows, PR heads, the push webhooks).
|
|
107
95
|
*/
|
|
108
96
|
export async function handleGithubGitSmartHttp(req: {
|
|
109
97
|
method: string;
|
|
@@ -114,72 +102,52 @@ export async function handleGithubGitSmartHttp(req: {
|
|
|
114
102
|
readOnly?: boolean;
|
|
115
103
|
/** request Content-Encoding (git clients gzip large POST bodies) */
|
|
116
104
|
contentEncoding?: string;
|
|
117
|
-
/** request Git-Protocol header (protocol v2 negotiation) */
|
|
105
|
+
/** request Git-Protocol header (protocol v2 negotiation — the library answers v0, as git falls back to) */
|
|
118
106
|
gitProtocol?: string;
|
|
119
107
|
occurredAt?: string;
|
|
120
108
|
}): Promise<GitSmartHttpResponse | null> {
|
|
121
109
|
const route = parseGitSmartHttpPath(req.method, req.pathname);
|
|
122
110
|
if (!route) return null;
|
|
123
|
-
|
|
124
|
-
// ── GET info/refs — the smart service advertisement ─────────────────────────────────────
|
|
111
|
+
const tail = route.kind === 'info-refs' ? 'info/refs' : route.kind === 'upload-pack' ? 'git-upload-pack' : 'git-receive-pack';
|
|
125
112
|
if (route.kind === 'info-refs') {
|
|
126
113
|
const service = req.query?.get('service') ?? '';
|
|
127
114
|
if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
|
|
128
|
-
// Only the smart protocol is served. 403 is the FIRST-PARTY refusal status: git's own
|
|
129
|
-
// http-backend answers "403 Forbidden — Dumb HTTP protocol not supported" when dumb
|
|
130
|
-
// HTTP is disabled (http-backend.c), and GitHub refuses dumb clients the same way.
|
|
131
115
|
return plain(403, 'Dumb HTTP protocol is not supported: pass ?service=git-upload-pack or ?service=git-receive-pack.');
|
|
132
116
|
}
|
|
133
117
|
if (service === 'git-receive-pack' && req.readOnly) return plain(403, 'twin is read-only (mirror mode); pushes are rejected');
|
|
134
|
-
const dir = resolveRepoDir(route.repo, req.root);
|
|
135
|
-
if (!dir) return repoNotFound();
|
|
136
|
-
const cmd = service === 'git-upload-pack' ? 'upload-pack' : 'receive-pack';
|
|
137
|
-
const res = runGit(dir, [cmd, '--stateless-rpc', '--advertise-refs', '.'], { env: serviceEnv(req.gitProtocol) });
|
|
138
|
-
if (res.code !== 0) throw new Error(`github twin git plane: \`git ${cmd} --advertise-refs\` failed (exit ${res.code}): ${res.stderr.trim()}`);
|
|
139
|
-
return {
|
|
140
|
-
status: 200,
|
|
141
|
-
contentType: `application/x-${service}-advertisement`,
|
|
142
|
-
body: Buffer.concat([pktLine(`# service=${service}\n`), FLUSH_PKT, res.stdout]),
|
|
143
|
-
webhooks: [],
|
|
144
|
-
};
|
|
145
118
|
}
|
|
146
|
-
|
|
147
|
-
// ── POST git-upload-pack / git-receive-pack — the stateless RPC ─────────────────────────
|
|
148
|
-
// Read-only refusal comes FIRST on both routes (same ordering as the advertisement): a
|
|
149
|
-
// mirror answers 403 to a push whether or not the repo exists.
|
|
150
119
|
if (route.kind === 'receive-pack' && req.readOnly) return plain(403, 'twin is read-only (mirror mode); pushes are rejected');
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const cmd = route.kind === 'upload-pack' ? 'upload-pack' : 'receive-pack';
|
|
161
|
-
const before = route.kind === 'receive-pack' ? listGitPlaneRefs(route.repo, req.root) : [];
|
|
162
|
-
const res = runGit(dir, [cmd, '--stateless-rpc', '.'], { stdin: input, env: serviceEnv(req.gitProtocol) });
|
|
163
|
-
if (res.code !== 0) {
|
|
164
|
-
// A garbage/aborted request makes the service exit non-zero. Whatever it managed to say
|
|
165
|
-
// rides back IN-BAND (that is how the wire reports protocol errors — the client renders
|
|
166
|
-
// it); with nothing on stdout, answer a 400 naming the failure. Never an unhandled 500.
|
|
167
|
-
if (res.stdout.length === 0) return plain(400, `git ${cmd} rejected the request: ${res.stderr.trim() || 'protocol error'}`);
|
|
168
|
-
}
|
|
169
|
-
const webhooks = route.kind === 'receive-pack'
|
|
120
|
+
if (!resolvePlaneRepo(route.repo, req.root)) return repoNotFound();
|
|
121
|
+
const before = route.kind === 'receive-pack' ? await listGitPlaneRefs(route.repo, req.root) : [];
|
|
122
|
+
// POST hands the kernel the body already in hand; a Request round-trip would copy the whole pack.
|
|
123
|
+
const answer = route.kind === 'info-refs'
|
|
124
|
+
? await serveSmartHttp(new Request(`http://twin/${route.repo}.git/info/refs?service=${req.query?.get('service') ?? ''}`), planeRepo(route.repo, req.root), tail)
|
|
125
|
+
: await serveSmartHttpPost(req.body ?? new Uint8Array(), req.contentEncoding ?? '', { ...planeRepo(route.repo, req.root), policy: rulesPolicy(route.repo, req.root) }, tail as 'git-upload-pack' | 'git-receive-pack');
|
|
126
|
+
if (!answer) return null;
|
|
127
|
+
const body = new Uint8Array(await answer.arrayBuffer());
|
|
128
|
+
const webhooks = route.kind === 'receive-pack' && answer.status === 200
|
|
170
129
|
? await reconcileGitPlaneRefs(route.repo, before, { ...(req.root !== undefined ? { root: req.root } : {}), ...(req.occurredAt !== undefined ? { occurredAt: req.occurredAt } : {}) })
|
|
171
130
|
: [];
|
|
172
|
-
return { status:
|
|
131
|
+
return { status: answer.status, contentType: answer.headers.get('content-type') ?? 'application/octet-stream', body, webhooks };
|
|
173
132
|
}
|
|
174
133
|
|
|
175
134
|
const ZERO_SHA = '0'.repeat(40);
|
|
176
135
|
|
|
136
|
+
/** An active ruleset requiring pull requests refuses a direct push to its branch, as it refuses an API commit
|
|
137
|
+
* (semantics/push-reactions.ts directPushRules); git reports the ref `ng` with GitHub's reason. */
|
|
138
|
+
function rulesPolicy(repo: string, root?: string): (cmd: { name: string; new: string }) => string | null {
|
|
139
|
+
return (cmd) => {
|
|
140
|
+
if (!cmd.name.startsWith('refs/heads/') || cmd.new === ZERO_SHA) return null;
|
|
141
|
+
return directPushRules(githubState(root), repo, cmd.name.slice('refs/heads/'.length)).refused ? 'push declined due to repository rule violations' : null;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
177
145
|
// Per-write ordinal folded into every reconcile write's fields: the kernel dedupes actions
|
|
178
146
|
// by content + occurredAt millisecond, so a ref REVISITING a value under a pinned clock
|
|
179
147
|
// (force-push A→B→A replayed in one ms) would silently collide with its own earlier action
|
|
180
|
-
// and leave the projection at B while the bare repo says A (docs/
|
|
181
|
-
//
|
|
182
|
-
let reconcileSeq = 0;
|
|
148
|
+
// and leave the projection at B while the bare repo says A (docs/contributing/adding-a-twin.md §5 — the
|
|
149
|
+
// upstash `rev` pattern). Strictly increasing within the process; harmless extra field.
|
|
150
|
+
let reconcileSeq = 0; // counter: strictly increasing within the process; never state
|
|
183
151
|
|
|
184
152
|
/**
|
|
185
153
|
* Fold the bare repo's ref changes since `before` into the kernel event log — the SAME
|
|
@@ -194,7 +162,7 @@ export async function reconcileGitPlaneRefs(
|
|
|
194
162
|
): Promise<GithubWriteEvent[]> {
|
|
195
163
|
const root = opts.root;
|
|
196
164
|
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
197
|
-
const after = listGitPlaneRefs(repo, root);
|
|
165
|
+
const after = await listGitPlaneRefs(repo, root);
|
|
198
166
|
const beforeBy = new Map(before.map((r) => [r.ref, r]));
|
|
199
167
|
const afterBy = new Map(after.map((r) => [r.ref, r]));
|
|
200
168
|
const events: GithubWriteEvent[] = [];
|
|
@@ -218,9 +186,7 @@ export async function reconcileGitPlaneRefs(
|
|
|
218
186
|
}, root);
|
|
219
187
|
// GitHub's `synchronize`: a push to a PR's head branch moves the PR head — the old
|
|
220
188
|
// head's statuses/reviews stop matching (exact-head re-earn).
|
|
221
|
-
|
|
222
|
-
events.push({ event: 'pull_request', action: 'synchronize', repository: repo, number: n });
|
|
223
|
-
}
|
|
189
|
+
await syncPrHeadsToBranchTip(repo, name, r.object_sha, root, occurredAt, (pr, base) => events.push({ event: 'pull_request', action: 'synchronize', repository: base, number: Number(pr.number), pr }));
|
|
224
190
|
}
|
|
225
191
|
events.push({ event: 'push', action: '', repository: repo, number: 0, ref: r.ref, before: prev?.object_sha ?? ZERO_SHA, after: r.object_sha });
|
|
226
192
|
}
|
|
@@ -228,19 +194,17 @@ export async function reconcileGitPlaneRefs(
|
|
|
228
194
|
if (afterBy.has(r.ref)) continue;
|
|
229
195
|
await applyTwinWrite(SERVICE, {
|
|
230
196
|
operation: 'git_ref.delete', subjectType: 'git_ref', subjectId: `gitref:${repo}#${r.ref}`,
|
|
231
|
-
fields: { repository: repo, ref: r.ref, rev: ++reconcileSeq }, occurredAt, actor: { kind: 'agent' },
|
|
197
|
+
fields: { deleted: true, repository: repo, ref: r.ref, rev: ++reconcileSeq }, occurredAt, actor: { kind: 'agent' },
|
|
232
198
|
}, root);
|
|
233
199
|
const name = branchName(r.ref);
|
|
234
200
|
if (name) {
|
|
235
201
|
await applyTwinWrite(SERVICE, {
|
|
236
202
|
operation: 'branch.delete', subjectType: 'branch', subjectId: `branch:${repo}#${name}`,
|
|
237
|
-
fields: { repository: repo, name, rev: ++reconcileSeq }, occurredAt, actor: { kind: 'agent' },
|
|
203
|
+
fields: { deleted: true, repository: repo, name, rev: ++reconcileSeq }, occurredAt, actor: { kind: 'agent' },
|
|
238
204
|
}, root);
|
|
239
205
|
// Real GitHub CLOSES an open PR whose head branch is deleted (shared with the REST
|
|
240
206
|
// DELETE .../git/refs path — both converge on the same helper).
|
|
241
|
-
|
|
242
|
-
events.push({ event: 'pull_request', action: 'closed', repository: repo, number: n });
|
|
243
|
-
}
|
|
207
|
+
await closePrsForDeletedBranch(repo, name, root, occurredAt, (pr, base) => events.push({ event: 'pull_request', action: 'closed', repository: base, number: Number(pr.number), pr }), kernelAtomic(occurredAt, root), { operation: 'git-receive-pack', actor: 'external' });
|
|
244
208
|
}
|
|
245
209
|
events.push({ event: 'push', action: '', repository: repo, number: 0, ref: r.ref, before: r.object_sha, after: ZERO_SHA });
|
|
246
210
|
}
|