@volter/twin-github 0.1.0 → 0.1.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 +162 -20
- package/client/github-mirror.tsx +10 -4
- package/package.json +2 -2
- package/src/cli.ts +3 -2
- package/src/github-a11y-snapshot.uitest.ts +117 -0
- package/src/github-budget.ts +198 -0
- package/src/github-capabilities.ts +502 -23
- package/src/github-connector.ts +832 -61
- package/src/github-events.ts +11 -1
- package/src/github-git-http.ts +248 -0
- package/src/github-git-plane.ts +511 -0
- package/src/github-graphql.ts +212 -1
- package/src/github-journey.uitest.ts +193 -0
- package/src/github-mirror-state.ts +369 -0
- package/src/github-mirror-ui.ts +53 -372
- package/src/github-server.ts +125 -21
- package/src/github-shared.ts +26 -0
- package/src/github-twin.ts +821 -53
- package/src/github-ui-conformance.ts +2 -2
- package/src/index.ts +71 -5
- package/test-fixtures/github-openapi-operations.json +467 -34
package/src/github-graphql.ts
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
// issues(first) { totalCount nodes { number title state } } } }
|
|
13
13
|
// query { node(id) { ... on PullRequest { number title } } } — id = "PR_<owner>/<repo>#<n>"
|
|
14
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 } } }
|
|
15
18
|
// Anything else (other root fields, unmodeled selection fields) → a typed GraphQL error.
|
|
16
|
-
import { applyGithubWrite } from './github-twin.ts';
|
|
19
|
+
import { applyGithubWrite, prPlaneDiffFiles } from './github-twin.ts';
|
|
17
20
|
import { githubState, type GithubState } from './github-twin.ts';
|
|
18
21
|
|
|
19
22
|
export type GraphqlResult = { data?: Record<string, unknown> | null; errors?: Array<{ message: string; type?: string }> };
|
|
@@ -33,6 +36,29 @@ const REPO_FIELDS = new Set([
|
|
|
33
36
|
// RepositoryInfo (gh pr create / gh repo view): the `repo` fragment + repo-level merge settings.
|
|
34
37
|
'id', 'owner', 'login', 'hasIssuesEnabled', 'description', 'hasWikiEnabled', 'viewerPermission',
|
|
35
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',
|
|
36
62
|
]);
|
|
37
63
|
const PR_FIELDS = new Set(['number', 'title', 'state', 'id', '__typename',
|
|
38
64
|
// PR connection node fields gh pr view/list select (modeled from the PR projection).
|
|
@@ -72,6 +98,8 @@ function selectionFields(query: string, parentToken: string): string[] {
|
|
|
72
98
|
function argValue(query: string, argName: string, variables: Record<string, unknown>): string | undefined {
|
|
73
99
|
const lit = new RegExp(`${argName}:\\s*"([^"]*)"`).exec(query);
|
|
74
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];
|
|
75
103
|
const ref = new RegExp(`${argName}:\\s*\\$([A-Za-z_][A-Za-z0-9_]*)`).exec(query);
|
|
76
104
|
if (ref && variables[ref[1]!] !== undefined) return String(variables[ref[1]!]);
|
|
77
105
|
return undefined;
|
|
@@ -98,6 +126,21 @@ export async function resolveGithubGraphql(req: { query: string; variables?: Rec
|
|
|
98
126
|
|
|
99
127
|
// ── mutation addComment(input: { subjectId, body }) ──────────────────────────────────────
|
|
100
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
|
+
}
|
|
101
144
|
if (/\baddComment\b/.test(query)) {
|
|
102
145
|
// subjectId/body come from variables or inline input. subjectId "PR_<repo>#<n>" or "I_…".
|
|
103
146
|
const subjectId = String(vars.subjectId ?? argValue(query, 'subjectId', vars) ?? '');
|
|
@@ -154,6 +197,68 @@ export async function resolveGithubGraphql(req: { query: string; variables?: Rec
|
|
|
154
197
|
if (bad) return { errors: [unmodeled(bad, 'Repository')] };
|
|
155
198
|
const known = state.repos.find((r) => r.full_name === repo);
|
|
156
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
|
+
}
|
|
157
262
|
// unmodeled subfields under pullRequests/issues nodes are honest errors too (connection-level
|
|
158
263
|
// fields like pageInfo/edges are allowed; only unknown PullRequest/Issue node fields error).
|
|
159
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')] }; }
|
|
@@ -185,3 +290,109 @@ export async function resolveGithubGraphql(req: { query: string; variables?: Rec
|
|
|
185
290
|
const unknownRoot = rootFields.find((f) => !['viewer', 'repository', 'node'].includes(f));
|
|
186
291
|
return { errors: [unmodeled(unknownRoot ?? rootFields[0] ?? 'unknown', 'Query')] };
|
|
187
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
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// GITHUB PILOT UI JOURNEY (TWIN-47 / H1, dev/02) — the first real proof of the uiJourney
|
|
2
|
+
// harness: seed PRs/issues/reviews through the pack's OWN write path (`applyGithubWrite`,
|
|
3
|
+
// same handler github-writes.test.ts / github-ui-structure.ts already use — never
|
|
4
|
+
// hand-written events.jsonl), boot the real mirror server (`createGithubMirrorServer`) on an
|
|
5
|
+
// ephemeral port, and drive it with real headless Playwright chromium using role/text
|
|
6
|
+
// locators ONLY — the locators an agent transfers from the real GitHub UI, never a
|
|
7
|
+
// className/test-id a test author invented. Proves navigation + LIVE interactivity: tab
|
|
8
|
+
// clicks actually swap the rendered view, and selecting a PR actually flows PR-specific data
|
|
9
|
+
// (body/review content) that the list row never shows.
|
|
10
|
+
//
|
|
11
|
+
// NOTE on assertions: we import the bare `playwright` library (not `@playwright/test`), so
|
|
12
|
+
// there is no web-first `expect(locator).toBeVisible()`. Instead we use Playwright's own
|
|
13
|
+
// auto-waiting/polling primitive directly — `locator.waitFor({ state })` — which retries until
|
|
14
|
+
// the DOM state holds or the page's default timeout elapses, then throws (failing the
|
|
15
|
+
// journey) if it never does. `visible`/`attached` prove seeded data reached the live DOM;
|
|
16
|
+
// `detached` proves a view actually unmounted (not merely CSS-hidden) after a tab switch.
|
|
17
|
+
//
|
|
18
|
+
// Must never hard-fail on a browserless machine: skip cleanly (loud console line) when
|
|
19
|
+
// `browserAvailable()` is false. The GATE-level advisory (never-silently-green) is the job of
|
|
20
|
+
// scripts/ui-journeys.ts; this file's own skip is a courtesy for anyone running `bun test`
|
|
21
|
+
// directly on a machine with no chromium cache.
|
|
22
|
+
//
|
|
23
|
+
// TWIN-49 (H3, dev/02) additions: the pilot journey now ALSO asserts the vendor-faithful
|
|
24
|
+
// pathname at each navigation step (client-side pushState routing — see pathFor/stateFor in
|
|
25
|
+
// github-mirror.tsx), an issue-detail case flips `github.journey.issue_detail` from a census
|
|
26
|
+
// todo to a real spec (the Issues row is now a role-reachable <button>), and a NEW deep-link
|
|
27
|
+
// journey `goto()`s a PR path directly (no clicking) and proves back/forward (popstate).
|
|
28
|
+
import { describe, test } from 'bun:test';
|
|
29
|
+
import type { Page } from 'playwright';
|
|
30
|
+
import { requireBrowser, runUiJourney, JOURNEY_TIMEOUT_MS, visible, gone, atPath, waitForMount } from '@volter/twin-tooling';
|
|
31
|
+
|
|
32
|
+
const JOURNEY_LABEL = 'github UI journey';
|
|
33
|
+
import { applyGithubWrite } from './github-twin.ts';
|
|
34
|
+
import { createGithubMirrorServer } from './github-mirror-ui.ts';
|
|
35
|
+
|
|
36
|
+
// `.first()`: some seeded titles legitimately render TWICE at once (the currently-selected PR's
|
|
37
|
+
// list row AND its Conversation <h2> both carry the title) — that duplication is real product
|
|
38
|
+
// behavior, not a bug, so we assert against "at least/at most this one instance" rather than
|
|
39
|
+
// forcing every locator to be uniquely-matching.
|
|
40
|
+
/** Wait until `text` renders visibly on the live page (throws/fails the journey if it never does). */
|
|
41
|
+
/** Wait until NO element matching `text` remains in the DOM (proves a real unmount, not CSS hiding). */
|
|
42
|
+
/** Wait until the live page's pathname matches `path` exactly (proves pushState/popstate — not
|
|
43
|
+
* the address-bar decoration text, the REAL `location.pathname` the browser tracks). */
|
|
44
|
+
/** Wait until a DOM mount has actually happened after a fresh `page.goto` (the harness's own
|
|
45
|
+
* mount-wait only covers the FIRST navigation; a deep-link journey re-navigates mid-test). */
|
|
46
|
+
|
|
47
|
+
describe('github UI journey (pilot, TWIN-47 dev/02)', () => {
|
|
48
|
+
test('seeded PRs/issues/reviews are reachable by real browser navigation', async () => {
|
|
49
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
50
|
+
|
|
51
|
+
await runUiJourney({
|
|
52
|
+
label: 'github seeded PRs/issues/reviews are reachable by real browser navigat',
|
|
53
|
+
seed: async (root) => {
|
|
54
|
+
// Real write path — same handler github-writes.test.ts / github-ui-structure.ts use.
|
|
55
|
+
// PR #1 (earlier): a distinct, greppable title + body only the detail pane shows.
|
|
56
|
+
await applyGithubWrite({
|
|
57
|
+
method: 'POST', path: '/repos/acme/twin-journey/pulls', root,
|
|
58
|
+
body: JSON.stringify({ title: 'Twin Journey Alpha PR', head: 'alpha', base: 'main', body: 'alpha-pr-body-marker' }),
|
|
59
|
+
occurredAt: '2026-05-01T00:00:00Z',
|
|
60
|
+
});
|
|
61
|
+
// PR #2 (later, so it sorts first by updatedAt): distinct title/body + a review whose
|
|
62
|
+
// body is ONLY reachable once this PR is selected (never shown in the list row).
|
|
63
|
+
await applyGithubWrite({
|
|
64
|
+
method: 'POST', path: '/repos/acme/twin-journey/pulls', root,
|
|
65
|
+
body: JSON.stringify({ title: 'Twin Journey Beta PR', head: 'beta', base: 'main', body: 'beta-pr-body-marker' }),
|
|
66
|
+
occurredAt: '2026-05-02T00:00:00Z',
|
|
67
|
+
});
|
|
68
|
+
await applyGithubWrite({
|
|
69
|
+
method: 'POST', path: '/repos/acme/twin-journey/pulls/2/reviews', root,
|
|
70
|
+
body: JSON.stringify({ event: 'APPROVED', body: 'beta-review-body-marker' }),
|
|
71
|
+
occurredAt: '2026-05-02T01:00:00Z',
|
|
72
|
+
});
|
|
73
|
+
// One issue with a distinct title + body (the body is ONLY reachable once the issue is
|
|
74
|
+
// opened — the Issues list row never renders it) — proves both the Issues tab swap AND
|
|
75
|
+
// the issue-detail selection (dev/02: github.journey.issue_detail).
|
|
76
|
+
await applyGithubWrite({
|
|
77
|
+
method: 'POST', path: '/repos/acme/twin-journey/issues', root,
|
|
78
|
+
body: JSON.stringify({ title: 'Twin Journey Tracking Issue', body: 'tracking-issue-body-marker' }),
|
|
79
|
+
occurredAt: '2026-05-03T00:00:00Z',
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
serve: (root) => {
|
|
83
|
+
const server = createGithubMirrorServer({ root, port: 0 });
|
|
84
|
+
return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop() };
|
|
85
|
+
},
|
|
86
|
+
journey: async (page) => {
|
|
87
|
+
// 1. Navigation lands on the PR list; both seeded PR titles render (list view shows
|
|
88
|
+
// ALL matching PRs, so both are visible before any selection). URL: the "all repos"
|
|
89
|
+
// cross-repo pulls page (no PR opened yet) — /pulls.
|
|
90
|
+
await visible(page, 'Twin Journey Alpha PR');
|
|
91
|
+
await visible(page, 'Twin Journey Beta PR');
|
|
92
|
+
await atPath(page, '/pulls');
|
|
93
|
+
|
|
94
|
+
// 2. Click the Issues TAB (a real <button>, role-reachable) → the seeded issue title
|
|
95
|
+
// appears AND the pulls-only markers (the seeded PR titles) are gone — proves the
|
|
96
|
+
// click handler fired and the view actually swapped (hydration is live, not SSR
|
|
97
|
+
// residue left over from the initial HTML). URL: /issues (no issue opened yet).
|
|
98
|
+
await page.getByRole('button', { name: /^Issues/ }).click();
|
|
99
|
+
await visible(page, 'Twin Journey Tracking Issue');
|
|
100
|
+
await gone(page, 'Twin Journey Alpha PR');
|
|
101
|
+
await gone(page, 'Twin Journey Beta PR');
|
|
102
|
+
await atPath(page, '/issues');
|
|
103
|
+
|
|
104
|
+
// 2b. Click the seeded issue's row (dev/02: github.journey.issue_detail — the Issues
|
|
105
|
+
// row is a role-reachable <button>, not a bare onClick <div>). With only one seeded
|
|
106
|
+
// issue, its detail pane is already shown as the split-pane default (same
|
|
107
|
+
// single-item fallback the PR view uses, so no "gone before click" assertion here
|
|
108
|
+
// — that's covered by the multi-PR case below); the URL is what proves the click
|
|
109
|
+
// actually navigated: it becomes the issue's own repo-scoped detail path — issues
|
|
110
|
+
// and PRs share one number counter, so this repo's 3rd object (after the 2 seeded
|
|
111
|
+
// PRs) is issue #3.
|
|
112
|
+
await visible(page, 'tracking-issue-body-marker');
|
|
113
|
+
await page.getByRole('button', { name: /Twin Journey Tracking Issue/ }).click();
|
|
114
|
+
await atPath(page, '/acme/twin-journey/issues/3');
|
|
115
|
+
|
|
116
|
+
// 3. Click back to Pull requests — the PR titles reappear (round-trip proves the tab
|
|
117
|
+
// state, not a one-way navigation, actually drives the render). URL: back to /pulls
|
|
118
|
+
// (no PR opened in this run yet, so no numbered path).
|
|
119
|
+
await page.getByRole('button', { name: /^Pull requests/ }).click();
|
|
120
|
+
await visible(page, 'Twin Journey Alpha PR');
|
|
121
|
+
await atPath(page, '/pulls');
|
|
122
|
+
|
|
123
|
+
// 4. Click the Alpha PR's row (a real <button>, role-reachable via its title text) →
|
|
124
|
+
// its body appears in the detail pane — content the list row NEVER renders (the
|
|
125
|
+
// list row only shows title/repo/state/meta, never pr.body). This proves selection
|
|
126
|
+
// state flows PR-specific data through a live store-door fetch (`GET /twin/store/mirror`), not a static shell.
|
|
127
|
+
// URL: opening a PR always routes to ITS OWN repo's numbered path, even from the
|
|
128
|
+
// "all repos" list — /acme/twin-journey/pull/1.
|
|
129
|
+
await gone(page, 'alpha-pr-body-marker'); // not shown pre-selection (Beta sorts first)
|
|
130
|
+
await page.getByRole('button', { name: /Twin Journey Alpha PR/ }).click();
|
|
131
|
+
await visible(page, 'alpha-pr-body-marker');
|
|
132
|
+
await atPath(page, '/acme/twin-journey/pull/1');
|
|
133
|
+
|
|
134
|
+
// 5. Click the Beta PR's row → its review body (seeded via a REAL review write)
|
|
135
|
+
// becomes visible — proves the review data-coupling reaches the live DOM too, and
|
|
136
|
+
// that switching selection re-renders (Alpha's body is no longer shown). URL moves
|
|
137
|
+
// to PR #2's path.
|
|
138
|
+
await page.getByRole('button', { name: /Twin Journey Beta PR/ }).click();
|
|
139
|
+
await visible(page, 'beta-review-body-marker');
|
|
140
|
+
await gone(page, 'alpha-pr-body-marker');
|
|
141
|
+
await atPath(page, '/acme/twin-journey/pull/2');
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}, JOURNEY_TIMEOUT_MS);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('github UI journey — deep link + back/forward (TWIN-49 / H3, dev/02)', () => {
|
|
148
|
+
test('a direct deep link to a PR renders it without clicking, and back restores it (popstate)', async () => {
|
|
149
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
150
|
+
|
|
151
|
+
await runUiJourney({
|
|
152
|
+
label: 'github a direct deep link to a PR renders it without clicking, and bac',
|
|
153
|
+
seed: async (root) => {
|
|
154
|
+
await applyGithubWrite({
|
|
155
|
+
method: 'POST', path: '/repos/acme/deep-link-journey/pulls', root,
|
|
156
|
+
body: JSON.stringify({ title: 'Deep Link Alpha PR', head: 'alpha', base: 'main', body: 'deep-link-alpha-body-marker' }),
|
|
157
|
+
occurredAt: '2026-05-10T00:00:00Z',
|
|
158
|
+
});
|
|
159
|
+
await applyGithubWrite({
|
|
160
|
+
method: 'POST', path: '/repos/acme/deep-link-journey/pulls', root,
|
|
161
|
+
body: JSON.stringify({ title: 'Deep Link Beta PR', head: 'beta', base: 'main', body: 'deep-link-beta-body-marker' }),
|
|
162
|
+
occurredAt: '2026-05-10T01:00:00Z',
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
serve: (root) => {
|
|
166
|
+
const server = createGithubMirrorServer({ root, port: 0 });
|
|
167
|
+
return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop() };
|
|
168
|
+
},
|
|
169
|
+
journey: async (page) => {
|
|
170
|
+
// Deep link: goto the PR #2 path DIRECTLY (no clicking at all) — the client must
|
|
171
|
+
// derive the initial view + selection from `location.pathname` on mount, not from any
|
|
172
|
+
// in-memory click history.
|
|
173
|
+
const origin = new URL(page.url()).origin;
|
|
174
|
+
await page.goto(`${origin}/acme/deep-link-journey/pull/2`);
|
|
175
|
+
await waitForMount(page);
|
|
176
|
+
await visible(page, 'deep-link-beta-body-marker');
|
|
177
|
+
await gone(page, 'deep-link-alpha-body-marker'); // proves PR #2 rendered, not a fallback/default
|
|
178
|
+
await atPath(page, '/acme/deep-link-journey/pull/2');
|
|
179
|
+
|
|
180
|
+
// Click to another view (real navigation, pushState) — proves the deep-linked page is
|
|
181
|
+
// still live/interactive, not a dead static render.
|
|
182
|
+
await page.getByRole('button', { name: /^Issues/ }).click();
|
|
183
|
+
await atPath(page, '/acme/deep-link-journey/issues');
|
|
184
|
+
await gone(page, 'deep-link-beta-body-marker');
|
|
185
|
+
|
|
186
|
+
// Back (popstate tooth): the PR view AND its URL are both restored.
|
|
187
|
+
await page.goBack();
|
|
188
|
+
await atPath(page, '/acme/deep-link-journey/pull/2');
|
|
189
|
+
await visible(page, 'deep-link-beta-body-marker');
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}, JOURNEY_TIMEOUT_MS);
|
|
193
|
+
});
|