@volter/twin-github 0.1.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -36
- package/client/github-mirror.css +277 -319
- package/client/github-mirror.d.ts +418 -0
- package/client/github-mirror.js +485 -0
- package/client/github-mirror.tsx +153 -357
- package/client/pulls-rest.ts +159 -0
- package/client/pulls-workspace.tsx +841 -0
- package/dist/client/github-mirror.bundle.js +239 -0
- package/dist/client/github-mirror.css +916 -0
- package/dist/client/github-mirror.d.ts +418 -0
- package/dist/client/github-mirror.js +485 -0
- package/dist/client/github-mirror.tsx +1315 -0
- package/dist/client/pulls-rest.d.ts +42 -0
- package/dist/client/pulls-rest.js +140 -0
- package/dist/client/pulls-rest.ts +159 -0
- package/dist/client/pulls-workspace.bundle.js +22 -0
- package/dist/client/pulls-workspace.d.ts +114 -0
- package/dist/client/pulls-workspace.js +418 -0
- package/dist/client/pulls-workspace.tsx +841 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/generated/graphql-sdl.gen.json +1 -0
- package/dist/src/generated/graphql.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/github-budget.d.ts +69 -0
- package/dist/src/github-budget.js +172 -0
- package/dist/src/github-capabilities.d.ts +5 -0
- package/dist/src/github-capabilities.js +4468 -0
- package/dist/src/github-conformance.d.ts +43 -0
- package/dist/src/github-conformance.js +76 -0
- package/dist/src/github-connector.d.ts +307 -0
- package/dist/src/github-connector.js +1398 -0
- package/dist/src/github-events.d.ts +41 -0
- package/dist/src/github-events.js +232 -0
- package/dist/src/github-git-http.d.ts +49 -0
- package/dist/src/github-git-http.js +185 -0
- package/dist/src/github-git-plane.d.ts +114 -0
- package/dist/src/github-git-plane.js +407 -0
- package/dist/src/github-mirror-state.d.ts +2 -0
- package/dist/src/github-mirror-state.js +335 -0
- package/dist/src/github-mirror-ui.d.ts +20 -0
- package/dist/src/github-mirror-ui.js +101 -0
- package/dist/src/github-server.d.ts +14 -0
- package/dist/src/github-server.js +240 -0
- package/dist/src/github-shared.d.ts +12 -0
- package/dist/src/github-shared.js +21 -0
- package/dist/src/github-twin.d.ts +1411 -0
- package/dist/src/github-twin.js +4084 -0
- package/dist/src/github-ui-conformance.d.ts +4 -0
- package/dist/src/github-ui-conformance.js +105 -0
- package/dist/src/github-ui-structure.d.ts +18 -0
- package/dist/src/github-ui-structure.js +251 -0
- package/dist/src/graphql-wire.d.ts +22 -0
- package/dist/src/graphql-wire.js +89 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +104 -0
- package/dist/src/manifest.d.ts +8 -0
- package/dist/src/manifest.js +597 -0
- package/dist/src/npm-registry.d.ts +7 -0
- package/dist/src/npm-registry.js +47 -0
- package/dist/src/screens/app-installation.d.ts +3 -0
- package/dist/src/screens/app-installation.js +166 -0
- package/dist/src/screens/app-manifest.d.ts +3 -0
- package/dist/src/screens/app-manifest.js +81 -0
- package/dist/src/screens/oauth.d.ts +15 -0
- package/dist/src/screens/oauth.js +257 -0
- package/dist/src/screens/session.d.ts +8 -0
- package/dist/src/screens/session.js +170 -0
- package/dist/src/semantics/actions.d.ts +2 -0
- package/dist/src/semantics/actions.js +413 -0
- package/dist/src/semantics/activity.d.ts +4 -0
- package/dist/src/semantics/activity.js +161 -0
- package/dist/src/semantics/apps.d.ts +2 -0
- package/dist/src/semantics/apps.js +144 -0
- package/dist/src/semantics/branches.d.ts +2 -0
- package/dist/src/semantics/branches.js +136 -0
- package/dist/src/semantics/checks.d.ts +2 -0
- package/dist/src/semantics/checks.js +176 -0
- package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
- package/dist/src/semantics/code-scanning-upload.js +97 -0
- package/dist/src/semantics/codespaces.d.ts +2 -0
- package/dist/src/semantics/codespaces.js +58 -0
- package/dist/src/semantics/commits.d.ts +2 -0
- package/dist/src/semantics/commits.js +109 -0
- package/dist/src/semantics/contents.d.ts +2 -0
- package/dist/src/semantics/contents.js +131 -0
- package/dist/src/semantics/deployments.d.ts +2 -0
- package/dist/src/semantics/deployments.js +127 -0
- package/dist/src/semantics/gists.d.ts +2 -0
- package/dist/src/semantics/gists.js +86 -0
- package/dist/src/semantics/git.d.ts +2 -0
- package/dist/src/semantics/git.js +235 -0
- package/dist/src/semantics/graphql.d.ts +7 -0
- package/dist/src/semantics/graphql.js +512 -0
- package/dist/src/semantics/index.d.ts +5 -0
- package/dist/src/semantics/index.js +62 -0
- package/dist/src/semantics/issues.d.ts +2 -0
- package/dist/src/semantics/issues.js +456 -0
- package/dist/src/semantics/keys.d.ts +2 -0
- package/dist/src/semantics/keys.js +66 -0
- package/dist/src/semantics/labels.d.ts +2 -0
- package/dist/src/semantics/labels.js +100 -0
- package/dist/src/semantics/meta.d.ts +12 -0
- package/dist/src/semantics/meta.js +144 -0
- package/dist/src/semantics/notifications.d.ts +4 -0
- package/dist/src/semantics/notifications.js +62 -0
- package/dist/src/semantics/orgs.d.ts +2 -0
- package/dist/src/semantics/orgs.js +420 -0
- package/dist/src/semantics/packages.d.ts +2 -0
- package/dist/src/semantics/packages.js +55 -0
- package/dist/src/semantics/pages.d.ts +2 -0
- package/dist/src/semantics/pages.js +176 -0
- package/dist/src/semantics/projects.d.ts +2 -0
- package/dist/src/semantics/projects.js +239 -0
- package/dist/src/semantics/pulls.d.ts +2 -0
- package/dist/src/semantics/pulls.js +462 -0
- package/dist/src/semantics/push-reactions.d.ts +101 -0
- package/dist/src/semantics/push-reactions.js +513 -0
- package/dist/src/semantics/releases.d.ts +19 -0
- package/dist/src/semantics/releases.js +231 -0
- package/dist/src/semantics/repo-invitations.d.ts +2 -0
- package/dist/src/semantics/repo-invitations.js +98 -0
- package/dist/src/semantics/repos.d.ts +17 -0
- package/dist/src/semantics/repos.js +390 -0
- package/dist/src/semantics/rulesets.d.ts +2 -0
- package/dist/src/semantics/rulesets.js +111 -0
- package/dist/src/semantics/search.d.ts +2 -0
- package/dist/src/semantics/search.js +84 -0
- package/dist/src/semantics/security.d.ts +2 -0
- package/dist/src/semantics/security.js +177 -0
- package/dist/src/semantics/shared.d.ts +61 -0
- package/dist/src/semantics/shared.js +151 -0
- package/dist/src/semantics/users.d.ts +2 -0
- package/dist/src/semantics/users.js +255 -0
- package/dist/src/semantics/webhooks.d.ts +2 -0
- package/dist/src/semantics/webhooks.js +107 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
- package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
- package/dist/test-fixtures/github-comment-schema.json +702 -0
- package/dist/test-fixtures/github-known-deviations.json +94 -0
- package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
- package/dist/test-fixtures/github-openapi-operations.json +362 -0
- package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
- package/dist/test-fixtures/github-pull-schema.json +3601 -0
- package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
- package/dist/test-fixtures/github-review-schema.json +236 -0
- package/package.json +20 -11
- package/src/cli.ts +7 -6
- package/src/generated/graphql-sdl.gen.json +1 -0
- package/src/generated/graphql.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/github-a11y-snapshot.uitest.ts +5 -5
- package/src/github-budget.ts +4 -4
- package/src/github-capabilities.ts +1993 -556
- package/src/github-conformance.ts +12 -7
- package/src/github-connector.ts +108 -96
- package/src/github-events.ts +225 -95
- package/src/github-git-http.ts +48 -84
- package/src/github-git-plane.ts +247 -385
- package/src/github-journey.uitest.ts +28 -44
- package/src/github-mirror-state.ts +23 -61
- package/src/github-mirror-ui.ts +16 -10
- package/src/github-server.ts +158 -60
- package/src/github-shared.ts +1 -1
- package/src/github-twin.ts +1525 -4494
- package/src/github-ui-conformance.ts +7 -8
- package/src/github-ui-structure.ts +19 -18
- package/src/graphql-wire.ts +115 -0
- package/src/index.ts +50 -7
- package/src/manifest.ts +605 -0
- package/src/npm-registry.ts +43 -0
- package/src/screens/app-installation.tsx +217 -0
- package/src/screens/app-manifest.tsx +97 -0
- package/src/screens/oauth.tsx +258 -0
- package/src/screens/session.tsx +193 -0
- package/src/semantics/actions.ts +395 -0
- package/src/semantics/activity.ts +171 -0
- package/src/semantics/apps.ts +133 -0
- package/src/semantics/branches.ts +133 -0
- package/src/semantics/checks.ts +163 -0
- package/src/semantics/code-scanning-upload.ts +92 -0
- package/src/semantics/codespaces.ts +58 -0
- package/src/semantics/commits.ts +109 -0
- package/src/semantics/contents.ts +112 -0
- package/src/semantics/deployments.ts +116 -0
- package/src/semantics/gists.ts +85 -0
- package/src/semantics/git.ts +226 -0
- package/src/semantics/graphql.ts +505 -0
- package/src/semantics/index.ts +68 -0
- package/src/semantics/issues.ts +434 -0
- package/src/semantics/keys.ts +66 -0
- package/src/semantics/labels.ts +91 -0
- package/src/semantics/meta.ts +139 -0
- package/src/semantics/notifications.ts +58 -0
- package/src/semantics/orgs.ts +383 -0
- package/src/semantics/packages.ts +59 -0
- package/src/semantics/pages.ts +154 -0
- package/src/semantics/projects.ts +246 -0
- package/src/semantics/pulls.ts +421 -0
- package/src/semantics/push-reactions.ts +471 -0
- package/src/semantics/releases.ts +213 -0
- package/src/semantics/repo-invitations.ts +112 -0
- package/src/semantics/repos.ts +373 -0
- package/src/semantics/rulesets.ts +107 -0
- package/src/semantics/search.ts +83 -0
- package/src/semantics/security.ts +153 -0
- package/src/semantics/shared.ts +181 -0
- package/src/semantics/users.ts +252 -0
- package/src/semantics/webhooks.ts +101 -0
- package/test-fixtures/github-known-deviations.json +7 -8
- package/test-fixtures/github-openapi-operations.json +46 -227
- package/src/github-graphql.ts +0 -398
package/src/github-graphql.ts
DELETED
|
@@ -1,398 +0,0 @@
|
|
|
1
|
-
// GitHub GraphQL API (v4) — a FAITHFUL SUBSET over the SAME projection the REST twin serves.
|
|
2
|
-
// GitHub's GraphQL graph is enormous; this models a deliberately small, real slice and is
|
|
3
|
-
// HONEST about its partial coverage: a field the twin does not model raises an
|
|
4
|
-
// `undefinedField`-style GraphQL error (the same error class GitHub raises), rather than
|
|
5
|
-
// fabricating a value. The op is resolved against `githubState` (the kernel projection) so it
|
|
6
|
-
// is consistent with the REST surface byte-for-byte.
|
|
7
|
-
//
|
|
8
|
-
// Modeled subset:
|
|
9
|
-
// query { viewer { login name } }
|
|
10
|
-
// query { repository(owner, name) { name nameWithOwner isPrivate
|
|
11
|
-
// pullRequests(first) { totalCount nodes { number title state } }
|
|
12
|
-
// issues(first) { totalCount nodes { number title state } } } }
|
|
13
|
-
// query { node(id) { ... on PullRequest { number title } } } — id = "PR_<owner>/<repo>#<n>"
|
|
14
|
-
// mutation { addComment(input: { subjectId, body }) { commentEdge { node { id body } } } }
|
|
15
|
-
// query { repository(owner, name) { discussions(first) { nodes { id number title body category { name slug isAnswerable }
|
|
16
|
-
// comments { nodes { id body isAnswer } } } } } } — id = "D_<owner>/<repo>#<n>"
|
|
17
|
-
// mutation { addDiscussionComment(input: { discussionId, body, replyToId? }) { comment { id body url } } }
|
|
18
|
-
// Anything else (other root fields, unmodeled selection fields) → a typed GraphQL error.
|
|
19
|
-
import { applyGithubWrite, prPlaneDiffFiles } from './github-twin.ts';
|
|
20
|
-
import { githubState, type GithubState } from './github-twin.ts';
|
|
21
|
-
|
|
22
|
-
export type GraphqlResult = { data?: Record<string, unknown> | null; errors?: Array<{ message: string; type?: string }> };
|
|
23
|
-
|
|
24
|
-
// A field the twin doesn't model — surfaced as GitHub's `undefinedField` error class (honest
|
|
25
|
-
// partial coverage). The query still fails rather than returning a fabricated value.
|
|
26
|
-
function unmodeled(field: string, onType: string): { message: string; type: string } {
|
|
27
|
-
return { message: `Field '${field}' doesn't exist on type '${onType}' in the twin's modeled GraphQL subset`, type: 'undefinedField' };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// The fields the twin models per type — a selection naming anything else is an honest error.
|
|
31
|
-
const VIEWER_FIELDS = new Set(['login', 'name', '__typename']);
|
|
32
|
-
// `repo` appears as a token from the `...repo` fragment spread gh's RepositoryInfo query uses; the
|
|
33
|
-
// fragment's own fields live in a separate `fragment repo on Repository {…}` block (validated below).
|
|
34
|
-
const REPO_FIELDS = new Set([
|
|
35
|
-
'name', 'nameWithOwner', 'isPrivate', 'pullRequests', 'issues', '__typename',
|
|
36
|
-
// RepositoryInfo (gh pr create / gh repo view): the `repo` fragment + repo-level merge settings.
|
|
37
|
-
'id', 'owner', 'login', 'hasIssuesEnabled', 'description', 'hasWikiEnabled', 'viewerPermission',
|
|
38
|
-
'defaultBranchRef', 'parent', 'mergeCommitAllowed', 'rebaseMergeAllowed', 'squashMergeAllowed', 'repo',
|
|
39
|
-
// gh pr view <n> / gh issue view <n> resolve ONE node by number.
|
|
40
|
-
'pullRequest', 'issue',
|
|
41
|
-
// Discussions: real GitHub serves them over GraphQL alone (repository.discussions; addDiscussionComment).
|
|
42
|
-
'discussions',
|
|
43
|
-
]);
|
|
44
|
-
const DISCUSSION_FIELDS = new Set(['id', 'number', 'title', 'body', 'url', 'createdAt', 'updatedAt', 'category', 'name', 'slug', 'isAnswerable', 'comments', 'author', 'login', 'isAnswered', 'closed', 'locked', '__typename']);
|
|
45
|
-
// The single-PR fields `gh pr view --json …` selects — the exact set the repo's own
|
|
46
|
-
// merge-gate scripts read (headRefOid/state/baseRefName/statusCheckRollup/
|
|
47
|
-
// closingIssuesReferences/changedFiles/labels/body/comments/assignees/reviewRequests; see
|
|
48
|
-
// scripts/human-approval-gate.ts, break-glass-gate.ts, finalize-agent-review.ts,
|
|
49
|
-
// review-prerequisites.ts). `statusCheckRollup` is gh's ALIAS for commits(last:1){…} — the
|
|
50
|
-
// token list admits both spellings. Anything else stays an honest undefinedField error.
|
|
51
|
-
const PR_VIEW_FIELDS = new Set([
|
|
52
|
-
'number', 'id', 'url', 'title', 'state', 'body', 'isDraft', 'headRefOid', 'headRefName',
|
|
53
|
-
'baseRefName', 'changedFiles', 'additions', 'deletions', 'createdAt', 'updatedAt', 'closedAt',
|
|
54
|
-
'mergedAt', 'labels', 'closingIssuesReferences', 'statusCheckRollup', 'commits', 'comments',
|
|
55
|
-
'assignees', 'reviewRequests', '__typename',
|
|
56
|
-
]);
|
|
57
|
-
// The single-issue fields `gh issue view --json …` selects (human-approval-gate reads a
|
|
58
|
-
// linked issue's labels; finalize-agent-review reads its comments and posts one).
|
|
59
|
-
const ISSUE_VIEW_FIELDS = new Set([
|
|
60
|
-
'number', 'id', 'url', 'title', 'state', 'body', 'labels', 'comments', 'assignees',
|
|
61
|
-
'createdAt', 'updatedAt', 'closedAt', 'author', '__typename',
|
|
62
|
-
]);
|
|
63
|
-
const PR_FIELDS = new Set(['number', 'title', 'state', 'id', '__typename',
|
|
64
|
-
// PR connection node fields gh pr view/list select (modeled from the PR projection).
|
|
65
|
-
'url', 'baseRefName', 'headRefName', 'isCrossRepository', 'headRepositoryOwner', 'login', 'name']);
|
|
66
|
-
const ISSUE_FIELDS = new Set(['number', 'title', 'state', 'id', '__typename']);
|
|
67
|
-
// Relay connection-level fields valid alongside nodes/totalCount (not object fields).
|
|
68
|
-
const CONNECTION_FIELDS = new Set(['totalCount', 'nodes', 'edges', 'pageInfo', 'node', 'cursor', 'endCursor', 'hasNextPage', '__typename']);
|
|
69
|
-
|
|
70
|
-
// Extract the inner-brace selection-set field names for a given parent (very small parser —
|
|
71
|
-
// sufficient for the modeled subset; it reads identifiers, ignoring args/aliases/nesting we
|
|
72
|
-
// resolve explicitly). Returns the top-level selected names under the FIRST `{...}` after the
|
|
73
|
-
// parent token.
|
|
74
|
-
function selectionFields(query: string, parentToken: string): string[] {
|
|
75
|
-
const idx = query.indexOf(parentToken);
|
|
76
|
-
if (idx < 0) return [];
|
|
77
|
-
// Skip an optional argument list `(...)` after the parent token BEFORE locating the selection
|
|
78
|
-
// set — real gh queries put object args there (e.g. `orderBy: { field: CREATED_AT }`), and the
|
|
79
|
-
// naive `indexOf('{')` would otherwise mistake that argument brace for the selection set.
|
|
80
|
-
let p = idx + parentToken.length;
|
|
81
|
-
while (p < query.length && /\s/.test(query[p]!)) p++;
|
|
82
|
-
if (query[p] === '(') { let d = 0; for (; p < query.length; p++) { if (query[p] === '(') d++; else if (query[p] === ')') { d--; if (d === 0) { p++; break; } } } }
|
|
83
|
-
const open = query.indexOf('{', p);
|
|
84
|
-
if (open < 0) return [];
|
|
85
|
-
let depth = 0; let end = -1;
|
|
86
|
-
for (let i = open; i < query.length; i++) { if (query[i] === '{') depth++; else if (query[i] === '}') { depth--; if (depth === 0) { end = i; break; } } }
|
|
87
|
-
if (end < 0) return [];
|
|
88
|
-
let inner = query.slice(open + 1, end);
|
|
89
|
-
// iteratively strip ALL nested blocks (and arg parens) so we only see THIS level's fields.
|
|
90
|
-
let prev = '';
|
|
91
|
-
while (prev !== inner) { prev = inner; inner = inner.replace(/\{[^{}]*\}/g, ' '); }
|
|
92
|
-
inner = inner.replace(/\([^()]*\)/g, ' ');
|
|
93
|
-
return [...inner.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)].map((m) => m[0]).filter((t) => t !== 'on');
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Resolve an argument value for `argName` from the query text: a string literal `arg:"x"` or
|
|
97
|
-
// a variable reference `arg:$v` (looked up in `variables`). Returns undefined when absent.
|
|
98
|
-
function argValue(query: string, argName: string, variables: Record<string, unknown>): string | undefined {
|
|
99
|
-
const lit = new RegExp(`${argName}:\\s*"([^"]*)"`).exec(query);
|
|
100
|
-
if (lit) return lit[1];
|
|
101
|
-
const num = new RegExp(`${argName}:\\s*(-?\\d+)`).exec(query); // Int literal (pullRequest(number: 2))
|
|
102
|
-
if (num) return num[1];
|
|
103
|
-
const ref = new RegExp(`${argName}:\\s*\\$([A-Za-z_][A-Za-z0-9_]*)`).exec(query);
|
|
104
|
-
if (ref && variables[ref[1]!] !== undefined) return String(variables[ref[1]!]);
|
|
105
|
-
return undefined;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function repoToNodes(state: GithubState, repo: string) {
|
|
109
|
-
const owner = repo.split('/')[0]!;
|
|
110
|
-
const prs = state.prs.filter((p) => p.repository === repo).map((p) => ({
|
|
111
|
-
number: p.number, title: p.title ?? null, state: (p.merged ? 'MERGED' : (p.state ?? 'open')).toUpperCase(), id: `PR_${repo}#${p.number}`,
|
|
112
|
-
// PR connection node fields gh's `pr view`/`pr list` request — all from the SAME PR projection.
|
|
113
|
-
url: `https://github.com/${repo}/pull/${p.number}`, baseRefName: p.base_ref ?? null, headRefName: p.head_ref ?? null,
|
|
114
|
-
isCrossRepository: false, headRepositoryOwner: { id: `U_${owner}`, login: owner, name: null },
|
|
115
|
-
}));
|
|
116
|
-
const issues = state.issues.filter((i) => i.repository === repo).map((i) => ({ number: i.number, title: i.title ?? null, state: (i.state ?? 'open').toUpperCase(), id: `I_${repo}#${i.number}` }));
|
|
117
|
-
return { prs, issues };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Resolve a GraphQL operation against the github world (the SAME projection as REST). */
|
|
121
|
-
export async function resolveGithubGraphql(req: { query: string; variables?: Record<string, unknown>; root?: string }): Promise<GraphqlResult> {
|
|
122
|
-
const query = req.query;
|
|
123
|
-
const vars = req.variables ?? {};
|
|
124
|
-
const state = githubState(req.root);
|
|
125
|
-
const isMutation = /^\s*mutation\b/.test(query);
|
|
126
|
-
|
|
127
|
-
// ── mutation addComment(input: { subjectId, body }) ──────────────────────────────────────
|
|
128
|
-
if (isMutation) {
|
|
129
|
-
// ── mutation addDiscussionComment(input: { discussionId, body, replyToId? }) ──────────────
|
|
130
|
-
// discussionId "D_<repo>#<n>" (what repository.discussions returns); maps onto the SAME write path the
|
|
131
|
-
// REST twin uses for a discussion comment.
|
|
132
|
-
if (/\baddDiscussionComment\b/.test(query)) {
|
|
133
|
-
const discussionId = String(vars.discussionId ?? argValue(query, 'discussionId', vars) ?? '');
|
|
134
|
-
const body = String(vars.body ?? argValue(query, 'body', vars) ?? '');
|
|
135
|
-
const m = /^D_(.+)#(\d+)$/.exec(discussionId);
|
|
136
|
-
if (!m) return { errors: [{ message: `Could not resolve to a node with the global id of '${discussionId}'`, type: 'NOT_FOUND' }] };
|
|
137
|
-
const repo = m[1]!; const number = Number(m[2]);
|
|
138
|
-
const replyTo = /^DC_(.+)#(\d+)$/.exec(String(vars.replyToId ?? argValue(query, 'replyToId', vars) ?? ''));
|
|
139
|
-
const res = await applyGithubWrite({ method: 'POST', path: `/repos/${repo}/discussions/${number}/comments`, body: JSON.stringify({ body, ...(replyTo ? { parent_id: Number(replyTo[2]) } : {}) }), root: req.root });
|
|
140
|
-
if (res.response.status !== 201) return { errors: [{ message: 'addDiscussionComment failed', type: res.response.status === 404 ? 'NOT_FOUND' : 'UNPROCESSABLE' }] };
|
|
141
|
-
const cid = (res.response.body as { id?: number }).id;
|
|
142
|
-
return { data: { addDiscussionComment: { comment: { id: `DC_${repo}#${cid}`, body, url: `https://github.com/${repo}/discussions/${number}#discussioncomment-${cid}` } } } };
|
|
143
|
-
}
|
|
144
|
-
if (/\baddComment\b/.test(query)) {
|
|
145
|
-
// subjectId/body come from variables or inline input. subjectId "PR_<repo>#<n>" or "I_…".
|
|
146
|
-
const subjectId = String(vars.subjectId ?? argValue(query, 'subjectId', vars) ?? '');
|
|
147
|
-
const body = String(vars.body ?? argValue(query, 'body', vars) ?? '');
|
|
148
|
-
const m = /^(PR|I)_(.+)#(\d+)$/.exec(subjectId);
|
|
149
|
-
if (!m) return { errors: [{ message: `Could not resolve to a node with the global id of '${subjectId}'`, type: 'NOT_FOUND' }] };
|
|
150
|
-
const repo = m[2]!; const number = Number(m[3]);
|
|
151
|
-
// addComment maps onto the SAME write path the REST twin uses (issue comment).
|
|
152
|
-
const res = await applyGithubWrite({ method: 'POST', path: `/repos/${repo}/issues/${number}/comments`, body: JSON.stringify({ body }), root: req.root });
|
|
153
|
-
if (res.response.status !== 201) return { errors: [{ message: 'addComment failed', type: 'UNPROCESSABLE' }] };
|
|
154
|
-
const cid = (res.response.body as { id?: number }).id;
|
|
155
|
-
return { data: { addComment: { commentEdge: { node: { id: `IC_${repo}#${cid}`, body } } } } };
|
|
156
|
-
}
|
|
157
|
-
// mutation createPullRequest(input: { repositoryId, baseRefName, headRefName, title, body, draft })
|
|
158
|
-
// — gh pr create's mutation. Maps onto the SAME REST create path; returns the new PR's node id+url.
|
|
159
|
-
if (/\bcreatePullRequest\b/.test(query)) {
|
|
160
|
-
const input = (vars.input ?? {}) as Record<string, unknown>;
|
|
161
|
-
const m = /^R_(.+)$/.exec(String(input.repositoryId ?? ''));
|
|
162
|
-
if (!m) return { errors: [{ message: `Could not resolve to a Repository with the global id of '${String(input.repositoryId ?? '')}'`, type: 'NOT_FOUND' }] };
|
|
163
|
-
const repo = m[1]!;
|
|
164
|
-
const res = await applyGithubWrite({ method: 'POST', path: `/repos/${repo}/pulls`, body: JSON.stringify({ title: input.title, head: input.headRefName, base: input.baseRefName, body: input.body, draft: input.draft === true }), root: req.root });
|
|
165
|
-
if (res.response.status !== 201) return { errors: [{ message: 'createPullRequest failed', type: 'UNPROCESSABLE' }] };
|
|
166
|
-
const number = (res.response.body as { number?: number }).number;
|
|
167
|
-
return { data: { createPullRequest: { pullRequest: { id: `PR_${repo}#${number}`, url: `https://github.com/${repo}/pull/${number}` } } } };
|
|
168
|
-
}
|
|
169
|
-
return { errors: [{ message: 'Only addComment + createPullRequest are modeled in the twin GraphQL mutation subset', type: 'undefinedField' }] };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── query — resolve the recognized root fields; reject unmodeled ones honestly ────────────
|
|
173
|
-
const rootFields = selectionFields(query, query.match(/^\s*query/) ? 'query' : '{');
|
|
174
|
-
// viewer
|
|
175
|
-
if (/\bviewer\b/.test(query)) {
|
|
176
|
-
const bad = selectionFields(query, 'viewer').find((f) => !VIEWER_FIELDS.has(f));
|
|
177
|
-
if (bad) return { errors: [unmodeled(bad, 'User')] };
|
|
178
|
-
return { data: { viewer: { login: 'octocat', name: state.userProfile.name ?? 'The Octocat' } } };
|
|
179
|
-
}
|
|
180
|
-
// node(id)
|
|
181
|
-
if (/\bnode\b/.test(query)) {
|
|
182
|
-
const id = String(vars.id ?? argValue(query, 'id', vars) ?? '');
|
|
183
|
-
const m = /^(PR|I)_(.+)#(\d+)$/.exec(id);
|
|
184
|
-
if (!m) return { errors: [{ message: `Could not resolve to a node with the global id of '${id}'`, type: 'NOT_FOUND' }] };
|
|
185
|
-
const repo = m[2]!; const number = Number(m[3]);
|
|
186
|
-
const { prs, issues } = repoToNodes(state, repo);
|
|
187
|
-
const obj = m[1] === 'PR' ? prs.find((p) => p.number === number) : issues.find((i) => i.number === number);
|
|
188
|
-
if (!obj) return { errors: [{ message: `Could not resolve to a node with the global id of '${id}'`, type: 'NOT_FOUND' }] };
|
|
189
|
-
return { data: { node: { number: obj.number, title: obj.title, state: obj.state, id } } };
|
|
190
|
-
}
|
|
191
|
-
// repository(owner, name)
|
|
192
|
-
if (/\brepository\b/.test(query)) {
|
|
193
|
-
const owner = String(vars.owner ?? argValue(query, 'owner', vars) ?? '');
|
|
194
|
-
const name = String(vars.name ?? argValue(query, 'name', vars) ?? '');
|
|
195
|
-
const repo = `${owner}/${name}`;
|
|
196
|
-
const bad = selectionFields(query, 'repository').find((f) => !REPO_FIELDS.has(f));
|
|
197
|
-
if (bad) return { errors: [unmodeled(bad, 'Repository')] };
|
|
198
|
-
const known = state.repos.find((r) => r.full_name === repo);
|
|
199
|
-
const { prs, issues } = repoToNodes(state, repo);
|
|
200
|
-
// ── repository.pullRequest(number:) — the single-PR read gh pr view sends ──────────────
|
|
201
|
-
// (`\bpullRequest\b(?!s)` so the plural connection never shadows it.)
|
|
202
|
-
// Both single-node branches anchor on the FIELD-WITH-ARGS form (`pullRequest(` /
|
|
203
|
-
// `issue(`): a bare word match would hijack queries whose owner/name ARGUMENT contains
|
|
204
|
-
// the token (repository(name: "issue-tracker") — a real §9 round-two find).
|
|
205
|
-
const prTokenAt = query.search(/\bpullRequest\b(?!s)\s*\(/);
|
|
206
|
-
if (prTokenAt >= 0) {
|
|
207
|
-
const sub = query.slice(prTokenAt);
|
|
208
|
-
const bad = selectionFields(sub, 'pullRequest').find((f) => !PR_VIEW_FIELDS.has(f));
|
|
209
|
-
if (bad) return { errors: [unmodeled(bad, 'PullRequest')] };
|
|
210
|
-
const num = Number(vars.number ?? vars.pr_number ?? vars.prNumber ?? argValue(sub, 'number', vars) ?? NaN);
|
|
211
|
-
const pr = state.prs.find((p) => p.repository === repo && p.number === num);
|
|
212
|
-
if (!pr) return { errors: [{ message: `Could not resolve to a PullRequest with the number of ${num}.`, type: 'NOT_FOUND' }] };
|
|
213
|
-
return { data: { repository: { pullRequest: resolvePullRequestView(state, repo, pr, req.root) } } };
|
|
214
|
-
}
|
|
215
|
-
// ── repository.issue(number:) — gh issue view's single-issue read ──────────────────────
|
|
216
|
-
const issueTokenAt = query.search(/\bissue\b(?!s)\s*\(/);
|
|
217
|
-
if (issueTokenAt >= 0) {
|
|
218
|
-
const sub = query.slice(issueTokenAt);
|
|
219
|
-
const bad = selectionFields(sub, 'issue').find((f) => !ISSUE_VIEW_FIELDS.has(f));
|
|
220
|
-
if (bad) return { errors: [unmodeled(bad, 'Issue')] };
|
|
221
|
-
const num = Number(vars.number ?? argValue(sub, 'number', vars) ?? NaN);
|
|
222
|
-
const issue = state.issues.find((i) => i.repository === repo && i.number === num);
|
|
223
|
-
if (!issue) return { errors: [{ message: `Could not resolve to an Issue with the number of ${num}.`, type: 'NOT_FOUND' }] };
|
|
224
|
-
const labels = (issue.labels ?? []).map((name) => ({ name }));
|
|
225
|
-
const comments = state.comments
|
|
226
|
-
.filter((c) => c.repository === repo && c.number === num && c.kind === 'issue')
|
|
227
|
-
.map((c) => ({
|
|
228
|
-
id: `IC_${repo}#${c.id}`, author: null, authorAssociation: 'NONE', body: c.body ?? '',
|
|
229
|
-
createdAt: c.created_at ?? null, includesCreatedEdit: false, isMinimized: false, minimizedReason: null,
|
|
230
|
-
reactionGroups: [], url: `https://github.com/${repo}/issues/${num}#issuecomment-${c.id}`, viewerDidAuthor: false,
|
|
231
|
-
}));
|
|
232
|
-
return { data: { repository: { issue: {
|
|
233
|
-
number: num, id: `I_${repo}#${num}`, url: `https://github.com/${repo}/issues/${num}`,
|
|
234
|
-
title: issue.title ?? null, state: (issue.state ?? 'open').toUpperCase(), body: issue.body ?? '',
|
|
235
|
-
author: null, createdAt: null, updatedAt: null, closedAt: null,
|
|
236
|
-
labels: { nodes: labels, totalCount: labels.length },
|
|
237
|
-
comments: { nodes: comments, totalCount: comments.length, pageInfo: { hasNextPage: false, endCursor: null } },
|
|
238
|
-
assignees: { nodes: (issue.assignees ?? []).map((login) => ({ login, id: `U_${login}`, name: null })), totalCount: (issue.assignees ?? []).length },
|
|
239
|
-
} } } };
|
|
240
|
-
}
|
|
241
|
-
// ── repository.discussions(first, orderBy) — the community's threads, with their comments ─────
|
|
242
|
-
if (/\bdiscussions\b/.test(query)) {
|
|
243
|
-
const bad = selectionFields(query, 'discussions').find((f) => !DISCUSSION_FIELDS.has(f) && !CONNECTION_FIELDS.has(f));
|
|
244
|
-
if (bad) return { errors: [unmodeled(bad, 'Discussion')] };
|
|
245
|
-
const categories = state.discussionCategories.filter((c) => c.repository === repo);
|
|
246
|
-
const nodes = state.discussions.filter((d) => d.repository === repo).map((d) => {
|
|
247
|
-
const cat = categories.find((c) => c.slug === d.category_slug);
|
|
248
|
-
const comments = state.discussionComments.filter((c) => c.repository === repo && c.discussion_number === d.number).map((c) => ({
|
|
249
|
-
id: `DC_${repo}#${c.id}`, body: c.body ?? '', createdAt: c.created_at ?? null, author: null, isAnswer: Boolean(c.is_answer),
|
|
250
|
-
replyTo: c.parent_id ? { id: `DC_${repo}#${c.parent_id}` } : null,
|
|
251
|
-
}));
|
|
252
|
-
return {
|
|
253
|
-
id: `D_${repo}#${d.number}`, number: d.number, title: d.title ?? null, body: d.body ?? '', url: `https://github.com/${repo}/discussions/${d.number}`,
|
|
254
|
-
createdAt: d.created_at ?? null, updatedAt: d.updated_at ?? null, author: null, closed: d.state === 'closed', locked: Boolean(d.locked),
|
|
255
|
-
isAnswered: d.answer_comment_id !== undefined && d.answer_comment_id !== null,
|
|
256
|
-
category: cat ? { id: `DIC_${repo}#${cat.id}`, name: cat.name, slug: cat.slug, isAnswerable: Boolean(cat.is_answerable) } : null,
|
|
257
|
-
comments: { nodes: comments, totalCount: comments.length, pageInfo: { hasNextPage: false, endCursor: null } },
|
|
258
|
-
};
|
|
259
|
-
});
|
|
260
|
-
return { data: { repository: { id: `R_${repo}`, nameWithOwner: repo, discussions: { nodes, totalCount: nodes.length, pageInfo: { hasNextPage: false, endCursor: null } } } } };
|
|
261
|
-
}
|
|
262
|
-
// unmodeled subfields under pullRequests/issues nodes are honest errors too (connection-level
|
|
263
|
-
// fields like pageInfo/edges are allowed; only unknown PullRequest/Issue node fields error).
|
|
264
|
-
if (/\bpullRequests\b/.test(query)) { const b = selectionFields(query, 'pullRequests').find((f) => !PR_FIELDS.has(f) && !CONNECTION_FIELDS.has(f)); if (b) return { errors: [unmodeled(b, 'PullRequest')] }; }
|
|
265
|
-
if (/\bissues\b/.test(query)) { const b = selectionFields(query, 'issues').find((f) => !ISSUE_FIELDS.has(f) && !CONNECTION_FIELDS.has(f)); if (b) return { errors: [unmodeled(b, 'Issue')] }; }
|
|
266
|
-
// Honor the connection filter args gh sends: pullRequests(headRefName, states).
|
|
267
|
-
const headRefName = argValue(query, 'headRefName', vars);
|
|
268
|
-
const statesArg = vars.states ?? vars.state;
|
|
269
|
-
const prStates = Array.isArray(statesArg) ? statesArg.map((s) => String(s).toUpperCase()) : undefined;
|
|
270
|
-
const prNodes = prs
|
|
271
|
-
.filter((p) => headRefName === undefined || p.headRefName === headRefName)
|
|
272
|
-
.filter((p) => prStates === undefined || prStates.includes(p.state));
|
|
273
|
-
// The repo `fragment` fields (gh's RepositoryInfo); derived from the SAME repo projection REST
|
|
274
|
-
// serves. viewerPermission is ADMIN — the twin's loopback caller is the unauthenticated owner.
|
|
275
|
-
const repoFragment = {
|
|
276
|
-
id: `R_${repo}`, name, owner: { login: owner },
|
|
277
|
-
hasIssuesEnabled: known?.has_issues ?? true, description: known?.description ?? null,
|
|
278
|
-
hasWikiEnabled: known?.has_wiki ?? true, viewerPermission: 'ADMIN',
|
|
279
|
-
defaultBranchRef: { name: known?.default_branch ?? 'main' },
|
|
280
|
-
};
|
|
281
|
-
const repository: Record<string, unknown> = {
|
|
282
|
-
...repoFragment, nameWithOwner: repo, isPrivate: known?.private ?? false,
|
|
283
|
-
parent: null, mergeCommitAllowed: true, rebaseMergeAllowed: true, squashMergeAllowed: true,
|
|
284
|
-
pullRequests: { totalCount: prNodes.length, nodes: prNodes, pageInfo: { hasNextPage: false, endCursor: null } },
|
|
285
|
-
issues: { totalCount: issues.length, nodes: issues.map((i) => ({ number: i.number, title: i.title, state: i.state })), pageInfo: { hasNextPage: false, endCursor: null } },
|
|
286
|
-
};
|
|
287
|
-
return { data: { repository } };
|
|
288
|
-
}
|
|
289
|
-
// any other top-level field is outside the modeled subset.
|
|
290
|
-
const unknownRoot = rootFields.find((f) => !['viewer', 'repository', 'node'].includes(f));
|
|
291
|
-
return { errors: [unmodeled(unknownRoot ?? rootFields[0] ?? 'unknown', 'Query')] };
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// ── The single-PR view model (repository.pullRequest) ──────────────────────────────────────
|
|
295
|
-
// Every field folds from the SAME projection the REST twin serves — headRefOid is the PR's
|
|
296
|
-
// resolved head sha, statusCheckRollup folds the SAME check-run/commit-status rows the REST
|
|
297
|
-
// checks surface reads, closingIssuesReferences derives from the PR body's closing keywords
|
|
298
|
-
// (the same linkage real GitHub computes). Nothing here is a second store.
|
|
299
|
-
|
|
300
|
-
// GitHub GraphQL enum spellings for the rollup (StatusState / CheckStatusState / CheckConclusionState).
|
|
301
|
-
function statusStateEnum(s: string | undefined): string {
|
|
302
|
-
const v = (s ?? 'pending').toUpperCase();
|
|
303
|
-
return ['ERROR', 'EXPECTED', 'FAILURE', 'PENDING', 'SUCCESS'].includes(v) ? v : 'PENDING';
|
|
304
|
-
}
|
|
305
|
-
function checkStatusEnum(s: string | undefined): string {
|
|
306
|
-
const v = (s ?? 'queued').toUpperCase();
|
|
307
|
-
return ['QUEUED', 'IN_PROGRESS', 'COMPLETED', 'WAITING', 'PENDING', 'REQUESTED'].includes(v) ? v : 'QUEUED';
|
|
308
|
-
}
|
|
309
|
-
function checkConclusionEnum(s: string | undefined): string | null {
|
|
310
|
-
if (!s) return null;
|
|
311
|
-
const v = s.toUpperCase();
|
|
312
|
-
return ['ACTION_REQUIRED', 'TIMED_OUT', 'CANCELLED', 'FAILURE', 'SUCCESS', 'NEUTRAL', 'SKIPPED', 'STARTUP_FAILURE', 'STALE'].includes(v) ? v : null;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** The closing-keyword issue linkage (Closes/Fixes/Resolves #n) real GitHub derives from the PR body. */
|
|
316
|
-
export function closingIssueNumbersFromBody(body: string | undefined): number[] {
|
|
317
|
-
const out: number[] = [];
|
|
318
|
-
for (const m of (body ?? '').matchAll(/\b(?:clos(?:e[sd]?|ing)|fix(?:e[sd]|ing)?|resolv(?:e[sd]?|ing))\s*:?\s+#(\d+)/gi)) {
|
|
319
|
-
const n = Number(m[1]);
|
|
320
|
-
if (!out.includes(n)) out.push(n);
|
|
321
|
-
}
|
|
322
|
-
return out;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
type GhPrRow = GithubState['prs'][number];
|
|
326
|
-
|
|
327
|
-
function resolvePullRequestView(state: GithubState, repo: string, pr: GhPrRow, root?: string): Record<string, unknown> {
|
|
328
|
-
const headSha = pr.head_sha ?? '';
|
|
329
|
-
// Latest commit-status per context (GitHub's rollup shows each context once, most recent wins).
|
|
330
|
-
const latestByContext = new Map<string, GithubState['statuses'][number]>();
|
|
331
|
-
for (const s of state.statuses.filter((s) => s.repository === repo && s.sha === headSha).sort((a, b) => a.id - b.id)) {
|
|
332
|
-
latestByContext.set(s.context ?? 'default', s);
|
|
333
|
-
}
|
|
334
|
-
const runs = state.checkRuns.filter((c) => c.repository === repo && c.head_sha === headSha).sort((a, b) => a.id - b.id);
|
|
335
|
-
const contexts: Array<Record<string, unknown>> = [
|
|
336
|
-
...runs.map((c) => ({
|
|
337
|
-
__typename: 'CheckRun', name: c.name,
|
|
338
|
-
status: checkStatusEnum(c.status), conclusion: checkConclusionEnum(c.conclusion),
|
|
339
|
-
startedAt: c.started_at ?? null, completedAt: c.completed_at ?? null, detailsUrl: c.details_url ?? null,
|
|
340
|
-
})),
|
|
341
|
-
...[...latestByContext.values()].map((s) => ({
|
|
342
|
-
__typename: 'StatusContext', context: s.context, state: statusStateEnum(s.state),
|
|
343
|
-
targetUrl: s.target_url ?? null, description: s.description ?? null, createdAt: s.created_at ?? null,
|
|
344
|
-
})),
|
|
345
|
-
];
|
|
346
|
-
const anyFailed = contexts.some((c) =>
|
|
347
|
-
(c.__typename === 'CheckRun' && ['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(String(c.conclusion)))
|
|
348
|
-
|| (c.__typename === 'StatusContext' && ['FAILURE', 'ERROR'].includes(String(c.state))));
|
|
349
|
-
const anyPending = contexts.some((c) =>
|
|
350
|
-
(c.__typename === 'CheckRun' && c.conclusion === null)
|
|
351
|
-
|| (c.__typename === 'StatusContext' && ['PENDING', 'EXPECTED'].includes(String(c.state))));
|
|
352
|
-
const rollupState = contexts.length === 0 ? null : anyFailed ? 'FAILURE' : anyPending ? 'PENDING' : 'SUCCESS';
|
|
353
|
-
const rollup = {
|
|
354
|
-
nodes: [{
|
|
355
|
-
commit: {
|
|
356
|
-
statusCheckRollup: rollupState === null ? null : {
|
|
357
|
-
state: rollupState,
|
|
358
|
-
contexts: { nodes: contexts, totalCount: contexts.length, pageInfo: { hasNextPage: false, endCursor: null } },
|
|
359
|
-
},
|
|
360
|
-
},
|
|
361
|
-
}],
|
|
362
|
-
};
|
|
363
|
-
const comments = state.comments
|
|
364
|
-
.filter((c) => c.repository === repo && c.number === pr.number && c.kind === 'issue')
|
|
365
|
-
.map((c) => ({
|
|
366
|
-
id: `IC_${repo}#${c.id}`, author: null, authorAssociation: 'NONE',
|
|
367
|
-
body: c.body ?? '', createdAt: c.created_at ?? null, includesCreatedEdit: false, isMinimized: false,
|
|
368
|
-
minimizedReason: null, reactionGroups: [], url: `https://github.com/${repo}/pull/${pr.number}#issuecomment-${c.id}`,
|
|
369
|
-
viewerDidAuthor: false,
|
|
370
|
-
}));
|
|
371
|
-
const closing = closingIssueNumbersFromBody(pr.body)
|
|
372
|
-
.filter((n) => state.issues.some((i) => i.repository === repo && i.number === n))
|
|
373
|
-
.map((n) => {
|
|
374
|
-
const issue = state.issues.find((i) => i.repository === repo && i.number === n)!;
|
|
375
|
-
return { number: n, title: issue.title ?? null, url: `https://github.com/${repo}/issues/${n}` };
|
|
376
|
-
});
|
|
377
|
-
const stateEnum = pr.merged ? 'MERGED' : (pr.state ?? 'open').toUpperCase();
|
|
378
|
-
// changedFiles: carried count → carried per-file list → the REAL diff from the git plane
|
|
379
|
-
// (a pushed PR's true file count — merge-gate scope classification depends on this being
|
|
380
|
-
// the actual paths, not 0) → 0 only when no evidence exists anywhere.
|
|
381
|
-
const planeFiles = pr.changed_files === undefined && !pr.files?.length ? prPlaneDiffFiles(state, repo, pr, root) : null;
|
|
382
|
-
return {
|
|
383
|
-
number: pr.number, id: `PR_${repo}#${pr.number}`, url: `https://github.com/${repo}/pull/${pr.number}`,
|
|
384
|
-
title: pr.title ?? null, state: stateEnum, body: pr.body ?? '', isDraft: pr.draft ?? false,
|
|
385
|
-
headRefOid: headSha, headRefName: pr.head_ref ?? null, baseRefName: pr.base_ref ?? null,
|
|
386
|
-
changedFiles: pr.changed_files ?? pr.files?.length ?? planeFiles?.length ?? 0,
|
|
387
|
-
additions: pr.additions ?? 0, deletions: pr.deletions ?? 0,
|
|
388
|
-
createdAt: null, updatedAt: null, closedAt: null, mergedAt: pr.merged_at ?? null,
|
|
389
|
-
labels: { nodes: (pr.labels ?? []).map((name) => ({ name })), totalCount: (pr.labels ?? []).length },
|
|
390
|
-
closingIssuesReferences: { nodes: closing, totalCount: closing.length, pageInfo: { hasNextPage: false, endCursor: null } },
|
|
391
|
-
// gh's `statusCheckRollup` json field is the alias for commits(last:1){…} — serve BOTH
|
|
392
|
-
// spellings so the alias and the raw field name each resolve.
|
|
393
|
-
statusCheckRollup: rollup, commits: rollup,
|
|
394
|
-
comments: { nodes: comments, totalCount: comments.length, pageInfo: { hasNextPage: false, endCursor: null } },
|
|
395
|
-
assignees: { nodes: (pr.assignees ?? []).map((login) => ({ login, id: `U_${login}`, name: null })), totalCount: (pr.assignees ?? []).length },
|
|
396
|
-
reviewRequests: { nodes: (pr.requested_reviewers ?? []).map((login) => ({ requestedReviewer: { __typename: 'User', login } })), totalCount: (pr.requested_reviewers ?? []).length },
|
|
397
|
-
};
|
|
398
|
-
}
|