@volter/twin-github 0.1.1 → 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,505 @@
|
|
|
1
|
+
// GitHub's GraphQL semantics on the GraphQL wire (../graphql-wire.ts): resolvers for what the stored
|
|
2
|
+
// objects do not hold as the schema names it, and the stored fields each type reads as they are.
|
|
3
|
+
// Every answer folds from the same projection the REST twin serves, over the request's handler
|
|
4
|
+
// interface (`context.ctx`, semanticsContext()): its tree, its clock, its write path. A mutation GitHub
|
|
5
|
+
// also publishes over REST writes through the REST twin's own path (in-process); the draft flag, which
|
|
6
|
+
// REST cannot set, is written through the context after the machine allows it. A query selecting a
|
|
7
|
+
// field nothing here models is answered with GitHub's undefinedField error by the wire's fallback.
|
|
8
|
+
//
|
|
9
|
+
// Global ids are the twin's: R_<owner>/<repo>, PR_<repo>#<n>, I_<repo>#<n>, IC_<repo>#<id> (an issue
|
|
10
|
+
// comment), D_<repo>#<n> (a discussion), DC_<repo>#<id> (its comment), DIC_<repo>#<id> (a category),
|
|
11
|
+
// LA_<repo>#<name> (a label), U_<login>.
|
|
12
|
+
import { GraphQLError } from 'graphql';
|
|
13
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
14
|
+
import {
|
|
15
|
+
GITHUB_WORLD_USER, applyGithubWrite, discussionCategoriesFor, nextRepoNumber, prPlaneDiffFiles, toGithubRest,
|
|
16
|
+
type GithubState,
|
|
17
|
+
} from '../github-twin.ts';
|
|
18
|
+
import type { FieldResolver, GraphqlContext } from '../graphql-wire.ts';
|
|
19
|
+
import { announce, gh, githubStateOf, nextSeq, put } from './shared.ts';
|
|
20
|
+
|
|
21
|
+
type Obj = Record<string, unknown>;
|
|
22
|
+
type Pr = GithubState['prs'][number];
|
|
23
|
+
type Issue = GithubState['issues'][number];
|
|
24
|
+
|
|
25
|
+
/** GitHub's GraphQL error: its message and its `type` (NOT_FOUND, UNPROCESSABLE). */
|
|
26
|
+
const fail = (message: string, type: string): never => {
|
|
27
|
+
throw new GraphQLError(message, { extensions: { type } });
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A connection as the twin answers one: every node, no further page. */
|
|
31
|
+
function conn(nodes: unknown[]): Obj {
|
|
32
|
+
return { nodes, edges: nodes.map((node, i) => ({ node, cursor: btoa(String(i)) })), totalCount: nodes.length, pageInfo: { hasNextPage: false, hasPreviousPage: false, endCursor: null, startCursor: null } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pullRequestNode(repo: string, p: Pr): Obj {
|
|
36
|
+
// the head's owner: the fork's, for a pull request from a fork
|
|
37
|
+
const owner = (p.head_repo ?? repo).split('/')[0]!;
|
|
38
|
+
return {
|
|
39
|
+
__typename: 'PullRequest',
|
|
40
|
+
_repo: repo,
|
|
41
|
+
_pr: p,
|
|
42
|
+
number: p.number,
|
|
43
|
+
title: p.title ?? null,
|
|
44
|
+
state: p.merged ? 'MERGED' : (p.state ?? 'open'),
|
|
45
|
+
id: `PR_${repo}#${p.number}`,
|
|
46
|
+
url: `https://github.com/${repo}/pull/${p.number}`,
|
|
47
|
+
baseRefName: p.base_ref ?? null,
|
|
48
|
+
headRefName: p.head_ref ?? null,
|
|
49
|
+
isCrossRepository: p.head_repo !== undefined && p.head_repo !== repo,
|
|
50
|
+
headRepositoryOwner: { __typename: 'User', id: `U_${owner}`, login: owner, name: null },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function issueNode(repo: string, i: Issue): Obj {
|
|
55
|
+
return { __typename: 'Issue', _repo: repo, _issue: i, number: i.number, title: i.title ?? null, state: i.state ?? 'open', id: `I_${repo}#${i.number}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const prsOf = (state: GithubState, repo: string) => state.prs.filter((p) => p.repository === repo && p.is_pull_request);
|
|
59
|
+
const issuesOf = (state: GithubState, repo: string) => state.issues.filter((i) => i.repository === repo);
|
|
60
|
+
|
|
61
|
+
/** The issue comments on an issue or a pull request, as GitHub's GraphQL renders them. */
|
|
62
|
+
function issueComments(state: GithubState, repo: string, number: number, kind: 'issues' | 'pull'): Obj[] {
|
|
63
|
+
return state.comments
|
|
64
|
+
.filter((c) => c.repository === repo && c.number === number && c.kind === 'issue')
|
|
65
|
+
.map((c) => ({
|
|
66
|
+
__typename: 'IssueComment', id: `IC_${repo}#${c.id}`, author: null, authorAssociation: 'NONE', body: c.body ?? '',
|
|
67
|
+
createdAt: c.created_at ?? null, includesCreatedEdit: false, isMinimized: false, minimizedReason: null,
|
|
68
|
+
reactionGroups: [], url: `https://github.com/${repo}/${kind}/${number}#issuecomment-${c.id}`, viewerDidAuthor: false,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Labels by name, with the repository catalog's colour and description where it holds the label. */
|
|
73
|
+
function labelNodes(state: GithubState, repo: string, names: string[] | undefined): Obj[] {
|
|
74
|
+
return (names ?? []).map((name) => {
|
|
75
|
+
const l = state.labels.find((x) => x.repository === repo && x.name === name);
|
|
76
|
+
return { __typename: 'Label', id: `LA_${repo}#${name}`, name, description: l?.description ?? null, color: l?.color ?? 'ededed' };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const userNodes = (logins: string[] | undefined): Obj[] => (logins ?? []).map((login) => ({ __typename: 'User', login, id: `U_${login}`, name: null }));
|
|
81
|
+
|
|
82
|
+
// GitHub GraphQL enum spellings for the rollup (StatusState / CheckStatusState / CheckConclusionState).
|
|
83
|
+
function statusStateEnum(s: string | undefined): string {
|
|
84
|
+
const v = (s ?? 'pending').toUpperCase();
|
|
85
|
+
return ['ERROR', 'EXPECTED', 'FAILURE', 'PENDING', 'SUCCESS'].includes(v) ? v : 'PENDING';
|
|
86
|
+
}
|
|
87
|
+
function checkStatusEnum(s: string | undefined): string {
|
|
88
|
+
const v = (s ?? 'queued').toUpperCase();
|
|
89
|
+
return ['QUEUED', 'IN_PROGRESS', 'COMPLETED', 'WAITING', 'PENDING', 'REQUESTED'].includes(v) ? v : 'QUEUED';
|
|
90
|
+
}
|
|
91
|
+
function checkConclusionEnum(s: string | undefined): string | null {
|
|
92
|
+
if (!s) return null;
|
|
93
|
+
const v = s.toUpperCase();
|
|
94
|
+
return ['ACTION_REQUIRED', 'TIMED_OUT', 'CANCELLED', 'FAILURE', 'SUCCESS', 'NEUTRAL', 'SKIPPED', 'STARTUP_FAILURE', 'STALE'].includes(v) ? v : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The closing-keyword issue linkage (Closes/Fixes/Resolves #n) real GitHub derives from the PR body. */
|
|
98
|
+
export function closingIssueNumbersFromBody(body: string | undefined): number[] {
|
|
99
|
+
const out: number[] = [];
|
|
100
|
+
for (const m of (body ?? '').matchAll(/\b(?:clos(?:e[sd]?|ing)|fix(?:e[sd]|ing)?|resolv(?:e[sd]?|ing))\s*:?\s+#(\d+)/gi)) {
|
|
101
|
+
const n = Number(m[1]);
|
|
102
|
+
if (!out.includes(n)) out.push(n);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The head commit's status-check rollup: each check run, and each status context once (the most
|
|
108
|
+
* recent wins), folded from the rows the REST checks surface reads. gh reads it as `commits(last: 1)`. */
|
|
109
|
+
function headCommits(state: GithubState, repo: string, pr: Pr): Obj[] {
|
|
110
|
+
const headSha = pr.head_sha ?? '';
|
|
111
|
+
const latestByContext = new Map<string, GithubState['statuses'][number]>();
|
|
112
|
+
for (const s of state.statuses.filter((s) => s.repository === repo && s.sha === headSha).sort((a, b) => a.id - b.id)) latestByContext.set(s.context ?? 'default', s);
|
|
113
|
+
const runs = state.checkRuns.filter((c) => c.repository === repo && c.head_sha === headSha).sort((a, b) => a.id - b.id);
|
|
114
|
+
const contexts: Obj[] = [
|
|
115
|
+
...runs.map((c) => ({
|
|
116
|
+
__typename: 'CheckRun', name: c.name, checkSuite: { __typename: 'CheckSuite', workflowRun: null },
|
|
117
|
+
status: checkStatusEnum(c.status), conclusion: checkConclusionEnum(c.conclusion),
|
|
118
|
+
startedAt: c.started_at ?? null, completedAt: c.completed_at ?? null, detailsUrl: c.details_url ?? null,
|
|
119
|
+
})),
|
|
120
|
+
...[...latestByContext.values()].map((s) => ({
|
|
121
|
+
__typename: 'StatusContext', context: s.context, state: statusStateEnum(s.state),
|
|
122
|
+
targetUrl: s.target_url ?? null, description: s.description ?? null, createdAt: s.created_at ?? null,
|
|
123
|
+
})),
|
|
124
|
+
];
|
|
125
|
+
const anyFailed = contexts.some((c) =>
|
|
126
|
+
(c.__typename === 'CheckRun' && ['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(String(c.conclusion)))
|
|
127
|
+
|| (c.__typename === 'StatusContext' && ['FAILURE', 'ERROR'].includes(String(c.state))));
|
|
128
|
+
const anyPending = contexts.some((c) =>
|
|
129
|
+
(c.__typename === 'CheckRun' && c.conclusion === null)
|
|
130
|
+
|| (c.__typename === 'StatusContext' && ['PENDING', 'EXPECTED'].includes(String(c.state))));
|
|
131
|
+
const rollupState = contexts.length === 0 ? null : anyFailed ? 'FAILURE' : anyPending ? 'PENDING' : 'SUCCESS';
|
|
132
|
+
return [{ __typename: 'PullRequestCommit', commit: { __typename: 'Commit', statusCheckRollup: rollupState === null ? null : { state: rollupState, contexts: conn(contexts) } } }];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The handler interface of the request a resolver runs in. */
|
|
136
|
+
const ctxOf = (c: GraphqlContext): SemanticsContext => c.ctx as SemanticsContext;
|
|
137
|
+
|
|
138
|
+
/** An earlier answer of a discussion another comment is now marked as, unmarked. */
|
|
139
|
+
async function unmarkAnswer(ctx: SemanticsContext, id: number): Promise<void> {
|
|
140
|
+
await put(ctx, { operation: 'discussion_comment.unmark_answer', subjectType: 'discussion_comment', subjectId: `discomment:${id}`, fields: { is_answer: false } });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function githubGraphql(): { resolvers: Record<string, FieldResolver>; stored: Record<string, string[]> } {
|
|
144
|
+
const state = (c: GraphqlContext): GithubState => githubStateOf(ctxOf(c));
|
|
145
|
+
const pr = (s: Obj): Pr => s._pr as Pr;
|
|
146
|
+
const issue = (s: Obj): Issue => s._issue as Issue;
|
|
147
|
+
/** A write through the REST twin's own path (the fetch, in-process), at this request's root. */
|
|
148
|
+
const write = (c: GraphqlContext, method: string, path: string, body: unknown, occurredAt?: string) => {
|
|
149
|
+
const root = ctxOf(c).root;
|
|
150
|
+
return applyGithubWrite({ method, path, body: JSON.stringify(body), ...(root !== undefined ? { root } : {}), ...(occurredAt ? { occurredAt } : {}) });
|
|
151
|
+
};
|
|
152
|
+
/** markPullRequestReadyForReview / convertPullRequestToDraft: an open pull request's draft
|
|
153
|
+
* flag, as the pull request's machines allow it (GraphQL is `external` to the REST operations). */
|
|
154
|
+
const readiness = (draft: boolean): FieldResolver => async (_s, args, c) => {
|
|
155
|
+
const ctx = ctxOf(c);
|
|
156
|
+
const input = (args.input ?? {}) as { pullRequestId?: string; clientMutationId?: string };
|
|
157
|
+
const match = /^PR_(.+)#(\d+)$/.exec(String(input.pullRequestId ?? ''));
|
|
158
|
+
const pull = match && state(c).prs.find((p) => p.is_pull_request && p.repository === match[1] && p.number === Number(match[2]));
|
|
159
|
+
if (!pull) return fail('Could not resolve to a PullRequest node', 'NOT_FOUND');
|
|
160
|
+
const op = draft ? 'graphql/convertPullRequestToDraft' : 'graphql/markPullRequestReadyForReview';
|
|
161
|
+
const refused = ctx.legal('pull-request', 'state', op, pull.state, undefined, pull.id, 'external')
|
|
162
|
+
?? ctx.legal('pull-request', 'draft', op, pull.draft, String(draft), pull.id, 'external');
|
|
163
|
+
if (refused) return fail(refused.message, 'UNPROCESSABLE');
|
|
164
|
+
await put(ctx, { operation: 'pull_request.update', subjectType: 'pull_request', subjectId: pull.id, fields: { number: pull.number, repository: pull.repository, draft } });
|
|
165
|
+
const st = state(c);
|
|
166
|
+
const changed = st.prs.find((p) => p.id === pull.id);
|
|
167
|
+
if (!changed || changed.draft !== draft) return fail('Could not change pull request draft state', 'UNPROCESSABLE');
|
|
168
|
+
await announce(ctx, [{ event: 'pull_request', action: draft ? 'converted_to_draft' : 'ready_for_review', repository: pull.repository, number: pull.number, pr: toGithubRest(changed, st.milestones) }]);
|
|
169
|
+
return { clientMutationId: input.clientMutationId ?? null, pullRequest: pullRequestNode(pull.repository, changed) };
|
|
170
|
+
};
|
|
171
|
+
/** A stored project as GraphQL answers it: its node id, number, title and state. */
|
|
172
|
+
const projectNode = (p: Record<string, unknown>, owner: string) => ({
|
|
173
|
+
__typename: 'ProjectV2', id: `PVT_${String(p.id)}`, number: p.number, title: p.title, closed: p.closed === true, public: p.public === true,
|
|
174
|
+
url: `https://github.com/orgs/${owner}/projects/${String(p.number)}`, shortDescription: p.short_description ?? null, readme: p.readme ?? null,
|
|
175
|
+
});
|
|
176
|
+
const queryNode: FieldResolver = (_s, args, c) => {
|
|
177
|
+
const m = /^(PR|I)_(.+)#(\d+)$/.exec(String(args.id ?? ''));
|
|
178
|
+
if (!m) throw new Error(`Could not resolve to a node with the global id of '${String(args.id ?? '')}'`);
|
|
179
|
+
const repo = m[2]!;
|
|
180
|
+
const number = Number(m[3]);
|
|
181
|
+
const s = state(c);
|
|
182
|
+
const hit = m[1] === 'PR' ? prsOf(s, repo).find((p) => p.number === number) : issuesOf(s, repo).find((i) => i.number === number);
|
|
183
|
+
if (!hit) throw new Error(`Could not resolve to a node with the global id of '${String(args.id)}'`);
|
|
184
|
+
return m[1] === 'PR' ? pullRequestNode(repo, hit as Pr) : issueNode(repo, hit as Issue);
|
|
185
|
+
};
|
|
186
|
+
const repositoryPullRequests: FieldResolver = (r, args, c) => {
|
|
187
|
+
const repo = String(r._repo);
|
|
188
|
+
const states = Array.isArray(args.states) ? args.states.map((s) => String(s).toUpperCase()) : undefined;
|
|
189
|
+
return prsOf(state(c), repo)
|
|
190
|
+
.map((p) => pullRequestNode(repo, p))
|
|
191
|
+
.filter((p) => args.headRefName === undefined || p.headRefName === args.headRefName)
|
|
192
|
+
.filter((p) => states === undefined || states.includes(String(p.state).toUpperCase()));
|
|
193
|
+
};
|
|
194
|
+
const repositoryPullRequest: FieldResolver = (r, args, c) => {
|
|
195
|
+
const repo = String(r._repo);
|
|
196
|
+
const hit = prsOf(state(c), repo).find((p) => p.number === Number(args.number));
|
|
197
|
+
return hit ? pullRequestNode(repo, hit) : fail(`Could not resolve to a PullRequest with the number of ${String(args.number)}.`, 'NOT_FOUND');
|
|
198
|
+
};
|
|
199
|
+
const repositoryIssue: FieldResolver = (r, args, c) => {
|
|
200
|
+
const repo = String(r._repo);
|
|
201
|
+
const hit = issuesOf(state(c), repo).find((i) => i.number === Number(args.number));
|
|
202
|
+
return hit ? issueNode(repo, hit) : fail(`Could not resolve to an Issue with the number of ${String(args.number)}.`, 'NOT_FOUND');
|
|
203
|
+
};
|
|
204
|
+
const repositoryDiscussions: FieldResolver = (r, _args, c) => {
|
|
205
|
+
const repo = String(r._repo);
|
|
206
|
+
const st = state(c);
|
|
207
|
+
const categories = discussionCategoriesFor(repo);
|
|
208
|
+
return conn(st.discussions.filter((d) => d.repository === repo).map((d) => {
|
|
209
|
+
const cat = categories.find((c) => c.slug === d.category_slug);
|
|
210
|
+
const comments = st.discussionComments.filter((c) => c.repository === repo && c.discussion_number === d.number).map((c) => ({
|
|
211
|
+
__typename: 'DiscussionComment', id: `DC_${repo}#${c.id}`, body: c.body ?? '', createdAt: c.created_at ?? null, author: null, isAnswer: Boolean(c.is_answer),
|
|
212
|
+
url: `https://github.com/${repo}/discussions/${d.number}#discussioncomment-${c.id}`,
|
|
213
|
+
replyTo: c.parent_id ? { __typename: 'DiscussionComment', id: `DC_${repo}#${c.parent_id}` } : null,
|
|
214
|
+
}));
|
|
215
|
+
return {
|
|
216
|
+
__typename: 'Discussion', id: `D_${repo}#${d.number}`, number: d.number, title: d.title ?? null, body: d.body ?? '', url: `https://github.com/${repo}/discussions/${d.number}`,
|
|
217
|
+
createdAt: d.created_at ?? null, updatedAt: d.updated_at ?? null, author: null, closed: d.state === 'closed', locked: Boolean(d.locked),
|
|
218
|
+
isAnswered: d.answer_comment_id !== undefined && d.answer_comment_id !== null,
|
|
219
|
+
category: cat ? { __typename: 'DiscussionCategory', id: `DIC_${repo}#${cat.id}`, name: cat.name, slug: cat.slug, isAnswerable: Boolean(cat.is_answerable) } : null,
|
|
220
|
+
comments: conn(comments),
|
|
221
|
+
};
|
|
222
|
+
}));
|
|
223
|
+
};
|
|
224
|
+
const repositoryDiscussionCategories: FieldResolver = (r, args, c) => {
|
|
225
|
+
const repo = String(r._repo);
|
|
226
|
+
const st = state(c);
|
|
227
|
+
if (!st.repos.some((x) => x.full_name === repo)) return fail('Repository not found', 'NOT_FOUND');
|
|
228
|
+
const all = discussionCategoriesFor(repo).map((c) => ({ __typename: 'DiscussionCategory', id: `DIC_${repo}#${c.id}`, name: c.name, slug: c.slug, description: c.description, isAnswerable: Boolean(c.is_answerable) }));
|
|
229
|
+
const first = typeof args.first === 'number' ? args.first : all.length;
|
|
230
|
+
const after = typeof args.after === 'string' ? args.after : undefined;
|
|
231
|
+
const start = after ? all.findIndex((c) => c.id === after) + 1 : 0;
|
|
232
|
+
if (first < 0 || first > 100 || (after && start === 0)) return fail('Invalid discussion category pagination', 'UNPROCESSABLE');
|
|
233
|
+
const nodes = all.slice(start, start + first);
|
|
234
|
+
return { nodes, edges: nodes.map((node) => ({ cursor: node.id, node })), totalCount: all.length, pageInfo: { hasNextPage: start + nodes.length < all.length, hasPreviousPage: start > 0, endCursor: nodes.at(-1)?.id ?? null, startCursor: nodes[0]?.id ?? null } };
|
|
235
|
+
};
|
|
236
|
+
const pullRequestChangedFiles: FieldResolver = async (s, _args, c) => {
|
|
237
|
+
const p = pr(s);
|
|
238
|
+
const planeFiles = p.changed_files === undefined ? await prPlaneDiffFiles(state(c), String(s._repo), p, ctxOf(c).root) : null;
|
|
239
|
+
return p.changed_files ?? planeFiles?.length ?? 0;
|
|
240
|
+
};
|
|
241
|
+
const pullRequestClosingIssuesReferences: FieldResolver = (s, _args, c) => {
|
|
242
|
+
const repo = String(s._repo);
|
|
243
|
+
const st = state(c);
|
|
244
|
+
return conn(closingIssueNumbersFromBody(pr(s).body).map((n) => st.issues.find((i) => i.repository === repo && i.number === n)).filter((i): i is Issue => !!i).map((i) => issueNode(repo, i)));
|
|
245
|
+
};
|
|
246
|
+
const mutationAddComment: FieldResolver = async (_s, args, c) => {
|
|
247
|
+
const input = (args.input ?? {}) as { subjectId?: string; body?: string };
|
|
248
|
+
const subjectId = String(input.subjectId ?? '');
|
|
249
|
+
const body = String(input.body ?? '');
|
|
250
|
+
const m = /^(PR|I)_(.+)#(\d+)$/.exec(subjectId);
|
|
251
|
+
if (!m) return fail(`Could not resolve to a node with the global id of '${subjectId}'`, 'NOT_FOUND');
|
|
252
|
+
const repo = m[2]!;
|
|
253
|
+
const res = await write(c, 'POST', `/repos/${repo}/issues/${Number(m[3])}/comments`, { body });
|
|
254
|
+
if (res.response.status !== 201) return fail('addComment failed', 'UNPROCESSABLE');
|
|
255
|
+
const cid = (res.response.body as { id?: number }).id;
|
|
256
|
+
return { commentEdge: { cursor: btoa('0'), node: { __typename: 'IssueComment', id: `IC_${repo}#${cid}`, body } } };
|
|
257
|
+
};
|
|
258
|
+
const mutationCreatePullRequest: FieldResolver = async (_s, args, c) => {
|
|
259
|
+
const input = (args.input ?? {}) as Record<string, unknown>;
|
|
260
|
+
const m = /^R_(.+)$/.exec(String(input.repositoryId ?? ''));
|
|
261
|
+
if (!m) return fail(`Could not resolve to a Repository with the global id of '${String(input.repositoryId ?? '')}'`, 'NOT_FOUND');
|
|
262
|
+
const repo = m[1]!;
|
|
263
|
+
const res = await write(c, 'POST', `/repos/${repo}/pulls`, { title: input.title, head: input.headRefName, base: input.baseRefName, body: input.body, draft: input.draft === true });
|
|
264
|
+
if (res.response.status !== 201) return fail('createPullRequest failed', 'UNPROCESSABLE');
|
|
265
|
+
const number = (res.response.body as { number?: number }).number;
|
|
266
|
+
const made = prsOf(state(c), repo).find((p) => p.number === number);
|
|
267
|
+
return { clientMutationId: input.clientMutationId ?? null, pullRequest: made ? pullRequestNode(repo, made) : { __typename: 'PullRequest', id: `PR_${repo}#${number}`, url: `https://github.com/${repo}/pull/${number}` } };
|
|
268
|
+
};
|
|
269
|
+
const mutationUpdateProjectV2: FieldResolver = async (_s, args, c) => {
|
|
270
|
+
const ctx = ctxOf(c);
|
|
271
|
+
const input = (args.input ?? {}) as { projectId?: string; title?: string; closed?: boolean; public?: boolean; readme?: string; shortDescription?: string; clientMutationId?: string };
|
|
272
|
+
const id = /^PVT_(\d+)$/.exec(String(input.projectId ?? ''))?.[1];
|
|
273
|
+
const project = id ? state(c).projects.find((p) => p.id === Number(id)) : undefined;
|
|
274
|
+
if (!project) return fail(`Could not resolve to a node with the global id of '${String(input.projectId ?? '')}'`, 'NOT_FOUND');
|
|
275
|
+
// closing and reopening are the project's moves (manifest.ts), stored as its `closed` flag
|
|
276
|
+
if (input.closed !== undefined) {
|
|
277
|
+
const refused = ctx.legal('projects-v2', 'state', 'graphql/updateProjectV2', project.closed ? 'closed' : 'open', input.closed ? 'closed' : 'open', String(project.id), 'external');
|
|
278
|
+
if (refused) return fail(refused.message, 'UNPROCESSABLE');
|
|
279
|
+
}
|
|
280
|
+
const patch = { ...(input.title !== undefined ? { title: input.title } : {}), ...(input.closed !== undefined ? { closed: input.closed } : {}), ...(input.public !== undefined ? { public: input.public } : {}), ...(input.readme !== undefined ? { readme: input.readme } : {}), ...(input.shortDescription !== undefined ? { short_description: input.shortDescription } : {}) };
|
|
281
|
+
await put(ctx, { operation: 'project.update', subjectType: 'project', subjectId: `project:${project.id}`, fields: { ...patch, updated_at: ctx.occurredAt } });
|
|
282
|
+
const updated = state(c).projects.find((p) => p.id === project.id)!;
|
|
283
|
+
return { clientMutationId: input.clientMutationId ?? null, projectV2: projectNode(updated as unknown as Record<string, unknown>, project.owner) };
|
|
284
|
+
};
|
|
285
|
+
return {
|
|
286
|
+
resolvers: {
|
|
287
|
+
'Query.viewer': (_s, _args, c) => ({ __typename: 'User', login: 'octocat', name: state(c).userProfile.name ?? 'The Octocat' }),
|
|
288
|
+
'Query.node': queryNode,
|
|
289
|
+
'Query.repository': (_s, args, c) => {
|
|
290
|
+
const repo = `${String(args.owner)}/${String(args.name)}`;
|
|
291
|
+
return { _repo: repo, _known: state(c).repos.find((r) => r.full_name === repo) ?? null, name: args.name, owner: { __typename: 'User', login: args.owner } };
|
|
292
|
+
},
|
|
293
|
+
'Repository.id': (r, _args, c) => `R_${String(r._repo)}`,
|
|
294
|
+
'Repository.name': (r, _args, c) => r.name ?? String(r._repo).split('/')[1],
|
|
295
|
+
'Repository.owner': (r, _args, c) => r.owner ?? { __typename: 'User', id: `U_${String(r._repo).split('/')[0]}`, login: String(r._repo).split('/')[0] },
|
|
296
|
+
'Repository.nameWithOwner': (r, _args, c) => r._repo,
|
|
297
|
+
'Repository.isPrivate': (r, _args, c) => (r._known as Obj | null)?.private ?? false,
|
|
298
|
+
'Repository.description': (r, _args, c) => (r._known as Obj | null)?.description ?? null,
|
|
299
|
+
'Repository.hasIssuesEnabled': (r, _args, c) => (r._known as Obj | null)?.has_issues ?? true,
|
|
300
|
+
'Repository.hasWikiEnabled': (r, _args, c) => (r._known as Obj | null)?.has_wiki ?? true,
|
|
301
|
+
// the twin's loopback caller is the repository's unauthenticated owner
|
|
302
|
+
'Repository.viewerPermission': () => 'ADMIN',
|
|
303
|
+
'Repository.defaultBranchRef': (r, _args, c) => ({ name: (r._known as Obj | null)?.default_branch ?? 'main' }),
|
|
304
|
+
'Repository.parent': () => null,
|
|
305
|
+
'Repository.mergeCommitAllowed': () => true,
|
|
306
|
+
'Repository.rebaseMergeAllowed': () => true,
|
|
307
|
+
'Repository.squashMergeAllowed': () => true,
|
|
308
|
+
'Repository.pullRequests': repositoryPullRequests,
|
|
309
|
+
'Repository.issues': (r, _args, c) => issuesOf(state(c), String(r._repo)).map((i) => issueNode(String(r._repo), i)),
|
|
310
|
+
// ONE pull request by number: the read `gh pr view` sends
|
|
311
|
+
'Repository.pullRequest': repositoryPullRequest,
|
|
312
|
+
// ONE issue by number: `gh issue view`
|
|
313
|
+
'Repository.issue': repositoryIssue,
|
|
314
|
+
// Discussions: GitHub serves them over GraphQL alone. Every discussion, with its comments.
|
|
315
|
+
'Repository.discussions': repositoryDiscussions,
|
|
316
|
+
// One of a repository's discussion categories by its slug, or null (docs.github.com/graphql/reference/objects#repository)
|
|
317
|
+
'Repository.discussionCategory': (r, args, c) => {
|
|
318
|
+
const repo = String(r._repo);
|
|
319
|
+
if (!state(c).repos.some((x) => x.full_name === repo)) return fail('Repository not found', 'NOT_FOUND');
|
|
320
|
+
const cat = discussionCategoriesFor(repo).find((x) => x.slug === String(args.slug ?? ''));
|
|
321
|
+
return cat ? { __typename: 'DiscussionCategory', id: `DIC_${repo}#${cat.id}`, name: cat.name, slug: cat.slug, description: cat.description, isAnswerable: Boolean(cat.is_answerable) } : null;
|
|
322
|
+
},
|
|
323
|
+
// A repository's discussion categories, paged by category id; the repository must exist.
|
|
324
|
+
'Repository.discussionCategories': repositoryDiscussionCategories,
|
|
325
|
+
// A pull request's view, folded from the REST projection: the head is its resolved sha; the
|
|
326
|
+
// closing issues are the body's closing keywords (as GitHub derives them); changedFiles is the
|
|
327
|
+
// carried count, the carried file list, or the plane's real diff, 0 only without evidence.
|
|
328
|
+
'PullRequest.body': (s, _args, c) => pr(s).body ?? '',
|
|
329
|
+
'PullRequest.isDraft': (s, _args, c) => pr(s).draft ?? false,
|
|
330
|
+
'PullRequest.headRefOid': (s, _args, c) => pr(s).head_sha ?? '',
|
|
331
|
+
'PullRequest.changedFiles': pullRequestChangedFiles,
|
|
332
|
+
'PullRequest.additions': (s, _args, c) => pr(s).additions ?? 0,
|
|
333
|
+
'PullRequest.deletions': (s, _args, c) => pr(s).deletions ?? 0,
|
|
334
|
+
'PullRequest.createdAt': (s, _args, c) => pr(s).created_at ?? null,
|
|
335
|
+
'PullRequest.updatedAt': (s, _args, c) => pr(s).updated_at ?? pr(s).created_at ?? null,
|
|
336
|
+
'PullRequest.closedAt': () => null,
|
|
337
|
+
'PullRequest.mergedAt': (s, _args, c) => pr(s).merged_at ?? null,
|
|
338
|
+
'PullRequest.author': () => null,
|
|
339
|
+
'PullRequest.labels': (s, _args, c) => conn(labelNodes(state(c), String(s._repo), pr(s).labels)),
|
|
340
|
+
'PullRequest.assignees': (s, _args, c) => conn(userNodes(pr(s).assignees)),
|
|
341
|
+
'PullRequest.reviewRequests': (s, _args, c) => conn((pr(s).requested_reviewers ?? []).map((login) => ({ __typename: 'ReviewRequest', requestedReviewer: { __typename: 'User', login } }))),
|
|
342
|
+
'PullRequest.comments': (s, _args, c) => conn(issueComments(state(c), String(s._repo), pr(s).number, 'pull')),
|
|
343
|
+
'PullRequest.commits': (s, _args, c) => conn(headCommits(state(c), String(s._repo), pr(s))),
|
|
344
|
+
'PullRequest.closingIssuesReferences': pullRequestClosingIssuesReferences,
|
|
345
|
+
'Issue.url': (s, _args, c) => `https://github.com/${String(s._repo)}/issues/${issue(s).number}`,
|
|
346
|
+
'Issue.body': (s, _args, c) => issue(s).body ?? '',
|
|
347
|
+
'Issue.author': () => null,
|
|
348
|
+
'Issue.createdAt': (s, _args, c) => issue(s).created_at ?? null,
|
|
349
|
+
'Issue.updatedAt': (s, _args, c) => issue(s).updated_at ?? issue(s).created_at ?? null,
|
|
350
|
+
'Issue.closedAt': () => null,
|
|
351
|
+
'Issue.labels': (s, _args, c) => conn(labelNodes(state(c), String(s._repo), issue(s).labels)),
|
|
352
|
+
'Issue.assignees': (s, _args, c) => conn(userNodes(issue(s).assignees)),
|
|
353
|
+
'Issue.comments': (s, _args, c) => conn(issueComments(state(c), String(s._repo), issue(s).number, 'issues')),
|
|
354
|
+
'Issue.repository': (s, _args, c) => ({ _repo: s._repo, _known: state(c).repos.find((r) => r.full_name === s._repo) ?? null }),
|
|
355
|
+
// a comment on an issue or pull request, written through the REST twin's comment path
|
|
356
|
+
'Mutation.addComment': mutationAddComment,
|
|
357
|
+
// a comment on a discussion (or a reply, replyToId), written through the discussion comment path
|
|
358
|
+
'Mutation.addDiscussionComment': async (_s, args, c) => {
|
|
359
|
+
const input = (args.input ?? {}) as { discussionId?: string; body?: string; replyToId?: string };
|
|
360
|
+
const discussionId = String(input.discussionId ?? '');
|
|
361
|
+
const body = String(input.body ?? '');
|
|
362
|
+
const m = /^D_(.+)#(\d+)$/.exec(discussionId);
|
|
363
|
+
if (!m) return fail(`Could not resolve to a node with the global id of '${discussionId}'`, 'NOT_FOUND');
|
|
364
|
+
const repo = m[1]!;
|
|
365
|
+
const number = Number(m[2]);
|
|
366
|
+
const replyTo = /^DC_(.+)#(\d+)$/.exec(String(input.replyToId ?? ''));
|
|
367
|
+
if (!state(c).discussions.some((d) => d.repository === repo && d.number === number)) return fail(`Could not resolve to a node with the global id of '${discussionId}'`, 'NOT_FOUND');
|
|
368
|
+
if (!body) return fail('Body can\'t be blank', 'UNPROCESSABLE');
|
|
369
|
+
const ctx = ctxOf(c);
|
|
370
|
+
const cid = nextSeq(ctx, 'discomment');
|
|
371
|
+
await put(ctx, { operation: 'discussion_comment.create', subjectType: 'discussion_comment', subjectId: `discomment:${cid}`, fields: { repository: repo, discussion_number: number, parent_id: replyTo ? Number(replyTo[2]) : null, body, is_answer: false, created_at: ctx.occurredAt, updated_at: ctx.occurredAt } });
|
|
372
|
+
await announce(ctx, [{ event: 'discussion_comment', action: 'created', repository: repo, number }]);
|
|
373
|
+
return { comment: { __typename: 'DiscussionComment', id: `DC_${repo}#${cid}`, body, url: `https://github.com/${repo}/discussions/${number}#discussioncomment-${cid}` } };
|
|
374
|
+
},
|
|
375
|
+
// gh pr create's mutation, through the REST create path
|
|
376
|
+
'Mutation.createPullRequest': mutationCreatePullRequest,
|
|
377
|
+
// A discussion in an existing category, through the discussion create path.
|
|
378
|
+
'Mutation.createDiscussion': async (_s, args, c) => {
|
|
379
|
+
const input = (args.input ?? {}) as { repositoryId?: string; categoryId?: string; title?: string; body?: string; clientMutationId?: string };
|
|
380
|
+
const repo = /^R_(.+)$/.exec(String(input.repositoryId ?? ''))?.[1];
|
|
381
|
+
const st = state(c);
|
|
382
|
+
if (!repo || !st.repos.some((r) => r.full_name === repo)) return fail('Repository not found', 'NOT_FOUND');
|
|
383
|
+
const category = discussionCategoriesFor(repo).find((c) => `DIC_${repo}#${c.id}` === input.categoryId);
|
|
384
|
+
if (!category) return fail('Discussion category not found in repository', 'NOT_FOUND');
|
|
385
|
+
if (!input.title) return fail('Title can\'t be blank', 'UNPROCESSABLE');
|
|
386
|
+
const ctx = ctxOf(c);
|
|
387
|
+
const number = nextRepoNumber(st, repo);
|
|
388
|
+
await put(ctx, { operation: 'discussion.create', subjectType: 'discussion', subjectId: `discussion:${repo}#${number}`, fields: { number, repository: repo, title: String(input.title), body: input.body ?? '', category_slug: category.slug, state: 'open', locked: false, created_at: ctx.occurredAt, updated_at: ctx.occurredAt } });
|
|
389
|
+
await announce(ctx, [{ event: 'discussion', action: 'created', repository: repo, number }]);
|
|
390
|
+
const d = state(c).discussions.find((x) => x.repository === repo && x.number === number)!;
|
|
391
|
+
return {
|
|
392
|
+
clientMutationId: input.clientMutationId ?? null,
|
|
393
|
+
discussion: {
|
|
394
|
+
__typename: 'Discussion', id: `D_${repo}#${number}`, number, title: d.title, body: d.body, url: `https://github.com/${repo}/discussions/${number}`, createdAt: d.created_at, updatedAt: d.updated_at,
|
|
395
|
+
category: { __typename: 'DiscussionCategory', id: `DIC_${repo}#${category.id}`, name: category.name, slug: category.slug, description: category.description, isAnswerable: Boolean(category.is_answerable) },
|
|
396
|
+
closed: false, locked: false, isAnswered: false, comments: conn([]),
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
},
|
|
400
|
+
// the comment that answers a question, in a discussion whose category takes answers (Q&A); it replaces an earlier answer
|
|
401
|
+
'Mutation.markDiscussionCommentAsAnswer': async (_s, args, c) => {
|
|
402
|
+
const input = (args.input ?? {}) as { id?: string; clientMutationId?: string };
|
|
403
|
+
const m = /^DC_(.+)#(\d+)$/.exec(String(input.id ?? ''));
|
|
404
|
+
const st = state(c);
|
|
405
|
+
const comment = m ? st.discussionComments.find((x) => x.repository === m[1] && x.id === Number(m[2])) : undefined;
|
|
406
|
+
if (!m || !comment) return fail(`Could not resolve to a node with the global id of '${String(input.id ?? '')}'`, 'NOT_FOUND');
|
|
407
|
+
const repo = m[1]!;
|
|
408
|
+
const d = st.discussions.find((x) => x.repository === repo && x.number === comment.discussion_number)!;
|
|
409
|
+
if (!discussionCategoriesFor(repo).find((x) => x.slug === d.category_slug)?.is_answerable) return fail('Discussion is not answerable', 'UNPROCESSABLE');
|
|
410
|
+
const ctx = ctxOf(c);
|
|
411
|
+
for (const earlier of st.discussionComments.filter((x) => x.repository === repo && x.discussion_number === d.number && x.is_answer && x.id !== comment.id)) await unmarkAnswer(ctx, earlier.id);
|
|
412
|
+
await put(ctx, { operation: 'discussion_comment.mark_answer', subjectType: 'discussion_comment', subjectId: `discomment:${comment.id}`, fields: { is_answer: true } });
|
|
413
|
+
await announce(ctx, [{ event: 'discussion', action: 'answered', repository: repo, number: d.number }]);
|
|
414
|
+
return { clientMutationId: input.clientMutationId ?? null, discussion: { __typename: 'Discussion', id: `D_${repo}#${d.number}`, number: d.number, title: d.title, isAnswered: true } };
|
|
415
|
+
},
|
|
416
|
+
// an organization by its login: the owner a project is created under
|
|
417
|
+
// a person by their login (the id a project's collaborator is granted by)
|
|
418
|
+
'Query.user': (_s, args, c) => {
|
|
419
|
+
const login = String(args.login ?? '');
|
|
420
|
+
const st = state(c);
|
|
421
|
+
const known = login === GITHUB_WORLD_USER || st.oauthGrants.some((g) => g.login === login) || st.orgMembers.some((m) => m.login === login) || st.collaborators.some((x) => x.login === login) || st.repos.some((r) => r.owner === login);
|
|
422
|
+
return known ? { __typename: 'User', login, name: null, id: `U_${login}` } : fail(`Could not resolve to a User with the login of '${login}'.`, 'NOT_FOUND');
|
|
423
|
+
},
|
|
424
|
+
'Query.organization': (_s, args, c) => {
|
|
425
|
+
const org = state(c).orgs.find((o) => o.login === String(args.login ?? ''));
|
|
426
|
+
if (!org) return fail(`Could not resolve to an Organization with the login of '${String(args.login ?? '')}'.`, 'NOT_FOUND');
|
|
427
|
+
return { __typename: 'Organization', login: org.login, name: org.name ?? org.login, id: `O_${org.login}` };
|
|
428
|
+
},
|
|
429
|
+
// Projects are created and edited through GraphQL only (docs.github.com/issues/planning-and-tracking-with-projects/
|
|
430
|
+
// automating-your-project/using-the-api-to-manage-projects); the REST API reads them
|
|
431
|
+
'Mutation.createProjectV2': async (_s, args, c) => {
|
|
432
|
+
const ctx = ctxOf(c);
|
|
433
|
+
const input = (args.input ?? {}) as { ownerId?: string; title?: string; clientMutationId?: string };
|
|
434
|
+
const login = /^O_(.+)$/.exec(String(input.ownerId ?? ''))?.[1];
|
|
435
|
+
const st = state(c);
|
|
436
|
+
if (!login || !st.orgs.some((o) => o.login === login)) return fail(`Could not resolve to a node with the global id of '${String(input.ownerId ?? '')}'`, 'NOT_FOUND');
|
|
437
|
+
if (!input.title) return fail('Title can\'t be blank', 'UNPROCESSABLE');
|
|
438
|
+
const number = Math.max(0, ...st.projects.filter((p) => p.owner === login && p.owner_type === 'organization').map((p) => p.number)) + 1;
|
|
439
|
+
const id = nextSeq(ctx, 'project');
|
|
440
|
+
await put(ctx, { operation: 'project.create', subjectType: 'project', subjectId: `project:${id}`, fields: { owner: login, owner_type: 'organization', number, title: input.title, short_description: null, readme: null, public: false, closed: false, created_at: ctx.occurredAt, updated_at: ctx.occurredAt, creator_login: gh(ctx).caller ?? GITHUB_WORLD_USER } });
|
|
441
|
+
const made = state(c).projects.find((p) => p.id === id)!;
|
|
442
|
+
return { clientMutationId: input.clientMutationId ?? null, projectV2: projectNode(made as unknown as Record<string, unknown>, login) };
|
|
443
|
+
},
|
|
444
|
+
'Mutation.updateProjectV2': mutationUpdateProjectV2,
|
|
445
|
+
// A project's collaborators and their roles, which a project's admin grants (its Manage access settings, or this
|
|
446
|
+
// mutation): a person by their user id, ADMIN, WRITER or READER, or NONE to take their access away
|
|
447
|
+
// (docs.github.com/graphql/reference/mutations#updateprojectv2collaborators,
|
|
448
|
+
// docs.github.com/issues/planning-and-tracking-with-projects/managing-your-project/managing-access-to-your-projects).
|
|
449
|
+
// Where the documentation stops and the twin decides: a team's id is not modeled, only a person's.
|
|
450
|
+
'Mutation.updateProjectV2Collaborators': async (_s, args, c) => {
|
|
451
|
+
const ctx = ctxOf(c);
|
|
452
|
+
const input = (args.input ?? {}) as { projectId?: string; collaborators?: Array<{ userId?: string; teamId?: string; role?: string }>; clientMutationId?: string };
|
|
453
|
+
const pid = Number(/^PVT_(\d+)$/.exec(String(input.projectId ?? ''))?.[1]);
|
|
454
|
+
const project = state(c).projects.find((p) => p.id === pid);
|
|
455
|
+
if (!project) return fail(`Could not resolve to a node with the global id of '${String(input.projectId ?? '')}'`, 'NOT_FOUND');
|
|
456
|
+
const granted: Obj[] = [];
|
|
457
|
+
for (const who of input.collaborators ?? []) {
|
|
458
|
+
const login = /^U_(.+)$/.exec(String(who.userId ?? ''))?.[1];
|
|
459
|
+
if (!login) return fail(`Could not resolve to a node with the global id of '${String(who.userId ?? who.teamId ?? '')}'`, 'NOT_FOUND');
|
|
460
|
+
const role = String(who.role ?? '').toUpperCase();
|
|
461
|
+
if (!['ADMIN', 'WRITER', 'READER', 'NONE'].includes(role)) return fail(`Argument 'role' on InputObject 'ProjectV2Collaborator' has an invalid value (${role}).`, 'UNPROCESSABLE');
|
|
462
|
+
await put(ctx, { operation: role === 'NONE' ? 'project_collaborator.remove' : 'project_collaborator.grant', subjectType: 'project_collaborator', subjectId: `projectcollab:${pid}:${login}`, fields: role === 'NONE' ? { deleted: true, project_id: pid, login } : { project_id: pid, login, role } });
|
|
463
|
+
granted.push({ __typename: 'User', login, id: `U_${login}` });
|
|
464
|
+
}
|
|
465
|
+
return { clientMutationId: input.clientMutationId ?? null, collaborators: granted };
|
|
466
|
+
},
|
|
467
|
+
// an open pull request's draft flag (GitHub's REST edit has no draft parameter)
|
|
468
|
+
'Mutation.markPullRequestReadyForReview': readiness(false),
|
|
469
|
+
'Mutation.convertPullRequestToDraft': readiness(true),
|
|
470
|
+
},
|
|
471
|
+
stored: {
|
|
472
|
+
User: ['login', 'name', 'id'],
|
|
473
|
+
Organization: ['login', 'name', 'id'],
|
|
474
|
+
ProjectV2: ['id', 'number', 'title', 'closed', 'public', 'url', 'shortDescription', 'readme'],
|
|
475
|
+
CreateProjectV2Payload: ['clientMutationId', 'projectV2'],
|
|
476
|
+
UpdateProjectV2Payload: ['clientMutationId', 'projectV2'],
|
|
477
|
+
UpdateProjectV2CollaboratorsPayload: ['clientMutationId', 'collaborators'],
|
|
478
|
+
Ref: ['name'],
|
|
479
|
+
PullRequest: ['number', 'title', 'state', 'id', 'url', 'baseRefName', 'headRefName', 'isCrossRepository', 'headRepositoryOwner'],
|
|
480
|
+
Issue: ['number', 'title', 'state', 'id'],
|
|
481
|
+
IssueComment: ['id', 'author', 'authorAssociation', 'body', 'createdAt', 'includesCreatedEdit', 'isMinimized', 'minimizedReason', 'reactionGroups', 'url', 'viewerDidAuthor'],
|
|
482
|
+
ReactionGroup: ['content', 'users'],
|
|
483
|
+
Label: ['id', 'name', 'description', 'color'],
|
|
484
|
+
ReviewRequest: ['requestedReviewer'],
|
|
485
|
+
PullRequestCommit: ['commit'],
|
|
486
|
+
Commit: ['statusCheckRollup'],
|
|
487
|
+
StatusCheckRollup: ['state', 'contexts'],
|
|
488
|
+
CheckRun: ['name', 'checkSuite', 'status', 'conclusion', 'startedAt', 'completedAt', 'detailsUrl'],
|
|
489
|
+
CheckSuite: ['workflowRun'],
|
|
490
|
+
WorkflowRun: ['workflow'],
|
|
491
|
+
Workflow: ['name'],
|
|
492
|
+
StatusContext: ['context', 'state', 'targetUrl', 'description', 'createdAt'],
|
|
493
|
+
MarkDiscussionCommentAsAnswerPayload: ['clientMutationId', 'discussion'],
|
|
494
|
+
Discussion: ['id', 'number', 'title', 'body', 'url', 'createdAt', 'updatedAt', 'author', 'closed', 'locked', 'isAnswered', 'category', 'comments'],
|
|
495
|
+
DiscussionComment: ['id', 'body', 'createdAt', 'author', 'isAnswer', 'replyTo', 'url'],
|
|
496
|
+
DiscussionCategory: ['id', 'name', 'slug', 'description', 'isAnswerable'],
|
|
497
|
+
AddCommentPayload: ['commentEdge'],
|
|
498
|
+
AddDiscussionCommentPayload: ['comment'],
|
|
499
|
+
CreatePullRequestPayload: ['clientMutationId', 'pullRequest'],
|
|
500
|
+
CreateDiscussionPayload: ['clientMutationId', 'discussion'],
|
|
501
|
+
MarkPullRequestReadyForReviewPayload: ['clientMutationId', 'pullRequest'],
|
|
502
|
+
ConvertPullRequestToDraftPayload: ['clientMutationId', 'pullRequest'],
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// GitHub semantics, by operationId. Families move here from github-twin.ts's hand-written read and
|
|
2
|
+
// write switches; a write GitHub announces emits its webhook from its handler.
|
|
3
|
+
import type { Semantics } from '@volter/world-core';
|
|
4
|
+
import { actionsSemantics } from './actions.ts';
|
|
5
|
+
import { activitySemantics } from './activity.ts';
|
|
6
|
+
import { appsSemantics } from './apps.ts';
|
|
7
|
+
import { branchesSemantics } from './branches.ts';
|
|
8
|
+
import { checksSemantics } from './checks.ts';
|
|
9
|
+
import { codespacesSemantics } from './codespaces.ts';
|
|
10
|
+
import { commitsSemantics } from './commits.ts';
|
|
11
|
+
import { contentsSemantics } from './contents.ts';
|
|
12
|
+
import { deploymentsSemantics } from './deployments.ts';
|
|
13
|
+
import { gistsSemantics } from './gists.ts';
|
|
14
|
+
import { gitSemantics } from './git.ts';
|
|
15
|
+
import { issuesSemantics } from './issues.ts';
|
|
16
|
+
import { keysSemantics } from './keys.ts';
|
|
17
|
+
import { labelsSemantics } from './labels.ts';
|
|
18
|
+
import { metaSemantics } from './meta.ts';
|
|
19
|
+
import { orgsSemantics } from './orgs.ts';
|
|
20
|
+
import { packagesSemantics } from './packages.ts';
|
|
21
|
+
import { pagesSemantics } from './pages.ts';
|
|
22
|
+
import { projectsSemantics } from './projects.ts';
|
|
23
|
+
import { pullsSemantics } from './pulls.ts';
|
|
24
|
+
import { releasesSemantics } from './releases.ts';
|
|
25
|
+
import { reposSemantics } from './repos.ts';
|
|
26
|
+
import { rulesetsSemantics } from './rulesets.ts';
|
|
27
|
+
import { searchSemantics } from './search.ts';
|
|
28
|
+
import { codeScanningUpload } from './code-scanning-upload.ts';
|
|
29
|
+
import { repoInvitationsSemantics } from './repo-invitations.ts';
|
|
30
|
+
import { securitySemantics } from './security.ts';
|
|
31
|
+
import { usersSemantics } from './users.ts';
|
|
32
|
+
import { webhooksSemantics } from './webhooks.ts';
|
|
33
|
+
|
|
34
|
+
export type GithubSemanticsOptions = { readOnly?: boolean };
|
|
35
|
+
|
|
36
|
+
export function githubSemantics(opts: GithubSemanticsOptions = {}): Record<string, Semantics> {
|
|
37
|
+
return {
|
|
38
|
+
...metaSemantics(opts),
|
|
39
|
+
...usersSemantics(),
|
|
40
|
+
...gistsSemantics(),
|
|
41
|
+
...activitySemantics(),
|
|
42
|
+
...searchSemantics(),
|
|
43
|
+
...orgsSemantics(),
|
|
44
|
+
...projectsSemantics(),
|
|
45
|
+
...securitySemantics(),
|
|
46
|
+
...codeScanningUpload,
|
|
47
|
+
...repoInvitationsSemantics,
|
|
48
|
+
...pagesSemantics(),
|
|
49
|
+
...packagesSemantics(),
|
|
50
|
+
...appsSemantics(),
|
|
51
|
+
...codespacesSemantics(),
|
|
52
|
+
...actionsSemantics(),
|
|
53
|
+
...keysSemantics(),
|
|
54
|
+
...rulesetsSemantics(),
|
|
55
|
+
...deploymentsSemantics(),
|
|
56
|
+
...releasesSemantics(),
|
|
57
|
+
...webhooksSemantics(),
|
|
58
|
+
...labelsSemantics(),
|
|
59
|
+
...commitsSemantics(),
|
|
60
|
+
...checksSemantics(),
|
|
61
|
+
...gitSemantics(),
|
|
62
|
+
...contentsSemantics(),
|
|
63
|
+
...reposSemantics(),
|
|
64
|
+
...branchesSemantics(),
|
|
65
|
+
...issuesSemantics(),
|
|
66
|
+
...pullsSemantics(),
|
|
67
|
+
};
|
|
68
|
+
}
|