@volter/twin-github 0.1.2 → 2.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.
- 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 +232 -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 +104 -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 +170 -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 +225 -95
- package/src/github-git-http.ts +48 -84
- 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 +50 -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 +193 -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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// GitHub's security alert families: code scanning alerts and analyses, Dependabot alerts, and secret
|
|
2
|
+
// scanning alerts with their locations. GitHub raises them itself: secret scanning and Dependabot when code is
|
|
3
|
+
// pushed (./push-reactions.ts), code scanning from an uploaded SARIF analysis (./code-scanning-upload.ts); here
|
|
4
|
+
// they are read and moved through their states: dismissing needs a reason, resolving a resolution.
|
|
5
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
6
|
+
import { GITHUB_WORLD_USER, githubNotFound, resolvePlaneRef, paginate, toGithubCodeScanningAlert, toGithubCodeScanningAnalysis, toGithubDependabotAlert, toGithubSecretScanningAlert } from '../github-twin.ts';
|
|
7
|
+
import { asView, gh, put, send } from './shared.ts';
|
|
8
|
+
import { dependabotOn, dependabotReads, manifestsAt } from './push-reactions.ts';
|
|
9
|
+
|
|
10
|
+
const wantState = (query: string | undefined): string | null => new URLSearchParams((query ?? '').replace(/^\?/, '')).get('state');
|
|
11
|
+
const alertNumber = (ctx: SemanticsContext): number => Number(decodeURIComponent(gh(ctx).seg[5]!));
|
|
12
|
+
const invalid = (ctx: SemanticsContext, resource: string, field: string): Response => ctx.reply({ message: 'Validation Failed', errors: [{ resource, field, code: 'invalid' }] }, 422);
|
|
13
|
+
|
|
14
|
+
/** PATCH an alert: to `dismissed` with one of the vendor's reasons, or back (clearing the reason). */
|
|
15
|
+
async function dismissable(ctx: SemanticsContext, kind: 'code_scanning' | 'dependabot'): Promise<Response> {
|
|
16
|
+
const { repo, body, occurredAt, state, caller } = gh(ctx);
|
|
17
|
+
const number = alertNumber(ctx);
|
|
18
|
+
const alerts = kind === 'code_scanning' ? state().codeScanningAlerts : state().dependabotAlerts;
|
|
19
|
+
const existing = alerts.find((a) => a.repository === repo && a.number === number);
|
|
20
|
+
if (!existing) return send(ctx, githubNotFound());
|
|
21
|
+
const newState = body.state !== undefined ? String(body.state) : existing.state;
|
|
22
|
+
// Dismissing requires a reason (real GitHub 422s without it).
|
|
23
|
+
if (newState === 'dismissed' && !body.dismissed_reason) return ctx.reply({ message: 'dismissed_reason is required when dismissing an alert' }, 422);
|
|
24
|
+
const validReasons = kind === 'code_scanning' ? ['false positive', "won't fix", 'used in tests'] : ['fix_started', 'inaccurate', 'no_bandwidth', 'not_used', 'tolerable_risk'];
|
|
25
|
+
if (newState === 'dismissed' && !validReasons.includes(String(body.dismissed_reason))) return invalid(ctx, kind === 'code_scanning' ? 'CodeScanningAlert' : 'DependabotAlert', 'dismissed_reason');
|
|
26
|
+
const fields: Record<string, unknown> = { number, repository: repo, state: newState, updated_at: occurredAt };
|
|
27
|
+
if (newState === 'dismissed') { fields.dismissed_reason = String(body.dismissed_reason); fields.dismissed_comment = body.dismissed_comment !== undefined ? String(body.dismissed_comment) : null; fields.dismissed_at = occurredAt; fields.dismissed_by_login = caller ?? GITHUB_WORLD_USER; }
|
|
28
|
+
else { fields.dismissed_reason = null; fields.dismissed_comment = null; fields.dismissed_at = null; fields.dismissed_by_login = null; }
|
|
29
|
+
if (kind === 'code_scanning') {
|
|
30
|
+
await put(ctx, { operation: 'code_scanning_alert.update', subjectType: 'code_scanning_alert', subjectId: `csalert:${repo}#${number}`, fields });
|
|
31
|
+
return ctx.reply(toGithubCodeScanningAlert(state().codeScanningAlerts.find((x) => x.repository === repo && x.number === number)!));
|
|
32
|
+
}
|
|
33
|
+
await put(ctx, { operation: 'dependabot_alert.update', subjectType: 'dependabot_alert', subjectId: `dependabot:${repo}#${number}`, fields });
|
|
34
|
+
return ctx.reply(toGithubDependabotAlert(state().dependabotAlerts.find((x) => x.repository === repo && x.number === number)!));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function vulnerabilityAlerts(ctx: SemanticsContext, on: boolean): Promise<Response> {
|
|
38
|
+
const { repo, root, caller, state } = gh(ctx);
|
|
39
|
+
const st = state();
|
|
40
|
+
const r = st.repos.find((x) => x.full_name === repo);
|
|
41
|
+
if (!r) return send(ctx, githubNotFound());
|
|
42
|
+
const me = caller ?? GITHUB_WORLD_USER;
|
|
43
|
+
const admin = r.owner === me || st.orgMembers.some((m) => m.org === r.owner && m.login === me && m.role === 'admin' && m.state === 'active') || st.collaborators.some((c) => c.repository === repo && c.login === me && c.permission === 'admin');
|
|
44
|
+
if (!admin) return send(ctx, githubNotFound());
|
|
45
|
+
await put(ctx, { operation: on ? 'vulnerability_alerts.enable' : 'vulnerability_alerts.disable', subjectType: 'vulnerability_alerts', subjectId: `vulnalerts:${repo}`, fields: { repository: repo, enabled: on } });
|
|
46
|
+
const tip = on ? await resolvePlaneRef(repo, r.default_branch ?? 'main', r.default_branch ?? 'main', root) : null;
|
|
47
|
+
if (tip) await dependabotReads(ctx, repo, tip, await manifestsAt(repo, tip, root));
|
|
48
|
+
return send(ctx, { status: 204, body: undefined });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function securitySemantics(): Record<string, Semantics> {
|
|
52
|
+
const codeScanningListRecentAnalyses: Semantics = async (ctx) => {
|
|
53
|
+
const { repo, query, state } = gh(ctx);
|
|
54
|
+
const q = new URLSearchParams((query ?? '').replace(/^\?/, ''));
|
|
55
|
+
const items = state().codeScanningAnalyses.filter((a) => a.repository === repo && (!q.get('sarif_id') || a.sarif_id === q.get('sarif_id')) && (!q.get('ref') || a.ref === q.get('ref')));
|
|
56
|
+
// most recent first, as GitHub lists them
|
|
57
|
+
return ctx.reply(paginate([...items].sort((x, y) => String(y.created_at ?? '').localeCompare(String(x.created_at ?? '')) || y.id - x.id), query).map(toGithubCodeScanningAnalysis));
|
|
58
|
+
};
|
|
59
|
+
const codeScanningGetAnalysis: Semantics = async (ctx) => {
|
|
60
|
+
const { repo, state } = gh(ctx);
|
|
61
|
+
const id = alertNumber(ctx);
|
|
62
|
+
const a = state().codeScanningAnalyses.find((x) => x.repository === repo && x.id === id);
|
|
63
|
+
return send(ctx, a ? { status: 200, body: toGithubCodeScanningAnalysis(a) } : githubNotFound());
|
|
64
|
+
};
|
|
65
|
+
const codeScanningDeleteAnalysis: Semantics = async (ctx) => {
|
|
66
|
+
const { repo, state } = gh(ctx);
|
|
67
|
+
const id = alertNumber(ctx);
|
|
68
|
+
if (!state().codeScanningAnalyses.find((a) => a.repository === repo && a.id === id)) return send(ctx, githubNotFound());
|
|
69
|
+
await put(ctx, { operation: 'code_scanning_analysis.delete', subjectType: 'code_scanning_analysis', subjectId: `csanalysis:${id}`, fields: { deleted: true, repository: repo } });
|
|
70
|
+
return ctx.reply({ next_analysis_url: null, confirm_delete_url: null });
|
|
71
|
+
};
|
|
72
|
+
const secretScanningListAlertsForRepo: Semantics = async (ctx) => {
|
|
73
|
+
const { repo, query, state } = gh(ctx);
|
|
74
|
+
const want = wantState(query);
|
|
75
|
+
let items = state().secretScanningAlerts.filter((a) => a.repository === repo);
|
|
76
|
+
if (want) items = items.filter((a) => a.state === want);
|
|
77
|
+
return ctx.reply(paginate(items, query).map(toGithubSecretScanningAlert));
|
|
78
|
+
};
|
|
79
|
+
const secretScanningGetAlert: Semantics = async (ctx) => {
|
|
80
|
+
const { repo, state } = gh(ctx);
|
|
81
|
+
const n = alertNumber(ctx);
|
|
82
|
+
const a = state().secretScanningAlerts.find((x) => x.repository === repo && x.number === n);
|
|
83
|
+
return send(ctx, a ? { status: 200, body: toGithubSecretScanningAlert(a) } : githubNotFound());
|
|
84
|
+
};
|
|
85
|
+
const secretScanningListLocationsForAlert: Semantics = async (ctx) => {
|
|
86
|
+
const { repo, state } = gh(ctx);
|
|
87
|
+
const n = alertNumber(ctx);
|
|
88
|
+
const alert = state().secretScanningAlerts.find((x) => x.repository === repo && x.number === n);
|
|
89
|
+
if (!alert) return send(ctx, githubNotFound());
|
|
90
|
+
return ctx.reply((alert.locations ?? []).map((l) => ({ type: 'commit', details: { ...l, blob_url: `https://api.github.com/repos/${repo}/git/blobs/${l.blob_sha}`, commit_url: `https://api.github.com/repos/${repo}/git/commits/${l.commit_sha}` } })));
|
|
91
|
+
};
|
|
92
|
+
const secretScanningUpdateAlert: Semantics = async (ctx) => {
|
|
93
|
+
const { repo, body, occurredAt, state } = gh(ctx);
|
|
94
|
+
const number = alertNumber(ctx);
|
|
95
|
+
const existing = state().secretScanningAlerts.find((a) => a.repository === repo && a.number === number);
|
|
96
|
+
if (!existing) return send(ctx, githubNotFound());
|
|
97
|
+
const newState = body.state !== undefined ? String(body.state) : existing.state;
|
|
98
|
+
if (newState === 'resolved' && !body.resolution) return ctx.reply({ message: 'resolution is required when resolving an alert' }, 422);
|
|
99
|
+
if (newState === 'resolved' && !['false_positive', 'wont_fix', 'revoked', 'used_in_tests'].includes(String(body.resolution))) return invalid(ctx, 'SecretScanningAlert', 'resolution');
|
|
100
|
+
const fields: Record<string, unknown> = { number, repository: repo, state: newState, updated_at: occurredAt };
|
|
101
|
+
if (newState === 'resolved') { fields.resolution = String(body.resolution); fields.resolution_comment = body.resolution_comment !== undefined ? String(body.resolution_comment) : null; fields.resolved_at = occurredAt; }
|
|
102
|
+
else { fields.resolution = null; fields.resolution_comment = null; fields.resolved_at = null; }
|
|
103
|
+
await put(ctx, { operation: 'secret_scanning_alert.update', subjectType: 'secret_scanning_alert', subjectId: `secretscan:${repo}#${number}`, fields });
|
|
104
|
+
return ctx.reply(toGithubSecretScanningAlert(state().secretScanningAlerts.find((x) => x.repository === repo && x.number === number)!));
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
'code-scanning/list-alerts-for-repo': async (ctx) => {
|
|
108
|
+
const { repo, query, state } = gh(ctx);
|
|
109
|
+
const want = wantState(query);
|
|
110
|
+
let items = state().codeScanningAlerts.filter((a) => a.repository === repo);
|
|
111
|
+
if (want) items = items.filter((a) => a.state === want);
|
|
112
|
+
return ctx.reply(paginate(items, query).map((a) => asView('code-scanning-alert-items', toGithubCodeScanningAlert(a))));
|
|
113
|
+
},
|
|
114
|
+
'code-scanning/get-alert': async (ctx) => {
|
|
115
|
+
const { repo, state } = gh(ctx);
|
|
116
|
+
const n = alertNumber(ctx);
|
|
117
|
+
const a = state().codeScanningAlerts.find((x) => x.repository === repo && x.number === n);
|
|
118
|
+
return send(ctx, a ? { status: 200, body: toGithubCodeScanningAlert(a) } : githubNotFound());
|
|
119
|
+
},
|
|
120
|
+
'code-scanning/update-alert': async (ctx) => dismissable(ctx, 'code_scanning'),
|
|
121
|
+
'code-scanning/list-recent-analyses': codeScanningListRecentAnalyses,
|
|
122
|
+
'code-scanning/get-analysis': codeScanningGetAnalysis,
|
|
123
|
+
'code-scanning/delete-analysis': codeScanningDeleteAnalysis,
|
|
124
|
+
// Dependabot alerts on or off for a repository: GET answers 204 when on and 404 when off; turning them on reads the
|
|
125
|
+
// default branch's manifests at once (docs.github.com/rest/repos/repos#check-if-vulnerability-alerts-are-enabled-for-a-repository,
|
|
126
|
+
// #enable-vulnerability-alerts, #disable-vulnerability-alerts). Only an admin of the repository turns them on or off.
|
|
127
|
+
'repos/check-vulnerability-alerts': async (ctx) => send(ctx, dependabotOn(ctx, gh(ctx).repo) ? { status: 204, body: undefined } : githubNotFound()),
|
|
128
|
+
'repos/enable-vulnerability-alerts': async (ctx) => vulnerabilityAlerts(ctx, true),
|
|
129
|
+
'repos/disable-vulnerability-alerts': async (ctx) => vulnerabilityAlerts(ctx, false),
|
|
130
|
+
// with alerts off there are none to list: GitHub answers 403
|
|
131
|
+
'dependabot/list-alerts-for-repo': async (ctx) => {
|
|
132
|
+
const { repo, query, state } = gh(ctx);
|
|
133
|
+
if (!dependabotOn(ctx, repo)) return ctx.reply({ message: 'Dependabot alerts are disabled for this repository.', documentation_url: 'https://docs.github.com/rest/dependabot/alerts#list-dependabot-alerts-for-a-repository' }, 403);
|
|
134
|
+
const want = wantState(query);
|
|
135
|
+
let items = state().dependabotAlerts.filter((a) => a.repository === repo);
|
|
136
|
+
if (want) items = items.filter((a) => a.state === want);
|
|
137
|
+
return ctx.reply(paginate(items, query).map(toGithubDependabotAlert));
|
|
138
|
+
},
|
|
139
|
+
'dependabot/get-alert': async (ctx) => {
|
|
140
|
+
const { repo, state } = gh(ctx);
|
|
141
|
+
const n = alertNumber(ctx);
|
|
142
|
+
const a = state().dependabotAlerts.find((x) => x.repository === repo && x.number === n);
|
|
143
|
+
return send(ctx, a ? { status: 200, body: toGithubDependabotAlert(a) } : githubNotFound());
|
|
144
|
+
},
|
|
145
|
+
'dependabot/update-alert': async (ctx) => dismissable(ctx, 'dependabot'),
|
|
146
|
+
'secret-scanning/list-alerts-for-repo': secretScanningListAlertsForRepo,
|
|
147
|
+
'secret-scanning/get-alert': secretScanningGetAlert,
|
|
148
|
+
// where the secret was found: each commit and file secret scanning read it in
|
|
149
|
+
'secret-scanning/list-locations-for-alert': secretScanningListLocationsForAlert,
|
|
150
|
+
// resolving needs one of the vendor's resolutions; reopening clears it
|
|
151
|
+
'secret-scanning/update-alert': secretScanningUpdateAlert,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// What every GitHub handler (and GraphQL resolver) shares: the request as GitHub's helpers read it (the
|
|
2
|
+
// path's segments, the query, the JSON body), the tree read through the pack's projection over the
|
|
3
|
+
// context's whole-tree read, GitHub's answers, and the webhooks a write announces.
|
|
4
|
+
//
|
|
5
|
+
// Reads: GitHub's projection (`githubState`) folds every stored type in one pass, in tree order
|
|
6
|
+
// (`ctx.tree()`), and replays open/closed history from the log, which it still reads from the kernel:
|
|
7
|
+
// `ctx.history` is per subject and carries no actor, so it cannot tell a local create from an
|
|
8
|
+
// observation. Writes go through the context (`put`, `ctx.atomically`).
|
|
9
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
10
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
11
|
+
import { callerLogin, githubState, resolveGithubNumber, twinUserId, type GithubAtomic, type GithubResponse, type GithubState, type GithubWrite, type GithubWriteEvent } from '../github-twin.ts';
|
|
12
|
+
import { emitGithubEvent, emitGithubInstallationEvents } from '../github-events.ts';
|
|
13
|
+
import surface from '../generated/surface.gen.json' with { type: 'json' };
|
|
14
|
+
import { spanning } from '../manifest.ts';
|
|
15
|
+
|
|
16
|
+
const VIEWS = new Map((surface.resources as Array<{ name: string; fields: Array<{ name: string }> }>).map((r) => [r.name, new Set(r.fields.map((f) => f.name))]));
|
|
17
|
+
/** An object as one of GitHub's views of it answers: a list's user is a simple-user, a list's pull request
|
|
18
|
+
* pull-request-simple, a search hit an issue-search-result-item. The view keeps the fields the spec gives
|
|
19
|
+
* it, from the full object it is a view of. */
|
|
20
|
+
export function asView(view: string, full: Record<string, unknown>): Record<string, unknown> {
|
|
21
|
+
const keep = VIEWS.get(view);
|
|
22
|
+
if (!keep) throw new Error(`github: the spec has no view named ${view}`);
|
|
23
|
+
return Object.fromEntries(Object.entries(full).filter(([k]) => keep.has(k)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Gh = {
|
|
27
|
+
ctx: SemanticsContext;
|
|
28
|
+
/** the path's segments as sent (percent-encoded), one per segment of the operation's path: a parameter
|
|
29
|
+
* that spans slashes is one segment holding its encoded value; an issue or pull number resolved
|
|
30
|
+
* through the alias map */
|
|
31
|
+
seg: string[];
|
|
32
|
+
/** the path, trailing slashes dropped */
|
|
33
|
+
path: string;
|
|
34
|
+
/** the query string with its `?`, when there is one */
|
|
35
|
+
query: string | undefined;
|
|
36
|
+
/** `owner/name` for a path under /repos */
|
|
37
|
+
repo: string;
|
|
38
|
+
root: string | undefined;
|
|
39
|
+
occurredAt: string;
|
|
40
|
+
/** the credential, as the twin's user door minted it */
|
|
41
|
+
token: string | undefined;
|
|
42
|
+
/** the caller's login, when the credential names one */
|
|
43
|
+
caller: string | undefined;
|
|
44
|
+
/** the JSON body as sent */
|
|
45
|
+
body: Record<string, any>;
|
|
46
|
+
state(): GithubState;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function gh(ctx: SemanticsContext): Gh {
|
|
50
|
+
const url = new URL(ctx.call.request.url);
|
|
51
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
52
|
+
const sent = path.replace(/^\/+/, '').split('/');
|
|
53
|
+
const template = ctx.call.operation.path.replace(/^\/+/, '').split('/');
|
|
54
|
+
const extra = sent.length - template.length;
|
|
55
|
+
const at = extra > 0 ? template.findIndex((t) => t.startsWith('{') && spanning.includes(t.slice(1, -1))) : -1;
|
|
56
|
+
const seg = at === -1 ? sent : [...sent.slice(0, at), sent.slice(at, at + extra + 1).join('%2F'), ...sent.slice(at + extra + 1)];
|
|
57
|
+
if (seg[0] === 'repos' && (seg[3] === 'issues' || seg[3] === 'pulls') && seg[4] && /^\d+$/.test(seg[4])) seg[4] = resolveGithubNumber(seg[1]!, seg[2]!, seg[4], ctx.root);
|
|
58
|
+
const token = (ctx.call.request.headers.get('authorization') ?? '').replace(/^(?:bearer|token)\s+/i, '').trim() || undefined;
|
|
59
|
+
return {
|
|
60
|
+
ctx,
|
|
61
|
+
seg,
|
|
62
|
+
path,
|
|
63
|
+
query: url.search || undefined,
|
|
64
|
+
repo: `${seg[1]}/${seg[2]}`,
|
|
65
|
+
root: ctx.root,
|
|
66
|
+
occurredAt: ctx.occurredAt,
|
|
67
|
+
token,
|
|
68
|
+
caller: callerLogin(token, ctx.root),
|
|
69
|
+
body: (ctx.body ?? {}) as Record<string, any>,
|
|
70
|
+
state: () => githubStateOf(ctx),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** GitHub's projection over this request's tree. */
|
|
75
|
+
export const githubStateOf = (ctx: SemanticsContext): GithubState => githubState(ctx.root);
|
|
76
|
+
|
|
77
|
+
/** GitHub's answer: JSON, or nothing for a 204 or an answer with no body. */
|
|
78
|
+
export function send(ctx: SemanticsContext, r: GithubResponse): Response {
|
|
79
|
+
if (r.status === 204 || r.body === undefined) return ctx.raw(null, { status: r.status });
|
|
80
|
+
return ctx.reply(r.body, r.status);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A stored write, as the kernel records it: the subject's type, id, fields and operation name. */
|
|
84
|
+
export function put(ctx: SemanticsContext, w: { operation: string; subjectType: string; subjectId: string; fields: Record<string, unknown> }): Promise<Record<string, unknown>> {
|
|
85
|
+
return ctx.write(w.subjectType, w.subjectId, w.fields, w.operation);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The events announced while an in-process call runs, for the caller that asked (applyGithubWrite
|
|
89
|
+
* answers them beside the response, as the hand-written writes did). */
|
|
90
|
+
const announcements = new AsyncLocalStorage<GithubWriteEvent[]>();
|
|
91
|
+
export async function collectAnnouncements<T>(run: () => Promise<T>): Promise<{ value: T; events: GithubWriteEvent[] }> {
|
|
92
|
+
const events: GithubWriteEvent[] = [];
|
|
93
|
+
const value = await announcements.run(events, run);
|
|
94
|
+
// an in-process call made inside another (a GraphQL mutation writing through REST) announces to both
|
|
95
|
+
announcements.getStore()?.push(...events);
|
|
96
|
+
return { value, events };
|
|
97
|
+
}
|
|
98
|
+
export function noteAnnounced(events: GithubWriteEvent[]): void {
|
|
99
|
+
announcements.getStore()?.push(...events);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Deliver the webhooks a write announces. Fire-and-forget, like GitHub: a delivery failure never
|
|
103
|
+
* fails the write. */
|
|
104
|
+
export async function announce(ctx: SemanticsContext, events: Array<GithubWriteEvent | undefined>): Promise<void> {
|
|
105
|
+
for (const event of events) {
|
|
106
|
+
if (!event) continue;
|
|
107
|
+
// a branch that moved is read by GitHub's own processes (workflows, secret scanning, Dependabot)
|
|
108
|
+
if (event.event === 'push' && event.ref && event.after) {
|
|
109
|
+
const { reactToPush } = await import('./push-reactions.ts');
|
|
110
|
+
await reactToPush(ctx, event.repository, String(event.ref), String(event.before ?? '0'.repeat(40)), String(event.after));
|
|
111
|
+
}
|
|
112
|
+
// and so is a pull request opened or pushed to: its workflows run (semantics/push-reactions.ts)
|
|
113
|
+
if (event.event === 'pull_request' && event.number) {
|
|
114
|
+
const { reactToPullRequest } = await import('./push-reactions.ts');
|
|
115
|
+
await reactToPullRequest(ctx, event.repository, Number(event.number), String(event.action ?? ''));
|
|
116
|
+
}
|
|
117
|
+
// and someone else's act on an issue or pull request may notify the World's user
|
|
118
|
+
if (event.event === 'issues' || event.event === 'issue_comment' || event.event === 'pull_request' || event.event === 'pull_request_review') {
|
|
119
|
+
const { notify } = await import('./notifications.ts');
|
|
120
|
+
await notify(ctx, event);
|
|
121
|
+
}
|
|
122
|
+
noteAnnounced([event]);
|
|
123
|
+
try {
|
|
124
|
+
await emitGithubEvent(event, { occurredAt: ctx.occurredAt, root: ctx.root });
|
|
125
|
+
} catch {
|
|
126
|
+
/* fire-and-forget */
|
|
127
|
+
}
|
|
128
|
+
// and the GitHub Apps installed on the repository hear of it on their own webhooks
|
|
129
|
+
try {
|
|
130
|
+
await emitGithubInstallationEvents(event, { occurredAt: ctx.occurredAt, root: ctx.root });
|
|
131
|
+
} catch {
|
|
132
|
+
/* fire-and-forget */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The stored types whose subject ids carry each sequence prefix (`sshkey:3`). */
|
|
138
|
+
const SEQ_TYPES: Record<string, string[]> = {
|
|
139
|
+
artifact: ['actions_artifact'], asset: ['release_asset'], autolink: ['autolink'], cache: ['actions_cache'], check: ['check_run'],
|
|
140
|
+
comment: ['issue_comment', 'pull_request_review_comment'], commitcomment: ['commit_comment'], csanalysis: ['code_scanning_analysis'],
|
|
141
|
+
deploykey: ['deploy_key'], deployment: ['deployment'], depstatus: ['deployment_status'], discomment: ['discussion_comment'],
|
|
142
|
+
gpgkey: ['user_gpg_key'], installation: ['app_installation'], issuetype: ['issue_type'], label: ['label'], orghook: ['org_webhook'],
|
|
143
|
+
package: ['package'], packageversion: ['package_version'], pagesbuild: ['pages_build'], project: ['project'], projectfield: ['project_field'],
|
|
144
|
+
projectitem: ['project_item'], projectview: ['project_view'], reaction: ['reaction'], release: ['release'], repo: ['repository'], repoinvite: ['repo_invitation'],
|
|
145
|
+
review: ['pull_request_review'], rulesuite: ['rule_suite'], ruleset: ['ruleset'], sshkey: ['user_ssh_key'], status: ['commit_status'],
|
|
146
|
+
team: ['team'], teamdisc: ['team_discussion'], teamdisccomment: ['team_discussion_comment'], thread: ['review_thread'], webhook: ['webhook'],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** One past the highest `<prefix>:<n>` subject id stored, tombstones included. */
|
|
150
|
+
export function nextSeq(ctx: SemanticsContext, prefix: string): number {
|
|
151
|
+
const pattern = new RegExp(`^${prefix}:(\\d+)$`);
|
|
152
|
+
let max = 0;
|
|
153
|
+
for (const type of SEQ_TYPES[prefix] ?? []) {
|
|
154
|
+
for (const r of ctx.rowsRaw(type, { withDeleted: true })) {
|
|
155
|
+
const m = pattern.exec(String(r.id));
|
|
156
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return max + 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The pack helpers' atomic decision (GithubAtomic), made through this request's context. */
|
|
163
|
+
export function atomic(ctx: SemanticsContext): GithubAtomic {
|
|
164
|
+
return <T>(decide: () => { value: T; write?: { operation: string; subjectType: string; subjectId: string; fields: Record<string, unknown> } }) =>
|
|
165
|
+
ctx.atomically<T>(() => {
|
|
166
|
+
const d = decide();
|
|
167
|
+
return d.write ? { value: d.value, write: { resource: d.write.subjectType, id: d.write.subjectId, fields: d.write.fields, operation: d.write.operation } } : { value: d.value };
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The pack helpers' writer (GithubWrite), through this request's context. */
|
|
172
|
+
export const writer = (ctx: SemanticsContext): GithubWrite => (w) => put(ctx, w);
|
|
173
|
+
|
|
174
|
+
/** Who a commit or tag made through the API is by when the request names no one: the signed-in person, as
|
|
175
|
+
* GitHub defaults author and committer to the authenticated user (docs.github.com/rest/repos/contents), with
|
|
176
|
+
* the noreply address GitHub gives an account that keeps its email private. */
|
|
177
|
+
export function identityOf(g: Gh): { name: string; email: string } {
|
|
178
|
+
const login = g.caller ?? 'octocat';
|
|
179
|
+
const profile = login === 'octocat' ? g.state().userProfile : undefined;
|
|
180
|
+
return { name: String(profile?.name ?? login), email: `${twinUserId(login)}+${login}@users.noreply.github.com` };
|
|
181
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// GitHub's users family: a user by login or account id, the authenticated user and their profile,
|
|
2
|
+
// the follow graph, emails, SSH and GPG keys, blocks, and the activity streams scoped to a user.
|
|
3
|
+
// The twin models one follow actor, the authenticated `octocat`.
|
|
4
|
+
import { me } from './activity.ts';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import type { Semantics } from '@volter/world-core';
|
|
7
|
+
import {
|
|
8
|
+
githubActivityEvents, githubNotFound, paginate, seedUser, toGithubOrg, toGithubRepo, toGithubUser, toGithubUserEmail, toGithubUserGpgKey, toGithubUserSshKey, toSimpleUser, twinUserId,
|
|
9
|
+
} from '../github-twin.ts';
|
|
10
|
+
import { asView, gh, nextSeq, put, send } from './shared.ts';
|
|
11
|
+
|
|
12
|
+
/** GET /users/:login/events[/public] and /users/:login/received_events[/public]: events = activity the
|
|
13
|
+
* user PERFORMED (actor); received_events = activity in repos the user OWNS. Both filter the SAME
|
|
14
|
+
* modeled stream. */
|
|
15
|
+
const userEvents = (received: boolean, only: 'public' | undefined): Semantics => async (ctx) => {
|
|
16
|
+
const { seg, query, state } = gh(ctx);
|
|
17
|
+
const login = decodeURIComponent(seg[1]!);
|
|
18
|
+
const all = githubActivityEvents(state());
|
|
19
|
+
let items = !received
|
|
20
|
+
? all.filter((e) => (e.actor as { login?: string }).login === login)
|
|
21
|
+
: all.filter((e) => String((e.repo as { name?: string }).name ?? '').split('/')[0] === login);
|
|
22
|
+
if (only === 'public') items = items.filter((e) => e.public === true);
|
|
23
|
+
return ctx.reply(paginate(items, query));
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function usersSemantics(): Record<string, Semantics> {
|
|
27
|
+
const usersGetByUsername: Semantics = async (ctx) => {
|
|
28
|
+
const { seg, state } = gh(ctx);
|
|
29
|
+
const login = decodeURIComponent(seg[1]!);
|
|
30
|
+
return ctx.reply(toGithubUser(login === 'octocat' ? { ...seedUser('octocat'), ...state().userProfile, login } : seedUser(login)));
|
|
31
|
+
};
|
|
32
|
+
const reposListForAuthenticatedUser: Semantics = async (ctx) => {
|
|
33
|
+
const { caller, query, state } = gh(ctx);
|
|
34
|
+
const me = caller ?? 'octocat';
|
|
35
|
+
const params = new URLSearchParams(query ?? '');
|
|
36
|
+
const affiliation = new Set((params.get('affiliation') ?? 'owner,collaborator,organization_member').split(',').map((a) => a.trim()));
|
|
37
|
+
const visibility = params.get('visibility') ?? 'all';
|
|
38
|
+
const st = state();
|
|
39
|
+
const memberOf = new Set(st.orgMembers.filter((m) => m.login === me && (m.state ?? 'active') === 'active').map((m) => m.org));
|
|
40
|
+
const collaborating = new Set(st.collaborators.filter((c) => c.login === me).map((c) => c.repository));
|
|
41
|
+
const mine = st.repos.filter((r) =>
|
|
42
|
+
((affiliation.has('owner') && r.owner === me) || (affiliation.has('collaborator') && collaborating.has(r.full_name)) || (affiliation.has('organization_member') && memberOf.has(r.owner)))
|
|
43
|
+
&& (visibility === 'all' || (visibility === 'private') === (r.private === true)));
|
|
44
|
+
return ctx.reply(paginate([...mine].sort((a, b) => a.full_name.localeCompare(b.full_name)), query).map((r) => asView('repository', toGithubRepo(r))));
|
|
45
|
+
};
|
|
46
|
+
const orgsListForAuthenticatedUser: Semantics = async (ctx) => {
|
|
47
|
+
const { caller, query, state } = gh(ctx);
|
|
48
|
+
const me = caller ?? 'octocat';
|
|
49
|
+
const st = state();
|
|
50
|
+
const logins = new Set(st.orgMembers.filter((m) => m.login === me && (m.state ?? 'active') === 'active').map((m) => m.org));
|
|
51
|
+
const SIMPLE = ['login', 'id', 'node_id', 'url', 'repos_url', 'events_url', 'hooks_url', 'issues_url', 'members_url', 'public_members_url', 'avatar_url', 'description'];
|
|
52
|
+
const simple = (o: Record<string, unknown>) => Object.fromEntries(SIMPLE.filter((k) => k in o).map((k) => [k, o[k]]));
|
|
53
|
+
return ctx.reply(paginate(st.orgs.filter((o) => logins.has(o.login)), query).map((o) => simple(toGithubOrg(o))));
|
|
54
|
+
};
|
|
55
|
+
const reposListForUser: Semantics = async (ctx) => {
|
|
56
|
+
const { seg, query, state } = gh(ctx);
|
|
57
|
+
const login = decodeURIComponent(seg[1]!);
|
|
58
|
+
return ctx.reply(paginate(state().repos.filter((r) => r.owner === login), query).map(toGithubRepo));
|
|
59
|
+
};
|
|
60
|
+
const usersListFollowersForUser: Semantics = async (ctx) => {
|
|
61
|
+
const { seg, query, state } = gh(ctx);
|
|
62
|
+
const login = decodeURIComponent(seg[1]!);
|
|
63
|
+
return ctx.reply(paginate(state().followEdges.filter((e) => e.to === login).map((e) => asView('simple-user', toGithubUser(seedUser(e.from)))), query));
|
|
64
|
+
};
|
|
65
|
+
const usersListFollowingForUser: Semantics = async (ctx) => {
|
|
66
|
+
const { seg, query, state } = gh(ctx);
|
|
67
|
+
const login = decodeURIComponent(seg[1]!);
|
|
68
|
+
return ctx.reply(paginate(state().followEdges.filter((e) => e.from === login).map((e) => asView('simple-user', toGithubUser(seedUser(e.to)))), query));
|
|
69
|
+
};
|
|
70
|
+
const usersCheckFollowingForUser: Semantics = async (ctx) => {
|
|
71
|
+
const { seg, state } = gh(ctx);
|
|
72
|
+
const [login, other] = [decodeURIComponent(seg[1]!), decodeURIComponent(seg[3]!)];
|
|
73
|
+
return send(ctx, state().followEdges.some((e) => e.from === login && e.to === other) ? { status: 204, body: undefined } : githubNotFound());
|
|
74
|
+
};
|
|
75
|
+
const usersGetById: Semantics = async (ctx) => {
|
|
76
|
+
const { seg, state } = gh(ctx);
|
|
77
|
+
if (!/^\d+$/.test(seg[1]!)) return send(ctx, githubNotFound());
|
|
78
|
+
const st = state();
|
|
79
|
+
const known = new Set<string>(['octocat', ...st.orgMembers.map((m) => m.login), ...st.oauthGrants.map((g) => g.login), ...st.prs.map((p) => p.user_login ?? '')].filter((l): l is string => !!l));
|
|
80
|
+
const login = [...known].find((l) => twinUserId(l) === Number(seg[1]));
|
|
81
|
+
return send(ctx, login ? { status: 200, body: { ...toSimpleUser(login), id: twinUserId(login) } } : githubNotFound());
|
|
82
|
+
};
|
|
83
|
+
const usersListFollowedByAuthenticatedUser: Semantics = async (ctx) => {
|
|
84
|
+
const { query, state } = gh(ctx);
|
|
85
|
+
return ctx.reply(paginate(state().followEdges.filter((e) => e.from === me(ctx)).map((e) => asView('simple-user', toGithubUser(seedUser(e.to)))), query));
|
|
86
|
+
};
|
|
87
|
+
const usersCheckPersonIsFollowedByAuthenticated: Semantics = async (ctx) => {
|
|
88
|
+
const { seg, state } = gh(ctx);
|
|
89
|
+
return send(ctx, state().followEdges.some((e) => e.from === me(ctx) && e.to === decodeURIComponent(seg[2]!)) ? { status: 204, body: undefined } : githubNotFound());
|
|
90
|
+
};
|
|
91
|
+
const usersListFollowersForAuthenticatedUser: Semantics = async (ctx) => {
|
|
92
|
+
const { query, state } = gh(ctx);
|
|
93
|
+
return ctx.reply(paginate(state().followEdges.filter((e) => e.to === me(ctx)).map((e) => asView('simple-user', toGithubUser(seedUser(e.from)))), query));
|
|
94
|
+
};
|
|
95
|
+
const usersListEmailsForAuthenticatedUser: Semantics = async (ctx) => {
|
|
96
|
+
const { query, state } = gh(ctx);
|
|
97
|
+
return ctx.reply(paginate(state().userEmails, query).map(toGithubUserEmail));
|
|
98
|
+
};
|
|
99
|
+
const usersListPublicEmailsForAuthenticatedUser: Semantics = async (ctx) => {
|
|
100
|
+
const { query, state } = gh(ctx);
|
|
101
|
+
return ctx.reply(paginate(state().userEmails.filter((e) => e.visibility === 'public'), query).map(toGithubUserEmail));
|
|
102
|
+
};
|
|
103
|
+
const usersListPublicSshKeysForAuthenticatedUser: Semantics = async (ctx) => {
|
|
104
|
+
const { query, state } = gh(ctx);
|
|
105
|
+
return ctx.reply(paginate(state().userSshKeys, query).map(toGithubUserSshKey));
|
|
106
|
+
};
|
|
107
|
+
const usersGetPublicSshKeyForAuthenticatedUser: Semantics = async (ctx) => {
|
|
108
|
+
const { seg, state } = gh(ctx);
|
|
109
|
+
const k = state().userSshKeys.find((x) => x.id === Number(decodeURIComponent(seg[2]!)));
|
|
110
|
+
return send(ctx, k ? { status: 200, body: toGithubUserSshKey(k) } : githubNotFound());
|
|
111
|
+
};
|
|
112
|
+
const usersListGpgKeysForAuthenticatedUser: Semantics = async (ctx) => {
|
|
113
|
+
const { query, state } = gh(ctx);
|
|
114
|
+
return ctx.reply(paginate(state().userGpgKeys, query).map(toGithubUserGpgKey));
|
|
115
|
+
};
|
|
116
|
+
const usersGetGpgKeyForAuthenticatedUser: Semantics = async (ctx) => {
|
|
117
|
+
const { seg, state } = gh(ctx);
|
|
118
|
+
const k = state().userGpgKeys.find((x) => x.id === Number(decodeURIComponent(seg[2]!)));
|
|
119
|
+
return send(ctx, k ? { status: 200, body: toGithubUserGpgKey(k) } : githubNotFound());
|
|
120
|
+
};
|
|
121
|
+
const usersListBlockedByAuthenticatedUser: Semantics = async (ctx) => {
|
|
122
|
+
const { query, state } = gh(ctx);
|
|
123
|
+
return ctx.reply(paginate([...state().blockedUsers].sort().map((l) => asView('simple-user', toGithubUser(seedUser(l)))), query));
|
|
124
|
+
};
|
|
125
|
+
const usersCheckBlocked: Semantics = async (ctx) => {
|
|
126
|
+
const { seg, state } = gh(ctx);
|
|
127
|
+
return send(ctx, state().blockedUsers.has(decodeURIComponent(seg[2]!)) ? { status: 204, body: undefined } : githubNotFound());
|
|
128
|
+
};
|
|
129
|
+
const usersGetAuthenticated: Semantics = async (ctx) => {
|
|
130
|
+
const { caller, state } = gh(ctx);
|
|
131
|
+
if (caller) return ctx.reply(toGithubUser(seedUser(caller)));
|
|
132
|
+
return ctx.reply(toGithubUser({ ...seedUser('octocat'), ...state().userProfile, login: 'octocat' }));
|
|
133
|
+
};
|
|
134
|
+
const usersUpdateAuthenticated: Semantics = async (ctx) => {
|
|
135
|
+
const { body, state } = gh(ctx);
|
|
136
|
+
const fields: Record<string, unknown> = {};
|
|
137
|
+
for (const k of ['name', 'bio', 'company', 'blog', 'location', 'email'] as const) if (body[k] !== undefined) fields[k] = body[k];
|
|
138
|
+
await put(ctx, { operation: 'user_profile.update', subjectType: 'user_profile', subjectId: 'user_profile:octocat', fields });
|
|
139
|
+
return ctx.reply(toGithubUser({ ...seedUser('octocat'), ...state().userProfile, login: 'octocat' }));
|
|
140
|
+
};
|
|
141
|
+
const usersCreatePublicSshKeyForAuthenticatedUser: Semantics = async (ctx) => {
|
|
142
|
+
const { body, occurredAt, state } = gh(ctx);
|
|
143
|
+
if (!body.key) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'PublicKey', field: 'key', code: 'missing_field' }] }, 422);
|
|
144
|
+
const id = nextSeq(ctx, 'sshkey');
|
|
145
|
+
await put(ctx, { operation: 'user_ssh_key.create', subjectType: 'user_ssh_key', subjectId: `sshkey:${id}`, fields: { key: String(body.key), title: String(body.title ?? ''), created_at: occurredAt } });
|
|
146
|
+
return ctx.reply(toGithubUserSshKey(state().userSshKeys.find((x) => x.id === id)!), 201);
|
|
147
|
+
};
|
|
148
|
+
const usersDeletePublicSshKeyForAuthenticatedUser: Semantics = async (ctx) => {
|
|
149
|
+
const { seg, state } = gh(ctx);
|
|
150
|
+
const id = Number(decodeURIComponent(seg[2]!));
|
|
151
|
+
if (!state().userSshKeys.find((k) => k.id === id)) return send(ctx, githubNotFound());
|
|
152
|
+
await put(ctx, { operation: 'user_ssh_key.delete', subjectType: 'user_ssh_key', subjectId: `sshkey:${id}`, fields: { deleted: true } });
|
|
153
|
+
return send(ctx, { status: 204, body: undefined });
|
|
154
|
+
};
|
|
155
|
+
const usersCreateGpgKeyForAuthenticatedUser: Semantics = async (ctx) => {
|
|
156
|
+
const { body, occurredAt, state } = gh(ctx);
|
|
157
|
+
if (!body.armored_public_key) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'GpgKey', field: 'armored_public_key', code: 'missing_field' }] }, 422);
|
|
158
|
+
const id = nextSeq(ctx, 'gpgkey');
|
|
159
|
+
const raw = String(body.armored_public_key);
|
|
160
|
+
const keyId = createHash('sha256').update(raw).digest('hex').slice(0, 16).toUpperCase();
|
|
161
|
+
await put(ctx, { operation: 'user_gpg_key.create', subjectType: 'user_gpg_key', subjectId: `gpgkey:${id}`, fields: { key_id: keyId, raw_key: raw, created_at: occurredAt } });
|
|
162
|
+
return ctx.reply(toGithubUserGpgKey(state().userGpgKeys.find((x) => x.id === id)!), 201);
|
|
163
|
+
};
|
|
164
|
+
const usersDeleteGpgKeyForAuthenticatedUser: Semantics = async (ctx) => {
|
|
165
|
+
const { seg, state } = gh(ctx);
|
|
166
|
+
const id = Number(decodeURIComponent(seg[2]!));
|
|
167
|
+
if (!state().userGpgKeys.find((k) => k.id === id)) return send(ctx, githubNotFound());
|
|
168
|
+
await put(ctx, { operation: 'user_gpg_key.delete', subjectType: 'user_gpg_key', subjectId: `gpgkey:${id}`, fields: { deleted: true } });
|
|
169
|
+
return send(ctx, { status: 204, body: undefined });
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
// the twin's own account (octocat) answers with the profile its owner edited (PATCH /user)
|
|
173
|
+
'users/get-by-username': usersGetByUsername,
|
|
174
|
+
// the signed-in person's repositories, by GitHub's affiliation (owner, collaborator, organization_member;
|
|
175
|
+
// all three by default) and visibility (all, public, private), sorted by full_name
|
|
176
|
+
'repos/list-for-authenticated-user': reposListForAuthenticatedUser,
|
|
177
|
+
// the organizations the signed-in person is an active member of, in GitHub's simple organization shape
|
|
178
|
+
'orgs/list-for-authenticated-user': orgsListForAuthenticatedUser,
|
|
179
|
+
'repos/list-for-user': reposListForUser,
|
|
180
|
+
// who follows :login, and whom :login follows
|
|
181
|
+
'users/list-followers-for-user': usersListFollowersForUser,
|
|
182
|
+
'users/list-following-for-user': usersListFollowingForUser,
|
|
183
|
+
// 204 if :login follows :other, else 404
|
|
184
|
+
'users/check-following-for-user': usersCheckFollowingForUser,
|
|
185
|
+
'activity/list-events-for-authenticated-user': userEvents(false, undefined),
|
|
186
|
+
'activity/list-public-events-for-user': userEvents(false, 'public'),
|
|
187
|
+
'activity/list-received-events-for-user': userEvents(true, undefined),
|
|
188
|
+
'activity/list-received-public-events-for-user': userEvents(true, 'public'),
|
|
189
|
+
// an account by its id, among the accounts the twin knows (its own octocat, members, token holders, authors)
|
|
190
|
+
'users/get-by-id': usersGetById,
|
|
191
|
+
'users/list-followed-by-authenticated-user': usersListFollowedByAuthenticatedUser,
|
|
192
|
+
'users/check-person-is-followed-by-authenticated': usersCheckPersonIsFollowedByAuthenticated,
|
|
193
|
+
// the authenticated user's own followers
|
|
194
|
+
'users/list-followers-for-authenticated-user': usersListFollowersForAuthenticatedUser,
|
|
195
|
+
'users/list-emails-for-authenticated-user': usersListEmailsForAuthenticatedUser,
|
|
196
|
+
'users/list-public-emails-for-authenticated-user': usersListPublicEmailsForAuthenticatedUser,
|
|
197
|
+
'users/list-public-ssh-keys-for-authenticated-user': usersListPublicSshKeysForAuthenticatedUser,
|
|
198
|
+
'users/get-public-ssh-key-for-authenticated-user': usersGetPublicSshKeyForAuthenticatedUser,
|
|
199
|
+
'users/list-gpg-keys-for-authenticated-user': usersListGpgKeysForAuthenticatedUser,
|
|
200
|
+
'users/get-gpg-key-for-authenticated-user': usersGetGpgKeyForAuthenticatedUser,
|
|
201
|
+
'users/list-blocked-by-authenticated-user': usersListBlockedByAuthenticatedUser,
|
|
202
|
+
// "Check if a user is blocked by the authenticated user" → 204 blocked / 404 not.
|
|
203
|
+
'users/check-blocked': usersCheckBlocked,
|
|
204
|
+
// the token's own user when the twin's user door minted it; else the deterministic octocat
|
|
205
|
+
'users/get-authenticated': usersGetAuthenticated,
|
|
206
|
+
'users/update-authenticated': usersUpdateAuthenticated,
|
|
207
|
+
'users/add-email-for-authenticated-user': async (ctx) => emails(ctx, false),
|
|
208
|
+
'users/delete-email-for-authenticated-user': async (ctx) => emails(ctx, true),
|
|
209
|
+
'users/create-public-ssh-key-for-authenticated-user': usersCreatePublicSshKeyForAuthenticatedUser,
|
|
210
|
+
'users/delete-public-ssh-key-for-authenticated-user': usersDeletePublicSshKeyForAuthenticatedUser,
|
|
211
|
+
'users/create-gpg-key-for-authenticated-user': usersCreateGpgKeyForAuthenticatedUser,
|
|
212
|
+
'users/delete-gpg-key-for-authenticated-user': usersDeleteGpgKeyForAuthenticatedUser,
|
|
213
|
+
// block / unblock answer 204
|
|
214
|
+
'users/block': async (ctx) => block(ctx, true),
|
|
215
|
+
'users/unblock': async (ctx) => block(ctx, false),
|
|
216
|
+
// follow / unfollow answer 204
|
|
217
|
+
'users/follow': async (ctx) => follow(ctx, true),
|
|
218
|
+
'users/unfollow': async (ctx) => follow(ctx, false),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** POST /user/emails adds, DELETE removes; both take `{emails}` or a bare array. The first address a
|
|
223
|
+
* user adds is primary and public. */
|
|
224
|
+
async function emails(ctx: Parameters<Semantics>[0], remove: boolean): Promise<Response> {
|
|
225
|
+
const { body, state } = gh(ctx);
|
|
226
|
+
const list: string[] = Array.isArray(body.emails) ? body.emails.map(String) : Array.isArray(body) ? (body as string[]).map(String) : [];
|
|
227
|
+
if (!list.length) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Email', field: 'emails', code: 'missing_field' }] }, 422);
|
|
228
|
+
const st = state();
|
|
229
|
+
for (const email of list) {
|
|
230
|
+
const subjectId = `useremail:${email}`;
|
|
231
|
+
if (remove) {
|
|
232
|
+
await put(ctx, { operation: 'user_email.delete', subjectType: 'user_email', subjectId, fields: { deleted: true, email } });
|
|
233
|
+
} else {
|
|
234
|
+
const isFirst = st.userEmails.length === 0 && email === list[0];
|
|
235
|
+
await put(ctx, { operation: 'user_email.upsert', subjectType: 'user_email', subjectId, fields: { email, primary: isFirst, verified: true, visibility: isFirst ? 'public' : 'private' } });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (remove) return send(ctx, { status: 204, body: undefined });
|
|
239
|
+
return ctx.reply(state().userEmails.map(toGithubUserEmail), 201);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function block(ctx: Parameters<Semantics>[0], blocking: boolean): Promise<Response> {
|
|
243
|
+
const login = decodeURIComponent(gh(ctx).seg[2]!);
|
|
244
|
+
await put(ctx, { operation: blocking ? 'user_block.create' : 'user_block.delete', subjectType: 'user_block', subjectId: `block:${login}`, fields: blocking ? { login } : { login, deleted: true } });
|
|
245
|
+
return send(ctx, { status: 204, body: undefined });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function follow(ctx: Parameters<Semantics>[0], following: boolean): Promise<Response> {
|
|
249
|
+
const login = decodeURIComponent(gh(ctx).seg[2]!);
|
|
250
|
+
await put(ctx, { operation: following ? 'user_following.add' : 'user_following.remove', subjectType: 'user_following', subjectId: `following:${me(ctx)}:${login}`, fields: { login, following } });
|
|
251
|
+
return send(ctx, { status: 204, body: undefined });
|
|
252
|
+
}
|