@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,176 @@
|
|
|
1
|
+
// GitHub's Pages family: a repository's Pages site (create, read, update, delete) and its builds. A
|
|
2
|
+
// requested build is recorded built at once (the twin publishes nothing).
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { GITHUB_WORLD_USER, githubNotFound, paginate, toGithubPagesBuild, toGithubPagesSite } from "../github-twin.js";
|
|
5
|
+
import { gh, nextSeq, put, send } from "./shared.js";
|
|
6
|
+
import { canWrite } from "./push-reactions.js";
|
|
7
|
+
/** POST creates the site (409 when enabled), PUT edits it (404 when not): the source branch and path,
|
|
8
|
+
* the custom domain, the build type and HTTPS enforcement. */
|
|
9
|
+
async function writeSite(ctx, creating) {
|
|
10
|
+
const { repo, body, occurredAt, state } = gh(ctx);
|
|
11
|
+
// Pages belongs to a repository: none, no site (docs.github.com/rest/pages/pages#create-a-apiname-pages-site)
|
|
12
|
+
if (!state().repos.some((r) => r.full_name === repo))
|
|
13
|
+
return send(ctx, githubNotFound());
|
|
14
|
+
const existing = state().pagesSites.find((s) => s.repository === repo);
|
|
15
|
+
if (creating && existing)
|
|
16
|
+
return ctx.reply({ message: 'Pages is already enabled for this repository.' }, 409);
|
|
17
|
+
if (!creating && !existing)
|
|
18
|
+
return send(ctx, githubNotFound());
|
|
19
|
+
const src = body.source ?? {};
|
|
20
|
+
const fields = { repository: repo, updated_at: occurredAt };
|
|
21
|
+
if (creating)
|
|
22
|
+
fields.created_at = occurredAt;
|
|
23
|
+
if (src.branch !== undefined)
|
|
24
|
+
fields.source_branch = String(src.branch);
|
|
25
|
+
if (src.path !== undefined)
|
|
26
|
+
fields.source_path = String(src.path);
|
|
27
|
+
// a new custom domain starts its certificate and turns HTTPS enforcement off until it is issued; enforcing it
|
|
28
|
+
// before then is refused (docs.github.com/pages/getting-started-with-github-pages/securing-your-github-pages-site-with-https)
|
|
29
|
+
const domainChanged = body.cname !== undefined && body.cname !== (existing?.cname ?? null);
|
|
30
|
+
if (body.cname !== undefined)
|
|
31
|
+
fields.cname = body.cname;
|
|
32
|
+
if (domainChanged) {
|
|
33
|
+
fields.cert_requested_at = body.cname ? occurredAt : null;
|
|
34
|
+
fields.https_enforced = false;
|
|
35
|
+
}
|
|
36
|
+
if (body.build_type !== undefined)
|
|
37
|
+
fields.build_type = String(body.build_type);
|
|
38
|
+
if (body.https_enforced !== undefined) {
|
|
39
|
+
const refused = enforceHttps(ctx, body, existing, domainChanged, fields);
|
|
40
|
+
if (refused)
|
|
41
|
+
return refused;
|
|
42
|
+
}
|
|
43
|
+
if (creating) {
|
|
44
|
+
fields.status = 'building';
|
|
45
|
+
if (fields.source_branch === undefined)
|
|
46
|
+
fields.source_branch = 'main';
|
|
47
|
+
if (fields.source_path === undefined)
|
|
48
|
+
fields.source_path = '/';
|
|
49
|
+
}
|
|
50
|
+
await put(ctx, { operation: creating ? 'pages_site.create' : 'pages_site.update', subjectType: 'pages_site', subjectId: `pages:${repo}`, fields });
|
|
51
|
+
if (!creating)
|
|
52
|
+
return send(ctx, { status: 204, body: undefined });
|
|
53
|
+
return ctx.reply(siteBody(ctx, state().pagesSites.find((s) => s.repository === repo)), 201);
|
|
54
|
+
}
|
|
55
|
+
/** HTTPS enforcement turned on or off: refused while the custom domain's certificate is still being issued. */
|
|
56
|
+
function enforceHttps(ctx, body, existing, domainChanged, fields) {
|
|
57
|
+
const { occurredAt } = gh(ctx);
|
|
58
|
+
const cname = domainChanged ? body.cname : existing?.cname;
|
|
59
|
+
const requested = domainChanged ? occurredAt : existing?.cert_requested_at;
|
|
60
|
+
if (body.https_enforced === true && cname && certificateState(requested, occurredAt) !== 'approved') {
|
|
61
|
+
return ctx.reply({ message: 'The certificate does not exist yet', documentation_url: 'https://docs.github.com/rest/pages/pages#update-information-about-a-apiname-pages-site' }, 422);
|
|
62
|
+
}
|
|
63
|
+
fields.https_enforced = Boolean(body.https_enforced);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
// How long a custom domain's certificate takes to issue: GitHub says it can take a while and gives no figure, so
|
|
67
|
+
// fifteen minutes is the twin's; the refusal's wording above is the twin's too.
|
|
68
|
+
const CERT_MS = 15 * 60 * 1000;
|
|
69
|
+
function certificateState(requestedAt, now) {
|
|
70
|
+
return requestedAt && Date.parse(now) - Date.parse(requestedAt) >= CERT_MS ? 'approved' : 'authorization_pending';
|
|
71
|
+
}
|
|
72
|
+
/** The site as GitHub answers it: with its custom domain's certificate as the clock finds it. */
|
|
73
|
+
function siteBody(ctx, site) {
|
|
74
|
+
const body = toGithubPagesSite(site);
|
|
75
|
+
if (!site.cname)
|
|
76
|
+
return body;
|
|
77
|
+
const requested = site.cert_requested_at;
|
|
78
|
+
const state = certificateState(requested, gh(ctx).occurredAt);
|
|
79
|
+
return {
|
|
80
|
+
...body,
|
|
81
|
+
https_certificate: {
|
|
82
|
+
state, description: state === 'approved' ? 'The certificate has been approved.' : 'The certificate is being issued.',
|
|
83
|
+
domains: [site.cname, `www.${site.cname}`], ...(state === 'approved' && requested ? { expires_at: new Date(Date.parse(requested) + 90 * 86_400_000).toISOString().slice(0, 10) } : {}),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** A read under the site's builds: Not Found when Pages is not enabled. */
|
|
88
|
+
const builds = (serve) => async (ctx) => {
|
|
89
|
+
const { repo, state } = gh(ctx);
|
|
90
|
+
const st = state();
|
|
91
|
+
if (!st.pagesSites.find((s) => s.repository === repo))
|
|
92
|
+
return send(ctx, githubNotFound());
|
|
93
|
+
return serve(ctx, st.pagesBuilds.filter((b) => b.repository === repo));
|
|
94
|
+
};
|
|
95
|
+
const DEPLOY_DOCS = 'https://docs.github.com/rest/pages/pages#create-a-github-pages-deployment';
|
|
96
|
+
/** A Pages deployment by its id or the build version (a commit sha) it deployed. */
|
|
97
|
+
const deploymentOf = (ctx) => {
|
|
98
|
+
const { repo, seg } = gh(ctx);
|
|
99
|
+
const key = decodeURIComponent(seg[5]);
|
|
100
|
+
return ctx.tree().find((r) => r.type === 'pages_deployment' && r.repository === repo && r.deleted !== true && (String(r.number) === key || r.pages_build_version === key));
|
|
101
|
+
};
|
|
102
|
+
export function pagesSemantics() {
|
|
103
|
+
const reposGetPagesDeployment = async (ctx) => {
|
|
104
|
+
const d = deploymentOf(ctx);
|
|
105
|
+
return send(ctx, d ? { status: 200, body: { status: d.status } } : githubNotFound());
|
|
106
|
+
};
|
|
107
|
+
const reposCancelPagesDeployment = async (ctx) => {
|
|
108
|
+
const d = deploymentOf(ctx);
|
|
109
|
+
return send(ctx, d ? { status: 204, body: undefined } : githubNotFound());
|
|
110
|
+
};
|
|
111
|
+
const reposDeletePagesSite = async (ctx) => {
|
|
112
|
+
const { repo, state } = gh(ctx);
|
|
113
|
+
if (!state().pagesSites.find((s) => s.repository === repo))
|
|
114
|
+
return send(ctx, githubNotFound());
|
|
115
|
+
await put(ctx, { operation: 'pages_site.delete', subjectType: 'pages_site', subjectId: `pages:${repo}`, fields: { deleted: true, repository: repo } });
|
|
116
|
+
return send(ctx, { status: 204, body: undefined });
|
|
117
|
+
};
|
|
118
|
+
const reposGetPagesBuild = (ctx) => (builds((ctx, list) => {
|
|
119
|
+
const id = Number(decodeURIComponent(gh(ctx).seg[5]));
|
|
120
|
+
const b = list.find((x) => x.id === id);
|
|
121
|
+
return send(ctx, b ? { status: 200, body: toGithubPagesBuild(b) } : githubNotFound());
|
|
122
|
+
}))(ctx);
|
|
123
|
+
const reposRequestPagesBuild = async (ctx) => {
|
|
124
|
+
const { repo, occurredAt, state } = gh(ctx);
|
|
125
|
+
if (!state().pagesSites.find((s) => s.repository === repo))
|
|
126
|
+
return send(ctx, githubNotFound());
|
|
127
|
+
const id = nextSeq(ctx, 'pagesbuild');
|
|
128
|
+
await put(ctx, { operation: 'pages_build.create', subjectType: 'pages_build', subjectId: `pagesbuild:${id}`, fields: { repository: repo, status: 'built', commit: createHash('sha256').update(`${repo}:build:${id}`).digest('hex').slice(0, 40), duration: 1000, created_at: occurredAt } });
|
|
129
|
+
return ctx.reply({ status: 'queued', url: `https://api.github.com/repos/${repo}/pages/builds/latest` }, 201);
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
// A workflow's deploy step publishes the artifact its upload step made (actions/deploy-pages): with the run's
|
|
133
|
+
// GITHUB_TOKEN, which needs pages: write, or a person's with write access; pages_build_version and the run's
|
|
134
|
+
// oidc_token are required, and an artifact_id must name an artifact of the repository
|
|
135
|
+
// (docs.github.com/rest/pages/pages#create-a-github-pages-deployment). Where the documentation stops and the twin
|
|
136
|
+
// decides: the deployment succeeds at once and the site is built; the OIDC token is not verified.
|
|
137
|
+
'repos/create-pages-deployment': async (ctx) => {
|
|
138
|
+
const { repo, body, token, caller, occurredAt, state } = gh(ctx);
|
|
139
|
+
const st = state();
|
|
140
|
+
const site = st.pagesSites.find((x) => x.repository === repo);
|
|
141
|
+
if (!site)
|
|
142
|
+
return send(ctx, githubNotFound());
|
|
143
|
+
const grant = token ? ctx.tree().find((r) => r.type === 'app_token' && r.token === token && r.deleted !== true) : undefined;
|
|
144
|
+
if (grant ? grant.permissions?.pages !== 'write' : !canWrite(st, repo, caller ?? GITHUB_WORLD_USER))
|
|
145
|
+
return ctx.reply({ message: 'Resource not accessible by integration', documentation_url: DEPLOY_DOCS }, 403);
|
|
146
|
+
const absent = ['pages_build_version', 'oidc_token'].filter((k) => !body[k]);
|
|
147
|
+
if (body.artifact_id === undefined && body.artifact_url === undefined)
|
|
148
|
+
absent.push('artifact_id');
|
|
149
|
+
if (absent.length)
|
|
150
|
+
return ctx.reply({ message: 'Validation Failed', errors: absent.map((field) => ({ resource: 'PagesDeployment', field, code: 'missing_field' })), documentation_url: DEPLOY_DOCS }, 422);
|
|
151
|
+
if (body.artifact_id !== undefined && !st.actionsArtifacts.some((a) => a.repository === repo && a.id === Number(body.artifact_id)))
|
|
152
|
+
return send(ctx, githubNotFound());
|
|
153
|
+
const number = ctx.tree().filter((r) => r.type === 'pages_deployment' && r.repository === repo).length + 1;
|
|
154
|
+
await put(ctx, { operation: 'pages_deployment.create', subjectType: 'pages_deployment', subjectId: `pagesdeploy:${repo}#${number}`, fields: { repository: repo, number, pages_build_version: String(body.pages_build_version), artifact_id: body.artifact_id ?? null, environment: body.environment ?? 'github-pages', status: 'succeed', created_at: occurredAt } });
|
|
155
|
+
await put(ctx, { operation: 'pages_site.update', subjectType: 'pages_site', subjectId: `pages:${repo}`, fields: { repository: repo, status: 'built', updated_at: occurredAt } });
|
|
156
|
+
const page = site.cname ? `https://${site.cname}/` : `https://${repo.split('/')[0]}.github.io/${repo.split('/')[1]}/`;
|
|
157
|
+
return ctx.reply({ id: number, status_url: `https://api.github.com/repos/${repo}/pages/deployments/${number}`, page_url: page, preview_url: page });
|
|
158
|
+
},
|
|
159
|
+
'repos/get-pages-deployment': reposGetPagesDeployment,
|
|
160
|
+
// a deployment that has finished can no longer be cancelled; the twin's finish at once
|
|
161
|
+
'repos/cancel-pages-deployment': reposCancelPagesDeployment,
|
|
162
|
+
'repos/get-pages': async (ctx) => {
|
|
163
|
+
const { repo, state } = gh(ctx);
|
|
164
|
+
const site = state().pagesSites.find((s) => s.repository === repo);
|
|
165
|
+
return send(ctx, site ? { status: 200, body: siteBody(ctx, site) } : githubNotFound());
|
|
166
|
+
},
|
|
167
|
+
'repos/create-pages-site': async (ctx) => writeSite(ctx, true),
|
|
168
|
+
'repos/update-information-about-pages-site': async (ctx) => writeSite(ctx, false),
|
|
169
|
+
'repos/delete-pages-site': reposDeletePagesSite,
|
|
170
|
+
'repos/list-pages-builds': builds((ctx, list) => ctx.reply(paginate(list, gh(ctx).query).map(toGithubPagesBuild))),
|
|
171
|
+
'repos/get-latest-pages-build': builds((ctx, list) => send(ctx, list.length ? { status: 200, body: toGithubPagesBuild(list[0]) } : githubNotFound())),
|
|
172
|
+
'repos/get-pages-build': reposGetPagesBuild,
|
|
173
|
+
// GitHub queues a build (201); the twin records it built
|
|
174
|
+
'repos/request-pages-build': reposRequestPagesBuild,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { GITHUB_WORLD_USER, githubIssueId, githubNotFound, observedId, paginate, toGithubIssue, toGithubProject, toGithubRest, toSimpleUser, twinUserId, } from "../github-twin.js";
|
|
2
|
+
import { asView, gh, nextSeq, put, send } from "./shared.js";
|
|
3
|
+
const DATA_TYPES = ['text', 'number', 'date', 'single_select'];
|
|
4
|
+
const COLORS = ['BLUE', 'GRAY', 'GREEN', 'ORANGE', 'PINK', 'PURPLE', 'RED', 'YELLOW'];
|
|
5
|
+
const LAYOUTS = ['table', 'board', 'roadmap'];
|
|
6
|
+
const invalid = (ctx, resource, field, code = 'invalid') => ctx.reply({ message: 'Validation Failed', errors: [{ resource, field, code }], documentation_url: 'https://docs.github.com/rest/projects' }, 422);
|
|
7
|
+
/** The project a request names: `/orgs/{org}/…`, `/users/{username}/…`, or a user by id (`/user/{user_id}/…`,
|
|
8
|
+
* `/users/{user_id}/…` on the operations GitHub addresses that way). */
|
|
9
|
+
function projectOf(ctx) {
|
|
10
|
+
const { seg, state } = gh(ctx);
|
|
11
|
+
const byOrg = seg[0] === 'orgs';
|
|
12
|
+
const byId = ctx.call.operation.id === 'projects/create-draft-item-for-authenticated-user' || ctx.call.operation.id === 'projects/create-view-for-user';
|
|
13
|
+
const who = decodeURIComponent(seg[1]);
|
|
14
|
+
const number = Number(decodeURIComponent(seg[3]));
|
|
15
|
+
return state().projects.find((p) => p.number === number && p.owner_type === (byOrg ? 'organization' : 'user')
|
|
16
|
+
&& (byId ? String(twinUserId(p.owner)) === who : p.owner === who));
|
|
17
|
+
}
|
|
18
|
+
function projectUrl(p) {
|
|
19
|
+
return `https://api.github.com/${p.owner_type === 'organization' ? 'orgs' : 'users'}/${p.owner}/projectsV2/${p.number}`;
|
|
20
|
+
}
|
|
21
|
+
function toField(p, f) {
|
|
22
|
+
return {
|
|
23
|
+
id: f.id, node_id: `PVTF_${f.id}`, project_url: projectUrl(p), name: f.name, data_type: f.data_type,
|
|
24
|
+
...(f.options ? { options: f.options.map((o) => ({ id: o.id, name: { raw: o.name, html: o.name }, description: { raw: o.description, html: o.description }, color: o.color })) } : {}),
|
|
25
|
+
created_at: f.created_at ?? null, updated_at: f.updated_at ?? f.created_at ?? null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** What an item tracks: the issue, the pull request, or the draft's own title and body. */
|
|
29
|
+
function contentOf(st, item) {
|
|
30
|
+
if (item.content_type === 'DraftIssue')
|
|
31
|
+
return draftContent(item);
|
|
32
|
+
if (item.content_type === 'PullRequest')
|
|
33
|
+
return pullContent(st, item);
|
|
34
|
+
const iss = st.issues.find((x) => x.repository === item.content_repository && x.number === item.content_number);
|
|
35
|
+
return iss ? asView('issue', toGithubIssue(iss, st.milestones, undefined, st.comments)) : null;
|
|
36
|
+
}
|
|
37
|
+
/** A draft item's content: its own title and body. */
|
|
38
|
+
function draftContent(item) {
|
|
39
|
+
return { id: item.id, node_id: `DI_${item.id}`, title: item.title ?? '', body: item.body ?? null, user: toSimpleUser(item.creator_login ?? GITHUB_WORLD_USER), created_at: item.created_at ?? null, updated_at: item.updated_at ?? null };
|
|
40
|
+
}
|
|
41
|
+
/** A pull request item's content: the pull request. */
|
|
42
|
+
function pullContent(st, item) {
|
|
43
|
+
const pr = st.prs.find((x) => x.is_pull_request && x.repository === item.content_repository && x.number === item.content_number);
|
|
44
|
+
return pr ? asView('pull-request-simple', toGithubRest(pr, st.milestones)) : null;
|
|
45
|
+
}
|
|
46
|
+
function toItem(st, p, item, withFields) {
|
|
47
|
+
const fields = st.projectFields.filter((f) => f.project_id === p.id);
|
|
48
|
+
return {
|
|
49
|
+
id: item.id, node_id: `PVTI_${item.id}`, project_url: projectUrl(p), content_type: item.content_type, content: contentOf(st, item),
|
|
50
|
+
creator: toSimpleUser(item.creator_login ?? GITHUB_WORLD_USER), created_at: item.created_at ?? null, updated_at: item.updated_at ?? item.created_at ?? null,
|
|
51
|
+
archived_at: null, item_url: `${projectUrl(p)}/items/${item.id}`,
|
|
52
|
+
...(withFields ? { fields: fields.filter((f) => item.field_values?.[f.id] !== undefined).map((f) => ({ id: f.id, name: f.name, data_type: f.data_type, value: item.field_values[f.id] })) } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Whether an item passes a view's filter (see the header for the terms read). */
|
|
56
|
+
function passes(st, p, item, filter) {
|
|
57
|
+
const fields = st.projectFields.filter((f) => f.project_id === p.id);
|
|
58
|
+
for (const term of (filter ?? '').split(/\s+/).filter(Boolean)) {
|
|
59
|
+
const [key, want] = [term.slice(0, term.indexOf(':')), term.slice(term.indexOf(':') + 1)];
|
|
60
|
+
if (key === 'is') {
|
|
61
|
+
const kind = { issue: 'Issue', pr: 'PullRequest', draft: 'DraftIssue' }[want];
|
|
62
|
+
if (kind && item.content_type !== kind)
|
|
63
|
+
return false;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const field = fields.find((f) => f.name.toLowerCase() === key.toLowerCase());
|
|
67
|
+
if (!field)
|
|
68
|
+
continue;
|
|
69
|
+
const value = item.field_values?.[field.id];
|
|
70
|
+
const shown = field.data_type === 'single_select' ? field.options?.find((o) => o.id === value)?.name : value;
|
|
71
|
+
if (String(shown ?? '').toLowerCase() !== want.toLowerCase())
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
/** Operations on a project that exists, or GitHub's Not Found. */
|
|
77
|
+
const onProject = (run) => async (ctx) => {
|
|
78
|
+
const p = projectOf(ctx);
|
|
79
|
+
return p ? run(ctx, p) : send(ctx, githubNotFound());
|
|
80
|
+
};
|
|
81
|
+
const itemOf = (ctx, p) => gh(ctx).state().projectItems.find((i) => i.project_id === p.id && i.id === Number(decodeURIComponent(gh(ctx).seg[5])));
|
|
82
|
+
const listForOwner = (type) => async (ctx) => {
|
|
83
|
+
const { seg, query, state } = gh(ctx);
|
|
84
|
+
const owner = decodeURIComponent(seg[1]);
|
|
85
|
+
return ctx.reply(paginate(state().projects.filter((p) => p.owner === owner && p.owner_type === type), query).map(toGithubProject));
|
|
86
|
+
};
|
|
87
|
+
const createDraft = (ctx) => onProject(async (ctx, p) => {
|
|
88
|
+
const { body, occurredAt, caller } = gh(ctx);
|
|
89
|
+
if (typeof body.title !== 'string' || !body.title)
|
|
90
|
+
return invalid(ctx, 'ProjectV2Item', 'title', 'missing_field');
|
|
91
|
+
const id = nextSeq(ctx, 'projectitem');
|
|
92
|
+
await put(ctx, { operation: 'project_item.create', subjectType: 'project_item', subjectId: `projectitem:${id}`, fields: { project_id: p.id, content_type: 'DraftIssue', title: body.title, body: body.body ?? null, field_values: {}, creator_login: caller ?? GITHUB_WORLD_USER, created_at: occurredAt, updated_at: occurredAt } });
|
|
93
|
+
const st = gh(ctx).state();
|
|
94
|
+
return ctx.reply(toItem(st, p, st.projectItems.find((i) => i.id === id), false), 201);
|
|
95
|
+
})(ctx);
|
|
96
|
+
/** A single-select field's options: at least one, each named, with a colour of GitHub's when given. */
|
|
97
|
+
const validOptions = (raw) => Array.isArray(raw) && raw.length > 0 && !raw.some((o) => typeof o?.name !== 'string' || (o.color !== undefined && !COLORS.includes(String(o.color))));
|
|
98
|
+
const addField = onProject(async (ctx, p) => {
|
|
99
|
+
const { body, occurredAt, state } = gh(ctx);
|
|
100
|
+
if (typeof body.name !== 'string' || !body.name)
|
|
101
|
+
return invalid(ctx, 'ProjectV2Field', 'name', 'missing_field');
|
|
102
|
+
if (!DATA_TYPES.includes(String(body.data_type)))
|
|
103
|
+
return invalid(ctx, 'ProjectV2Field', 'data_type');
|
|
104
|
+
if (state().projectFields.some((f) => f.project_id === p.id && f.name.toLowerCase() === String(body.name).toLowerCase()))
|
|
105
|
+
return invalid(ctx, 'ProjectV2Field', 'name', 'already_exists');
|
|
106
|
+
const raw = body.single_select_options;
|
|
107
|
+
if (body.data_type === 'single_select' && !validOptions(raw))
|
|
108
|
+
return invalid(ctx, 'ProjectV2Field', 'single_select_options');
|
|
109
|
+
const id = nextSeq(ctx, 'projectfield');
|
|
110
|
+
const options = body.data_type === 'single_select' ? raw.map((o, i) => ({ id: (observedId(`projectfield:${id}:option:${i}`) >>> 0).toString(16).slice(0, 8), name: String(o.name), description: String(o.description ?? ''), color: String(o.color ?? 'GRAY') })) : undefined;
|
|
111
|
+
await put(ctx, { operation: 'project_field.create', subjectType: 'project_field', subjectId: `projectfield:${id}`, fields: { project_id: p.id, name: body.name, data_type: body.data_type, ...(options ? { options } : {}), created_at: occurredAt, updated_at: occurredAt } });
|
|
112
|
+
return ctx.reply(toField(p, gh(ctx).state().projectFields.find((f) => f.id === id)), 201);
|
|
113
|
+
});
|
|
114
|
+
const addItem = onProject(async (ctx, p) => {
|
|
115
|
+
const { body, occurredAt, caller, state } = gh(ctx);
|
|
116
|
+
if (body.type !== 'Issue' && body.type !== 'PullRequest')
|
|
117
|
+
return invalid(ctx, 'ProjectV2Item', 'type');
|
|
118
|
+
const st = state();
|
|
119
|
+
// by its id, or by its repository and number
|
|
120
|
+
const target = body.type === 'Issue'
|
|
121
|
+
? st.issues.find((i) => (body.id !== undefined ? githubIssueId(i.born_in ?? i.repository, i.number) === Number(body.id) : i.repository === `${String(body.owner)}/${String(body.repo)}` && i.number === Number(body.number)))
|
|
122
|
+
: st.prs.find((x) => x.is_pull_request && (body.id !== undefined ? observedId(`pull_request:${x.born_in ?? x.repository}#${x.number}`) === Number(body.id) : x.repository === `${String(body.owner)}/${String(body.repo)}` && x.number === Number(body.number)));
|
|
123
|
+
if (!target)
|
|
124
|
+
return send(ctx, githubNotFound());
|
|
125
|
+
const already = st.projectItems.find((i) => i.project_id === p.id && i.content_type === body.type && i.content_repository === target.repository && i.content_number === target.number);
|
|
126
|
+
if (already)
|
|
127
|
+
return ctx.reply(toItem(st, p, already, false), 201);
|
|
128
|
+
const id = nextSeq(ctx, 'projectitem');
|
|
129
|
+
await put(ctx, { operation: 'project_item.create', subjectType: 'project_item', subjectId: `projectitem:${id}`, fields: { project_id: p.id, content_type: body.type, content_repository: target.repository, content_number: target.number, field_values: {}, creator_login: caller ?? GITHUB_WORLD_USER, created_at: occurredAt, updated_at: occurredAt } });
|
|
130
|
+
const now = state();
|
|
131
|
+
return ctx.reply(toItem(now, p, now.projectItems.find((i) => i.id === id), false), 201);
|
|
132
|
+
});
|
|
133
|
+
// A field's value: text as given, a number, a date (YYYY-MM-DD), a single-select option's id; null clears it.
|
|
134
|
+
const updateItem = onProject(async (ctx, p) => {
|
|
135
|
+
const { body, occurredAt, state } = gh(ctx);
|
|
136
|
+
const item = itemOf(ctx, p);
|
|
137
|
+
if (!item)
|
|
138
|
+
return send(ctx, githubNotFound());
|
|
139
|
+
if (!Array.isArray(body.fields) || !body.fields.length)
|
|
140
|
+
return invalid(ctx, 'ProjectV2Item', 'fields', 'missing_field');
|
|
141
|
+
const fields = state().projectFields.filter((f) => f.project_id === p.id);
|
|
142
|
+
const values = { ...item.field_values };
|
|
143
|
+
for (const change of body.fields) {
|
|
144
|
+
const field = fields.find((f) => f.id === Number(change.id));
|
|
145
|
+
if (!field)
|
|
146
|
+
return invalid(ctx, 'ProjectV2ItemFieldValue', 'id');
|
|
147
|
+
const v = change.value;
|
|
148
|
+
const ok = v === null
|
|
149
|
+
|| (field.data_type === 'text' && typeof v === 'string')
|
|
150
|
+
|| (field.data_type === 'number' && typeof v === 'number')
|
|
151
|
+
|| (field.data_type === 'date' && typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v))
|
|
152
|
+
|| (field.data_type === 'single_select' && field.options?.some((o) => o.id === v));
|
|
153
|
+
if (!ok)
|
|
154
|
+
return invalid(ctx, 'ProjectV2ItemFieldValue', 'value');
|
|
155
|
+
if (v === null)
|
|
156
|
+
delete values[field.id];
|
|
157
|
+
else
|
|
158
|
+
values[field.id] = v;
|
|
159
|
+
}
|
|
160
|
+
await put(ctx, { operation: 'project_item.update', subjectType: 'project_item', subjectId: `projectitem:${item.id}`, fields: { field_values: values, updated_at: occurredAt } });
|
|
161
|
+
const st = state();
|
|
162
|
+
return ctx.reply(toItem(st, p, st.projectItems.find((i) => i.id === item.id), true));
|
|
163
|
+
});
|
|
164
|
+
const createView = (ctx) => onProject(async (ctx, p) => {
|
|
165
|
+
const { body, occurredAt, caller, state } = gh(ctx);
|
|
166
|
+
if (typeof body.name !== 'string' || !body.name)
|
|
167
|
+
return invalid(ctx, 'ProjectV2View', 'name', 'missing_field');
|
|
168
|
+
if (!LAYOUTS.includes(String(body.layout)))
|
|
169
|
+
return invalid(ctx, 'ProjectV2View', 'layout');
|
|
170
|
+
const number = Math.max(0, ...state().projectViews.filter((v) => v.project_id === p.id).map((v) => v.number)) + 1;
|
|
171
|
+
const id = nextSeq(ctx, 'projectview');
|
|
172
|
+
const list = (k) => (Array.isArray(body[k]) ? body[k] : []);
|
|
173
|
+
await put(ctx, { operation: 'project_view.create', subjectType: 'project_view', subjectId: `projectview:${id}`, fields: { project_id: p.id, number, name: body.name, layout: body.layout, filter: typeof body.filter === 'string' ? body.filter : null, visible_fields: list('visible_fields'), sort_by: list('sort_by'), group_by: list('group_by'), vertical_group_by: list('vertical_group_by'), creator_login: caller ?? GITHUB_WORLD_USER, created_at: occurredAt, updated_at: occurredAt } });
|
|
174
|
+
const v = state().projectViews.find((x) => x.id === id);
|
|
175
|
+
return ctx.reply({
|
|
176
|
+
id: v.id, number: v.number, name: v.name, layout: v.layout, node_id: `PVTV_${v.id}`, project_url: projectUrl(p),
|
|
177
|
+
html_url: `https://github.com/${p.owner_type === 'organization' ? 'orgs' : 'users'}/${p.owner}/projects/${p.number}/views/${v.number}`,
|
|
178
|
+
creator: toSimpleUser(v.creator_login ?? GITHUB_WORLD_USER), created_at: v.created_at ?? null, updated_at: v.updated_at ?? null,
|
|
179
|
+
filter: v.filter ?? null, visible_fields: v.visible_fields ?? [], sort_by: v.sort_by ?? [], group_by: v.group_by ?? [], vertical_group_by: v.vertical_group_by ?? [],
|
|
180
|
+
}, 201);
|
|
181
|
+
})(ctx);
|
|
182
|
+
export function projectsSemantics() {
|
|
183
|
+
const listFields = onProject(async (ctx, p) => ctx.reply(paginate(gh(ctx).state().projectFields.filter((f) => f.project_id === p.id), gh(ctx).query).map((f) => toField(p, f))));
|
|
184
|
+
const getField = (ctx) => onProject(async (ctx, p) => {
|
|
185
|
+
const f = gh(ctx).state().projectFields.find((x) => x.project_id === p.id && x.id === Number(decodeURIComponent(gh(ctx).seg[5])));
|
|
186
|
+
return f ? ctx.reply(toField(p, f)) : send(ctx, githubNotFound());
|
|
187
|
+
})(ctx);
|
|
188
|
+
const listItems = (ctx) => onProject(async (ctx, p) => {
|
|
189
|
+
const st = gh(ctx).state();
|
|
190
|
+
return ctx.reply(paginate(st.projectItems.filter((i) => i.project_id === p.id), gh(ctx).query).map((i) => toItem(st, p, i, true)));
|
|
191
|
+
})(ctx);
|
|
192
|
+
const getItem = (ctx) => onProject(async (ctx, p) => {
|
|
193
|
+
const item = itemOf(ctx, p);
|
|
194
|
+
return item ? ctx.reply(toItem(gh(ctx).state(), p, item, true)) : send(ctx, githubNotFound());
|
|
195
|
+
})(ctx);
|
|
196
|
+
const deleteItem = (ctx) => onProject(async (ctx, p) => {
|
|
197
|
+
const item = itemOf(ctx, p);
|
|
198
|
+
if (!item)
|
|
199
|
+
return send(ctx, githubNotFound());
|
|
200
|
+
await put(ctx, { operation: 'project_item.delete', subjectType: 'project_item', subjectId: `projectitem:${item.id}`, fields: { deleted: true, project_id: p.id } });
|
|
201
|
+
return send(ctx, { status: 204, body: undefined });
|
|
202
|
+
})(ctx);
|
|
203
|
+
const listViewItems = (ctx) => onProject(async (ctx, p) => {
|
|
204
|
+
const st = gh(ctx).state();
|
|
205
|
+
const view = st.projectViews.find((v) => v.project_id === p.id && v.number === Number(decodeURIComponent(gh(ctx).seg[5])));
|
|
206
|
+
if (!view)
|
|
207
|
+
return send(ctx, githubNotFound());
|
|
208
|
+
return ctx.reply(paginate(st.projectItems.filter((i) => i.project_id === p.id && passes(st, p, i, view.filter)), gh(ctx).query).map((i) => toItem(st, p, i, true)));
|
|
209
|
+
})(ctx);
|
|
210
|
+
const get = onProject(async (ctx, p) => ctx.reply(toGithubProject(p)));
|
|
211
|
+
return {
|
|
212
|
+
'projects/list-for-org': listForOwner('organization'),
|
|
213
|
+
'projects/list-for-user': listForOwner('user'),
|
|
214
|
+
'projects/get-for-org': get,
|
|
215
|
+
'projects/get-for-user': get,
|
|
216
|
+
'projects/create-draft-item-for-org': createDraft,
|
|
217
|
+
'projects/create-draft-item-for-authenticated-user': createDraft,
|
|
218
|
+
'projects/list-fields-for-org': listFields,
|
|
219
|
+
'projects/list-fields-for-user': listFields,
|
|
220
|
+
'projects/add-field-for-org': addField,
|
|
221
|
+
'projects/add-field-for-user': addField,
|
|
222
|
+
'projects/get-field-for-org': getField,
|
|
223
|
+
'projects/get-field-for-user': getField,
|
|
224
|
+
'projects/list-items-for-org': listItems,
|
|
225
|
+
'projects/list-items-for-user': listItems,
|
|
226
|
+
'projects/add-item-for-org': addItem,
|
|
227
|
+
'projects/add-item-for-user': addItem,
|
|
228
|
+
'projects/get-org-item': getItem,
|
|
229
|
+
'projects/get-user-item': getItem,
|
|
230
|
+
'projects/update-item-for-org': updateItem,
|
|
231
|
+
'projects/update-item-for-user': updateItem,
|
|
232
|
+
'projects/delete-item-for-org': deleteItem,
|
|
233
|
+
'projects/delete-item-for-user': deleteItem,
|
|
234
|
+
'projects/create-view-for-org': createView,
|
|
235
|
+
'projects/create-view-for-user': createView,
|
|
236
|
+
'projects/list-view-items-for-org': listViewItems,
|
|
237
|
+
'projects/list-view-items-for-user': listViewItems,
|
|
238
|
+
};
|
|
239
|
+
}
|