@volter/twin-github 0.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/README.md +88 -36
  2. package/client/github-mirror.css +277 -319
  3. package/client/github-mirror.d.ts +418 -0
  4. package/client/github-mirror.js +485 -0
  5. package/client/github-mirror.tsx +153 -357
  6. package/client/pulls-rest.ts +159 -0
  7. package/client/pulls-workspace.tsx +841 -0
  8. package/dist/client/github-mirror.bundle.js +239 -0
  9. package/dist/client/github-mirror.css +916 -0
  10. package/dist/client/github-mirror.d.ts +418 -0
  11. package/dist/client/github-mirror.js +485 -0
  12. package/dist/client/github-mirror.tsx +1315 -0
  13. package/dist/client/pulls-rest.d.ts +42 -0
  14. package/dist/client/pulls-rest.js +140 -0
  15. package/dist/client/pulls-rest.ts +159 -0
  16. package/dist/client/pulls-workspace.bundle.js +22 -0
  17. package/dist/client/pulls-workspace.d.ts +114 -0
  18. package/dist/client/pulls-workspace.js +418 -0
  19. package/dist/client/pulls-workspace.tsx +841 -0
  20. package/dist/src/cli.d.ts +2 -0
  21. package/dist/src/cli.js +40 -0
  22. package/dist/src/generated/graphql-sdl.gen.json +1 -0
  23. package/dist/src/generated/graphql.gen.json +1 -0
  24. package/dist/src/generated/surface.gen.json +1 -0
  25. package/dist/src/generated/ui.gen.json +1 -0
  26. package/dist/src/github-budget.d.ts +69 -0
  27. package/dist/src/github-budget.js +172 -0
  28. package/dist/src/github-capabilities.d.ts +5 -0
  29. package/dist/src/github-capabilities.js +4468 -0
  30. package/dist/src/github-conformance.d.ts +43 -0
  31. package/dist/src/github-conformance.js +76 -0
  32. package/dist/src/github-connector.d.ts +307 -0
  33. package/dist/src/github-connector.js +1398 -0
  34. package/dist/src/github-events.d.ts +41 -0
  35. package/dist/src/github-events.js +230 -0
  36. package/dist/src/github-git-http.d.ts +49 -0
  37. package/dist/src/github-git-http.js +185 -0
  38. package/dist/src/github-git-plane.d.ts +114 -0
  39. package/dist/src/github-git-plane.js +407 -0
  40. package/dist/src/github-mirror-state.d.ts +2 -0
  41. package/dist/src/github-mirror-state.js +335 -0
  42. package/dist/src/github-mirror-ui.d.ts +20 -0
  43. package/dist/src/github-mirror-ui.js +101 -0
  44. package/dist/src/github-server.d.ts +14 -0
  45. package/dist/src/github-server.js +240 -0
  46. package/dist/src/github-shared.d.ts +12 -0
  47. package/dist/src/github-shared.js +21 -0
  48. package/dist/src/github-twin.d.ts +1411 -0
  49. package/dist/src/github-twin.js +4084 -0
  50. package/dist/src/github-ui-conformance.d.ts +4 -0
  51. package/dist/src/github-ui-conformance.js +105 -0
  52. package/dist/src/github-ui-structure.d.ts +18 -0
  53. package/dist/src/github-ui-structure.js +251 -0
  54. package/dist/src/graphql-wire.d.ts +22 -0
  55. package/dist/src/graphql-wire.js +89 -0
  56. package/dist/src/index.d.ts +17 -0
  57. package/dist/src/index.js +101 -0
  58. package/dist/src/manifest.d.ts +8 -0
  59. package/dist/src/manifest.js +597 -0
  60. package/dist/src/npm-registry.d.ts +7 -0
  61. package/dist/src/npm-registry.js +47 -0
  62. package/dist/src/screens/app-installation.d.ts +3 -0
  63. package/dist/src/screens/app-installation.js +166 -0
  64. package/dist/src/screens/app-manifest.d.ts +3 -0
  65. package/dist/src/screens/app-manifest.js +81 -0
  66. package/dist/src/screens/oauth.d.ts +15 -0
  67. package/dist/src/screens/oauth.js +257 -0
  68. package/dist/src/screens/session.d.ts +8 -0
  69. package/dist/src/screens/session.js +159 -0
  70. package/dist/src/semantics/actions.d.ts +2 -0
  71. package/dist/src/semantics/actions.js +413 -0
  72. package/dist/src/semantics/activity.d.ts +4 -0
  73. package/dist/src/semantics/activity.js +161 -0
  74. package/dist/src/semantics/apps.d.ts +2 -0
  75. package/dist/src/semantics/apps.js +144 -0
  76. package/dist/src/semantics/branches.d.ts +2 -0
  77. package/dist/src/semantics/branches.js +136 -0
  78. package/dist/src/semantics/checks.d.ts +2 -0
  79. package/dist/src/semantics/checks.js +176 -0
  80. package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
  81. package/dist/src/semantics/code-scanning-upload.js +97 -0
  82. package/dist/src/semantics/codespaces.d.ts +2 -0
  83. package/dist/src/semantics/codespaces.js +58 -0
  84. package/dist/src/semantics/commits.d.ts +2 -0
  85. package/dist/src/semantics/commits.js +109 -0
  86. package/dist/src/semantics/contents.d.ts +2 -0
  87. package/dist/src/semantics/contents.js +131 -0
  88. package/dist/src/semantics/deployments.d.ts +2 -0
  89. package/dist/src/semantics/deployments.js +127 -0
  90. package/dist/src/semantics/gists.d.ts +2 -0
  91. package/dist/src/semantics/gists.js +86 -0
  92. package/dist/src/semantics/git.d.ts +2 -0
  93. package/dist/src/semantics/git.js +235 -0
  94. package/dist/src/semantics/graphql.d.ts +7 -0
  95. package/dist/src/semantics/graphql.js +512 -0
  96. package/dist/src/semantics/index.d.ts +5 -0
  97. package/dist/src/semantics/index.js +62 -0
  98. package/dist/src/semantics/issues.d.ts +2 -0
  99. package/dist/src/semantics/issues.js +456 -0
  100. package/dist/src/semantics/keys.d.ts +2 -0
  101. package/dist/src/semantics/keys.js +66 -0
  102. package/dist/src/semantics/labels.d.ts +2 -0
  103. package/dist/src/semantics/labels.js +100 -0
  104. package/dist/src/semantics/meta.d.ts +12 -0
  105. package/dist/src/semantics/meta.js +144 -0
  106. package/dist/src/semantics/notifications.d.ts +4 -0
  107. package/dist/src/semantics/notifications.js +62 -0
  108. package/dist/src/semantics/orgs.d.ts +2 -0
  109. package/dist/src/semantics/orgs.js +420 -0
  110. package/dist/src/semantics/packages.d.ts +2 -0
  111. package/dist/src/semantics/packages.js +55 -0
  112. package/dist/src/semantics/pages.d.ts +2 -0
  113. package/dist/src/semantics/pages.js +176 -0
  114. package/dist/src/semantics/projects.d.ts +2 -0
  115. package/dist/src/semantics/projects.js +239 -0
  116. package/dist/src/semantics/pulls.d.ts +2 -0
  117. package/dist/src/semantics/pulls.js +462 -0
  118. package/dist/src/semantics/push-reactions.d.ts +101 -0
  119. package/dist/src/semantics/push-reactions.js +513 -0
  120. package/dist/src/semantics/releases.d.ts +19 -0
  121. package/dist/src/semantics/releases.js +231 -0
  122. package/dist/src/semantics/repo-invitations.d.ts +2 -0
  123. package/dist/src/semantics/repo-invitations.js +98 -0
  124. package/dist/src/semantics/repos.d.ts +17 -0
  125. package/dist/src/semantics/repos.js +390 -0
  126. package/dist/src/semantics/rulesets.d.ts +2 -0
  127. package/dist/src/semantics/rulesets.js +111 -0
  128. package/dist/src/semantics/search.d.ts +2 -0
  129. package/dist/src/semantics/search.js +84 -0
  130. package/dist/src/semantics/security.d.ts +2 -0
  131. package/dist/src/semantics/security.js +177 -0
  132. package/dist/src/semantics/shared.d.ts +61 -0
  133. package/dist/src/semantics/shared.js +151 -0
  134. package/dist/src/semantics/users.d.ts +2 -0
  135. package/dist/src/semantics/users.js +255 -0
  136. package/dist/src/semantics/webhooks.d.ts +2 -0
  137. package/dist/src/semantics/webhooks.js +107 -0
  138. package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
  139. package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
  140. package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
  141. package/dist/test-fixtures/github-comment-schema.json +702 -0
  142. package/dist/test-fixtures/github-known-deviations.json +94 -0
  143. package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
  144. package/dist/test-fixtures/github-openapi-operations.json +362 -0
  145. package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
  146. package/dist/test-fixtures/github-pull-schema.json +3601 -0
  147. package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
  148. package/dist/test-fixtures/github-review-schema.json +236 -0
  149. package/package.json +20 -11
  150. package/src/cli.ts +7 -6
  151. package/src/generated/graphql-sdl.gen.json +1 -0
  152. package/src/generated/graphql.gen.json +1 -0
  153. package/src/generated/surface.gen.json +1 -0
  154. package/src/generated/ui.gen.json +1 -0
  155. package/src/github-a11y-snapshot.uitest.ts +5 -5
  156. package/src/github-budget.ts +4 -4
  157. package/src/github-capabilities.ts +1993 -556
  158. package/src/github-conformance.ts +12 -7
  159. package/src/github-connector.ts +108 -96
  160. package/src/github-events.ts +223 -95
  161. package/src/github-git-http.ts +47 -83
  162. package/src/github-git-plane.ts +247 -385
  163. package/src/github-journey.uitest.ts +28 -44
  164. package/src/github-mirror-state.ts +23 -61
  165. package/src/github-mirror-ui.ts +16 -10
  166. package/src/github-server.ts +158 -60
  167. package/src/github-shared.ts +1 -1
  168. package/src/github-twin.ts +1525 -4494
  169. package/src/github-ui-conformance.ts +7 -8
  170. package/src/github-ui-structure.ts +19 -18
  171. package/src/graphql-wire.ts +115 -0
  172. package/src/index.ts +47 -7
  173. package/src/manifest.ts +605 -0
  174. package/src/npm-registry.ts +43 -0
  175. package/src/screens/app-installation.tsx +217 -0
  176. package/src/screens/app-manifest.tsx +97 -0
  177. package/src/screens/oauth.tsx +258 -0
  178. package/src/screens/session.tsx +186 -0
  179. package/src/semantics/actions.ts +395 -0
  180. package/src/semantics/activity.ts +171 -0
  181. package/src/semantics/apps.ts +133 -0
  182. package/src/semantics/branches.ts +133 -0
  183. package/src/semantics/checks.ts +163 -0
  184. package/src/semantics/code-scanning-upload.ts +92 -0
  185. package/src/semantics/codespaces.ts +58 -0
  186. package/src/semantics/commits.ts +109 -0
  187. package/src/semantics/contents.ts +112 -0
  188. package/src/semantics/deployments.ts +116 -0
  189. package/src/semantics/gists.ts +85 -0
  190. package/src/semantics/git.ts +226 -0
  191. package/src/semantics/graphql.ts +505 -0
  192. package/src/semantics/index.ts +68 -0
  193. package/src/semantics/issues.ts +434 -0
  194. package/src/semantics/keys.ts +66 -0
  195. package/src/semantics/labels.ts +91 -0
  196. package/src/semantics/meta.ts +139 -0
  197. package/src/semantics/notifications.ts +58 -0
  198. package/src/semantics/orgs.ts +383 -0
  199. package/src/semantics/packages.ts +59 -0
  200. package/src/semantics/pages.ts +154 -0
  201. package/src/semantics/projects.ts +246 -0
  202. package/src/semantics/pulls.ts +421 -0
  203. package/src/semantics/push-reactions.ts +471 -0
  204. package/src/semantics/releases.ts +213 -0
  205. package/src/semantics/repo-invitations.ts +112 -0
  206. package/src/semantics/repos.ts +373 -0
  207. package/src/semantics/rulesets.ts +107 -0
  208. package/src/semantics/search.ts +83 -0
  209. package/src/semantics/security.ts +153 -0
  210. package/src/semantics/shared.ts +181 -0
  211. package/src/semantics/users.ts +252 -0
  212. package/src/semantics/webhooks.ts +101 -0
  213. package/test-fixtures/github-known-deviations.json +7 -8
  214. package/test-fixtures/github-openapi-operations.json +46 -227
  215. package/src/github-graphql.ts +0 -398
@@ -0,0 +1,421 @@
1
+ // GitHub's pulls family: pull requests (create, read, list with GitHub's filters, edit), their
2
+ // reviews (submit, dismiss, edit; the implicit review that wraps a standalone review comment), review
3
+ // comments and replies, files and commits (carried by a local write, or read from the git plane),
4
+ // requested reviewers, updating the branch, and merging through the gates (draft, required checks,
5
+ // rulesets) into the plane. The pulls family answers for pull requests only: `state.prs` also holds
6
+ // the counter shells the projection makes for issue numbers, so every door reads `is_pull_request`.
7
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
8
+ import {
9
+ authorAssociation,
10
+ applyTriageFields, GITHUB_WORLD_USER, githubNotFound, hasBareRepo, implicitReviewId, listGitPlaneRefs, mergePullRequest, nextRepoNumber,
11
+ mergeIntoBranchInPlane, normalizeLogins, paginate, planeAncestors, syncPrHeadsToBranchTip, planeCommitBody, prPlaneDiffFiles, readGitCommitFromPlane, resolvePlaneRef, reviewStateForEvent, serializeReviewComment,
12
+ toAssignees, toGithubIssue, toGithubPullFile, toGithubRest, toGithubReview, type GithubPr, type GithubReview, type GithubState,
13
+ } from '../github-twin.ts';
14
+ import { announce, asView, gh, nextSeq, put, send, writer, type Gh } from './shared.ts';
15
+ import { assignable } from './repos.ts';
16
+ import { closingIssueNumbers } from './push-reactions.ts';
17
+
18
+ const numberOf = (g: Gh): number => Number(decodeURIComponent(g.seg[4]!));
19
+ /** `owner`'s repository in `repo`'s network (the forks of one root, docs.github.com/pull-requests/collaborating-with-
20
+ * pull-requests/working-with-forks/about-forks), or nothing. */
21
+ function forkInNetwork(st: GithubState, repo: string, owner: string): string | undefined {
22
+ const rootOf = (name: string): string => {
23
+ let at = name;
24
+ for (let hops = 0; hops < 50; hops++) { const parent = st.repos.find((r) => r.full_name === at)?.parent_full_name; if (!parent) break; at = parent; }
25
+ return at;
26
+ };
27
+ const network = rootOf(repo);
28
+ return st.repos.find((r) => r.owner.toLowerCase() === owner.toLowerCase() && r.full_name !== repo && rootOf(r.full_name) === network)?.full_name;
29
+ }
30
+ /** The pull request a path names, as a pull request (never an issue's counter shell). */
31
+ const pullOf = (g: Gh): GithubPr | undefined => {
32
+ const n = numberOf(g);
33
+ return g.state().prs.find((p) => p.repository === g.repo && p.number === n && p.is_pull_request);
34
+ };
35
+ /** A door INTO a pull request 404s when nobody opened it: an empty list would say it is real and quiet. */
36
+ const intoPull = (serve: (ctx: SemanticsContext, g: Gh, pr: GithubPr, n: number) => Promise<Response>): Semantics => async (ctx) => {
37
+ const g = gh(ctx);
38
+ const pr = pullOf(g);
39
+ return pr ? serve(ctx, g, pr, pr.number) : send(ctx, githubNotFound());
40
+ };
41
+
42
+ /** A pull request as GitHub answers it whole: its fields, the counts GitHub computes (commits, changed files,
43
+ * additions, deletions, issue comments and review comments) each from what the list endpoints answer unless
44
+ * the stored pull request carries it (an observed one's pulled counts), and its mergeability. */
45
+ async function fullPull(g: Gh, pr: GithubPr): Promise<Record<string, unknown>> {
46
+ const st = g.state();
47
+ const files = await filesOf(g, pr);
48
+ const commits = (await planeCommitsOf(g, pr))?.length ?? pr.commits ?? 0;
49
+ const on = (kind: string) => st.comments.filter((c) => c.repository === pr.repository && c.number === pr.number && c.kind === kind).length;
50
+ return {
51
+ ...toGithubRest(pr, st.milestones),
52
+ commits: pr.commits ?? commits,
53
+ changed_files: pr.changed_files ?? files.length,
54
+ additions: pr.additions ?? files.reduce((n, f) => n + (Number(f.additions) || 0), 0),
55
+ deletions: pr.deletions ?? files.reduce((n, f) => n + (Number(f.deletions) || 0), 0),
56
+ comments: on('issue'),
57
+ review_comments: on('review'),
58
+ ...(await mergeability(g, pr)),
59
+ };
60
+ }
61
+
62
+ /** Write a pull request's fields and answer it as GitHub renders it (whole, or as the operation's view), announcing `action`. */
63
+ async function writePull(ctx: SemanticsContext, g: Gh, number: number, operation: string, fields: Record<string, unknown>, action: string, status = 200, view?: string): Promise<Response> {
64
+ const id = `${g.repo}#${number}`;
65
+ await put(ctx, { operation, subjectType: 'pull_request', subjectId: id, fields: { number, repository: g.repo, ...fields } });
66
+ const st = g.state();
67
+ const found = st.prs.find((p) => p.id === id);
68
+ const out = found ? toGithubRest(found, st.milestones) : { number };
69
+ await announce(ctx, [{ event: 'pull_request', action, repository: g.repo, number, pr: out }]);
70
+ return ctx.reply(view ? asView(view, out) : found ? await fullPull(g, found) : out, status);
71
+ }
72
+
73
+ export function pullsSemantics(): Record<string, Semantics> {
74
+ return {
75
+ // GitHub's filters: state (open|closed|all, default open), head (owner:branch or branch), base.
76
+ // The default order is newest first; a pull request's number is its creation order.
77
+ 'pulls/list': async (ctx) => {
78
+ const { repo, query, state } = gh(ctx);
79
+ const st = state();
80
+ const lp = new URLSearchParams((query ?? '').replace(/^\?/, ''));
81
+ const stateF = (lp.get('state') ?? 'open').toLowerCase();
82
+ const headF = lp.get('head') ?? undefined;
83
+ const baseF = lp.get('base') ?? undefined;
84
+ const headBranch = headF !== undefined ? (headF.includes(':') ? headF.slice(headF.indexOf(':') + 1) : headF) : undefined;
85
+ // `head` is user:ref-name; the user is whoever holds the head branch (the fork's owner for a pull request from a fork)
86
+ const headUser = headF?.includes(':') ? headF.slice(0, headF.indexOf(':')).toLowerCase() : undefined;
87
+ const filtered = st.prs.filter((p) => p.repository === repo && p.is_pull_request).filter((p) => {
88
+ const prState = p.merged ? 'closed' : (p.state ?? 'open');
89
+ if (stateF !== 'all' && prState !== stateF) return false;
90
+ if (headBranch !== undefined && p.head_ref !== headBranch && p.head_sha !== headBranch) return false;
91
+ if (headUser !== undefined && (p.head_repo ?? p.repository).split('/')[0]!.toLowerCase() !== headUser) return false;
92
+ if (baseF !== undefined && p.base_ref !== baseF) return false;
93
+ return true;
94
+ });
95
+ const dirP = (lp.get('direction') ?? 'desc').toLowerCase() === 'asc' ? 1 : -1;
96
+ return ctx.reply(paginate([...filtered].sort((a, b) => (a.number - b.number) * dirP), query).map((p) => asView('pull-request-simple', toGithubRest(p, st.milestones))));
97
+ },
98
+ // GitHub's pull request carries its counts (commits, changed_files, additions, deletions, comments,
99
+ // review_comments; all required by the schema): each from what the list endpoints answer, unless the
100
+ // stored pull request already carries it (an observed one's pulled counts)
101
+ 'pulls/get': async (ctx) => {
102
+ const g = gh(ctx);
103
+ const pr = pullOf(g);
104
+ if (!pr) return send(ctx, githubNotFound());
105
+ return send(ctx, { status: 200, body: await fullPull(g, pr) });
106
+ },
107
+ // 204 when merged, else 404
108
+ 'pulls/check-if-merged': async (ctx) => send(ctx, pullOf(gh(ctx))?.merged ? { status: 204, body: undefined } : githubNotFound()),
109
+ // one review comment, addressed without its pull request
110
+ 'pulls/get-review-comment': async (ctx) => {
111
+ const { repo, seg, state } = gh(ctx);
112
+ const cid = Number(decodeURIComponent(seg[5]!));
113
+ const c = state().comments.find((x) => x.repository === repo && x.kind === 'review' && x.id === cid);
114
+ return send(ctx, c ? { status: 200, body: serializeReviewComment(c) } : githubNotFound());
115
+ },
116
+ // The IMPLICIT REVIEW rides the list, as on GitHub: a review comment posted on its own is wrapped
117
+ // in a review, synthesized from the comments naming it (submitted at the earliest, authored by the
118
+ // first, COMMENTED), never stored; the projection counts it into the review count the same way.
119
+ 'pulls/list-reviews': intoPull(async (ctx, g, _pr, n) => {
120
+ const st = g.state();
121
+ const items: GithubReview[] = st.reviews.filter((r) => r.repository === g.repo && r.number === n);
122
+ const wrapperId = implicitReviewId(g.repo, n);
123
+ const wrapped = st.comments.filter((c) => c.repository === g.repo && c.number === n && c.kind === 'review' && c.review_id === wrapperId);
124
+ if (wrapped.length > 0) {
125
+ const dated = wrapped.map((c) => c.created_at).filter((d): d is string => d !== undefined).sort();
126
+ const author = wrapped.find((c) => c.user_login !== undefined);
127
+ items.push({
128
+ id: wrapperId, repository: g.repo, number: n, state: 'COMMENTED', body: '',
129
+ ...(dated[0] !== undefined ? { submitted_at: dated[0] } : {}),
130
+ ...(author?.user_login !== undefined ? { user_login: author.user_login } : {}),
131
+ ...(author?.user_type !== undefined ? { user_type: author.user_type } : {}),
132
+ });
133
+ }
134
+ return ctx.reply(paginate(items, g.query).map(toGithubReview));
135
+ }),
136
+ // one review, on the (repo, number, id) the dismiss and edit writes use
137
+ 'pulls/get-review': intoPull(async (ctx, g, _pr, n) => {
138
+ const rid = Number(decodeURIComponent(g.seg[6]!));
139
+ const r = g.state().reviews.find((x) => x.repository === g.repo && x.number === n && x.id === rid);
140
+ return send(ctx, r ? { status: 200, body: toGithubReview(r) } : githubNotFound());
141
+ }),
142
+ // the diff-thread comments (with their diff anchors when a local write set them)
143
+ 'pulls/list-review-comments': intoPull(async (ctx, g, _pr, n) =>
144
+ ctx.reply(paginate(g.state().comments.filter((c) => c.repository === g.repo && c.number === n && c.kind === 'review'), g.query).map(serializeReviewComment))),
145
+ // The plane's real diff when the base and head both exist there (an observed pull request with a
146
+ // pulled count but no per-file evidence keeps none, so the list never contradicts its own count).
147
+ 'pulls/list-files': intoPull(async (ctx, g, pr) => ctx.reply(paginate(await filesOf(g, pr), g.query).map((f) => toGithubPullFile(g.repo, pr.head_sha ?? '', f)))),
148
+ // In a repository with history, the plane's commits: those the head reaches that the base did not
149
+ // hold (a merged one's base is its merge commit's first parent), oldest first; none without history.
150
+ 'pulls/list-commits': intoPull(async (ctx, g, pr) => {
151
+ const plane = await planeCommitsOf(g, pr);
152
+ if (plane) {
153
+ const bodies = [];
154
+ for (const sha of paginate(plane, g.query)) { const b = await planeCommitBody(g.repo, sha, g.root); if (b) bodies.push(b); }
155
+ return ctx.reply(bodies);
156
+ }
157
+ return ctx.reply([]);
158
+ }),
159
+ // {users, teams}: teams are not modeled
160
+ 'pulls/list-requested-reviewers': intoPull(async (ctx, _g, pr) => ctx.reply({ users: toAssignees(pr.requested_reviewers), teams: [] })),
161
+ // 422 without title, head and base. `head` may be owner:branch, a branch, a ref or a sha: the branch
162
+ // is the head ref; its tip (a registered ref or branch) is head_sha. In a repository whose history
163
+ // is in the plane, a head branch that does not exist there is GitHub's 422. An `owner:branch` head whose owner
164
+ // is not this repository's names that owner's fork in the same network, and the branch is read there
165
+ // (docs.github.com/rest/pulls/pulls#create-a-pull-request: "For cross-repository pull requests in the same
166
+ // network, namespace head with a user like this: username:branch"); an owner with no fork in the network is 422.
167
+ 'pulls/create': async (ctx) => {
168
+ const { repo, body, occurredAt, caller, root, state } = gh(ctx);
169
+ const missing = (['title', 'head', 'base'] as const).filter((k) => !body[k]);
170
+ if (missing.length) return ctx.reply({ message: 'Validation Failed', errors: missing.map((field) => ({ resource: 'PullRequest', field, code: 'missing_field' })) }, 422);
171
+ const number = nextRepoNumber(state(), repo);
172
+ const id = `${repo}#${number}`;
173
+ const headRaw = String(body.head);
174
+ const headRef = headRaw.includes(':') ? headRaw.slice(headRaw.indexOf(':') + 1) : headRaw;
175
+ const branchName = headRef.replace(/^refs\/heads\//, '');
176
+ const invalidHead = () => ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'PullRequest', field: 'head', code: 'invalid' }], documentation_url: 'https://docs.github.com/rest/pulls/pulls#create-a-pull-request' }, 422);
177
+ const headOwner = headRaw.includes(':') ? headRaw.slice(0, headRaw.indexOf(':')) : undefined;
178
+ let headRepo = repo;
179
+ if (headOwner !== undefined && headOwner.toLowerCase() !== repo.split('/')[0]!.toLowerCase()) {
180
+ const fork = forkInNetwork(state(), repo, headOwner);
181
+ if (!fork) return invalidHead();
182
+ headRepo = fork;
183
+ }
184
+ let resolvedHeadSha = headRaw;
185
+ if (!/^[0-9a-f]{40}$/i.test(headRaw)) {
186
+ const st0 = state();
187
+ const tip = st0.gitRefs.find((x) => x.repository === headRepo && x.ref === `refs/heads/${branchName}`)?.object_sha
188
+ ?? st0.branches.find((b) => b.repository === headRepo && b.name === branchName)?.commit_sha;
189
+ if (tip) resolvedHeadSha = tip;
190
+ else if (headRepo !== repo || (hasBareRepo(repo, root) && !(await listGitPlaneRefs(repo, root)).some((r) => r.ref === `refs/heads/${branchName}`))) return invalidHead();
191
+ }
192
+ const fields: Record<string, unknown> = { number, repository: repo, ...(headRepo !== repo ? { head_repo: headRepo } : {}), title: body.title, body: body.body, base_ref: body.base, head_sha: resolvedHeadSha, head_ref: branchName, state: 'open', user_login: caller ?? 'octocat', user_type: 'User', created_at: occurredAt, author_association: authorAssociation(state(), repo, caller ?? 'octocat'), maintainer_can_modify: body.maintainer_can_modify === true };
193
+ if (body.draft !== undefined) fields.draft = body.draft === true;
194
+ await put(ctx, { operation: 'pull_request.create', subjectType: 'pull_request', subjectId: id, fields });
195
+ // read back through the projection, so the derived counts agree with the carried lists
196
+ const st = state();
197
+ const created = st.prs.find((p) => p.id === id) ?? { id, number, repository: repo, review_count: 0, comment_count: 0, title: body.title, body: body.body, base_ref: body.base, head_sha: resolvedHeadSha, head_ref: branchName, state: 'open' };
198
+ const out = toGithubRest(created, st.milestones);
199
+ await announce(ctx, [{ event: 'pull_request', action: 'opened', repository: repo, number, pr: out }]);
200
+ const stored = st.prs.find((p) => p.id === id);
201
+ return ctx.reply(stored ? await fullPull(gh(ctx), stored) : out, 201);
202
+ },
203
+ // REST has no draft parameter on an edit (draft transitions are GraphQL mutations)
204
+ 'pulls/update': intoPull(async (ctx, g, pr, number) => {
205
+ const { body } = g;
206
+ const fields: Record<string, unknown> = {};
207
+ if (body.title !== undefined) fields.title = body.title;
208
+ if (body.body !== undefined) fields.body = body.body;
209
+ if (body.base !== undefined) fields.base_ref = body.base;
210
+ if (body.state !== undefined) fields.state = body.state;
211
+ applyTriageFields(fields, body);
212
+ const reopening = body.state === 'open' && (pr.state ?? 'open') === 'closed';
213
+ return writePull(ctx, g, number, 'pull_request.update', fields, body.state === 'closed' ? 'closed' : reopening ? 'reopened' : 'edited');
214
+ }),
215
+ // A review binds to the pull request's head at submission (its commit_id; a caller may pin one),
216
+ // which is the exact-head semantic merge gates rely on. Inline `comments` become review comments
217
+ // wrapped by THIS review.
218
+ 'pulls/create-review': intoPull(async (ctx, g, pr, number) => {
219
+ const { repo, body, occurredAt, caller } = g;
220
+ const seq = nextSeq(ctx, 'review');
221
+ const commitId = body.commit_id !== undefined ? String(body.commit_id) : pr.head_sha;
222
+ const fields: Record<string, unknown> = { repository: repo, number, state: reviewStateForEvent(body.event), body: body.body, submitted_at: occurredAt, user_login: caller ?? GITHUB_WORLD_USER, author_association: authorAssociation(g.state(), repo, caller ?? GITHUB_WORLD_USER) };
223
+ if (commitId !== undefined) fields.commit_id = commitId;
224
+ await put(ctx, { operation: 'pull_request_review.submit', subjectType: 'pull_request_review', subjectId: `review:${seq}`, fields });
225
+ if (Array.isArray(body.comments)) {
226
+ for (const raw of body.comments as Array<Record<string, unknown>>) {
227
+ const cSeq = nextSeq(ctx, 'comment');
228
+ const cFields: Record<string, unknown> = { repository: repo, number, body: raw.body, created_at: occurredAt, updated_at: occurredAt, review_id: seq, user_login: caller ?? GITHUB_WORLD_USER, author_association: authorAssociation(g.state(), repo, caller ?? GITHUB_WORLD_USER), ...(commitId !== undefined ? { commit_id: commitId } : {}) };
229
+ if (raw.path !== undefined) cFields.path = String(raw.path);
230
+ if (raw.line !== undefined) cFields.line = Number(raw.line);
231
+ if (raw.side !== undefined) cFields.side = String(raw.side);
232
+ if (raw.start_line !== undefined) cFields.start_line = Number(raw.start_line);
233
+ await put(ctx, { operation: 'pull_request_review_comment.create', subjectType: 'pull_request_review_comment', subjectId: `comment:${cSeq}`, fields: cFields });
234
+ }
235
+ }
236
+ const review = toGithubReview(g.state().reviews.find((r) => r.repository === repo && r.number === number && r.id === seq)!);
237
+ await announce(ctx, [{ event: 'pull_request_review', action: 'submitted', repository: repo, number, review }]);
238
+ return ctx.reply(review);
239
+ }),
240
+ // dismiss (a message is required) or edit a submitted review's body: an overlay on the review
241
+ 'pulls/dismiss-review': async (ctx) => reviewOverlay(ctx, 'dismiss'),
242
+ 'pulls/update-review': async (ctx) => reviewOverlay(ctx, 'update'),
243
+ // A reply IN a review thread: it inherits the root comment's diff anchor and threads by
244
+ // in_reply_to; it rides the pull request's implicit review (a reply is not the reviewer's verdict).
245
+ 'pulls/create-reply-for-review-comment': async (ctx) => {
246
+ const g = gh(ctx);
247
+ const { repo, seg, body, occurredAt, caller, state } = g;
248
+ const number = numberOf(g);
249
+ const rootId = Number(decodeURIComponent(seg[6]!));
250
+ const parent = state().comments.find((c) => c.repository === repo && c.number === number && c.kind === 'review' && c.id === rootId);
251
+ if (!parent) return send(ctx, githubNotFound());
252
+ // an empty body is refused as a missing one is
253
+ if (body.body === undefined || String(body.body) === '') return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'PullRequestReviewComment', field: 'body', code: 'missing_field' }] }, 422);
254
+ const seq = nextSeq(ctx, 'comment');
255
+ const anchor: Record<string, unknown> = {};
256
+ if (parent.commit_id !== undefined) anchor.commit_id = parent.commit_id;
257
+ if (parent.path !== undefined) anchor.path = parent.path;
258
+ if (parent.line !== undefined) anchor.line = parent.line;
259
+ if (parent.side !== undefined) anchor.side = parent.side;
260
+ if (parent.diff_hunk !== undefined) anchor.diff_hunk = parent.diff_hunk;
261
+ const fields: Record<string, unknown> = { repository: repo, number, body: body.body, created_at: occurredAt, updated_at: occurredAt, in_reply_to: rootId, review_id: implicitReviewId(repo, number), user_login: caller ?? GITHUB_WORLD_USER, author_association: authorAssociation(state(), repo, caller ?? GITHUB_WORLD_USER), ...anchor };
262
+ await put(ctx, { operation: 'pull_request_review_comment.create', subjectType: 'pull_request_review_comment', subjectId: `comment:${seq}`, fields });
263
+ const comment = serializeReviewComment(state().comments.find((c) => c.repository === repo && c.kind === 'review' && c.id === seq)!);
264
+ await announce(ctx, [{ event: 'pull_request_review_comment', action: 'created', repository: repo, number, comment }]);
265
+ return ctx.reply(comment, 201);
266
+ },
267
+ // A review (diff-thread) comment, wrapped in the pull request's implicit review, bound to the head
268
+ // (or its parent's commit when it replies), with the diff anchor a local write gives it.
269
+ 'pulls/create-review-comment': intoPull(async (ctx, g, _pr, number) => {
270
+ const { repo, body, occurredAt, caller, state } = g;
271
+ const seq = nextSeq(ctx, 'comment');
272
+ const fields: Record<string, unknown> = { repository: repo, number, body: body.body, created_at: occurredAt, updated_at: occurredAt, review_id: implicitReviewId(repo, number), user_login: caller ?? GITHUB_WORLD_USER, author_association: authorAssociation(state(), repo, caller ?? GITHUB_WORLD_USER) };
273
+ const parent = body.in_reply_to === undefined ? undefined : state().comments.find((c) => c.repository === repo && c.number === number && c.kind === 'review' && c.id === Number(body.in_reply_to));
274
+ const commitId = parent ? parent.commit_id : body.commit_id ?? state().prs.find((p) => p.repository === repo && p.number === number)?.head_sha;
275
+ if (commitId !== undefined) fields.commit_id = String(commitId);
276
+ if (body.path !== undefined) fields.path = String(body.path);
277
+ if (body.line !== undefined) fields.line = Number(body.line);
278
+ if (body.side !== undefined) fields.side = String(body.side);
279
+ if (body.start_line !== undefined) fields.start_line = Number(body.start_line);
280
+ if (body.diff_hunk !== undefined) fields.diff_hunk = String(body.diff_hunk);
281
+ if (body.in_reply_to !== undefined) fields.in_reply_to = Number(body.in_reply_to);
282
+ await put(ctx, { operation: 'pull_request_review_comment.create', subjectType: 'pull_request_review_comment', subjectId: `comment:${seq}`, fields });
283
+ const comment = serializeReviewComment(state().comments.find((c) => c.repository === repo && c.kind === 'review' && c.id === seq)!);
284
+ await announce(ctx, [{ event: 'pull_request_review_comment', action: 'created', repository: repo, number, comment }]);
285
+ return ctx.reply(comment, 201);
286
+ }),
287
+ // Merge through the gates: 405 when merged or closed or a draft; 409 when `sha` names a head the
288
+ // branch has moved from (the caller's optimistic precondition); then the required checks and the
289
+ // rulesets, and a real merge commit on the plane.
290
+ 'pulls/merge': intoPull(async (ctx, g, pr, number) => {
291
+ const { repo, body, occurredAt, caller, root, state } = g;
292
+ const refused = ctx.legal('pull-request', 'merged', 'pulls/merge', pr.merged)
293
+ ?? ctx.legal('pull-request', 'state', 'pulls/merge', pr.state ?? 'open')
294
+ ?? ctx.legal('pull-request', 'draft', 'pulls/merge', pr.draft);
295
+ if (refused) return ctx.refuse(refused);
296
+ if (body.sha !== undefined && String(body.sha) !== pr.head_sha) return ctx.reply({ message: 'Head branch was modified. Review and try the merge again.' }, 409);
297
+ const method = body.merge_method ? String(body.merge_method) : 'merge';
298
+ if (!['merge', 'squash', 'rebase'].includes(method)) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'PullRequest', field: 'merge_method', code: 'invalid' }] }, 422);
299
+ const base = pr.base_ref ?? 'main';
300
+ const before = hasBareRepo(repo, root) ? await resolvePlaneRef(repo, base, base, root) : null;
301
+ // what the pull request says it closes, read before the merge moves its base: its body and its commits' messages
302
+ const messages: string[] = [];
303
+ for (const sha of (await planeCommitsOf(g, pr)) ?? []) messages.push(String(((await planeCommitBody(repo, sha, root)) as { commit?: { message?: string } } | null)?.commit?.message ?? ''));
304
+ const closes = closingIssueNumbers([pr.body ?? '', ...messages]);
305
+ const landed = await mergePullRequest(repo, pr, method as 'merge' | 'squash' | 'rebase', occurredAt, root, caller, writer(ctx));
306
+ if (!landed.ok) return ctx.reply({ message: landed.message }, landed.status);
307
+ const st = state();
308
+ const merged = st.prs.find((p) => p.id === `${repo}#${number}`)!;
309
+ await announce(ctx, [{ event: 'pull_request', action: 'closed', repository: repo, number, pr: toGithubRest(merged, st.milestones) }]);
310
+ // The merge moved the base branch: a push to it, which GitHub's processes read as any push (workflows on push,
311
+ // Dependabot's manifests, the branch's files) (docs.github.com/webhooks/webhook-events-and-payloads#push: "a
312
+ // commit or tag is pushed", as a merge's commit is to the base branch).
313
+ if (before && before !== landed.sha) await announce(ctx, [{ event: 'push', action: '', repository: repo, number: 0, ref: `refs/heads/${base}`, before, after: landed.sha }]);
314
+ // A pull request merged into the default branch closes the issues it links with a closing keyword
315
+ // (docs.github.com/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue).
316
+ if (base === (st.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main')) {
317
+ for (const n of closes) {
318
+ const issue = state().issues.find((i) => i.repository === repo && i.number === n);
319
+ // the close is the issue's move by the merge (manifest.ts)
320
+ if (!issue || (issue.state ?? 'open') !== 'open' || ctx.legal('issue', 'state', 'pulls/merge', issue.state ?? 'open', 'closed', issue.id)) continue;
321
+ await put(ctx, { operation: 'issue.update', subjectType: 'issue', subjectId: issue.id, fields: { number: n, repository: repo, state: 'closed', state_reason: 'completed', updated_at: occurredAt } });
322
+ const now = state();
323
+ const closed = now.issues.find((i) => i.id === issue.id);
324
+ await announce(ctx, [{ event: 'issues', action: 'closed', repository: repo, number: n, ...(closed ? { issue: toGithubIssue(closed, now.milestones, undefined, now.comments) } : {}) }]);
325
+ }
326
+ }
327
+ return ctx.reply({ sha: landed.sha, merged: true, message: 'Pull Request successfully merged' });
328
+ }),
329
+ // a review is requested from someone with access to the repository, never from the pull request's author; a
330
+ // repository the twin knows only through its pull requests (an observed or seeded World) has no access list to check
331
+ 'pulls/request-reviewers': intoPull(async (ctx, g, pr, number) => {
332
+ const asked = normalizeLogins(g.body.reviewers);
333
+ const docs = 'https://docs.github.com/rest/pulls/review-requests#request-reviewers-for-a-pull-request';
334
+ if (pr.user_login && asked.includes(pr.user_login)) return ctx.reply({ message: 'Review cannot be requested from pull request author.', documentation_url: docs }, 422);
335
+ const st = g.state();
336
+ const access = new Set(assignable(st, g.repo));
337
+ if (st.repos.some((r) => r.full_name === g.repo) && asked.some((login) => !access.has(login))) return ctx.reply({ message: `Reviews may only be requested from collaborators. One or more of the users or teams you specified is not a collaborator of the ${g.repo} repository.`, documentation_url: docs }, 422);
338
+ const merged = [...(pr.requested_reviewers ?? [])];
339
+ for (const login of asked) if (!merged.includes(login)) merged.push(login);
340
+ return writePull(ctx, g, number, 'pull_request.request_reviewers', { requested_reviewers: merged }, 'review_requested', 201, 'pull-request-simple');
341
+ }),
342
+ 'pulls/remove-requested-reviewers': intoPull(async (ctx, g, pr, number) => {
343
+ const remove = new Set(normalizeLogins(g.body.reviewers));
344
+ return writePull(ctx, g, number, 'pull_request.remove_requested_reviewers', { requested_reviewers: (pr.requested_reviewers ?? []).filter((login) => !remove.has(login)) }, 'review_request_removed', 200, 'pull-request-simple');
345
+ }),
346
+ // brings the base's latest commits into the head branch with a merge commit, as GitHub's Update branch does (202);
347
+ // a head already up to date stays as it is, and one that conflicts is refused
348
+ // (the head branch is written where it lives: the fork, for a pull request from one)
349
+ 'pulls/update-branch': intoPull(async (ctx, g, pr, number) => {
350
+ const headRef = pr.head_ref ?? '';
351
+ const headRepo = pr.head_repo ?? g.repo;
352
+ const baseTip = await resolvePlaneRef(g.repo, pr.base_ref ?? 'main', pr.base_ref ?? 'main', g.root);
353
+ if (baseTip && headRef) {
354
+ const merged = await mergeIntoBranchInPlane(headRepo, headRef, baseTip, `Merge branch '${pr.base_ref ?? 'main'}' into ${headRef}`, 'merge', g.occurredAt, g.root);
355
+ if (merged && 'conflict' in merged) return ctx.reply({ message: 'merge conflict between base and head', documentation_url: 'https://docs.github.com/rest/pulls/pulls#update-a-pull-request-branch' }, 422);
356
+ if (merged && merged.sha !== pr.head_sha) {
357
+ await put(ctx, { operation: 'git_ref.update', subjectType: 'git_ref', subjectId: `gitref:${headRepo}#refs/heads/${headRef}`, fields: { repository: headRepo, ref: `refs/heads/${headRef}`, object_sha: merged.sha, object_type: 'commit' } });
358
+ await put(ctx, { operation: 'branch.create', subjectType: 'branch', subjectId: `branch:${headRepo}#${headRef}`, fields: { repository: headRepo, name: headRef, commit_sha: merged.sha } });
359
+ await syncPrHeadsToBranchTip(headRepo, headRef, merged.sha, g.root, g.occurredAt);
360
+ }
361
+ }
362
+ return ctx.reply({ message: 'Updating pull request branch.', url: `https://api.github.com/repos/${g.repo}/pulls/${number}` }, 202);
363
+ }),
364
+ };
365
+ }
366
+
367
+ /** Dismiss a review (PUT .../reviews/:rid/dismissals, a message required) or edit its body
368
+ * (PUT .../reviews/:rid), as an overlay on the submitted review. */
369
+ async function reviewOverlay(ctx: SemanticsContext, how: 'dismiss' | 'update'): Promise<Response> {
370
+ const g = gh(ctx);
371
+ const { repo, seg, body, state } = g;
372
+ const number = numberOf(g);
373
+ const rid = Number(decodeURIComponent(seg[6]!));
374
+ const submitted = state().reviews.find((r) => r.repository === repo && r.number === number && r.id === rid);
375
+ if (!submitted) return send(ctx, githubNotFound());
376
+ if (how === 'dismiss') {
377
+ if (!body.message) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'PullRequestReview', field: 'message', code: 'missing_field' }] }, 422);
378
+ // the dismissal is the review's move (manifest.ts), kept as an overlay on the submitted review
379
+ const refused = ctx.legal('pull-request-review', 'state', 'pulls/dismiss-review', submitted.state, 'DISMISSED', String(rid));
380
+ if (refused) return ctx.reply({ message: refused.message }, refused.status);
381
+ await put(ctx, { operation: 'pull_request_review_overlay.dismiss', subjectType: 'pull_request_review_overlay', subjectId: `reviewoverlay:${rid}`, fields: { review_id: rid, state: 'DISMISSED', dismissed_message: String(body.message) } });
382
+ } else {
383
+ if (body.body === undefined) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'PullRequestReview', field: 'body', code: 'missing_field' }] }, 422);
384
+ await put(ctx, { operation: 'pull_request_review_overlay.update', subjectType: 'pull_request_review_overlay', subjectId: `reviewoverlay:${rid}`, fields: { review_id: rid, body: String(body.body) } });
385
+ }
386
+ const review = toGithubReview(state().reviews.find((x) => x.id === rid)!);
387
+ await announce(ctx, [{ event: 'pull_request_review', action: how === 'dismiss' ? 'dismissed' : 'edited', repository: repo, number, review }]);
388
+ return ctx.reply(review);
389
+ }
390
+
391
+ /** A pull request's changed files: the plane's real diff when its base and head both exist there; never fabricated. */
392
+ async function filesOf(g: ReturnType<typeof gh>, pr: NonNullable<ReturnType<typeof pullOf>>) {
393
+ return (await prPlaneDiffFiles(g.state(), g.repo, pr, g.root)) ?? [];
394
+ }
395
+
396
+ /** An open pull request's mergeability in a repository with history, as GitHub computes it when the pull request is
397
+ * read (docs.github.com/rest/guides/using-the-rest-api-to-interact-with-your-git-database#checking-mergeability-of-pull-
398
+ * requests): whether its head merges into its base without conflicts. Where the documentation stops and the twin
399
+ * decides: a conflict-free merge reads `clean`, a conflicted one `dirty`; the computation is never pending. Nothing
400
+ * when the pull request is closed or its repository has no history (what it carries stands). */
401
+ async function mergeability(g: ReturnType<typeof gh>, pr: NonNullable<ReturnType<typeof pullOf>>): Promise<{ mergeable?: boolean; mergeable_state?: string }> {
402
+ const { repo, root } = g;
403
+ if (pr.merged || (pr.state ?? 'open') !== 'open' || !pr.head_sha || !hasBareRepo(repo, root)) return {};
404
+ const merged = await mergeIntoBranchInPlane(repo, pr.base_ref ?? 'main', pr.head_sha, '', 'merge', g.occurredAt, root, undefined, true);
405
+ if (!merged) return {};
406
+ return 'conflict' in merged ? { mergeable: false, mergeable_state: 'dirty' } : { mergeable: true, mergeable_state: 'clean' };
407
+ }
408
+
409
+ /** In a repository with history, the commits a pull request's head reaches that its base did not hold (a merged
410
+ * one's base is its merge commit's first parent), oldest first; undefined in a repository without history. */
411
+ async function planeCommitsOf(g: ReturnType<typeof gh>, pr: NonNullable<ReturnType<typeof pullOf>>): Promise<string[] | undefined> {
412
+ const { repo, root } = g;
413
+ if (!pr.head_sha || !hasBareRepo(repo, root)) return undefined;
414
+ const mergeCommit = pr.merge_commit_sha ? await readGitCommitFromPlane(repo, pr.merge_commit_sha, root) : null;
415
+ const base = mergeCommit?.parents[0] ?? await resolvePlaneRef(repo, pr.base_ref ?? 'main', pr.base_ref ?? 'main', root);
416
+ const held = base ? await planeAncestors(repo, base, root) : new Map<string, number>();
417
+ // oldest first: by commit time, and within one second (git's resolution) a parent before its child — the walk
418
+ // from the head meets a commit's parents after it, so a later walk position is the older commit
419
+ const walked = [...(await planeAncestors(repo, pr.head_sha, root))].map(([sha, at], i) => ({ sha, at, i }));
420
+ return walked.filter(({ sha }) => !held.has(sha)).sort((a, b) => (a.at - b.at) || (b.i - a.i)).map(({ sha }) => sha);
421
+ }