@volter/twin-github 0.1.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -36
- package/client/github-mirror.css +277 -319
- package/client/github-mirror.d.ts +418 -0
- package/client/github-mirror.js +485 -0
- package/client/github-mirror.tsx +153 -357
- package/client/pulls-rest.ts +159 -0
- package/client/pulls-workspace.tsx +841 -0
- package/dist/client/github-mirror.bundle.js +239 -0
- package/dist/client/github-mirror.css +916 -0
- package/dist/client/github-mirror.d.ts +418 -0
- package/dist/client/github-mirror.js +485 -0
- package/dist/client/github-mirror.tsx +1315 -0
- package/dist/client/pulls-rest.d.ts +42 -0
- package/dist/client/pulls-rest.js +140 -0
- package/dist/client/pulls-rest.ts +159 -0
- package/dist/client/pulls-workspace.bundle.js +22 -0
- package/dist/client/pulls-workspace.d.ts +114 -0
- package/dist/client/pulls-workspace.js +418 -0
- package/dist/client/pulls-workspace.tsx +841 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/generated/graphql-sdl.gen.json +1 -0
- package/dist/src/generated/graphql.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/github-budget.d.ts +69 -0
- package/dist/src/github-budget.js +172 -0
- package/dist/src/github-capabilities.d.ts +5 -0
- package/dist/src/github-capabilities.js +4468 -0
- package/dist/src/github-conformance.d.ts +43 -0
- package/dist/src/github-conformance.js +76 -0
- package/dist/src/github-connector.d.ts +307 -0
- package/dist/src/github-connector.js +1398 -0
- package/dist/src/github-events.d.ts +41 -0
- package/dist/src/github-events.js +232 -0
- package/dist/src/github-git-http.d.ts +49 -0
- package/dist/src/github-git-http.js +185 -0
- package/dist/src/github-git-plane.d.ts +114 -0
- package/dist/src/github-git-plane.js +407 -0
- package/dist/src/github-mirror-state.d.ts +2 -0
- package/dist/src/github-mirror-state.js +335 -0
- package/dist/src/github-mirror-ui.d.ts +20 -0
- package/dist/src/github-mirror-ui.js +101 -0
- package/dist/src/github-server.d.ts +14 -0
- package/dist/src/github-server.js +240 -0
- package/dist/src/github-shared.d.ts +12 -0
- package/dist/src/github-shared.js +21 -0
- package/dist/src/github-twin.d.ts +1411 -0
- package/dist/src/github-twin.js +4084 -0
- package/dist/src/github-ui-conformance.d.ts +4 -0
- package/dist/src/github-ui-conformance.js +105 -0
- package/dist/src/github-ui-structure.d.ts +18 -0
- package/dist/src/github-ui-structure.js +251 -0
- package/dist/src/graphql-wire.d.ts +22 -0
- package/dist/src/graphql-wire.js +89 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +104 -0
- package/dist/src/manifest.d.ts +8 -0
- package/dist/src/manifest.js +597 -0
- package/dist/src/npm-registry.d.ts +7 -0
- package/dist/src/npm-registry.js +47 -0
- package/dist/src/screens/app-installation.d.ts +3 -0
- package/dist/src/screens/app-installation.js +166 -0
- package/dist/src/screens/app-manifest.d.ts +3 -0
- package/dist/src/screens/app-manifest.js +81 -0
- package/dist/src/screens/oauth.d.ts +15 -0
- package/dist/src/screens/oauth.js +257 -0
- package/dist/src/screens/session.d.ts +8 -0
- package/dist/src/screens/session.js +170 -0
- package/dist/src/semantics/actions.d.ts +2 -0
- package/dist/src/semantics/actions.js +413 -0
- package/dist/src/semantics/activity.d.ts +4 -0
- package/dist/src/semantics/activity.js +161 -0
- package/dist/src/semantics/apps.d.ts +2 -0
- package/dist/src/semantics/apps.js +144 -0
- package/dist/src/semantics/branches.d.ts +2 -0
- package/dist/src/semantics/branches.js +136 -0
- package/dist/src/semantics/checks.d.ts +2 -0
- package/dist/src/semantics/checks.js +176 -0
- package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
- package/dist/src/semantics/code-scanning-upload.js +97 -0
- package/dist/src/semantics/codespaces.d.ts +2 -0
- package/dist/src/semantics/codespaces.js +58 -0
- package/dist/src/semantics/commits.d.ts +2 -0
- package/dist/src/semantics/commits.js +109 -0
- package/dist/src/semantics/contents.d.ts +2 -0
- package/dist/src/semantics/contents.js +131 -0
- package/dist/src/semantics/deployments.d.ts +2 -0
- package/dist/src/semantics/deployments.js +127 -0
- package/dist/src/semantics/gists.d.ts +2 -0
- package/dist/src/semantics/gists.js +86 -0
- package/dist/src/semantics/git.d.ts +2 -0
- package/dist/src/semantics/git.js +235 -0
- package/dist/src/semantics/graphql.d.ts +7 -0
- package/dist/src/semantics/graphql.js +512 -0
- package/dist/src/semantics/index.d.ts +5 -0
- package/dist/src/semantics/index.js +62 -0
- package/dist/src/semantics/issues.d.ts +2 -0
- package/dist/src/semantics/issues.js +456 -0
- package/dist/src/semantics/keys.d.ts +2 -0
- package/dist/src/semantics/keys.js +66 -0
- package/dist/src/semantics/labels.d.ts +2 -0
- package/dist/src/semantics/labels.js +100 -0
- package/dist/src/semantics/meta.d.ts +12 -0
- package/dist/src/semantics/meta.js +144 -0
- package/dist/src/semantics/notifications.d.ts +4 -0
- package/dist/src/semantics/notifications.js +62 -0
- package/dist/src/semantics/orgs.d.ts +2 -0
- package/dist/src/semantics/orgs.js +420 -0
- package/dist/src/semantics/packages.d.ts +2 -0
- package/dist/src/semantics/packages.js +55 -0
- package/dist/src/semantics/pages.d.ts +2 -0
- package/dist/src/semantics/pages.js +176 -0
- package/dist/src/semantics/projects.d.ts +2 -0
- package/dist/src/semantics/projects.js +239 -0
- package/dist/src/semantics/pulls.d.ts +2 -0
- package/dist/src/semantics/pulls.js +462 -0
- package/dist/src/semantics/push-reactions.d.ts +101 -0
- package/dist/src/semantics/push-reactions.js +513 -0
- package/dist/src/semantics/releases.d.ts +19 -0
- package/dist/src/semantics/releases.js +231 -0
- package/dist/src/semantics/repo-invitations.d.ts +2 -0
- package/dist/src/semantics/repo-invitations.js +98 -0
- package/dist/src/semantics/repos.d.ts +17 -0
- package/dist/src/semantics/repos.js +390 -0
- package/dist/src/semantics/rulesets.d.ts +2 -0
- package/dist/src/semantics/rulesets.js +111 -0
- package/dist/src/semantics/search.d.ts +2 -0
- package/dist/src/semantics/search.js +84 -0
- package/dist/src/semantics/security.d.ts +2 -0
- package/dist/src/semantics/security.js +177 -0
- package/dist/src/semantics/shared.d.ts +61 -0
- package/dist/src/semantics/shared.js +151 -0
- package/dist/src/semantics/users.d.ts +2 -0
- package/dist/src/semantics/users.js +255 -0
- package/dist/src/semantics/webhooks.d.ts +2 -0
- package/dist/src/semantics/webhooks.js +107 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
- package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
- package/dist/test-fixtures/github-comment-schema.json +702 -0
- package/dist/test-fixtures/github-known-deviations.json +94 -0
- package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
- package/dist/test-fixtures/github-openapi-operations.json +362 -0
- package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
- package/dist/test-fixtures/github-pull-schema.json +3601 -0
- package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
- package/dist/test-fixtures/github-review-schema.json +236 -0
- package/package.json +20 -11
- package/src/cli.ts +7 -6
- package/src/generated/graphql-sdl.gen.json +1 -0
- package/src/generated/graphql.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/github-a11y-snapshot.uitest.ts +5 -5
- package/src/github-budget.ts +4 -4
- package/src/github-capabilities.ts +1993 -556
- package/src/github-conformance.ts +12 -7
- package/src/github-connector.ts +108 -96
- package/src/github-events.ts +225 -95
- package/src/github-git-http.ts +48 -84
- package/src/github-git-plane.ts +247 -385
- package/src/github-journey.uitest.ts +28 -44
- package/src/github-mirror-state.ts +23 -61
- package/src/github-mirror-ui.ts +16 -10
- package/src/github-server.ts +158 -60
- package/src/github-shared.ts +1 -1
- package/src/github-twin.ts +1525 -4494
- package/src/github-ui-conformance.ts +7 -8
- package/src/github-ui-structure.ts +19 -18
- package/src/graphql-wire.ts +115 -0
- package/src/index.ts +50 -7
- package/src/manifest.ts +605 -0
- package/src/npm-registry.ts +43 -0
- package/src/screens/app-installation.tsx +217 -0
- package/src/screens/app-manifest.tsx +97 -0
- package/src/screens/oauth.tsx +258 -0
- package/src/screens/session.tsx +193 -0
- package/src/semantics/actions.ts +395 -0
- package/src/semantics/activity.ts +171 -0
- package/src/semantics/apps.ts +133 -0
- package/src/semantics/branches.ts +133 -0
- package/src/semantics/checks.ts +163 -0
- package/src/semantics/code-scanning-upload.ts +92 -0
- package/src/semantics/codespaces.ts +58 -0
- package/src/semantics/commits.ts +109 -0
- package/src/semantics/contents.ts +112 -0
- package/src/semantics/deployments.ts +116 -0
- package/src/semantics/gists.ts +85 -0
- package/src/semantics/git.ts +226 -0
- package/src/semantics/graphql.ts +505 -0
- package/src/semantics/index.ts +68 -0
- package/src/semantics/issues.ts +434 -0
- package/src/semantics/keys.ts +66 -0
- package/src/semantics/labels.ts +91 -0
- package/src/semantics/meta.ts +139 -0
- package/src/semantics/notifications.ts +58 -0
- package/src/semantics/orgs.ts +383 -0
- package/src/semantics/packages.ts +59 -0
- package/src/semantics/pages.ts +154 -0
- package/src/semantics/projects.ts +246 -0
- package/src/semantics/pulls.ts +421 -0
- package/src/semantics/push-reactions.ts +471 -0
- package/src/semantics/releases.ts +213 -0
- package/src/semantics/repo-invitations.ts +112 -0
- package/src/semantics/repos.ts +373 -0
- package/src/semantics/rulesets.ts +107 -0
- package/src/semantics/search.ts +83 -0
- package/src/semantics/security.ts +153 -0
- package/src/semantics/shared.ts +181 -0
- package/src/semantics/users.ts +252 -0
- package/src/semantics/webhooks.ts +101 -0
- package/test-fixtures/github-known-deviations.json +7 -8
- package/test-fixtures/github-openapi-operations.json +46 -227
- package/src/github-graphql.ts +0 -398
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// A repository's releases, their assets (metadata only: the bytes are out of scope) and its tags. Publishing a
|
|
2
|
+
// release whose tag does not exist yet creates the tag on its target (docs.github.com/rest/releases/releases#create-a-
|
|
3
|
+
// release); a draft makes none until it is published, and deleting a release keeps its tag. Creating, editing,
|
|
4
|
+
// publishing and deleting a release, and uploading an asset, announce the release webhook.
|
|
5
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
|
+
import { Buffer } from 'node:buffer';
|
|
7
|
+
import { GITHUB_WORLD_USER, githubNotFound, hasBareRepo, listGitPlaneRefs, paginate, planeAncestors, readGitTagFromPlane, resolvePlaneRef, toGithubRelease, toGithubReleaseAsset, toGithubTag, updateGitRefInPlane } from "../github-twin.js";
|
|
8
|
+
import { announce, gh, nextSeq, put, send } from "./shared.js";
|
|
9
|
+
const releaseOf = (ctx) => {
|
|
10
|
+
const { repo, seg, state } = gh(ctx);
|
|
11
|
+
const id = Number(decodeURIComponent(seg[4]));
|
|
12
|
+
const st = state();
|
|
13
|
+
return { id, st, rel: st.releases.find((r) => r.repository === repo && r.id === id) };
|
|
14
|
+
};
|
|
15
|
+
/** A published release's tag, made on its target (a branch or a commit) when the repository has history and no such tag. */
|
|
16
|
+
async function tagOnPublish(ctx, tag, target) {
|
|
17
|
+
const { repo, root, state } = gh(ctx);
|
|
18
|
+
if (!hasBareRepo(repo, root))
|
|
19
|
+
return;
|
|
20
|
+
const ref = `refs/tags/${tag}`;
|
|
21
|
+
if (!(await listGitPlaneRefs(repo, root)).some((r) => r.ref === ref))
|
|
22
|
+
await tagTarget(ctx, ref, target);
|
|
23
|
+
}
|
|
24
|
+
/** A release's tag, made on its target, as publishing the release makes it when the tag does not exist yet. */
|
|
25
|
+
async function tagTarget(ctx, ref, target) {
|
|
26
|
+
const { repo, root, state } = gh(ctx);
|
|
27
|
+
const sha = await resolvePlaneRef(repo, target, state().repos.find((r) => r.full_name === repo)?.default_branch ?? 'main', root);
|
|
28
|
+
if (!sha || !(await updateGitRefInPlane(repo, ref, sha, root, null)))
|
|
29
|
+
return;
|
|
30
|
+
await put(ctx, { operation: 'git_ref.create', subjectType: 'git_ref', subjectId: `gitref:${repo}#${ref}`, fields: { repository: repo, ref, object_sha: sha, object_type: 'commit' } });
|
|
31
|
+
}
|
|
32
|
+
/** GitHub's generated release notes for `tag` (docs.github.com/repositories/releasing-projects-on-github/automatically-
|
|
33
|
+
* generated-release-notes, docs.github.com/rest/releases/releases#generate-release-notes-content-for-a-release): the
|
|
34
|
+
* pull requests merged since the previous release, each with its author, the contributors whose first merged pull
|
|
35
|
+
* request is among them, and the full changelog's link. The previous release is the latest published one other than
|
|
36
|
+
* this tag's, unless `previous` names a tag. Where the documentation stops and the twin decides: a repository's
|
|
37
|
+
* release.yml categories are not read. */
|
|
38
|
+
export async function generatedNotes(ctx, tag, target, previous) {
|
|
39
|
+
const { repo, root, state } = gh(ctx);
|
|
40
|
+
const st = state();
|
|
41
|
+
const defaultBranch = st.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main';
|
|
42
|
+
const head = (await resolvePlaneRef(repo, tag, defaultBranch, root)) ?? (await resolvePlaneRef(repo, target ?? defaultBranch, defaultBranch, root));
|
|
43
|
+
const prevTag = previous ?? st.releases.filter((r) => r.repository === repo && !r.draft && r.tag_name !== tag && r.published_at).sort((a, b) => String(b.published_at).localeCompare(String(a.published_at)) || b.id - a.id)[0]?.tag_name;
|
|
44
|
+
const prevSha = prevTag ? await resolvePlaneRef(repo, prevTag, defaultBranch, root) : null;
|
|
45
|
+
const reach = head ? await planeAncestors(repo, head, root) : new Map();
|
|
46
|
+
const before = prevSha ? await planeAncestors(repo, prevSha, root) : new Map();
|
|
47
|
+
const merged = st.prs.filter((p) => p.repository === repo && p.is_pull_request && p.merged && p.merge_commit_sha).sort((a, b) => a.number - b.number);
|
|
48
|
+
const inRange = merged.filter((p) => reach.has(p.merge_commit_sha) && !before.has(p.merge_commit_sha));
|
|
49
|
+
const url = `https://github.com/${repo}`;
|
|
50
|
+
const lines = [];
|
|
51
|
+
if (inRange.length)
|
|
52
|
+
lines.push("## What's Changed", ...inRange.map((p) => `* ${p.title ?? ''} by @${p.user_login ?? 'ghost'} in ${url}/pull/${p.number}`), '');
|
|
53
|
+
const firsts = inRange.filter((p) => p.user_login && merged.find((m) => m.user_login === p.user_login) === p);
|
|
54
|
+
if (firsts.length)
|
|
55
|
+
lines.push('## New Contributors', ...firsts.map((p) => `* @${p.user_login} made their first contribution in ${url}/pull/${p.number}`), '');
|
|
56
|
+
lines.push(`**Full Changelog**: ${prevTag ? `${url}/compare/${prevTag}...${tag}` : `${url}/commits/${tag}`}`);
|
|
57
|
+
return { name: tag, body: lines.join('\n') };
|
|
58
|
+
}
|
|
59
|
+
/** A release asset's upload as it arrived: its raw bytes and their media type, which the server hands the upload
|
|
60
|
+
* operation (github-server.ts) since GitHub reads this body as the file, not as JSON. */
|
|
61
|
+
export const assetUploads = new AsyncLocalStorage();
|
|
62
|
+
export function releasesSemantics() {
|
|
63
|
+
const reposListReleases = async (ctx) => {
|
|
64
|
+
const { repo, query, state } = gh(ctx);
|
|
65
|
+
const st = state();
|
|
66
|
+
return ctx.reply(paginate(st.releases.filter((r) => r.repository === repo), query).map((r) => toGithubRelease(r, st.releaseAssets)));
|
|
67
|
+
};
|
|
68
|
+
const reposGetRelease = async (ctx) => {
|
|
69
|
+
const { st, rel } = releaseOf(ctx);
|
|
70
|
+
return send(ctx, rel ? { status: 200, body: toGithubRelease(rel, st.releaseAssets) } : githubNotFound());
|
|
71
|
+
};
|
|
72
|
+
const reposGetLatestRelease = async (ctx) => {
|
|
73
|
+
const { repo, state } = gh(ctx);
|
|
74
|
+
const st = state();
|
|
75
|
+
const latest = st.releases.filter((r) => r.repository === repo && !r.draft && !r.prerelease).sort((a, b) => b.id - a.id)[0];
|
|
76
|
+
return send(ctx, latest ? { status: 200, body: toGithubRelease(latest, st.releaseAssets) } : githubNotFound());
|
|
77
|
+
};
|
|
78
|
+
const reposGetReleaseByTag = async (ctx) => {
|
|
79
|
+
const { repo, seg, state } = gh(ctx);
|
|
80
|
+
const st = state();
|
|
81
|
+
const tag = decodeURIComponent(seg.slice(5).join('/'));
|
|
82
|
+
const rel = st.releases.find((r) => r.repository === repo && r.tag_name === tag);
|
|
83
|
+
return send(ctx, rel ? { status: 200, body: toGithubRelease(rel, st.releaseAssets) } : githubNotFound());
|
|
84
|
+
};
|
|
85
|
+
const reposGetReleaseAsset = async (ctx) => {
|
|
86
|
+
const { repo, seg, state } = gh(ctx);
|
|
87
|
+
const id = Number(decodeURIComponent(seg[5]));
|
|
88
|
+
const asset = state().releaseAssets.find((a) => a.repository === repo && a.id === id);
|
|
89
|
+
return send(ctx, asset ? { status: 200, body: toGithubReleaseAsset(asset) } : githubNotFound());
|
|
90
|
+
};
|
|
91
|
+
const reposListReleaseAssets = async (ctx) => {
|
|
92
|
+
const { id, st, rel } = releaseOf(ctx);
|
|
93
|
+
if (!rel)
|
|
94
|
+
return send(ctx, githubNotFound());
|
|
95
|
+
const { repo, query } = gh(ctx);
|
|
96
|
+
return ctx.reply(paginate(st.releaseAssets.filter((a) => a.repository === repo && a.release_id === id), query).map(toGithubReleaseAsset));
|
|
97
|
+
};
|
|
98
|
+
const reposListTags = async (ctx) => {
|
|
99
|
+
const { repo, query, root, state } = gh(ctx);
|
|
100
|
+
if (!hasBareRepo(repo, root))
|
|
101
|
+
return ctx.reply(paginate(state().tags.filter((t) => t.repository === repo), query).map(toGithubTag));
|
|
102
|
+
const tags = [];
|
|
103
|
+
for (const r of await listGitPlaneRefs(repo, root)) {
|
|
104
|
+
if (!r.ref.startsWith('refs/tags/'))
|
|
105
|
+
continue;
|
|
106
|
+
const commit = r.object_type === 'tag' ? ((await readGitTagFromPlane(repo, r.object_sha, root))?.object_sha ?? r.object_sha) : r.object_sha;
|
|
107
|
+
tags.push({ repository: repo, name: r.ref.slice('refs/tags/'.length), commit_sha: commit });
|
|
108
|
+
}
|
|
109
|
+
tags.sort((a, b) => b.name.localeCompare(a.name, undefined, { numeric: true }));
|
|
110
|
+
return ctx.reply(paginate(tags, query).map(toGithubTag));
|
|
111
|
+
};
|
|
112
|
+
const reposGenerateReleaseNotes = async (ctx) => {
|
|
113
|
+
const { repo, body, root, state } = gh(ctx);
|
|
114
|
+
if (!body.tag_name)
|
|
115
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Release', field: 'tag_name', code: 'missing_field' }] }, 422);
|
|
116
|
+
if (body.previous_tag_name !== undefined && !(await resolvePlaneRef(repo, String(body.previous_tag_name), state().repos.find((r) => r.full_name === repo)?.default_branch ?? 'main', root)))
|
|
117
|
+
return send(ctx, githubNotFound());
|
|
118
|
+
return ctx.reply(await generatedNotes(ctx, String(body.tag_name), body.target_commitish, body.previous_tag_name === undefined ? undefined : String(body.previous_tag_name)));
|
|
119
|
+
};
|
|
120
|
+
const reposUpdateRelease = async (ctx) => {
|
|
121
|
+
const { id, rel: existing } = releaseOf(ctx);
|
|
122
|
+
if (!existing)
|
|
123
|
+
return send(ctx, githubNotFound());
|
|
124
|
+
const { repo, body, occurredAt, state } = gh(ctx);
|
|
125
|
+
const fields = { repository: repo };
|
|
126
|
+
if (body.tag_name !== undefined)
|
|
127
|
+
fields.tag_name = String(body.tag_name);
|
|
128
|
+
if (body.target_commitish !== undefined)
|
|
129
|
+
fields.target_commitish = String(body.target_commitish);
|
|
130
|
+
if (body.name !== undefined)
|
|
131
|
+
fields.name = body.name;
|
|
132
|
+
if (body.body !== undefined)
|
|
133
|
+
fields.body = body.body;
|
|
134
|
+
if (body.prerelease !== undefined)
|
|
135
|
+
fields.prerelease = body.prerelease === true;
|
|
136
|
+
if (body.draft !== undefined) {
|
|
137
|
+
fields.draft = body.draft === true;
|
|
138
|
+
if (body.draft === false && !existing.published_at)
|
|
139
|
+
fields.published_at = occurredAt;
|
|
140
|
+
}
|
|
141
|
+
await put(ctx, { operation: 'release.update', subjectType: 'release', subjectId: `release:${id}`, fields });
|
|
142
|
+
const now = state().releases.find((r) => r.id === id);
|
|
143
|
+
if (!now.draft)
|
|
144
|
+
await tagOnPublish(ctx, now.tag_name, now.target_commitish ?? 'main');
|
|
145
|
+
const st = state();
|
|
146
|
+
const answer = toGithubRelease(st.releases.find((r) => r.id === id), st.releaseAssets);
|
|
147
|
+
await announce(ctx, [{ event: 'release', action: 'edited', repository: repo, number: id }]);
|
|
148
|
+
return ctx.reply(answer);
|
|
149
|
+
};
|
|
150
|
+
const reposDeleteRelease = async (ctx) => {
|
|
151
|
+
const { id, rel: existing } = releaseOf(ctx);
|
|
152
|
+
if (!existing)
|
|
153
|
+
return send(ctx, githubNotFound());
|
|
154
|
+
const { repo } = gh(ctx);
|
|
155
|
+
await put(ctx, { operation: 'release.delete', subjectType: 'release', subjectId: `release:${id}`, fields: { deleted: true, repository: repo, tag_name: existing.tag_name } });
|
|
156
|
+
await announce(ctx, [{ event: 'release', action: 'deleted', repository: repo, number: id }]);
|
|
157
|
+
return send(ctx, { status: 204, body: undefined });
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
// newest first
|
|
161
|
+
'repos/list-releases': reposListReleases,
|
|
162
|
+
'repos/get-release': reposGetRelease,
|
|
163
|
+
// the most recent PUBLISHED release: GitHub excludes drafts and prereleases from "latest"
|
|
164
|
+
'repos/get-latest-release': reposGetLatestRelease,
|
|
165
|
+
'repos/get-release-by-tag': reposGetReleaseByTag,
|
|
166
|
+
'repos/get-release-asset': reposGetReleaseAsset,
|
|
167
|
+
'repos/list-release-assets': reposListReleaseAssets,
|
|
168
|
+
// tags, as published releases create them
|
|
169
|
+
// a repository's tags are its refs/tags, an annotated one peeled to the commit it tags; one known only through the
|
|
170
|
+
// API keeps the tags its releases made
|
|
171
|
+
'repos/list-tags': reposListTags,
|
|
172
|
+
// 422 without a tag_name; draft and prerelease default false; a published release gets its
|
|
173
|
+
// published_at (and its tag)
|
|
174
|
+
'repos/create-release': async (ctx) => {
|
|
175
|
+
const { repo, body, occurredAt, state, caller } = gh(ctx);
|
|
176
|
+
if (!body.tag_name)
|
|
177
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Release', field: 'tag_name', code: 'missing_field' }] }, 422);
|
|
178
|
+
const seq = nextSeq(ctx, 'release');
|
|
179
|
+
const draft = body.draft === true;
|
|
180
|
+
// generate_release_notes: the generated notes follow the body given, and name it when no name is
|
|
181
|
+
const notes = body.generate_release_notes === true ? await generatedNotes(ctx, String(body.tag_name), body.target_commitish, undefined) : undefined;
|
|
182
|
+
const fields = {
|
|
183
|
+
repository: repo, tag_name: String(body.tag_name), target_commitish: body.target_commitish ?? 'main',
|
|
184
|
+
name: body.name ?? notes?.name, body: notes ? [body.body, notes.body].filter((x) => typeof x === 'string' && x.length).join('\n\n') : body.body, draft, prerelease: body.prerelease === true,
|
|
185
|
+
created_at: occurredAt, published_at: draft ? undefined : occurredAt, author_login: caller ?? GITHUB_WORLD_USER,
|
|
186
|
+
};
|
|
187
|
+
await put(ctx, { operation: 'release.create', subjectType: 'release', subjectId: `release:${seq}`, fields });
|
|
188
|
+
if (!draft)
|
|
189
|
+
await tagOnPublish(ctx, String(body.tag_name), String(fields.target_commitish));
|
|
190
|
+
const st = state();
|
|
191
|
+
const answer = toGithubRelease(st.releases.find((r) => r.id === seq), st.releaseAssets);
|
|
192
|
+
await announce(ctx, [{ event: 'release', action: draft ? 'created' : 'published', repository: repo, number: seq }]);
|
|
193
|
+
return ctx.reply(answer, 201);
|
|
194
|
+
},
|
|
195
|
+
// the notes a release of tag_name would be generated with, and its name (200); a previous_tag_name that is no tag is 404
|
|
196
|
+
'repos/generate-release-notes': reposGenerateReleaseNotes,
|
|
197
|
+
// an edit, including publishing a draft (the first publication stamps published_at)
|
|
198
|
+
'repos/update-release': reposUpdateRelease,
|
|
199
|
+
'repos/delete-release': reposDeleteRelease,
|
|
200
|
+
// An asset's METADATA (name, label, content type, size); its bytes are out of scope. The name comes
|
|
201
|
+
// from the JSON body or, as on GitHub's upload endpoint, the `?name=` query.
|
|
202
|
+
// The asset is the request body, its raw bytes, with its media type in Content-Type; its name and label are query
|
|
203
|
+
// parameters (docs.github.com/rest/releases/assets#upload-a-release-asset). A second asset of one name is 422, as
|
|
204
|
+
// GitHub documents. Where the documentation stops and the twin decides: an upload with no Content-Type or no bytes
|
|
205
|
+
// is refused as a missing field, and the bytes are kept as the asset's content.
|
|
206
|
+
'repos/upload-release-asset': async (ctx) => {
|
|
207
|
+
const { id: releaseId, rel } = releaseOf(ctx);
|
|
208
|
+
if (!rel)
|
|
209
|
+
return send(ctx, githubNotFound());
|
|
210
|
+
const { repo, query, occurredAt, state, caller } = gh(ctx);
|
|
211
|
+
const q = new URLSearchParams((query ?? '').replace(/^\?/, ''));
|
|
212
|
+
const name = q.get('name');
|
|
213
|
+
const upload = assetUploads.getStore() ?? { bytes: new Uint8Array(), type: null };
|
|
214
|
+
const absent = [...(!name ? ['name'] : []), ...(!upload.type ? ['content_type'] : []), ...(!upload.bytes.length ? ['size'] : [])];
|
|
215
|
+
if (absent.length)
|
|
216
|
+
return ctx.reply({ message: 'Validation Failed', errors: absent.map((field) => ({ resource: 'ReleaseAsset', field, code: 'missing_field' })), documentation_url: 'https://docs.github.com/rest/releases/assets#upload-a-release-asset' }, 422);
|
|
217
|
+
if (state().releaseAssets.some((a) => a.repository === repo && a.release_id === releaseId && a.name === name))
|
|
218
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'ReleaseAsset', field: 'name', code: 'already_exists' }], documentation_url: 'https://docs.github.com/rest/releases/assets#upload-a-release-asset' }, 422);
|
|
219
|
+
const seq = nextSeq(ctx, 'asset');
|
|
220
|
+
const fields = {
|
|
221
|
+
repository: repo, release_id: releaseId, name: String(name), label: q.get('label') ?? undefined,
|
|
222
|
+
content_type: upload.type, size: upload.bytes.length, content_b64: Buffer.from(upload.bytes).toString('base64'),
|
|
223
|
+
state: 'uploaded', download_count: 0, created_at: occurredAt, updated_at: occurredAt, uploader_login: caller ?? GITHUB_WORLD_USER,
|
|
224
|
+
};
|
|
225
|
+
await put(ctx, { operation: 'release_asset.create', subjectType: 'release_asset', subjectId: `asset:${seq}`, fields });
|
|
226
|
+
const answer = toGithubReleaseAsset(state().releaseAssets.find((a) => a.id === seq));
|
|
227
|
+
await announce(ctx, [{ event: 'release', action: 'released', repository: repo, number: releaseId }]);
|
|
228
|
+
return ctx.reply(answer, 201);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { GITHUB_WORLD_USER, githubNotFound, nodeId, paginate, toGithubRepo, toSimpleUser } from "../github-twin.js";
|
|
2
|
+
import { me } from "./activity.js";
|
|
3
|
+
import { announce, gh, nextSeq, put, send } from "./shared.js";
|
|
4
|
+
const WEEK = 7 * 86_400_000;
|
|
5
|
+
/** add-collaborator's permission names, as an invitation reports them. */
|
|
6
|
+
const INVITED_AS = { pull: 'read', triage: 'triage', push: 'write', maintain: 'maintain', admin: 'admin' };
|
|
7
|
+
const GRANTED_AS = { read: 'pull', triage: 'triage', write: 'push', maintain: 'maintain', admin: 'admin' };
|
|
8
|
+
/** The invitations still standing (not accepted, declined or withdrawn). */
|
|
9
|
+
function invitations(ctx) {
|
|
10
|
+
return ctx.tree()
|
|
11
|
+
.filter((r) => r.type === 'repo_invitation' && r.deleted !== true)
|
|
12
|
+
.map((r) => ({ id: Number(r.number), repository: String(r.repository), invitee: String(r.invitee), inviter: String(r.inviter), permission: String(r.permission), created_at: String(r.created_at) }));
|
|
13
|
+
}
|
|
14
|
+
const expired = (ctx, inv) => Date.parse(ctx.occurredAt) - Date.parse(inv.created_at) > WEEK;
|
|
15
|
+
function toInvitation(ctx, inv) {
|
|
16
|
+
const repo = gh(ctx).state().repos.find((r) => r.full_name === inv.repository);
|
|
17
|
+
return {
|
|
18
|
+
id: inv.id, node_id: nodeId('RepositoryInvitation', inv.id), repository: repo ? toGithubRepo(repo) : { full_name: inv.repository }, invitee: toSimpleUser(inv.invitee), inviter: toSimpleUser(inv.inviter),
|
|
19
|
+
permissions: INVITED_AS[inv.permission] ?? inv.permission, created_at: inv.created_at, expired: expired(ctx, inv),
|
|
20
|
+
url: `https://api.github.com/user/repository_invitations/${inv.id}`, html_url: `https://github.com/${inv.repository}/invitations`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const write = (ctx, operation, inv, fields) => put(ctx, { operation, subjectType: 'repo_invitation', subjectId: `repoinvite:${inv.id}`, fields: { number: inv.id, repository: inv.repository, invitee: inv.invitee, ...fields } });
|
|
24
|
+
/** The invitation an address names, when it is the caller's own (the invitee's side) or the repository's (the inviter's). */
|
|
25
|
+
function named(ctx, id, side) {
|
|
26
|
+
const inv = invitations(ctx).find((i) => i.id === Number(decodeURIComponent(id)));
|
|
27
|
+
if (!inv)
|
|
28
|
+
return undefined;
|
|
29
|
+
return side === 'invitee' ? (inv.invitee === me(ctx) ? inv : undefined) : (inv.repository === gh(ctx).repo ? inv : undefined);
|
|
30
|
+
}
|
|
31
|
+
const reposListInvitations = async (ctx) => {
|
|
32
|
+
const { repo, query } = gh(ctx);
|
|
33
|
+
return ctx.reply(paginate(invitations(ctx).filter((i) => i.repository === repo), query).map((i) => toInvitation(ctx, i)));
|
|
34
|
+
};
|
|
35
|
+
const reposUpdateInvitation = async (ctx) => {
|
|
36
|
+
const inv = named(ctx, gh(ctx).seg[4], 'repository');
|
|
37
|
+
if (!inv)
|
|
38
|
+
return send(ctx, githubNotFound());
|
|
39
|
+
const wanted = String(gh(ctx).body.permissions ?? '');
|
|
40
|
+
if (!GRANTED_AS[wanted])
|
|
41
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'RepositoryInvitation', field: 'permissions', code: 'invalid' }] }, 422);
|
|
42
|
+
const next = { ...inv, permission: GRANTED_AS[wanted] };
|
|
43
|
+
await write(ctx, 'repo_invitation.update', next, { permission: next.permission });
|
|
44
|
+
return ctx.reply(toInvitation(ctx, next));
|
|
45
|
+
};
|
|
46
|
+
const reposDeleteInvitation = async (ctx) => {
|
|
47
|
+
const inv = named(ctx, gh(ctx).seg[4], 'repository');
|
|
48
|
+
if (!inv)
|
|
49
|
+
return send(ctx, githubNotFound());
|
|
50
|
+
await write(ctx, 'repo_invitation.withdraw', inv, { deleted: true });
|
|
51
|
+
return send(ctx, { status: 204, body: undefined });
|
|
52
|
+
};
|
|
53
|
+
const reposDeclineInvitationForAuthenticatedUser = async (ctx) => {
|
|
54
|
+
const inv = named(ctx, gh(ctx).seg[2], 'invitee');
|
|
55
|
+
if (!inv)
|
|
56
|
+
return send(ctx, githubNotFound());
|
|
57
|
+
await write(ctx, 'repo_invitation.decline', inv, { deleted: true });
|
|
58
|
+
return send(ctx, { status: 204, body: undefined });
|
|
59
|
+
};
|
|
60
|
+
export const repoInvitationsSemantics = {
|
|
61
|
+
'repos/add-collaborator': async (ctx) => {
|
|
62
|
+
const { repo, seg, body, state, occurredAt, caller } = gh(ctx);
|
|
63
|
+
const login = decodeURIComponent(seg[4]);
|
|
64
|
+
const permission = String(body.permission ?? 'push');
|
|
65
|
+
const st = state();
|
|
66
|
+
const already = st.collaborators.some((c) => c.repository === repo && c.login === login);
|
|
67
|
+
const member = st.orgMembers.some((m) => m.org === repo.split('/')[0] && m.login === login && m.state === 'active');
|
|
68
|
+
if (already || member) {
|
|
69
|
+
await put(ctx, { operation: already ? 'collaborator.update' : 'collaborator.add', subjectType: 'collaborator', subjectId: `collab:${repo}#${login}`, fields: { repository: repo, login, permission } });
|
|
70
|
+
if (!already)
|
|
71
|
+
await announce(ctx, [{ event: 'member', action: 'added', repository: repo, number: 0 }]);
|
|
72
|
+
return send(ctx, { status: 204, body: undefined });
|
|
73
|
+
}
|
|
74
|
+
// inviting someone again changes the invitation that is still pending
|
|
75
|
+
const pending = invitations(ctx).find((i) => i.repository === repo && i.invitee === login);
|
|
76
|
+
const inv = { id: pending?.id ?? nextSeq(ctx, 'repoinvite'), repository: repo, invitee: login, inviter: pending?.inviter ?? caller ?? GITHUB_WORLD_USER, permission, created_at: pending?.created_at ?? occurredAt };
|
|
77
|
+
await write(ctx, pending ? 'repo_invitation.update' : 'repo_invitation.create', inv, { inviter: inv.inviter, permission, created_at: inv.created_at });
|
|
78
|
+
return ctx.reply(toInvitation(ctx, inv), 201);
|
|
79
|
+
},
|
|
80
|
+
'repos/list-invitations': reposListInvitations,
|
|
81
|
+
'repos/update-invitation': reposUpdateInvitation,
|
|
82
|
+
'repos/delete-invitation': reposDeleteInvitation,
|
|
83
|
+
'repos/list-invitations-for-authenticated-user': async (ctx) => {
|
|
84
|
+
const who = me(ctx);
|
|
85
|
+
return ctx.reply(paginate(invitations(ctx).filter((i) => i.invitee === who), gh(ctx).query).map((i) => toInvitation(ctx, i)));
|
|
86
|
+
},
|
|
87
|
+
// the invitee becomes a collaborator with the permission they were invited with
|
|
88
|
+
'repos/accept-invitation-for-authenticated-user': async (ctx) => {
|
|
89
|
+
const inv = named(ctx, gh(ctx).seg[2], 'invitee');
|
|
90
|
+
if (!inv || expired(ctx, inv))
|
|
91
|
+
return send(ctx, githubNotFound());
|
|
92
|
+
await write(ctx, 'repo_invitation.accept', inv, { deleted: true });
|
|
93
|
+
await put(ctx, { operation: 'collaborator.add', subjectType: 'collaborator', subjectId: `collab:${inv.repository}#${inv.invitee}`, fields: { repository: inv.repository, login: inv.invitee, permission: inv.permission } });
|
|
94
|
+
await announce(ctx, [{ event: 'member', action: 'added', repository: inv.repository, number: 0 }]);
|
|
95
|
+
return send(ctx, { status: 204, body: undefined });
|
|
96
|
+
},
|
|
97
|
+
'repos/decline-invitation-for-authenticated-user': reposDeclineInvitationForAuthenticatedUser,
|
|
98
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Semantics } from '@volter/world-core';
|
|
2
|
+
/** The people who can be assigned in a repository (docs.github.com/rest/issues/assignees). */
|
|
3
|
+
export declare function assignable(st: {
|
|
4
|
+
orgs: Array<{
|
|
5
|
+
login: string;
|
|
6
|
+
}>;
|
|
7
|
+
orgMembers: Array<{
|
|
8
|
+
org: string;
|
|
9
|
+
login: string;
|
|
10
|
+
state: string;
|
|
11
|
+
}>;
|
|
12
|
+
collaborators: Array<{
|
|
13
|
+
repository: string;
|
|
14
|
+
login: string;
|
|
15
|
+
}>;
|
|
16
|
+
}, repo: string): string[];
|
|
17
|
+
export declare function reposSemantics(): Record<string, Semantics>;
|