@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,456 @@
|
|
|
1
|
+
import { authorAssociation, adoptedSubjectId, applyTriageFields, buildIssueTimeline, GITHUB_WORLD_USER, githubIssueId, githubNotFound, issueCommentCount, nextRepoNumber, normalizeLabels, paginate, pullRequestAsIssue, serializeIssueComment, serializeReviewComment, toGithubIssue, toGithubReaction, toLabels, } from "../github-twin.js";
|
|
2
|
+
import { announce, asView, gh, nextSeq, put, send } from "./shared.js";
|
|
3
|
+
/** What every issue door reads: the repository's issues and the issue face of each pull request. */
|
|
4
|
+
function issuesOf(g) {
|
|
5
|
+
const st = g.state();
|
|
6
|
+
const repo = g.repo;
|
|
7
|
+
const inRepo = st.issues.filter((i) => i.repository === repo);
|
|
8
|
+
const pullsInRepo = st.prs.filter((p) => p.repository === repo && p.is_pull_request);
|
|
9
|
+
const pullByNumber = new Map(pullsInRepo.map((p) => [p.number, p]));
|
|
10
|
+
const asIssue = (i) => toGithubIssue(i, st.milestones, pullByNumber.get(i.number), st.comments);
|
|
11
|
+
// the repo's issues plus the issue face of each pull request (a number already an issue is not doubled)
|
|
12
|
+
const issueFaces = [...inRepo, ...pullsInRepo.filter((p) => !inRepo.some((i) => i.number === p.number)).map(pullRequestAsIssue)];
|
|
13
|
+
// A read INTO an issue 404s when nobody filed it: `200 []` would tell the caller the issue is real
|
|
14
|
+
// and quiet. A pull request number is a legitimate parent (a pull request IS an issue).
|
|
15
|
+
const issueFaceExists = (n) => issueFaces.some((i) => i.number === n);
|
|
16
|
+
// one timeline for every door onto it, so the timeline, the typed events and a single event agree
|
|
17
|
+
const timelineOf = (i) => buildIssueTimeline(repo, i, {
|
|
18
|
+
pull: pullByNumber.get(i.number),
|
|
19
|
+
comments: st.comments.filter((c) => c.repository === repo && c.number === i.number && c.kind === 'issue'),
|
|
20
|
+
reviews: st.reviews.filter((r) => r.repository === repo && r.number === i.number),
|
|
21
|
+
});
|
|
22
|
+
return { st, repo, inRepo, pullByNumber, asIssue, issueFaces, issueFaceExists, timelineOf };
|
|
23
|
+
}
|
|
24
|
+
const numberOf = (g) => Number(decodeURIComponent(g.seg[4]));
|
|
25
|
+
const TYPED_EVENTS = new Set(['labeled', 'assigned', 'milestoned', 'locked', 'closed', 'reopened', 'merged', 'renamed', 'unlabeled', 'unassigned']);
|
|
26
|
+
// GitHub's reaction content enum is fixed; an invalid content → 422.
|
|
27
|
+
const REACTION_CONTENTS = ['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes'];
|
|
28
|
+
/** The stored issue a write under /issues/:n names (the issue construct itself, not a pull request's face). */
|
|
29
|
+
function storedIssue(g) {
|
|
30
|
+
const number = numberOf(g);
|
|
31
|
+
const id = `${g.repo}#issue:${number}`;
|
|
32
|
+
return { number, id, existing: g.state().issues.find((i) => i.id === id) };
|
|
33
|
+
}
|
|
34
|
+
/** The pull request a write under /issues/:n reaches when no issue was filed at that number: GitHub's pull
|
|
35
|
+
* request is an issue, so its labels, assignees, milestone, title, body and state are written there too. */
|
|
36
|
+
function pullAtIssueAddress(g, number) {
|
|
37
|
+
return g.state().prs.find((p) => p.is_pull_request && p.repository === g.repo && p.number === number);
|
|
38
|
+
}
|
|
39
|
+
/** GET a comment's reactions (the comment must exist, as its own address says). */
|
|
40
|
+
const commentReactions = (kind) => async (ctx) => {
|
|
41
|
+
const { repo, seg, query, state } = gh(ctx);
|
|
42
|
+
const st = state();
|
|
43
|
+
const cid = decodeURIComponent(seg[5]);
|
|
44
|
+
if (!st.comments.some((c) => c.repository === repo && c.kind === kind && c.id === Number(cid)))
|
|
45
|
+
return send(ctx, githubNotFound());
|
|
46
|
+
return ctx.reply(paginate(st.reactions.filter((r) => r.repository === repo && r.subject_type === 'comment' && r.subject_key === cid), query).map(toGithubReaction));
|
|
47
|
+
};
|
|
48
|
+
/** POST a reaction on a comment, whose kind the path's family names. */
|
|
49
|
+
const reactToComment = (kind) => async (ctx) => {
|
|
50
|
+
const { repo, seg, body, occurredAt, state } = gh(ctx);
|
|
51
|
+
const content = String(body.content ?? '');
|
|
52
|
+
if (!REACTION_CONTENTS.includes(content))
|
|
53
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Reaction', field: 'content', code: 'invalid' }] }, 422);
|
|
54
|
+
const cid = decodeURIComponent(seg[5]);
|
|
55
|
+
if (!state().comments.some((c) => c.repository === repo && c.kind === kind && c.id === Number(cid)))
|
|
56
|
+
return send(ctx, githubNotFound());
|
|
57
|
+
const seq = nextSeq(ctx, 'reaction');
|
|
58
|
+
await put(ctx, { operation: 'reaction.create', subjectType: 'reaction', subjectId: `reaction:${seq}`, fields: { repository: repo, subject_type: 'comment', subject_key: cid, content, created_at: occurredAt } });
|
|
59
|
+
return ctx.reply(toGithubReaction({ id: seq, repository: repo, subject_type: 'comment', subject_key: cid, content, created_at: occurredAt }), 201);
|
|
60
|
+
};
|
|
61
|
+
/** DELETE a reaction by id (the reaction's own id, whatever it reacts to, as the twin always read it). */
|
|
62
|
+
const unreact = (idAt) => async (ctx) => {
|
|
63
|
+
const { repo, seg, state } = gh(ctx);
|
|
64
|
+
const id = Number(decodeURIComponent(seg[idAt]));
|
|
65
|
+
if (!state().reactions.find((r) => r.repository === repo && r.id === id))
|
|
66
|
+
return send(ctx, githubNotFound());
|
|
67
|
+
await put(ctx, { operation: 'reaction.delete', subjectType: 'reaction', subjectId: `reaction:${id}`, fields: { deleted: true, repository: repo } });
|
|
68
|
+
return send(ctx, { status: 204, body: undefined });
|
|
69
|
+
};
|
|
70
|
+
/** The per-comment door: GitHub addresses a comment by its own id, without its issue or pull request;
|
|
71
|
+
* issue comments and review comments are one stored construct with a `kind`, so both doors are this. */
|
|
72
|
+
const commentDoor = (kind, removing) => async (ctx) => {
|
|
73
|
+
const { repo, seg, body, state } = gh(ctx);
|
|
74
|
+
const cid = Number(decodeURIComponent(seg[5]));
|
|
75
|
+
const existing = state().comments.find((c) => c.repository === repo && c.kind === kind && c.id === cid);
|
|
76
|
+
if (!existing)
|
|
77
|
+
return send(ctx, githubNotFound());
|
|
78
|
+
const subjectType = kind === 'review' ? 'pull_request_review_comment' : 'issue_comment';
|
|
79
|
+
const subjectId = existing.subject_id;
|
|
80
|
+
if (!subjectId)
|
|
81
|
+
throw new Error('Stored comment identity is unavailable');
|
|
82
|
+
const event = kind === 'review' ? 'pull_request_review_comment' : 'issue_comment';
|
|
83
|
+
const serialize = kind === 'review' ? serializeReviewComment : serializeIssueComment;
|
|
84
|
+
if (removing) {
|
|
85
|
+
const comment = serialize(existing);
|
|
86
|
+
await put(ctx, { operation: `${subjectType}.delete`, subjectType, subjectId, fields: { deleted: true, repository: repo, number: existing.number } });
|
|
87
|
+
await announce(ctx, [{ event, action: 'deleted', repository: repo, number: existing.number, comment }]);
|
|
88
|
+
return send(ctx, { status: 204, body: undefined });
|
|
89
|
+
}
|
|
90
|
+
if (body.body === undefined)
|
|
91
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: kind === 'review' ? 'PullRequestReviewComment' : 'IssueComment', field: 'body', code: 'missing_field' }] }, 422);
|
|
92
|
+
// updated_at moves off created_at: the only signal a consumer has that a comment was edited
|
|
93
|
+
await put(ctx, { operation: `${subjectType}.update`, subjectType, subjectId, fields: { repository: repo, number: existing.number, body: String(body.body), updated_at: gh(ctx).occurredAt } });
|
|
94
|
+
const comment = serialize(state().comments.find((c) => c.repository === repo && c.kind === kind && c.id === cid));
|
|
95
|
+
await announce(ctx, [{ event, action: 'edited', repository: repo, number: existing.number, comment }]);
|
|
96
|
+
return ctx.reply(comment);
|
|
97
|
+
};
|
|
98
|
+
/** The issue's labels written whole: POST adds (union), PUT sets, DELETE /:name removes one (404 when
|
|
99
|
+
* it is not on the issue), DELETE removes all (204). The repository's label catalog is untouched. */
|
|
100
|
+
/** A pull request read at its issue address: the pull request as an issue, or Not Found. */
|
|
101
|
+
function pullAsIssue(ctx, pull, asIssue) {
|
|
102
|
+
return send(ctx, pull ? { status: 200, body: asIssue(pullRequestAsIssue(pull)) } : githubNotFound());
|
|
103
|
+
}
|
|
104
|
+
/** A label taken off the labels it is on, or nothing when it is not one of them. */
|
|
105
|
+
function withoutLabel(current, name) {
|
|
106
|
+
return current.includes(name) ? current.filter((l) => l !== name) : undefined;
|
|
107
|
+
}
|
|
108
|
+
/** Labels written at a pull request's issue address: the pull request's, announced as its own event. */
|
|
109
|
+
async function pullLabels(ctx, g, id, number, next, action, how) {
|
|
110
|
+
await put(ctx, { operation: 'pull_request.update', subjectType: 'pull_request', subjectId: id, fields: { number, repository: g.repo, labels: next } });
|
|
111
|
+
await announce(ctx, [{ event: 'pull_request', action, repository: g.repo, number }]);
|
|
112
|
+
return how === 'clear' ? send(ctx, { status: 204, body: undefined }) : ctx.reply(toLabels(next, g.repo, g.state().labels));
|
|
113
|
+
}
|
|
114
|
+
const writeLabels = (how) => async (ctx) => {
|
|
115
|
+
const g = gh(ctx);
|
|
116
|
+
const { repo, seg, body, occurredAt } = g;
|
|
117
|
+
const { number, id, existing } = storedIssue(g);
|
|
118
|
+
const pull = existing ? undefined : pullAtIssueAddress(g, number);
|
|
119
|
+
if (!existing && !pull)
|
|
120
|
+
return send(ctx, githubNotFound());
|
|
121
|
+
const current = (existing ?? pull).labels ?? [];
|
|
122
|
+
const next = how === 'remove' ? withoutLabel(current, decodeURIComponent(seg.slice(6).join('/'))) : how === 'clear' ? [] : how === 'set' ? normalizeLabels(body.labels) : [...current, ...normalizeLabels(body.labels).filter((l, i, all) => !current.includes(l) && all.indexOf(l) === i)];
|
|
123
|
+
if (!next)
|
|
124
|
+
return send(ctx, githubNotFound());
|
|
125
|
+
const action = how === 'add' || how === 'set' ? 'labeled' : 'unlabeled';
|
|
126
|
+
if (pull)
|
|
127
|
+
return pullLabels(ctx, g, pull.id, number, next, action, how);
|
|
128
|
+
await put(ctx, { operation: action === 'labeled' ? 'issue.add_labels' : 'issue.remove_labels', subjectType: 'issue', subjectId: id, fields: { number, repository: repo, labels: next, updated_at: occurredAt } });
|
|
129
|
+
const answer = how === 'clear' ? undefined : toLabels(g.state().issues.find((i) => i.id === id)?.labels, repo, g.state().labels);
|
|
130
|
+
await announce(ctx, [{ event: 'issues', action, repository: repo, number }]);
|
|
131
|
+
return how === 'clear' ? send(ctx, { status: 204, body: undefined }) : ctx.reply(answer);
|
|
132
|
+
};
|
|
133
|
+
// A pull request's conversation locks by its number here as an issue's does (GitHub's pull request is an
|
|
134
|
+
// issue): the pull request carries `locked` and announces pull_request locked/unlocked.
|
|
135
|
+
const lock = (locking) => async (ctx) => {
|
|
136
|
+
const g = gh(ctx);
|
|
137
|
+
const { number, id, existing } = storedIssue(g);
|
|
138
|
+
const reason = locking ? (g.body.lock_reason ?? null) : null;
|
|
139
|
+
if (!existing) {
|
|
140
|
+
const pull = g.state().prs.find((p) => p.is_pull_request && p.repository === g.repo && p.number === number);
|
|
141
|
+
if (!pull)
|
|
142
|
+
return send(ctx, githubNotFound());
|
|
143
|
+
await put(ctx, { operation: locking ? 'pull_request.lock' : 'pull_request.unlock', subjectType: 'pull_request', subjectId: pull.id, fields: { number, repository: g.repo, locked: locking, active_lock_reason: reason } });
|
|
144
|
+
await announce(ctx, [{ event: 'pull_request', action: locking ? 'locked' : 'unlocked', repository: g.repo, number }]);
|
|
145
|
+
return send(ctx, { status: 204, body: undefined });
|
|
146
|
+
}
|
|
147
|
+
await put(ctx, { operation: locking ? 'issue.lock' : 'issue.unlock', subjectType: 'issue', subjectId: id, fields: { number, repository: g.repo, locked: locking, active_lock_reason: reason, updated_at: g.occurredAt } });
|
|
148
|
+
await announce(ctx, [{ event: 'issues', action: locking ? 'locked' : 'unlocked', repository: g.repo, number }]);
|
|
149
|
+
return send(ctx, { status: 204, body: undefined });
|
|
150
|
+
};
|
|
151
|
+
/** The issue list, with GitHub's documented filters over the state the twin models: state (default
|
|
152
|
+
* open), labels (ANDed), assignee (a login, `none`, `*`), since, sort created|updated|comments with
|
|
153
|
+
* direction (default desc; ties by number). `creator` and `mentioned` are not served (the author and
|
|
154
|
+
* mentions are unmodeled). Pull requests carry no created_at/updated_at, so they sort with the
|
|
155
|
+
* undated rows and `since` never selects one. */
|
|
156
|
+
function listIssues(ctx) {
|
|
157
|
+
const g = gh(ctx);
|
|
158
|
+
const { st, repo, asIssue, issueFaces } = issuesOf(g);
|
|
159
|
+
const lq = new URLSearchParams((g.query ?? '').replace(/^\?/, ''));
|
|
160
|
+
const stateF = (lq.get('state') ?? 'open').toLowerCase();
|
|
161
|
+
const labelsF = (lq.get('labels') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
162
|
+
const assigneeF = lq.get('assignee') ?? undefined;
|
|
163
|
+
const sinceMs = lq.get('since') ? Date.parse(lq.get('since')) : Number.NaN;
|
|
164
|
+
const filtered = issueFaces.filter((i) => {
|
|
165
|
+
if (stateF !== 'all' && (i.state ?? 'open') !== stateF)
|
|
166
|
+
return false;
|
|
167
|
+
if (labelsF.length && !labelsF.every((l) => (i.labels ?? []).includes(l)))
|
|
168
|
+
return false;
|
|
169
|
+
if (assigneeF !== undefined) {
|
|
170
|
+
const on = i.assignees ?? [];
|
|
171
|
+
if (assigneeF === 'none') {
|
|
172
|
+
if (on.length)
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
else if (assigneeF === '*') {
|
|
176
|
+
if (!on.length)
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
else if (!on.includes(assigneeF))
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
if (Number.isFinite(sinceMs)) {
|
|
183
|
+
const upd = Date.parse(i.updated_at ?? i.created_at ?? '');
|
|
184
|
+
if (!Number.isFinite(upd) || upd < sinceMs)
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
});
|
|
189
|
+
const dir = (lq.get('direction') ?? 'desc').toLowerCase() === 'asc' ? 1 : -1;
|
|
190
|
+
const sortF = (lq.get('sort') ?? '').toLowerCase();
|
|
191
|
+
const sortKey = (i) => (sortF === 'updated' ? (i.updated_at ?? i.created_at ?? '') : (i.created_at ?? ''));
|
|
192
|
+
const ordered = [...filtered].sort((a, b) => {
|
|
193
|
+
if (sortF === 'comments') {
|
|
194
|
+
const ca = issueCommentCount(repo, a.number, st.comments);
|
|
195
|
+
const cb = issueCommentCount(repo, b.number, st.comments);
|
|
196
|
+
if (ca !== cb)
|
|
197
|
+
return ca < cb ? -dir : dir;
|
|
198
|
+
return (a.number - b.number) * dir;
|
|
199
|
+
}
|
|
200
|
+
const ka = sortKey(a);
|
|
201
|
+
const kb = sortKey(b);
|
|
202
|
+
if (ka !== kb)
|
|
203
|
+
return ka < kb ? -dir : dir;
|
|
204
|
+
return (a.number - b.number) * dir;
|
|
205
|
+
});
|
|
206
|
+
return ctx.reply(paginate(ordered, g.query).map(asIssue));
|
|
207
|
+
}
|
|
208
|
+
/** The issues visible to a sub-issue answer. */
|
|
209
|
+
const asPlainIssue = (st, i) => toGithubIssue(i, st.milestones, undefined, st.comments);
|
|
210
|
+
export function issuesSemantics() {
|
|
211
|
+
const issuesListEventsForRepo = async (ctx) => {
|
|
212
|
+
const g = gh(ctx);
|
|
213
|
+
const { issueFaces, timelineOf } = issuesOf(g);
|
|
214
|
+
const items = issueFaces.flatMap((i) => timelineOf(i).filter((e) => TYPED_EVENTS.has(String(e.event))).map((e) => asView('issue-event', { ...e, issue: { number: i.number, title: i.title } })));
|
|
215
|
+
return ctx.reply(paginate(items, g.query));
|
|
216
|
+
};
|
|
217
|
+
const issuesGetEvent = async (ctx) => {
|
|
218
|
+
const g = gh(ctx);
|
|
219
|
+
const { issueFaces, timelineOf } = issuesOf(g);
|
|
220
|
+
const eid = Number(decodeURIComponent(g.seg[5]));
|
|
221
|
+
for (const i of issueFaces) {
|
|
222
|
+
const ev = timelineOf(i).find((e) => e.id === eid && e.event !== 'cross-referenced');
|
|
223
|
+
if (ev)
|
|
224
|
+
return ctx.reply({ ...ev, issue: { number: i.number, title: i.title } });
|
|
225
|
+
}
|
|
226
|
+
return send(ctx, githubNotFound());
|
|
227
|
+
};
|
|
228
|
+
const issuesListEvents = async (ctx) => {
|
|
229
|
+
const g = gh(ctx);
|
|
230
|
+
const { issueFaces, timelineOf } = issuesOf(g);
|
|
231
|
+
const iss = issueFaces.find((i) => i.number === numberOf(g));
|
|
232
|
+
if (!iss)
|
|
233
|
+
return send(ctx, githubNotFound());
|
|
234
|
+
return ctx.reply(paginate(timelineOf(iss).filter((e) => TYPED_EVENTS.has(String(e.event))).map((e) => asView('issue-event-for-issue', e)), g.query));
|
|
235
|
+
};
|
|
236
|
+
const issuesListComments = async (ctx) => {
|
|
237
|
+
const g = gh(ctx);
|
|
238
|
+
const { st, repo, issueFaceExists } = issuesOf(g);
|
|
239
|
+
const n = numberOf(g);
|
|
240
|
+
if (!issueFaceExists(n))
|
|
241
|
+
return send(ctx, githubNotFound());
|
|
242
|
+
return ctx.reply(paginate(st.comments.filter((c) => c.repository === repo && c.number === n && c.kind === 'issue'), g.query).map(serializeIssueComment));
|
|
243
|
+
};
|
|
244
|
+
const issuesGetComment = async (ctx) => {
|
|
245
|
+
const { repo, seg, state } = gh(ctx);
|
|
246
|
+
const cid = Number(decodeURIComponent(seg[5]));
|
|
247
|
+
const c = state().comments.find((x) => x.repository === repo && x.kind === 'issue' && x.id === cid);
|
|
248
|
+
return send(ctx, c ? { status: 200, body: serializeIssueComment(c) } : githubNotFound());
|
|
249
|
+
};
|
|
250
|
+
const issuesListSubIssues = async (ctx) => {
|
|
251
|
+
const g = gh(ctx);
|
|
252
|
+
const { st, inRepo, issueFaceExists } = issuesOf(g);
|
|
253
|
+
const n = numberOf(g);
|
|
254
|
+
if (!issueFaceExists(n))
|
|
255
|
+
return send(ctx, githubNotFound());
|
|
256
|
+
const childNums = inRepo.find((i) => i.number === n)?.sub_issue_numbers ?? [];
|
|
257
|
+
const items = childNums.map((cn) => inRepo.find((i) => i.number === cn)).filter((i) => !!i);
|
|
258
|
+
return ctx.reply(paginate(items, g.query).map((i) => asPlainIssue(st, i)));
|
|
259
|
+
};
|
|
260
|
+
const issuesListLabelsOnIssue = async (ctx) => {
|
|
261
|
+
const g = gh(ctx);
|
|
262
|
+
const iss = issuesOf(g).issueFaces.find((i) => i.number === numberOf(g));
|
|
263
|
+
return send(ctx, iss ? { status: 200, body: paginate(toLabels(iss.labels, g.repo, g.state().labels), g.query) } : githubNotFound());
|
|
264
|
+
};
|
|
265
|
+
const reactionsListForIssue = async (ctx) => {
|
|
266
|
+
const g = gh(ctx);
|
|
267
|
+
const { st, repo, issueFaceExists } = issuesOf(g);
|
|
268
|
+
const n = numberOf(g);
|
|
269
|
+
if (!issueFaceExists(n))
|
|
270
|
+
return send(ctx, githubNotFound());
|
|
271
|
+
return ctx.reply(paginate(st.reactions.filter((r) => r.repository === repo && r.subject_type === 'issue' && r.subject_key === String(n)), g.query).map(toGithubReaction));
|
|
272
|
+
};
|
|
273
|
+
const issuesUpdate = async (ctx) => {
|
|
274
|
+
const g = gh(ctx);
|
|
275
|
+
const { repo, body, occurredAt, state } = g;
|
|
276
|
+
const { number, id, existing } = storedIssue(g);
|
|
277
|
+
if (!existing) {
|
|
278
|
+
const pull = pullAtIssueAddress(g, number);
|
|
279
|
+
if (!pull)
|
|
280
|
+
return send(ctx, githubNotFound());
|
|
281
|
+
const fields = { number, repository: repo };
|
|
282
|
+
if (body.title !== undefined)
|
|
283
|
+
fields.title = body.title;
|
|
284
|
+
if (body.body !== undefined)
|
|
285
|
+
fields.body = body.body;
|
|
286
|
+
if (body.state !== undefined)
|
|
287
|
+
fields.state = body.state;
|
|
288
|
+
applyTriageFields(fields, body);
|
|
289
|
+
await put(ctx, { operation: 'pull_request.update', subjectType: 'pull_request', subjectId: pull.id, fields });
|
|
290
|
+
const { asIssue, pullByNumber } = issuesOf(g);
|
|
291
|
+
await announce(ctx, [{ event: 'pull_request', action: body.state === 'closed' ? 'closed' : body.state === 'open' ? 'reopened' : 'edited', repository: repo, number }]);
|
|
292
|
+
return ctx.reply(asIssue(pullRequestAsIssue(pullByNumber.get(number))));
|
|
293
|
+
}
|
|
294
|
+
const fields = { number, repository: repo, updated_at: occurredAt };
|
|
295
|
+
if (body.title !== undefined)
|
|
296
|
+
fields.title = body.title;
|
|
297
|
+
if (body.body !== undefined)
|
|
298
|
+
fields.body = body.body;
|
|
299
|
+
if (body.state !== undefined)
|
|
300
|
+
fields.state = body.state;
|
|
301
|
+
if (body.state_reason !== undefined)
|
|
302
|
+
fields.state_reason = body.state_reason === null ? null : String(body.state_reason);
|
|
303
|
+
if (body.state === 'closed' && body.state_reason === undefined && (existing.state ?? 'open') !== 'closed')
|
|
304
|
+
fields.state_reason = 'completed';
|
|
305
|
+
if (body.state === 'open' && body.state_reason === undefined && (existing.state ?? 'open') === 'closed')
|
|
306
|
+
fields.state_reason = 'reopened';
|
|
307
|
+
applyTriageFields(fields, body);
|
|
308
|
+
await put(ctx, { operation: 'issue.update', subjectType: 'issue', subjectId: id, fields });
|
|
309
|
+
const st = state();
|
|
310
|
+
const iss = st.issues.find((i) => i.id === id);
|
|
311
|
+
const issue = iss ? toGithubIssue(iss, st.milestones, undefined, st.comments) : { number };
|
|
312
|
+
await announce(ctx, [{ event: 'issues', action: body.state === 'closed' ? 'closed' : 'edited', repository: repo, number, issue }]);
|
|
313
|
+
return ctx.reply(issue);
|
|
314
|
+
};
|
|
315
|
+
const reactionsCreateForIssue = async (ctx) => {
|
|
316
|
+
const g = gh(ctx);
|
|
317
|
+
const { repo, body, occurredAt, state } = g;
|
|
318
|
+
const content = String(body.content ?? '');
|
|
319
|
+
if (!REACTION_CONTENTS.includes(content))
|
|
320
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Reaction', field: 'content', code: 'invalid' }] }, 422);
|
|
321
|
+
const number = decodeURIComponent(g.seg[4]);
|
|
322
|
+
const st = state();
|
|
323
|
+
if (!(st.issues.some((i) => i.repository === repo && i.number === Number(number)) || st.prs.some((p) => p.repository === repo && p.number === Number(number) && p.is_pull_request)))
|
|
324
|
+
return send(ctx, githubNotFound());
|
|
325
|
+
const seq = nextSeq(ctx, 'reaction');
|
|
326
|
+
await put(ctx, { operation: 'reaction.create', subjectType: 'reaction', subjectId: `reaction:${seq}`, fields: { repository: repo, subject_type: 'issue', subject_key: number, content, created_at: occurredAt } });
|
|
327
|
+
return ctx.reply(toGithubReaction({ id: seq, repository: repo, subject_type: 'issue', subject_key: number, content, created_at: occurredAt }), 201);
|
|
328
|
+
};
|
|
329
|
+
const issuesAddSubIssue = async (ctx) => {
|
|
330
|
+
const g = gh(ctx);
|
|
331
|
+
const { repo, body, occurredAt, state } = g;
|
|
332
|
+
const number = numberOf(g);
|
|
333
|
+
const parentId = `${repo}#issue:${number}`;
|
|
334
|
+
const st0 = state();
|
|
335
|
+
const parent = st0.issues.find((i) => i.id === parentId);
|
|
336
|
+
const child = st0.issues.find((i) => i.repository === repo && githubIssueId(i.born_in ?? repo, i.number) === Number(body.sub_issue_id));
|
|
337
|
+
const childNum = child?.number ?? 0;
|
|
338
|
+
if (!parent || !child)
|
|
339
|
+
return send(ctx, githubNotFound());
|
|
340
|
+
const merged = [...(parent.sub_issue_numbers ?? [])];
|
|
341
|
+
if (!merged.includes(childNum))
|
|
342
|
+
merged.push(childNum);
|
|
343
|
+
await put(ctx, { operation: 'issue.add_sub_issue', subjectType: 'issue', subjectId: parentId, fields: { number, repository: repo, sub_issue_numbers: merged, updated_at: occurredAt } });
|
|
344
|
+
await put(ctx, { operation: 'issue.set_parent', subjectType: 'issue', subjectId: child.id, fields: { number: childNum, repository: repo, parent_issue_number: number, updated_at: occurredAt } });
|
|
345
|
+
const st = state();
|
|
346
|
+
const answer = asPlainIssue(st, st.issues.find((i) => i.id === parentId));
|
|
347
|
+
await announce(ctx, [{ event: 'issues', action: 'edited', repository: repo, number }]);
|
|
348
|
+
return ctx.reply(answer, 201);
|
|
349
|
+
};
|
|
350
|
+
const issuesRemoveSubIssue = async (ctx) => {
|
|
351
|
+
const g = gh(ctx);
|
|
352
|
+
const { repo, body, occurredAt, state } = g;
|
|
353
|
+
const number = numberOf(g);
|
|
354
|
+
const parentId = `${repo}#issue:${number}`;
|
|
355
|
+
const childNum = state().issues.find((i) => i.repository === repo && githubIssueId(i.born_in ?? repo, i.number) === Number(body.sub_issue_id))?.number;
|
|
356
|
+
const parent = state().issues.find((i) => i.id === parentId);
|
|
357
|
+
if (!parent || childNum === undefined)
|
|
358
|
+
return send(ctx, githubNotFound());
|
|
359
|
+
await put(ctx, { operation: 'issue.remove_sub_issue', subjectType: 'issue', subjectId: parentId, fields: { number, repository: repo, sub_issue_numbers: (parent.sub_issue_numbers ?? []).filter((cn) => cn !== childNum), updated_at: occurredAt } });
|
|
360
|
+
const st = state();
|
|
361
|
+
return ctx.reply(asPlainIssue(st, st.issues.find((i) => i.id === parentId)));
|
|
362
|
+
};
|
|
363
|
+
return {
|
|
364
|
+
'issues/list-for-repo': async (ctx) => listIssues(ctx),
|
|
365
|
+
// a pull request answers at its issue address too, carrying the `pull_request` key
|
|
366
|
+
'issues/get': async (ctx) => {
|
|
367
|
+
const g = gh(ctx);
|
|
368
|
+
const { inRepo, pullByNumber, asIssue } = issuesOf(g);
|
|
369
|
+
const n = numberOf(g);
|
|
370
|
+
const iss = inRepo.find((i) => i.number === n);
|
|
371
|
+
return iss ? ctx.reply(asIssue(iss)) : pullAsIssue(ctx, pullByNumber.get(n), asIssue);
|
|
372
|
+
},
|
|
373
|
+
// the typed issue events across the repo (cross-references are timeline-only; `merged` is typed)
|
|
374
|
+
'issues/list-events-for-repo': issuesListEventsForRepo,
|
|
375
|
+
'issues/get-event': issuesGetEvent,
|
|
376
|
+
// An issue's timeline and events: derived state changes (from the issue's present labels,
|
|
377
|
+
// assignees and milestone, and its open/closed history), the merge, reviews, comments and
|
|
378
|
+
// cross-references; a pull request number answers too.
|
|
379
|
+
// an issue's events are its typed events; comments and cross-references are the timeline's
|
|
380
|
+
'issues/list-events': issuesListEvents,
|
|
381
|
+
'issues/list-events-for-timeline': async (ctx) => timeline(ctx),
|
|
382
|
+
// issue comments (a pull request takes them too)
|
|
383
|
+
'issues/list-comments': issuesListComments,
|
|
384
|
+
'issues/get-comment': issuesGetComment,
|
|
385
|
+
'issues/list-sub-issues': issuesListSubIssues,
|
|
386
|
+
// the labels ON the issue (a pull request carries them too)
|
|
387
|
+
'issues/list-labels-on-issue': issuesListLabelsOnIssue,
|
|
388
|
+
'reactions/list-for-issue': reactionsListForIssue,
|
|
389
|
+
'reactions/list-for-issue-comment': commentReactions('issue'),
|
|
390
|
+
'reactions/list-for-pull-request-review-comment': commentReactions('review'),
|
|
391
|
+
// 422 without a title; labels, assignees and milestone are taken on create as on edit
|
|
392
|
+
'issues/create': async (ctx) => {
|
|
393
|
+
const { repo, body, occurredAt, root, state, caller } = gh(ctx);
|
|
394
|
+
if (!body.title)
|
|
395
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Issue', field: 'title', code: 'missing_field' }] }, 422);
|
|
396
|
+
const number = nextRepoNumber(state(), repo);
|
|
397
|
+
const id = `${repo}#issue:${number}`;
|
|
398
|
+
const fields = { number, repository: repo, title: body.title, body: body.body, state: 'open', created_at: occurredAt, updated_at: occurredAt, user_login: caller ?? GITHUB_WORLD_USER, author_association: authorAssociation(state(), repo, caller ?? GITHUB_WORLD_USER) };
|
|
399
|
+
applyTriageFields(fields, body);
|
|
400
|
+
await put(ctx, { operation: 'issue.create', subjectType: 'issue', subjectId: id, fields });
|
|
401
|
+
// Under live use the head performed the write and adopted the vendor's number; the answer
|
|
402
|
+
// carries it, read back from state so the create answer and a later GET agree.
|
|
403
|
+
const answeredId = adoptedSubjectId('issue', id, root);
|
|
404
|
+
const answeredNumber = Number(answeredId.split('#issue:').pop()) || number;
|
|
405
|
+
const st = state();
|
|
406
|
+
const created = st.issues.find((i) => i.id === answeredId) ?? st.issues.find((i) => i.id === id);
|
|
407
|
+
const out = created ? toGithubIssue(created, st.milestones, undefined, st.comments) : toGithubIssue({ id: answeredId, number: answeredNumber, repository: repo, title: body.title, body: body.body, state: 'open', created_at: occurredAt, updated_at: occurredAt });
|
|
408
|
+
await announce(ctx, [{ event: 'issues', action: 'opened', repository: repo, number: answeredNumber, issue: out }]);
|
|
409
|
+
return ctx.reply(out, 201);
|
|
410
|
+
},
|
|
411
|
+
// Closing with no reason is `completed`; reopening a closed issue is `reopened` (an explicit
|
|
412
|
+
// state_reason always wins).
|
|
413
|
+
'issues/update': issuesUpdate,
|
|
414
|
+
// a comment on an issue or a pull request, by the caller the token names
|
|
415
|
+
'issues/create-comment': async (ctx) => {
|
|
416
|
+
const g = gh(ctx);
|
|
417
|
+
const { repo, body, occurredAt, caller, state } = g;
|
|
418
|
+
const number = numberOf(g);
|
|
419
|
+
const st = state();
|
|
420
|
+
if (!(st.issues.some((i) => i.repository === repo && i.number === number) || st.prs.some((p) => p.repository === repo && p.number === number && p.is_pull_request)))
|
|
421
|
+
return send(ctx, githubNotFound());
|
|
422
|
+
const seq = nextSeq(ctx, 'comment');
|
|
423
|
+
await put(ctx, { operation: 'issue_comment.create', subjectType: 'issue_comment', subjectId: `comment:${seq}`, fields: { repository: repo, number, body: body.body, created_at: occurredAt, updated_at: occurredAt, user_login: caller ?? GITHUB_WORLD_USER } });
|
|
424
|
+
const comment = serializeIssueComment(state().comments.find((c) => c.repository === repo && c.kind === 'issue' && c.id === seq));
|
|
425
|
+
await announce(ctx, [{ event: 'issue_comment', action: 'created', repository: repo, number, comment }]);
|
|
426
|
+
return ctx.reply(comment, 201);
|
|
427
|
+
},
|
|
428
|
+
'issues/update-comment': commentDoor('issue', false),
|
|
429
|
+
'issues/delete-comment': commentDoor('issue', true),
|
|
430
|
+
'pulls/update-review-comment': commentDoor('review', false),
|
|
431
|
+
'pulls/delete-review-comment': commentDoor('review', true),
|
|
432
|
+
// a reaction on an issue or a pull request's issue face
|
|
433
|
+
'reactions/create-for-issue': reactionsCreateForIssue,
|
|
434
|
+
'reactions/create-for-issue-comment': reactToComment('issue'),
|
|
435
|
+
'reactions/create-for-pull-request-review-comment': reactToComment('review'),
|
|
436
|
+
'reactions/delete-for-issue': unreact(6),
|
|
437
|
+
'reactions/delete-for-issue-comment': unreact(7),
|
|
438
|
+
'reactions/delete-for-pull-request-comment': unreact(7),
|
|
439
|
+
'issues/add-labels': writeLabels('add'),
|
|
440
|
+
'issues/set-labels': writeLabels('set'),
|
|
441
|
+
'issues/remove-label': writeLabels('remove'),
|
|
442
|
+
'issues/remove-all-labels': writeLabels('clear'),
|
|
443
|
+
'issues/lock': lock(true),
|
|
444
|
+
'issues/unlock': lock(false),
|
|
445
|
+
// attach a child issue, named by its id (not its number), to a parent: both sides are written (201, the parent)
|
|
446
|
+
'issues/add-sub-issue': issuesAddSubIssue,
|
|
447
|
+
// detach a child (the body names it)
|
|
448
|
+
'issues/remove-sub-issue': issuesRemoveSubIssue,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function timeline(ctx) {
|
|
452
|
+
const g = gh(ctx);
|
|
453
|
+
const { issueFaces, timelineOf } = issuesOf(g);
|
|
454
|
+
const iss = issueFaces.find((i) => i.number === numberOf(g));
|
|
455
|
+
return send(ctx, iss ? { status: 200, body: paginate(timelineOf(iss), g.query) } : githubNotFound());
|
|
456
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { githubNotFound, paginate, toGithubAutolink, toGithubDeployKey } from "../github-twin.js";
|
|
2
|
+
import { gh, nextSeq, put, send } from "./shared.js";
|
|
3
|
+
const idOf = (ctx) => Number(decodeURIComponent(gh(ctx).seg[4]));
|
|
4
|
+
export function keysSemantics() {
|
|
5
|
+
const reposListDeployKeys = async (ctx) => {
|
|
6
|
+
const { repo, query, state } = gh(ctx);
|
|
7
|
+
return ctx.reply(paginate(state().deployKeys.filter((k) => k.repository === repo), query).map(toGithubDeployKey));
|
|
8
|
+
};
|
|
9
|
+
const reposGetDeployKey = async (ctx) => {
|
|
10
|
+
const { repo, state } = gh(ctx);
|
|
11
|
+
const k = state().deployKeys.find((x) => x.repository === repo && x.id === idOf(ctx));
|
|
12
|
+
return send(ctx, k ? { status: 200, body: toGithubDeployKey(k) } : githubNotFound());
|
|
13
|
+
};
|
|
14
|
+
const reposCreateDeployKey = async (ctx) => {
|
|
15
|
+
const { repo, body, occurredAt, state } = gh(ctx);
|
|
16
|
+
if (!body.key)
|
|
17
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'DeployKey', field: 'key', code: 'missing_field' }] }, 422);
|
|
18
|
+
const id = nextSeq(ctx, 'deploykey');
|
|
19
|
+
await put(ctx, { operation: 'deploy_key.create', subjectType: 'deploy_key', subjectId: `deploykey:${id}`, fields: { repository: repo, key: String(body.key), title: String(body.title ?? ''), read_only: body.read_only !== false, created_at: occurredAt } });
|
|
20
|
+
return ctx.reply(toGithubDeployKey(state().deployKeys.find((x) => x.id === id)), 201);
|
|
21
|
+
};
|
|
22
|
+
const reposDeleteDeployKey = async (ctx) => {
|
|
23
|
+
const { repo, state } = gh(ctx);
|
|
24
|
+
const id = idOf(ctx);
|
|
25
|
+
if (!state().deployKeys.find((k) => k.repository === repo && k.id === id))
|
|
26
|
+
return send(ctx, githubNotFound());
|
|
27
|
+
await put(ctx, { operation: 'deploy_key.delete', subjectType: 'deploy_key', subjectId: `deploykey:${id}`, fields: { deleted: true, repository: repo } });
|
|
28
|
+
return send(ctx, { status: 204, body: undefined });
|
|
29
|
+
};
|
|
30
|
+
const reposListAutolinks = async (ctx) => {
|
|
31
|
+
const { repo, query, state } = gh(ctx);
|
|
32
|
+
return ctx.reply(paginate(state().autolinks.filter((a) => a.repository === repo), query).map(toGithubAutolink));
|
|
33
|
+
};
|
|
34
|
+
const reposGetAutolink = async (ctx) => {
|
|
35
|
+
const { repo, state } = gh(ctx);
|
|
36
|
+
const a = state().autolinks.find((x) => x.repository === repo && x.id === idOf(ctx));
|
|
37
|
+
return send(ctx, a ? { status: 200, body: toGithubAutolink(a) } : githubNotFound());
|
|
38
|
+
};
|
|
39
|
+
const reposCreateAutolink = async (ctx) => {
|
|
40
|
+
const { repo, body, state } = gh(ctx);
|
|
41
|
+
const missing = ['key_prefix', 'url_template'].filter((k) => !body[k]);
|
|
42
|
+
if (missing.length)
|
|
43
|
+
return ctx.reply({ message: 'Validation Failed', errors: missing.map((field) => ({ resource: 'KeyLink', field, code: 'missing_field' })) }, 422);
|
|
44
|
+
const id = nextSeq(ctx, 'autolink');
|
|
45
|
+
await put(ctx, { operation: 'autolink.create', subjectType: 'autolink', subjectId: `autolink:${id}`, fields: { repository: repo, key_prefix: String(body.key_prefix), url_template: String(body.url_template), is_alphanumeric: body.is_alphanumeric !== false } });
|
|
46
|
+
return ctx.reply(toGithubAutolink(state().autolinks.find((x) => x.id === id)), 201);
|
|
47
|
+
};
|
|
48
|
+
const reposDeleteAutolink = async (ctx) => {
|
|
49
|
+
const { repo, state } = gh(ctx);
|
|
50
|
+
const id = idOf(ctx);
|
|
51
|
+
if (!state().autolinks.find((a) => a.repository === repo && a.id === id))
|
|
52
|
+
return send(ctx, githubNotFound());
|
|
53
|
+
await put(ctx, { operation: 'autolink.delete', subjectType: 'autolink', subjectId: `autolink:${id}`, fields: { deleted: true, repository: repo } });
|
|
54
|
+
return send(ctx, { status: 204, body: undefined });
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
'repos/list-deploy-keys': reposListDeployKeys,
|
|
58
|
+
'repos/get-deploy-key': reposGetDeployKey,
|
|
59
|
+
'repos/create-deploy-key': reposCreateDeployKey,
|
|
60
|
+
'repos/delete-deploy-key': reposDeleteDeployKey,
|
|
61
|
+
'repos/list-autolinks': reposListAutolinks,
|
|
62
|
+
'repos/get-autolink': reposGetAutolink,
|
|
63
|
+
'repos/create-autolink': reposCreateAutolink,
|
|
64
|
+
'repos/delete-autolink': reposDeleteAutolink,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { GITHUB_WORLD_USER, githubNotFound, paginate, toGithubLabel, toGithubMilestone } from "../github-twin.js";
|
|
2
|
+
import { gh, nextSeq, put, send } from "./shared.js";
|
|
3
|
+
/** A label's name as the path names it (a name may hold a slash). */
|
|
4
|
+
const labelName = (ctx) => decodeURIComponent(gh(ctx).seg.slice(4).join('/'));
|
|
5
|
+
export function labelsSemantics() {
|
|
6
|
+
const issuesListLabelsForRepo = async (ctx) => {
|
|
7
|
+
const { repo, query, state } = gh(ctx);
|
|
8
|
+
return ctx.reply(paginate(state().labels.filter((l) => l.repository === repo), query).map(toGithubLabel));
|
|
9
|
+
};
|
|
10
|
+
const issuesGetLabel = async (ctx) => {
|
|
11
|
+
const { repo, state } = gh(ctx);
|
|
12
|
+
const name = labelName(ctx);
|
|
13
|
+
const l = state().labels.find((x) => x.repository === repo && x.name === name);
|
|
14
|
+
return send(ctx, l ? { status: 200, body: toGithubLabel(l) } : githubNotFound());
|
|
15
|
+
};
|
|
16
|
+
const issuesUpdateLabel = async (ctx) => {
|
|
17
|
+
const { repo, body, state } = gh(ctx);
|
|
18
|
+
const name = labelName(ctx);
|
|
19
|
+
const existing = state().labels.find((l) => l.repository === repo && l.name === name);
|
|
20
|
+
if (!existing)
|
|
21
|
+
return send(ctx, githubNotFound());
|
|
22
|
+
const fields = { repository: repo, name: body.new_name !== undefined ? String(body.new_name) : name };
|
|
23
|
+
if (body.color !== undefined)
|
|
24
|
+
fields.color = String(body.color);
|
|
25
|
+
if (body.description !== undefined)
|
|
26
|
+
fields.description = body.description;
|
|
27
|
+
await put(ctx, { operation: 'label.update', subjectType: 'label', subjectId: `label:${existing.id}`, fields });
|
|
28
|
+
return ctx.reply(toGithubLabel(state().labels.find((l) => l.id === existing.id)));
|
|
29
|
+
};
|
|
30
|
+
const issuesDeleteLabel = async (ctx) => {
|
|
31
|
+
const { repo, state } = gh(ctx);
|
|
32
|
+
const name = labelName(ctx);
|
|
33
|
+
const existing = state().labels.find((l) => l.repository === repo && l.name === name);
|
|
34
|
+
if (!existing)
|
|
35
|
+
return send(ctx, githubNotFound());
|
|
36
|
+
await put(ctx, { operation: 'label.delete', subjectType: 'label', subjectId: `label:${existing.id}`, fields: { deleted: true, repository: repo, name } });
|
|
37
|
+
return send(ctx, { status: 204, body: undefined });
|
|
38
|
+
};
|
|
39
|
+
const issuesListMilestones = async (ctx) => {
|
|
40
|
+
const { repo, query, state } = gh(ctx);
|
|
41
|
+
return ctx.reply(paginate(state().milestones.filter((m) => m.repository === repo), query).map(toGithubMilestone));
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
'issues/list-labels-for-repo': issuesListLabelsForRepo,
|
|
45
|
+
'issues/get-label': issuesGetLabel,
|
|
46
|
+
'issues/create-label': async (ctx) => {
|
|
47
|
+
const { repo, body, state } = gh(ctx);
|
|
48
|
+
if (!body.name)
|
|
49
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Label', field: 'name', code: 'missing_field' }] }, 422);
|
|
50
|
+
const name = String(body.name);
|
|
51
|
+
if (state().labels.find((l) => l.repository === repo && l.name === name))
|
|
52
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Label', code: 'already_exists', field: 'name' }] }, 422);
|
|
53
|
+
const seq = nextSeq(ctx, 'label');
|
|
54
|
+
const fields = { repository: repo, name, color: body.color ?? 'ededed', description: body.description ?? null, default: false };
|
|
55
|
+
await put(ctx, { operation: 'label.create', subjectType: 'label', subjectId: `label:${seq}`, fields });
|
|
56
|
+
return ctx.reply(toGithubLabel({ id: seq, repository: repo, name, color: body.color ?? 'ededed', description: body.description ?? null, default: false }), 201);
|
|
57
|
+
},
|
|
58
|
+
// an edit may rename the label (`new_name`)
|
|
59
|
+
'issues/update-label': issuesUpdateLabel,
|
|
60
|
+
'issues/delete-label': issuesDeleteLabel,
|
|
61
|
+
'issues/list-milestones': issuesListMilestones,
|
|
62
|
+
'issues/get-milestone': async (ctx) => {
|
|
63
|
+
const { repo, seg, state } = gh(ctx);
|
|
64
|
+
const n = Number(decodeURIComponent(seg[4]));
|
|
65
|
+
const ms = state().milestones.find((m) => m.repository === repo && m.number === n);
|
|
66
|
+
return send(ctx, ms ? { status: 200, body: toGithubMilestone(ms) } : githubNotFound());
|
|
67
|
+
},
|
|
68
|
+
// numbered per repository: one past the highest
|
|
69
|
+
'issues/create-milestone': async (ctx) => {
|
|
70
|
+
const { repo, body, occurredAt, state, caller } = gh(ctx);
|
|
71
|
+
if (!body.title)
|
|
72
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Milestone', field: 'title', code: 'missing_field' }] }, 422);
|
|
73
|
+
let number = 0;
|
|
74
|
+
for (const m of state().milestones)
|
|
75
|
+
if (m.repository === repo)
|
|
76
|
+
number = Math.max(number, m.number);
|
|
77
|
+
number += 1;
|
|
78
|
+
const fields = { number, repository: repo, title: body.title, description: body.description, state: body.state ?? 'open', due_on: body.due_on, created_at: occurredAt, updated_at: occurredAt, creator_login: caller ?? GITHUB_WORLD_USER };
|
|
79
|
+
await put(ctx, { operation: 'milestone.create', subjectType: 'milestone', subjectId: `${repo}#milestone:${number}`, fields });
|
|
80
|
+
return ctx.reply(toGithubMilestone(state().milestones.find((m) => m.repository === repo && m.number === number)), 201);
|
|
81
|
+
},
|
|
82
|
+
'issues/update-milestone': async (ctx) => {
|
|
83
|
+
const { repo, seg, body, occurredAt, state } = gh(ctx);
|
|
84
|
+
const number = Number(decodeURIComponent(seg[4]));
|
|
85
|
+
if (!state().milestones.find((m) => m.id !== undefined && m.repository === repo && m.number === number))
|
|
86
|
+
return send(ctx, githubNotFound());
|
|
87
|
+
const fields = { number, repository: repo, updated_at: occurredAt };
|
|
88
|
+
if (body.title !== undefined)
|
|
89
|
+
fields.title = body.title;
|
|
90
|
+
if (body.description !== undefined)
|
|
91
|
+
fields.description = body.description;
|
|
92
|
+
if (body.state !== undefined)
|
|
93
|
+
fields.state = body.state;
|
|
94
|
+
if (body.due_on !== undefined)
|
|
95
|
+
fields.due_on = body.due_on;
|
|
96
|
+
await put(ctx, { operation: 'milestone.update', subjectType: 'milestone', subjectId: `${repo}#milestone:${number}`, fields });
|
|
97
|
+
return ctx.reply(toGithubMilestone(state().milestones.find((m) => m.repository === repo && m.number === number)));
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Semantics } from '@volter/world-core';
|
|
2
|
+
/** A .gitignore template's text, by its name (any case); undefined for a name the catalog lacks. */
|
|
3
|
+
export declare function gitignoreTemplate(name: string): string | undefined;
|
|
4
|
+
/** A license's text, by its key (any case); undefined for a key the catalog lacks. Given a holder, its placeholders are
|
|
5
|
+
* filled as the license's implementation note says: [year] with the year and [fullname] with the copyright holder. */
|
|
6
|
+
export declare function licenseTemplate(key: string, fill?: {
|
|
7
|
+
year: string;
|
|
8
|
+
fullname: string;
|
|
9
|
+
}): string | undefined;
|
|
10
|
+
export declare function metaSemantics(_opts: {
|
|
11
|
+
readOnly?: boolean;
|
|
12
|
+
}): Record<string, Semantics>;
|