@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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// GITHUB'S SIGN-IN — the door before every github.com page (docs/contributing/architecture.md, "Who is on a screen"):
|
|
3
|
+
// github.com/login shows the form, which posts to /session; the right password for the account signs its person in, and
|
|
4
|
+
// GitHub's session cookies (`user_session`, `logged_in`, `dotcom_user`) name them to every page after. A page asked for by nobody signed in sends its visitor to /login with `return_to`, and the sign-in
|
|
5
|
+
// returns them there. Built from @volter/world-ui's sign-in piece under GitHub's skin; nothing of GitHub's page is copied.
|
|
6
|
+
//
|
|
7
|
+
// A person with two-factor authentication (POST /_twin/users/:login/two-factor, standing in for GitHub's settings page)
|
|
8
|
+
// signs in in two steps, as GitHub's docs say (docs.github.com/authentication/securing-your-account-with-two-factor-
|
|
9
|
+
// authentication-2fa/accessing-github-using-two-factor-authentication: "you'll need to provide an authentication code"
|
|
10
|
+
// after the password): the right password sets no session but sends them to GitHub's two-factor page
|
|
11
|
+
// (/sessions/two-factor/app), whose form posts the code their TOTP app shows (`app_otp`) to /sessions/two-factor; the
|
|
12
|
+
// right code sets the session and returns them to return_to, a wrong one shows the page again with GitHub's message.
|
|
13
|
+
// Which person is between the two steps rides GitHub's own session cookie (`_gh_sess`). Mandatory 2FA
|
|
14
|
+
// (docs.github.com/authentication/securing-your-account-with-two-factor-authentication-2fa/about-mandatory-two-factor-
|
|
15
|
+
// authentication) is the person's to turn on in the World; the twin does not enrol anyone.
|
|
16
|
+
//
|
|
17
|
+
// Where GitHub's documentation stops and the twin decides: a person's password is what the World gave them
|
|
18
|
+
// (`POST /_twin/users/:login/password`; GitHub's sign-up and password pages are not modelled), an account is named by
|
|
19
|
+
// its login (not its email), a refused sign-in shows the form again with GitHub's message (answered 200), a code is
|
|
20
|
+
// taken for the current 30-second step or the one before (RFC 6238's one step of drift) and never twice, a pending
|
|
21
|
+
// two-factor sign-in does not expire, and a session does not end: no person of a World signs out yet, and signing out is built when one does.
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { applyTwinWriteAtomic, twinResources } from '@volter/world-core';
|
|
24
|
+
import { cookieOf, flowPage, SignIn, SignInCode, SIGN_IN_CSS } from '@volter/world-ui';
|
|
25
|
+
import { passwordHash, totpCode, totpStep } from "../github-twin.js";
|
|
26
|
+
const SERVICE = 'github';
|
|
27
|
+
const hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
28
|
+
// GitHub's skin over the sign-in: Primer's colours and its green primary button.
|
|
29
|
+
const SKIN = `
|
|
30
|
+
body { background: #f6f8fa; color: #1f2328; }
|
|
31
|
+
.sign-in-mark { background: #1f2328; }
|
|
32
|
+
.sign-in-error { background: #ffebe9; border-color: rgba(255,129,130,0.4); color: #1f2328; }
|
|
33
|
+
.sign-in-box { background: #ffffff; border-color: #d1d9e0; }
|
|
34
|
+
.sign-in-box input[type=text], .sign-in-box input[type=password] { border-color: #d1d9e0; }
|
|
35
|
+
.sign-in-hint { color: #59636e; }
|
|
36
|
+
.sign-in-submit { background: #1f883d; border-color: rgba(31,35,40,0.15); color: #ffffff; }
|
|
37
|
+
`;
|
|
38
|
+
/** The person the request's session cookie names, or undefined when nobody is signed in. */
|
|
39
|
+
export function signedIn(request, root) {
|
|
40
|
+
const token = cookieOf(request, 'user_session');
|
|
41
|
+
if (!token)
|
|
42
|
+
return undefined;
|
|
43
|
+
const session = twinResources(SERVICE, root).find((r) => r.type === 'user_session' && r.token === token && r.deleted !== true);
|
|
44
|
+
return session ? String(session.login) : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** Where a page asked for by nobody signed in sends its visitor: the sign-in, returning here after (or to the page a
|
|
47
|
+
* door stands in for, `page`), with an account the application suggests prefilled. */
|
|
48
|
+
export function toSignIn(request, suggested, page) {
|
|
49
|
+
const url = new URL(request.url);
|
|
50
|
+
return new Response(null, { status: 302, headers: { location: `/login?return_to=${encodeURIComponent(page ?? url.pathname + url.search)}${suggested ? `&login=${encodeURIComponent(suggested)}` : ''}` } });
|
|
51
|
+
}
|
|
52
|
+
/** A return_to GitHub follows: a path on github.com, never another site. */
|
|
53
|
+
const returnTo = (raw) => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
|
|
54
|
+
function page(fields, error) {
|
|
55
|
+
return flowPage({
|
|
56
|
+
title: 'Sign in to GitHub · GitHub',
|
|
57
|
+
css: [SIGN_IN_CSS, SKIN],
|
|
58
|
+
body: (_jsx(SignIn, { heading: "Sign in to GitHub", action: "/session", fields: { return_to: fields.return_to }, account: { name: 'login', label: 'Username or email address', ...(fields.login ? { value: fields.login } : {}) }, password: { name: 'password', label: 'Password' }, submit: "Sign in", ...(error ? { error } : {}) })),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async function formOf(request) {
|
|
62
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
63
|
+
}
|
|
64
|
+
async function signIn(request, flow) {
|
|
65
|
+
const f = await formOf(request);
|
|
66
|
+
const login = (f.login ?? '').trim();
|
|
67
|
+
const back = returnTo(f.return_to);
|
|
68
|
+
const credential = twinResources(SERVICE, flow.root).find((r) => r.type === 'user_credential' && String(r.login).toLowerCase() === login.toLowerCase() && r.deleted !== true);
|
|
69
|
+
// GitHub names neither which half was wrong
|
|
70
|
+
if (!login || !credential || credential.hash !== passwordHash(String(credential.login), f.password ?? ''))
|
|
71
|
+
return page({ return_to: back, login }, 'Incorrect username or password.');
|
|
72
|
+
const person = String(credential.login);
|
|
73
|
+
return twoFactorOf(person, flow.root) ? askSecondFactor(person, back, flow) : signInWithPasswordOnly(person, back, flow);
|
|
74
|
+
}
|
|
75
|
+
/** The password was right; GitHub asks a person with two-factor authentication for the second factor on a page of its own. */
|
|
76
|
+
async function askSecondFactor(person, back, flow) {
|
|
77
|
+
const at = flow.now();
|
|
78
|
+
const { value: pending } = await applyTwinWriteAtomic(SERVICE, (resources) => {
|
|
79
|
+
const n = resources.filter((r) => r.type === 'pending_sign_in').length + 1;
|
|
80
|
+
const pending = hex(`pending:${person}:${at}:${n}`).slice(0, 48);
|
|
81
|
+
return { kind: 'write', value: pending, write: { operation: 'pending_sign_in.create', subjectType: 'pending_sign_in', subjectId: `pending:${pending}`, fields: { token: pending, login: person, return_to: back, created_at: at }, occurredAt: at, actor: { kind: 'human', id: person } } };
|
|
82
|
+
}, flow.root);
|
|
83
|
+
const headers = new Headers({ location: '/sessions/two-factor/app' });
|
|
84
|
+
headers.append('set-cookie', `_gh_sess=${pending}; path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
85
|
+
return new Response(null, { status: 302, headers });
|
|
86
|
+
}
|
|
87
|
+
/** A person without two-factor authentication: the right password signs them in. */
|
|
88
|
+
function signInWithPasswordOnly(person, back, flow) {
|
|
89
|
+
return startSession(person, back, flow);
|
|
90
|
+
}
|
|
91
|
+
/** The person's two-factor secret, or undefined when they sign in with a password alone. */
|
|
92
|
+
function twoFactorOf(login, root) {
|
|
93
|
+
const f = twinResources(SERVICE, root).find((r) => r.type === 'user_two_factor' && String(r.login).toLowerCase() === login.toLowerCase() && r.deleted !== true);
|
|
94
|
+
return f ? { secret: String(f.secret), ...(typeof f.last_step === 'number' ? { last_step: f.last_step } : {}) } : undefined;
|
|
95
|
+
}
|
|
96
|
+
/** The sign-in waiting on its second factor that the request's `_gh_sess` cookie names. */
|
|
97
|
+
function pendingOf(request, root) {
|
|
98
|
+
const token = cookieOf(request, '_gh_sess');
|
|
99
|
+
if (!token)
|
|
100
|
+
return undefined;
|
|
101
|
+
const p = twinResources(SERVICE, root).find((r) => r.type === 'pending_sign_in' && r.token === token && r.deleted !== true && r.used !== true);
|
|
102
|
+
return p ? { token, login: String(p.login), return_to: returnTo(String(p.return_to)) } : undefined;
|
|
103
|
+
}
|
|
104
|
+
function codePage(error) {
|
|
105
|
+
return flowPage({
|
|
106
|
+
title: 'Two-factor authentication · GitHub',
|
|
107
|
+
css: [SIGN_IN_CSS, SKIN],
|
|
108
|
+
body: (_jsx(SignInCode, { heading: "Two-factor authentication", hint: "Open your two-factor authenticator (TOTP) app or browser extension to view your authentication code.", action: "/sessions/two-factor", fields: {}, code: { name: 'app_otp', label: 'Authentication code' }, submit: "Verify", ...(error ? { error } : {}) })),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function verifyCode(request, flow) {
|
|
112
|
+
const pending = pendingOf(request, flow.root);
|
|
113
|
+
// nobody half signed in: GitHub starts them at the sign-in again
|
|
114
|
+
if (!pending)
|
|
115
|
+
return new Response(null, { status: 302, headers: { location: '/login' } });
|
|
116
|
+
const f = await formOf(request);
|
|
117
|
+
const factor = twoFactorOf(pending.login, flow.root);
|
|
118
|
+
const code = (f.app_otp ?? '').replace(/\s+/g, '');
|
|
119
|
+
const now = totpStep(flow.now());
|
|
120
|
+
const step = factor ? [now, now - 1].find((s) => (factor.last_step === undefined || s > factor.last_step) && totpCode(factor.secret, s) === code) : undefined;
|
|
121
|
+
if (step === undefined)
|
|
122
|
+
return codePage('Two-factor authentication failed.');
|
|
123
|
+
const at = flow.now();
|
|
124
|
+
await applyTwinWriteAtomic(SERVICE, () => ({ kind: 'write', value: undefined, write: { operation: 'user_two_factor.use', subjectType: 'user_two_factor', subjectId: `twofactor:${pending.login.toLowerCase()}`, fields: { last_step: step }, occurredAt: at, actor: { kind: 'human', id: pending.login } } }), flow.root);
|
|
125
|
+
await applyTwinWriteAtomic(SERVICE, () => ({ kind: 'write', value: undefined, write: { operation: 'pending_sign_in.complete', subjectType: 'pending_sign_in', subjectId: `pending:${pending.token}`, fields: { used: true }, occurredAt: at, actor: { kind: 'human', id: pending.login } } }), flow.root);
|
|
126
|
+
const answer = await startSession(pending.login, pending.return_to, flow);
|
|
127
|
+
answer.headers.append('set-cookie', '_gh_sess=; path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax');
|
|
128
|
+
return answer;
|
|
129
|
+
}
|
|
130
|
+
/** The person is signed in: GitHub's session cookies, and back to where they were going. */
|
|
131
|
+
async function startSession(person, back, flow) {
|
|
132
|
+
const at = flow.now();
|
|
133
|
+
const { value: token } = await applyTwinWriteAtomic(SERVICE, (resources) => {
|
|
134
|
+
const n = resources.filter((r) => r.type === 'user_session').length + 1;
|
|
135
|
+
const token = hex(`session:${person}:${at}:${n}`).slice(0, 48);
|
|
136
|
+
return { kind: 'write', value: token, write: { operation: 'user_session.create', subjectType: 'user_session', subjectId: `session:${token}`, fields: { token, login: person, created_at: at }, occurredAt: at, actor: { kind: 'human', id: person } } };
|
|
137
|
+
}, flow.root);
|
|
138
|
+
const headers = new Headers({ location: back });
|
|
139
|
+
headers.append('set-cookie', `user_session=${token}; path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
140
|
+
headers.append('set-cookie', 'logged_in=yes; path=/; Secure; SameSite=Lax');
|
|
141
|
+
headers.append('set-cookie', `dotcom_user=${person}; path=/; Secure; SameSite=Lax`);
|
|
142
|
+
return new Response(null, { status: 302, headers });
|
|
143
|
+
}
|
|
144
|
+
/** github.com's sign-in paths, or undefined for any other request. */
|
|
145
|
+
export function githubSessionFlow(flow) {
|
|
146
|
+
return async (request) => {
|
|
147
|
+
const url = new URL(request.url);
|
|
148
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
149
|
+
if (path === '/login' && request.method === 'GET')
|
|
150
|
+
return page({ return_to: returnTo(url.searchParams.get('return_to') ?? undefined), ...(url.searchParams.get('login') ? { login: url.searchParams.get('login') } : {}) });
|
|
151
|
+
if (path === '/session' && request.method === 'POST')
|
|
152
|
+
return signIn(request, flow);
|
|
153
|
+
if (path === '/sessions/two-factor/app' && request.method === 'GET')
|
|
154
|
+
return pendingOf(request, flow.root) ? codePage() : new Response(null, { status: 302, headers: { location: '/login' } });
|
|
155
|
+
if (path === '/sessions/two-factor' && request.method === 'POST')
|
|
156
|
+
return verifyCode(request, flow);
|
|
157
|
+
return undefined;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// GitHub's Actions family: workflows, their runs and jobs (dispatch, rerun, cancel, the reviews of
|
|
2
|
+
// gated environments), repository and organization secrets and variables, caches and artifacts. No
|
|
3
|
+
// runner executes anything (real CI is the Actions non-goal): a dispatched run is queued and moves
|
|
4
|
+
// only by these operations and the twin's runner door. List answers wrap their items in a named
|
|
5
|
+
// array beside total_count, as GitHub's do.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { GITHUB_WORLD_USER, githubNotFound, hasBareRepo, paginate, resolvePlaneRef, toGithubActionsArtifact, toGithubActionsCache, toGithubActionsSecret, toGithubActionsVariable, toGithubEnvironment, toGithubJob, toGithubWorkflow, toGithubWorkflowRun, toSimpleUser, } from "../github-twin.js";
|
|
8
|
+
import { actionsCheckRun, canWrite, queueJobs, queueRun } from "./push-reactions.js";
|
|
9
|
+
import { gh, put, send } from "./shared.js";
|
|
10
|
+
const params = (query) => new URLSearchParams((query ?? '').replace(/^\?/, ''));
|
|
11
|
+
/** A workflow named by its numeric id or its file path (or the path's last segment). */
|
|
12
|
+
/** Runs as GitHub lists them: the most recently created first. */
|
|
13
|
+
const newestFirst = (runs) => runs.map((r, i) => [r, i]).sort((a, b) => String(b[0].created_at ?? '').localeCompare(String(a[0].created_at ?? '')) || b[1] - a[1]).map(([r]) => r);
|
|
14
|
+
function workflowOf(st, repo, key) {
|
|
15
|
+
return st.workflows.filter((w) => w.repository === repo).find((w) => String(w.id) === key || w.path === key || w.path.endsWith(`/${key}`));
|
|
16
|
+
}
|
|
17
|
+
const runOf = (ctx) => {
|
|
18
|
+
const { repo, seg, state } = gh(ctx);
|
|
19
|
+
const id = Number(decodeURIComponent(seg[5]));
|
|
20
|
+
const st = state();
|
|
21
|
+
return { id, st, run: st.workflowRuns.find((r) => r.repository === repo && r.id === id) };
|
|
22
|
+
};
|
|
23
|
+
const missing = (ctx, resource, fields) => ctx.reply({ message: 'Validation Failed', errors: fields.map((field) => ({ resource, field, code: 'missing_field' })) }, 422);
|
|
24
|
+
function envScope(ctx) {
|
|
25
|
+
const { repo, seg } = gh(ctx);
|
|
26
|
+
return { scope: 'env', container: `${repo}:${decodeURIComponent(seg[4])}`, ...(seg[6] ? { name: decodeURIComponent(seg[6]) } : {}) };
|
|
27
|
+
}
|
|
28
|
+
function repoScope(ctx) {
|
|
29
|
+
const { repo, seg } = gh(ctx);
|
|
30
|
+
return { scope: 'repo', container: repo, ...(seg[5] ? { name: decodeURIComponent(seg[5]) } : {}) };
|
|
31
|
+
}
|
|
32
|
+
/** The org scope as the legacy reads named it (decoded) … */
|
|
33
|
+
function orgReadScope(ctx) {
|
|
34
|
+
const { seg } = gh(ctx);
|
|
35
|
+
return { scope: 'org', container: decodeURIComponent(seg[1]), ...(seg[4] ? { name: decodeURIComponent(seg[4]) } : {}) };
|
|
36
|
+
}
|
|
37
|
+
/** … and as its writes did (the org segment as sent). */
|
|
38
|
+
function orgWriteScope(ctx) {
|
|
39
|
+
const { seg } = gh(ctx);
|
|
40
|
+
return { scope: 'org', container: String(seg[1]), ...(seg[4] ? { name: decodeURIComponent(seg[4]) } : {}) };
|
|
41
|
+
}
|
|
42
|
+
const listSecrets = (scopeOf) => async (ctx) => {
|
|
43
|
+
const { scope, container } = scopeOf(ctx);
|
|
44
|
+
const { query, state } = gh(ctx);
|
|
45
|
+
const inScope = state().actionsSecrets.filter((s) => s.scope === scope && s.container === container);
|
|
46
|
+
return ctx.reply({ total_count: inScope.length, secrets: paginate(inScope, query).map(toGithubActionsSecret) });
|
|
47
|
+
};
|
|
48
|
+
const getSecret = (scopeOf) => async (ctx) => {
|
|
49
|
+
const { scope, container, name } = scopeOf(ctx);
|
|
50
|
+
const s = gh(ctx).state().actionsSecrets.find((x) => x.scope === scope && x.container === container && x.name === name);
|
|
51
|
+
return send(ctx, s ? { status: 200, body: toGithubActionsSecret(s) } : githubNotFound());
|
|
52
|
+
};
|
|
53
|
+
// Create or update a secret (201 new, 204 existing). The encrypted value is accepted but never
|
|
54
|
+
// stored: the twin keeps nothing secret.
|
|
55
|
+
const putSecret = (scopeOf) => async (ctx) => {
|
|
56
|
+
const { scope, container, name } = scopeOf(ctx);
|
|
57
|
+
const { occurredAt, state } = gh(ctx);
|
|
58
|
+
const prior = state().actionsSecrets.find((s) => s.scope === scope && s.container === container && s.name === name);
|
|
59
|
+
const existed = !!prior;
|
|
60
|
+
// Rewriting a secret moves updated_at; it was created when first written.
|
|
61
|
+
await put(ctx, { operation: 'actions_secret.upsert', subjectType: 'actions_secret', subjectId: `secret:${scope}:${container}#${name}`, fields: { scope, container, name, created_at: prior?.created_at ?? occurredAt, updated_at: occurredAt, ...(scope === 'org' ? { visibility: String(gh(ctx).body.visibility ?? prior?.visibility ?? 'private') } : {}) } });
|
|
62
|
+
return send(ctx, { status: existed ? 204 : 201, body: undefined });
|
|
63
|
+
};
|
|
64
|
+
const deleteSecret = (scopeOf) => async (ctx) => {
|
|
65
|
+
const { scope, container, name } = scopeOf(ctx);
|
|
66
|
+
if (!gh(ctx).state().actionsSecrets.find((s) => s.scope === scope && s.container === container && s.name === name))
|
|
67
|
+
return send(ctx, githubNotFound());
|
|
68
|
+
await put(ctx, { operation: 'actions_secret.delete', subjectType: 'actions_secret', subjectId: `secret:${scope}:${container}#${name}`, fields: { deleted: true, scope, container, name } });
|
|
69
|
+
return send(ctx, { status: 204, body: undefined });
|
|
70
|
+
};
|
|
71
|
+
const listVariables = (scopeOf) => async (ctx) => {
|
|
72
|
+
const { scope, container } = scopeOf(ctx);
|
|
73
|
+
const { query, state } = gh(ctx);
|
|
74
|
+
const inScope = state().actionsVariables.filter((v) => v.scope === scope && v.container === container);
|
|
75
|
+
return ctx.reply({ total_count: inScope.length, variables: paginate(inScope, query).map(toGithubActionsVariable) });
|
|
76
|
+
};
|
|
77
|
+
const getVariable = (scopeOf) => async (ctx) => {
|
|
78
|
+
const { scope, container, name } = scopeOf(ctx);
|
|
79
|
+
const v = gh(ctx).state().actionsVariables.find((x) => x.scope === scope && x.container === container && x.name === name);
|
|
80
|
+
return send(ctx, v ? { status: 200, body: toGithubActionsVariable(v) } : githubNotFound());
|
|
81
|
+
};
|
|
82
|
+
// create (201); a name the scope already holds is a conflict
|
|
83
|
+
const createVariable = (scopeOf) => async (ctx) => {
|
|
84
|
+
const { scope, container } = scopeOf(ctx);
|
|
85
|
+
const { body, occurredAt, state } = gh(ctx);
|
|
86
|
+
const absent = ['name', 'value'].filter((k) => body[k] === undefined);
|
|
87
|
+
if (absent.length)
|
|
88
|
+
return missing(ctx, 'Variable', absent);
|
|
89
|
+
const name = String(body.name);
|
|
90
|
+
if (state().actionsVariables.some((v) => v.scope === scope && v.container === container && v.name === name))
|
|
91
|
+
return ctx.reply({ message: 'Variable already exists' }, 409);
|
|
92
|
+
await put(ctx, { operation: 'actions_variable.create', subjectType: 'actions_variable', subjectId: `variable:${scope}:${container}#${name}`, fields: { scope, container, name, value: String(body.value), created_at: occurredAt, updated_at: occurredAt, ...(scope === 'org' ? { visibility: String(body.visibility ?? 'private') } : {}) } });
|
|
93
|
+
return send(ctx, { status: 201, body: undefined });
|
|
94
|
+
};
|
|
95
|
+
const deleteVariable = (scopeOf) => async (ctx) => {
|
|
96
|
+
const { scope, container, name } = scopeOf(ctx);
|
|
97
|
+
if (!gh(ctx).state().actionsVariables.find((v) => v.scope === scope && v.container === container && v.name === name))
|
|
98
|
+
return send(ctx, githubNotFound());
|
|
99
|
+
await put(ctx, { operation: 'actions_variable.delete', subjectType: 'actions_variable', subjectId: `variable:${scope}:${container}#${name}`, fields: { deleted: true, scope, container, name } });
|
|
100
|
+
return send(ctx, { status: 204, body: undefined });
|
|
101
|
+
};
|
|
102
|
+
/** Disabling and enabling a workflow are its moves (manifest.ts); asking for the state it is already in answers
|
|
103
|
+
* as done, and moves nothing. */
|
|
104
|
+
const toggleWorkflow = (to, operation) => async (ctx) => {
|
|
105
|
+
const { repo, seg, state, occurredAt } = gh(ctx);
|
|
106
|
+
const wf = workflowOf(state(), repo, decodeURIComponent(seg[5]));
|
|
107
|
+
if (!wf)
|
|
108
|
+
return send(ctx, githubNotFound());
|
|
109
|
+
const refused = ctx.legal('workflow', 'state', operation, wf.state, to, String(wf.id));
|
|
110
|
+
if (refused)
|
|
111
|
+
return ctx.refuse(refused);
|
|
112
|
+
await put(ctx, { operation: `workflow.${to === 'active' ? 'enable' : 'disable'}`, subjectType: 'workflow', subjectId: `workflow:${wf.id}`, fields: { repository: wf.repository, name: wf.name, path: wf.path, state: to, created_at: wf.created_at, updated_at: occurredAt } });
|
|
113
|
+
return send(ctx, { status: 204, body: undefined });
|
|
114
|
+
};
|
|
115
|
+
export function actionsSemantics() {
|
|
116
|
+
const actionsListRepoWorkflows = async (ctx) => {
|
|
117
|
+
const { repo, query, state } = gh(ctx);
|
|
118
|
+
const inRepo = state().workflows.filter((w) => w.repository === repo);
|
|
119
|
+
return ctx.reply({ total_count: inRepo.length, workflows: paginate(inRepo, query).map(toGithubWorkflow) });
|
|
120
|
+
};
|
|
121
|
+
const actionsGetWorkflow = async (ctx) => {
|
|
122
|
+
const { repo, seg, state } = gh(ctx);
|
|
123
|
+
const wf = workflowOf(state(), repo, decodeURIComponent(seg[5]));
|
|
124
|
+
return send(ctx, wf ? { status: 200, body: toGithubWorkflow(wf) } : githubNotFound());
|
|
125
|
+
};
|
|
126
|
+
const actionsGetWorkflowRun = async (ctx) => {
|
|
127
|
+
const { run } = runOf(ctx);
|
|
128
|
+
return send(ctx, run ? { status: 200, body: toGithubWorkflowRun(run, runOf(ctx).st) } : githubNotFound());
|
|
129
|
+
};
|
|
130
|
+
const actionsListJobsForWorkflowRun = async (ctx) => {
|
|
131
|
+
const { id, st, run } = runOf(ctx);
|
|
132
|
+
if (!run)
|
|
133
|
+
return send(ctx, githubNotFound());
|
|
134
|
+
const { repo, query } = gh(ctx);
|
|
135
|
+
const inRun = st.jobs.filter((j) => j.repository === repo && j.run_id === id);
|
|
136
|
+
return ctx.reply({ total_count: inRun.length, jobs: paginate(inRun, query).map(toGithubJob) });
|
|
137
|
+
};
|
|
138
|
+
const actionsGetReviewsForRun = async (ctx) => {
|
|
139
|
+
const { st, run } = runOf(ctx);
|
|
140
|
+
if (!run)
|
|
141
|
+
return send(ctx, githubNotFound());
|
|
142
|
+
const { repo } = gh(ctx);
|
|
143
|
+
return ctx.reply((run.approvals ?? []).map((a) => {
|
|
144
|
+
const env = st.environments.find((e) => e.repository === repo && e.name === a.environment);
|
|
145
|
+
return { environments: env ? [{ id: toGithubEnvironment(env).id, name: env.name, url: toGithubEnvironment(env).url, html_url: toGithubEnvironment(env).html_url }] : [], state: a.state, comment: a.comment, user: toSimpleUser(a.user_login, 'User') };
|
|
146
|
+
}));
|
|
147
|
+
};
|
|
148
|
+
const actionsDownloadWorkflowRunLogs = async (ctx) => {
|
|
149
|
+
const { id, run } = runOf(ctx);
|
|
150
|
+
if (!run)
|
|
151
|
+
return send(ctx, githubNotFound());
|
|
152
|
+
return ctx.reply({ location: `https://pipelines.actions.githubusercontent.com/${gh(ctx).repo}/runs/${id}/logs` }, 302);
|
|
153
|
+
};
|
|
154
|
+
const actionsGetJobForWorkflowRun = async (ctx) => {
|
|
155
|
+
const { repo, seg, state } = gh(ctx);
|
|
156
|
+
const id = Number(decodeURIComponent(seg[5]));
|
|
157
|
+
const job = state().jobs.find((j) => j.repository === repo && j.id === id);
|
|
158
|
+
return send(ctx, job ? { status: 200, body: toGithubJob(job) } : githubNotFound());
|
|
159
|
+
};
|
|
160
|
+
const actionsCreateWorkflowDispatch = async (ctx) => {
|
|
161
|
+
const { repo, seg, body, caller, root, state } = gh(ctx);
|
|
162
|
+
const st = state();
|
|
163
|
+
const wf = workflowOf(st, repo, decodeURIComponent(seg[5]));
|
|
164
|
+
if (!wf)
|
|
165
|
+
return send(ctx, githubNotFound());
|
|
166
|
+
if (!body.ref)
|
|
167
|
+
return missing(ctx, 'WorkflowDispatch', ['ref']);
|
|
168
|
+
const ref = String(body.ref);
|
|
169
|
+
// head_sha: the commit the ref names, resolved through the git plane (an annotated tag peels to its commit) when the
|
|
170
|
+
// repository has history there, where GitHub refuses a ref it cannot find; a repository known only through the API
|
|
171
|
+
// keeps a deterministic stand-in derived from repo+ref+run.
|
|
172
|
+
const plane = hasBareRepo(repo, root);
|
|
173
|
+
const planeSha = plane ? await resolvePlaneRef(repo, ref, st.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main', root) : null;
|
|
174
|
+
if (plane && !planeSha)
|
|
175
|
+
return ctx.reply({ message: `No ref found for: ${ref}`, documentation_url: 'https://docs.github.com/rest/actions/workflows#create-a-workflow-dispatch-event' }, 422);
|
|
176
|
+
const runs = st.workflowRuns.filter((r) => r.repository === repo && r.workflow_id === wf.id).length;
|
|
177
|
+
const headSha = planeSha ?? createHash('sha256').update(`${repo}:${ref}:${runs + 1}`).digest('hex').slice(0, 40);
|
|
178
|
+
await queueRun(ctx, st, repo, wf, ref, headSha, 'workflow_dispatch', caller);
|
|
179
|
+
return send(ctx, { status: 204, body: undefined });
|
|
180
|
+
};
|
|
181
|
+
const actionsReviewPendingDeploymentsForRun = async (ctx) => {
|
|
182
|
+
const { id, st, run } = runOf(ctx);
|
|
183
|
+
if (!run)
|
|
184
|
+
return send(ctx, githubNotFound());
|
|
185
|
+
const { repo, body, occurredAt, caller } = gh(ctx);
|
|
186
|
+
const verdict = String(body.state ?? '');
|
|
187
|
+
if (verdict !== 'approved' && verdict !== 'rejected')
|
|
188
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'PendingDeployment', field: 'state', code: 'invalid' }] }, 422);
|
|
189
|
+
const ids = Array.isArray(body.environment_ids) ? body.environment_ids.map(Number) : [];
|
|
190
|
+
const envs = st.environments.filter((e) => e.repository === repo && ids.includes(Number(toGithubEnvironment(e).id)));
|
|
191
|
+
if (!ids.length || envs.length !== ids.length)
|
|
192
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'PendingDeployment', field: 'environment_ids', code: 'invalid' }] }, 422);
|
|
193
|
+
if (!caller || envs.some((e) => !(e.reviewers ?? []).includes(caller)))
|
|
194
|
+
return ctx.reply({ message: 'You are not a required reviewer of every environment named', documentation_url: 'https://docs.github.com/rest/actions/workflow-runs#review-pending-deployments-for-a-workflow-run' }, 403);
|
|
195
|
+
const approvals = [...(run.approvals ?? []), ...envs.map((e) => ({ environment: e.name, state: verdict, comment: String(body.comment ?? ''), user_login: caller, at: occurredAt }))];
|
|
196
|
+
await put(ctx, { operation: 'workflow_run.review', subjectType: 'workflow_run', subjectId: `run:${id}`, fields: { ...run, approvals, updated_at: occurredAt } });
|
|
197
|
+
return ctx.reply([]);
|
|
198
|
+
};
|
|
199
|
+
const actionsReRunWorkflow = async (ctx) => {
|
|
200
|
+
const { id, run } = runOf(ctx);
|
|
201
|
+
if (!run)
|
|
202
|
+
return send(ctx, githubNotFound());
|
|
203
|
+
const { repo, occurredAt, state } = gh(ctx);
|
|
204
|
+
const runFields = { repository: repo, workflow_id: run.workflow_id, name: run.name, run_number: run.run_number, event: run.event, status: 'queued', conclusion: null, head_sha: run.head_sha, head_branch: run.head_branch, created_at: run.created_at, updated_at: occurredAt };
|
|
205
|
+
await put(ctx, { operation: 'workflow_run.rerun', subjectType: 'workflow_run', subjectId: `run:${id}`, fields: runFields });
|
|
206
|
+
for (const job of state().jobs.filter((j) => j.repository === repo && j.run_id === id)) {
|
|
207
|
+
const jobFields = { run_id: id, repository: repo, name: job.name, status: 'queued', conclusion: null, head_sha: job.head_sha, steps: job.steps.map((s) => ({ ...s, status: 'queued', conclusion: null })) };
|
|
208
|
+
await put(ctx, { operation: 'workflow_job.rerun', subjectType: 'workflow_job', subjectId: `job:${job.id}`, fields: jobFields });
|
|
209
|
+
await put(ctx, actionsCheckRun(job, 'queued', null, occurredAt));
|
|
210
|
+
}
|
|
211
|
+
return ctx.reply({}, 201);
|
|
212
|
+
};
|
|
213
|
+
const actionsCancelWorkflowRun = async (ctx) => {
|
|
214
|
+
const { id, run } = runOf(ctx);
|
|
215
|
+
if (!run)
|
|
216
|
+
return send(ctx, githubNotFound());
|
|
217
|
+
const { repo, occurredAt, state } = gh(ctx);
|
|
218
|
+
const runFields = { repository: repo, workflow_id: run.workflow_id, name: run.name, run_number: run.run_number, event: run.event, status: 'completed', conclusion: 'cancelled', head_sha: run.head_sha, head_branch: run.head_branch, created_at: run.created_at, updated_at: occurredAt };
|
|
219
|
+
await put(ctx, { operation: 'workflow_run.cancel', subjectType: 'workflow_run', subjectId: `run:${id}`, fields: runFields });
|
|
220
|
+
for (const job of state().jobs.filter((j) => j.repository === repo && j.run_id === id)) {
|
|
221
|
+
const jobFields = { run_id: id, repository: repo, name: job.name, status: 'completed', conclusion: 'cancelled', head_sha: job.head_sha, started_at: job.started_at, completed_at: occurredAt, steps: job.steps.map((s) => ({ ...s, status: 'completed', conclusion: 'cancelled' })) };
|
|
222
|
+
await put(ctx, { operation: 'workflow_job.cancel', subjectType: 'workflow_job', subjectId: `job:${job.id}`, fields: jobFields });
|
|
223
|
+
await put(ctx, actionsCheckRun(job, 'completed', 'cancelled', occurredAt));
|
|
224
|
+
}
|
|
225
|
+
return ctx.reply({}, 202);
|
|
226
|
+
};
|
|
227
|
+
const actionsUpdateRepoVariable = async (ctx) => {
|
|
228
|
+
const { container: repo, name } = repoScope(ctx);
|
|
229
|
+
const { body, occurredAt, state } = gh(ctx);
|
|
230
|
+
const existing = state().actionsVariables.find((v) => v.scope === 'repo' && v.container === repo && v.name === name);
|
|
231
|
+
if (!existing)
|
|
232
|
+
return send(ctx, githubNotFound());
|
|
233
|
+
const newName = body.name !== undefined ? String(body.name) : name;
|
|
234
|
+
await put(ctx, { operation: 'actions_variable.update', subjectType: 'actions_variable', subjectId: `variable:repo:${repo}#${newName}`, fields: { scope: 'repo', container: repo, name: newName, value: body.value !== undefined ? String(body.value) : existing.value, created_at: existing.created_at, updated_at: occurredAt } });
|
|
235
|
+
if (newName !== name)
|
|
236
|
+
await put(ctx, { operation: 'actions_variable.delete', subjectType: 'actions_variable', subjectId: `variable:repo:${repo}#${name}`, fields: { deleted: true, scope: 'repo', container: repo, name } });
|
|
237
|
+
return send(ctx, { status: 204, body: undefined });
|
|
238
|
+
};
|
|
239
|
+
const actionsUpdateOrgVariable = async (ctx) => {
|
|
240
|
+
const { container: org, name } = orgWriteScope(ctx);
|
|
241
|
+
const { body, occurredAt, state } = gh(ctx);
|
|
242
|
+
const existing = state().actionsVariables.find((v) => v.scope === 'org' && v.container === org && v.name === name);
|
|
243
|
+
if (!existing)
|
|
244
|
+
return send(ctx, githubNotFound());
|
|
245
|
+
await put(ctx, { operation: 'actions_variable.update', subjectType: 'actions_variable', subjectId: `variable:org:${org}#${name}`, fields: { scope: 'org', container: org, name, value: body.value !== undefined ? String(body.value) : existing.value, created_at: existing.created_at, updated_at: occurredAt } });
|
|
246
|
+
return send(ctx, { status: 204, body: undefined });
|
|
247
|
+
};
|
|
248
|
+
const actionsGetActionsCacheList = async (ctx) => {
|
|
249
|
+
const { repo, query, state } = gh(ctx);
|
|
250
|
+
const inRepo = state().actionsCaches.filter((c) => c.repository === repo);
|
|
251
|
+
return ctx.reply({ total_count: inRepo.length, actions_caches: paginate(inRepo, query).map(toGithubActionsCache) });
|
|
252
|
+
};
|
|
253
|
+
const actionsListArtifactsForRepo = async (ctx) => {
|
|
254
|
+
const { repo, query, state } = gh(ctx);
|
|
255
|
+
const inRepo = state().actionsArtifacts.filter((a) => a.repository === repo);
|
|
256
|
+
return ctx.reply({ total_count: inRepo.length, artifacts: paginate(inRepo, query).map(toGithubActionsArtifact) });
|
|
257
|
+
};
|
|
258
|
+
const actionsGetArtifact = async (ctx) => {
|
|
259
|
+
const { repo, seg, state } = gh(ctx);
|
|
260
|
+
const a = state().actionsArtifacts.find((x) => x.repository === repo && x.id === Number(decodeURIComponent(seg[5])));
|
|
261
|
+
return send(ctx, a ? { status: 200, body: toGithubActionsArtifact(a) } : githubNotFound());
|
|
262
|
+
};
|
|
263
|
+
const actionsDeleteArtifact = async (ctx) => {
|
|
264
|
+
const { repo, seg, state } = gh(ctx);
|
|
265
|
+
const id = Number(decodeURIComponent(seg[5]));
|
|
266
|
+
const existing = state().actionsArtifacts.find((a) => a.repository === repo && a.id === id);
|
|
267
|
+
if (!existing)
|
|
268
|
+
return send(ctx, githubNotFound());
|
|
269
|
+
await put(ctx, { operation: 'actions_artifact.delete', subjectType: 'actions_artifact', subjectId: `artifact:${id}`, fields: { deleted: true, repository: repo, name: existing.name } });
|
|
270
|
+
return send(ctx, { status: 204, body: undefined });
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
'actions/disable-workflow': toggleWorkflow('disabled_manually', 'actions/disable-workflow'),
|
|
274
|
+
'actions/enable-workflow': toggleWorkflow('active', 'actions/enable-workflow'),
|
|
275
|
+
'actions/list-repo-workflows': actionsListRepoWorkflows,
|
|
276
|
+
// the id may be the numeric id OR the workflow file path
|
|
277
|
+
'actions/get-workflow': actionsGetWorkflow,
|
|
278
|
+
// runs scoped to one workflow (status/branch filters apply)
|
|
279
|
+
'actions/list-workflow-runs': async (ctx) => {
|
|
280
|
+
const { repo, seg, query, state } = gh(ctx);
|
|
281
|
+
const st = state();
|
|
282
|
+
const wf = workflowOf(st, repo, decodeURIComponent(seg[5]));
|
|
283
|
+
if (!wf)
|
|
284
|
+
return send(ctx, githubNotFound());
|
|
285
|
+
let runs = st.workflowRuns.filter((r) => r.repository === repo && r.workflow_id === wf.id);
|
|
286
|
+
const status = params(query).get('status');
|
|
287
|
+
if (status)
|
|
288
|
+
runs = runs.filter((r) => r.status === status || r.conclusion === status);
|
|
289
|
+
const branch = params(query).get('branch');
|
|
290
|
+
if (branch)
|
|
291
|
+
runs = runs.filter((r) => r.head_branch === branch);
|
|
292
|
+
return ctx.reply({ total_count: runs.length, workflow_runs: paginate(newestFirst(runs), query).map((r) => toGithubWorkflowRun(r, st)) });
|
|
293
|
+
},
|
|
294
|
+
// with GitHub's status/branch/event filters
|
|
295
|
+
'actions/list-workflow-runs-for-repo': async (ctx) => {
|
|
296
|
+
const { repo, query, state } = gh(ctx);
|
|
297
|
+
let inRepo = state().workflowRuns.filter((r) => r.repository === repo);
|
|
298
|
+
const p = params(query);
|
|
299
|
+
const status = p.get('status');
|
|
300
|
+
if (status)
|
|
301
|
+
inRepo = inRepo.filter((r) => r.status === status || r.conclusion === status);
|
|
302
|
+
const branch = p.get('branch');
|
|
303
|
+
if (branch)
|
|
304
|
+
inRepo = inRepo.filter((r) => r.head_branch === branch);
|
|
305
|
+
const event = p.get('event');
|
|
306
|
+
if (event)
|
|
307
|
+
inRepo = inRepo.filter((r) => r.event === event);
|
|
308
|
+
return ctx.reply({ total_count: inRepo.length, workflow_runs: paginate(newestFirst(inRepo), query).map((r) => toGithubWorkflowRun(r, gh(ctx).state())) });
|
|
309
|
+
},
|
|
310
|
+
'actions/get-workflow-run': actionsGetWorkflowRun,
|
|
311
|
+
'actions/list-jobs-for-workflow-run': actionsListJobsForWorkflowRun,
|
|
312
|
+
// the reviews of the run's gated environments, oldest first
|
|
313
|
+
'actions/get-reviews-for-run': actionsGetReviewsForRun,
|
|
314
|
+
// GitHub 302-redirects to a signed download URL; the twin answers the 302 with the location in
|
|
315
|
+
// its body (serving the bytes is not modeled)
|
|
316
|
+
'actions/download-workflow-run-logs': actionsDownloadWorkflowRunLogs,
|
|
317
|
+
'actions/get-job-for-workflow-run': actionsGetJobForWorkflowRun,
|
|
318
|
+
// workflow_dispatch: a queued run and its build job. `ref` selects the branch or sha; in a
|
|
319
|
+
// repository with history the ref must resolve there, as GitHub requires. 204.
|
|
320
|
+
'actions/create-workflow-dispatch': actionsCreateWorkflowDispatch,
|
|
321
|
+
// A required reviewer approves or rejects the run's gated environments. The caller (a personal
|
|
322
|
+
// token's user) must be one of each environment's reviewers; the review is recorded on the run.
|
|
323
|
+
'actions/review-pending-deployments-for-run': actionsReviewPendingDeploymentsForRun,
|
|
324
|
+
// A maintainer with write access approves a first-time contributor's run from a fork, which waits in
|
|
325
|
+
// action_required until then; the run is queued with its jobs (201)
|
|
326
|
+
// (docs.github.com/rest/actions/workflow-runs#approve-a-workflow-run-for-a-fork-pull-request,
|
|
327
|
+
// docs.github.com/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/approving-workflow-runs-from-public-forks).
|
|
328
|
+
// Where the documentation stops and the twin decides: it gives the refusal only as 403 Forbidden, and the twin
|
|
329
|
+
// words it for the two causes it has, a caller without write access and a run that is not waiting for approval.
|
|
330
|
+
'actions/approve-workflow-run': async (ctx) => {
|
|
331
|
+
const { id, st, run } = runOf(ctx);
|
|
332
|
+
if (!run)
|
|
333
|
+
return send(ctx, githubNotFound());
|
|
334
|
+
const { repo, occurredAt, caller } = gh(ctx);
|
|
335
|
+
const docs = 'https://docs.github.com/rest/actions/workflow-runs#approve-a-workflow-run-for-a-fork-pull-request';
|
|
336
|
+
if (!canWrite(st, repo, caller ?? GITHUB_WORLD_USER))
|
|
337
|
+
return ctx.reply({ message: 'Must have write access to the repository to approve workflow runs.', documentation_url: docs }, 403);
|
|
338
|
+
if (run.conclusion !== 'action_required')
|
|
339
|
+
return ctx.reply({ message: 'This workflow run is not waiting for approval.', documentation_url: docs }, 403);
|
|
340
|
+
const wf = st.workflows.find((w) => w.id === run.workflow_id);
|
|
341
|
+
if (!wf)
|
|
342
|
+
return send(ctx, githubNotFound());
|
|
343
|
+
await put(ctx, { operation: 'workflow_run.approve', subjectType: 'workflow_run', subjectId: `run:${id}`, fields: { repository: repo, workflow_id: run.workflow_id, name: run.name, run_number: run.run_number, event: run.event, status: 'queued', conclusion: null, head_sha: run.head_sha, head_branch: run.head_branch, created_at: run.created_at, updated_at: occurredAt } });
|
|
344
|
+
await queueJobs(ctx, st, repo, wf, id, run.run_number, run.head_branch ?? '', run.head_sha);
|
|
345
|
+
return ctx.reply({}, 201);
|
|
346
|
+
},
|
|
347
|
+
// a new attempt: the run and its jobs back to queued (201)
|
|
348
|
+
'actions/re-run-workflow': actionsReRunWorkflow,
|
|
349
|
+
// the run and its jobs to completed/cancelled (202)
|
|
350
|
+
'actions/cancel-workflow-run': actionsCancelWorkflowRun,
|
|
351
|
+
// repository secrets and variables (the secret's value is never returned; a variable's is)
|
|
352
|
+
'actions/list-repo-secrets': listSecrets(repoScope),
|
|
353
|
+
'actions/get-repo-secret': getSecret(repoScope),
|
|
354
|
+
'actions/create-or-update-repo-secret': putSecret(repoScope),
|
|
355
|
+
'actions/delete-repo-secret': deleteSecret(repoScope),
|
|
356
|
+
// environment secrets
|
|
357
|
+
'actions/list-environment-secrets': listSecrets(envScope),
|
|
358
|
+
'actions/get-environment-secret': getSecret(envScope),
|
|
359
|
+
'actions/create-or-update-environment-secret': putSecret(envScope),
|
|
360
|
+
'actions/delete-environment-secret': deleteSecret(envScope),
|
|
361
|
+
'actions/list-repo-variables': listVariables(repoScope),
|
|
362
|
+
'actions/get-repo-variable': getVariable(repoScope),
|
|
363
|
+
'actions/create-repo-variable': createVariable(repoScope),
|
|
364
|
+
// an edit may rename the variable: the new name is written and the old one deleted
|
|
365
|
+
'actions/update-repo-variable': actionsUpdateRepoVariable,
|
|
366
|
+
'actions/delete-repo-variable': deleteVariable(repoScope),
|
|
367
|
+
// organization secrets and variables
|
|
368
|
+
'actions/list-org-secrets': listSecrets(orgReadScope),
|
|
369
|
+
'actions/get-org-secret': getSecret(orgReadScope),
|
|
370
|
+
'actions/create-or-update-org-secret': putSecret(orgWriteScope),
|
|
371
|
+
'actions/delete-org-secret': deleteSecret(orgWriteScope),
|
|
372
|
+
'actions/list-org-variables': listVariables(orgReadScope),
|
|
373
|
+
'actions/get-org-variable': getVariable(orgReadScope),
|
|
374
|
+
'actions/create-org-variable': createVariable(orgWriteScope),
|
|
375
|
+
'actions/update-org-variable': actionsUpdateOrgVariable,
|
|
376
|
+
'actions/delete-org-variable': deleteVariable(orgWriteScope),
|
|
377
|
+
// caches: list, delete by id (404 when absent) or by ?key= (every cache of the repo without one)
|
|
378
|
+
'actions/get-actions-cache-list': actionsGetActionsCacheList,
|
|
379
|
+
'actions/delete-actions-cache-by-id': async (ctx) => deleteCaches(ctx),
|
|
380
|
+
'actions/delete-actions-cache-by-key': async (ctx) => deleteCaches(ctx),
|
|
381
|
+
// the artifacts one run's steps uploaded (docs.github.com/rest/actions/artifacts#list-workflow-run-artifacts), by name
|
|
382
|
+
// when `name` is given
|
|
383
|
+
'actions/list-workflow-run-artifacts': async (ctx) => {
|
|
384
|
+
const { id, st, run } = runOf(ctx);
|
|
385
|
+
if (!run)
|
|
386
|
+
return send(ctx, githubNotFound());
|
|
387
|
+
const { repo, query } = gh(ctx);
|
|
388
|
+
const name = params(query).get('name');
|
|
389
|
+
const inRun = st.actionsArtifacts.filter((a) => a.repository === repo && a.run_id === id && (!name || a.name === name));
|
|
390
|
+
return ctx.reply({ total_count: inRun.length, artifacts: paginate(inRun, query).map(toGithubActionsArtifact) });
|
|
391
|
+
},
|
|
392
|
+
'actions/list-artifacts-for-repo': actionsListArtifactsForRepo,
|
|
393
|
+
'actions/get-artifact': actionsGetArtifact,
|
|
394
|
+
'actions/delete-artifact': actionsDeleteArtifact,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** Delete caches: the one an id names (204, no body), or those a `key` names (200 with the deleted list). */
|
|
398
|
+
async function deleteCaches(ctx) {
|
|
399
|
+
const { repo, seg, query, state } = gh(ctx);
|
|
400
|
+
const key = params(query).get('key');
|
|
401
|
+
let targets = state().actionsCaches.filter((c) => c.repository === repo);
|
|
402
|
+
if (seg[5])
|
|
403
|
+
targets = targets.filter((c) => c.id === Number(decodeURIComponent(seg[5])));
|
|
404
|
+
else if (key)
|
|
405
|
+
targets = targets.filter((c) => c.key === key);
|
|
406
|
+
if (!targets.length)
|
|
407
|
+
return seg[5] ? send(ctx, githubNotFound()) : ctx.reply({ total_count: 0, actions_caches: [] });
|
|
408
|
+
for (const c of targets)
|
|
409
|
+
await put(ctx, { operation: 'actions_cache.delete', subjectType: 'actions_cache', subjectId: `cache:${c.id}`, fields: { deleted: true, repository: repo, key: c.key } });
|
|
410
|
+
if (seg[5])
|
|
411
|
+
return send(ctx, { status: 204, body: undefined });
|
|
412
|
+
return ctx.reply({ total_count: targets.length, actions_caches: targets.map(toGithubActionsCache) });
|
|
413
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
2
|
+
/** Who the credential names: its user, or the World's own for its shared token. */
|
|
3
|
+
export declare const me: (ctx: SemanticsContext) => string;
|
|
4
|
+
export declare function activitySemantics(): Record<string, Semantics>;
|