@volter/twin-github 0.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,171 @@
1
+ // GitHub's activity family: the feeds catalog and event streams, the notifications inbox and its
2
+ // threads, stars and repository watching. Each person stars and watches for themselves (the credential's
3
+ // user, the World's own user for its shared token). A thread's subscription is apart from its unread flag: GitHub
4
+ // subscribes a person to a conversation they take part in (docs.github.com/rest/activity/notifications#get-a-thread-
5
+ // subscription-for-the-authenticated-user); ignoring it mutes the thread, unsubscribing mutes it until their next
6
+ // @mention. Where the documentation stops and the twin decides: an unsubscribed thread reads as neither subscribed nor
7
+ // ignored, and ignoring a thread also takes it out of the inbox, as muting does in GitHub's own inbox.
8
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
9
+ import { GITHUB_WORLD_USER, githubActivityEvents, githubNotFound, paginate, seedUser, toGithubNotification, toGithubRepo, toGithubUser, type GithubNotification, type GithubRepo } from '../github-twin.ts';
10
+ import { announce, asView, gh, put, send } from './shared.ts';
11
+
12
+ /** Mark one notification thread read (or unread), keeping the fields it was stored with. */
13
+ function markThread(ctx: SemanticsContext, n: GithubNotification, unread: boolean): Promise<Record<string, unknown>> {
14
+ return put(ctx, { operation: 'notification.read', subjectType: 'notification', subjectId: `notification:${n.id}`, fields: { id: n.id, repository: n.repository, subject_title: n.subject_title, subject_type: n.subject_type, reason: n.reason, unread, updated_at: ctx.occurredAt } });
15
+ }
16
+
17
+ /** The inbox as GitHub lists it: the most recently updated thread first. */
18
+ const latestFirst = (threads: GithubNotification[]): GithubNotification[] => threads.map((n, i) => [n, i] as const).sort((a, b) => String(b[0].updated_at ?? '').localeCompare(String(a[0].updated_at ?? '')) || b[1] - a[1]).map(([n]) => n);
19
+
20
+ /** A thread's subscription as GitHub answers it. */
21
+ function subscriptionOf(n: GithubNotification): Record<string, unknown> {
22
+ const sub = n.subscription === null ? { subscribed: false, ignored: false, created_at: undefined } : (n.subscription ?? { subscribed: true, ignored: false, created_at: n.updated_at });
23
+ return { subscribed: sub.subscribed, ignored: sub.ignored, reason: null, created_at: sub.created_at ?? null, url: `https://api.github.com/notifications/threads/${n.id}/subscription`, thread_url: `https://api.github.com/notifications/threads/${n.id}` };
24
+ }
25
+
26
+ /** Who the credential names: its user, or the World's own for its shared token. */
27
+ export const me = (ctx: SemanticsContext): string => gh(ctx).caller ?? GITHUB_WORLD_USER;
28
+
29
+ /** GET .../stargazers and .../subscribers: everyone who stars or watches the repo. */
30
+ const repoFans = (stars: boolean): Semantics => async (ctx) => {
31
+ const { repo, query, state } = gh(ctx);
32
+ const st = state();
33
+ const items = (stars ? st.starEdges : st.watchEdges).filter((e) => e.to === repo).map((e) => asView('simple-user', toGithubUser(seedUser(e.from))));
34
+ return ctx.reply(paginate(items, query));
35
+ };
36
+
37
+ const star = (starred: boolean): Semantics => async (ctx) => {
38
+ const { seg } = gh(ctx);
39
+ const repo = `${seg[2]}/${seg[3]}`;
40
+ await put(ctx, { operation: starred ? 'star.add' : 'star.remove', subjectType: 'star', subjectId: `star:${me(ctx)}:${repo}`, fields: { repository: repo, starred } });
41
+ await announce(ctx, [{ event: 'star', action: starred ? 'created' : 'deleted', repository: repo, number: 0 }]);
42
+ return send(ctx, { status: 204, body: undefined });
43
+ };
44
+
45
+ const watch = (subscribed: boolean): Semantics => async (ctx) => {
46
+ const { repo } = gh(ctx);
47
+ await put(ctx, { operation: subscribed ? 'repo_subscription.watch' : 'repo_subscription.unwatch', subjectType: 'repo_subscription', subjectId: `watch:${me(ctx)}:${repo}`, fields: { repository: repo, subscribed } });
48
+ if (!subscribed) return send(ctx, { status: 204, body: undefined });
49
+ return ctx.reply({ subscribed: true, ignored: false, reason: null, repository_url: `https://api.github.com/repos/${repo}` });
50
+ };
51
+
52
+ export function activitySemantics(): Record<string, Semantics> {
53
+ const activityGetFeeds: Semantics = async (ctx) => {
54
+ const api = 'https://github.com';
55
+ const link = (href: string) => ({ href, type: 'application/atom+xml' });
56
+ return ctx.reply({
57
+ timeline_url: `${api}/timeline`, user_url: `${api}/{user}`,
58
+ current_user_public_url: `${api}/octocat`, current_user_url: `${api}/octocat.private?token=t`,
59
+ current_user_actor_url: `${api}/octocat.private.actor?token=t`,
60
+ current_user_organization_url: '', current_user_organization_urls: [],
61
+ security_advisories_url: `${api}/security-advisories`,
62
+ _links: { timeline: link(`${api}/timeline`), user: link(`${api}/{user}`), current_user_public: link(`${api}/octocat`), security_advisories: link(`${api}/security-advisories`) },
63
+ });
64
+ };
65
+ const activityListPublicEvents: Semantics = async (ctx) => {
66
+ const { query, state } = gh(ctx);
67
+ return ctx.reply(paginate(githubActivityEvents(state()), query));
68
+ };
69
+ const activityListRepoEvents: Semantics = async (ctx) => {
70
+ const { repo, query, state } = gh(ctx);
71
+ return ctx.reply(paginate(githubActivityEvents(state()).filter((e) => (e.repo as { name?: string }).name === repo), query));
72
+ };
73
+ const activityListNotificationsForAuthenticatedUser: Semantics = async (ctx) => {
74
+ const { query, state } = gh(ctx);
75
+ const all = new URLSearchParams((query ?? '').replace(/^\?/, '')).get('all') === 'true';
76
+ return ctx.reply(paginate(latestFirst(state().notifications.filter((n) => all || n.unread)), query).map(toGithubNotification));
77
+ };
78
+ const activityListRepoNotificationsForAuthenticatedUser: Semantics = async (ctx) => {
79
+ const { repo, query, state } = gh(ctx);
80
+ const all = new URLSearchParams((query ?? '').replace(/^\?/, '')).get('all') === 'true';
81
+ return ctx.reply(paginate(latestFirst(state().notifications.filter((n) => n.repository === repo && (all || n.unread))), query).map(toGithubNotification));
82
+ };
83
+ const activityGetThread: Semantics = async (ctx) => {
84
+ const { seg, state } = gh(ctx);
85
+ const n = state().notifications.find((x) => x.id === decodeURIComponent(seg[2]!));
86
+ return send(ctx, n ? { status: 200, body: toGithubNotification(n) } : githubNotFound());
87
+ };
88
+ const activityGetThreadSubscriptionForAuthenticatedUser: Semantics = async (ctx) => {
89
+ const { seg, state } = gh(ctx);
90
+ const n = state().notifications.find((x) => x.id === decodeURIComponent(seg[2]!));
91
+ return send(ctx, n ? { status: 200, body: subscriptionOf(n) } : githubNotFound());
92
+ };
93
+ const activitySetThreadSubscription: Semantics = async (ctx) => {
94
+ const { seg, body, state, occurredAt } = gh(ctx);
95
+ const n = state().notifications.find((x) => x.id === decodeURIComponent(seg[2]!));
96
+ if (!n) return send(ctx, githubNotFound());
97
+ const subscription = { subscribed: body.ignored !== true, ignored: body.ignored === true, created_at: occurredAt };
98
+ await put(ctx, { operation: 'notification.subscribe', subjectType: 'notification', subjectId: `notification:${n.id}`, fields: { id: n.id, subscription, ...(subscription.ignored ? { unread: false } : {}) } });
99
+ return ctx.reply(subscriptionOf({ ...n, subscription }));
100
+ };
101
+ const activityDeleteThreadSubscription: Semantics = async (ctx) => {
102
+ const { seg, state } = gh(ctx);
103
+ const n = state().notifications.find((x) => x.id === decodeURIComponent(seg[2]!));
104
+ if (!n) return send(ctx, githubNotFound());
105
+ await put(ctx, { operation: 'notification.unsubscribe', subjectType: 'notification', subjectId: `notification:${n.id}`, fields: { id: n.id, subscription: null } });
106
+ return send(ctx, { status: 204, body: undefined });
107
+ };
108
+ const activityMarkThreadAsRead: Semantics = async (ctx) => {
109
+ const { seg, state } = gh(ctx);
110
+ const n = state().notifications.find((x) => x.id === decodeURIComponent(seg[2]!));
111
+ if (!n) return send(ctx, githubNotFound());
112
+ await markThread(ctx, n, false);
113
+ return send(ctx, { status: 205, body: undefined });
114
+ };
115
+ const activityMarkNotificationsAsRead: Semantics = async (ctx) => {
116
+ for (const n of gh(ctx).state().notifications.filter((x) => x.unread)) await markThread(ctx, n, false);
117
+ return send(ctx, { status: 205, body: undefined });
118
+ };
119
+ const activityMarkRepoNotificationsAsRead: Semantics = async (ctx) => {
120
+ const { repo, state } = gh(ctx);
121
+ for (const n of state().notifications.filter((x) => x.repository === repo && x.unread)) await markThread(ctx, n, false);
122
+ return send(ctx, { status: 205, body: undefined });
123
+ };
124
+ const activityListReposStarredByAuthenticatedUser: Semantics = async (ctx) => {
125
+ const { query, state } = gh(ctx);
126
+ const st = state();
127
+ const mine = st.starEdges.filter((e) => e.from === me(ctx)).map((e) => e.to);
128
+ const items = mine.map((full) => st.repos.find((r) => r.full_name === full)).filter((r): r is GithubRepo => !!r).map((r) => asView('repository', toGithubRepo(r)));
129
+ return ctx.reply(paginate(items, query));
130
+ };
131
+ const activityCheckRepoIsStarredByAuthenticatedUser: Semantics = async (ctx) => {
132
+ const { seg, state } = gh(ctx);
133
+ return send(ctx, state().starEdges.some((e) => e.from === me(ctx) && e.to === `${seg[2]}/${seg[3]}`) ? { status: 204, body: undefined } : githubNotFound());
134
+ };
135
+ const activityGetRepoSubscription: Semantics = async (ctx) => {
136
+ const { repo, state } = gh(ctx);
137
+ return send(ctx, state().watchEdges.some((e) => e.from === me(ctx) && e.to === repo) ? { status: 200, body: { subscribed: true, ignored: false, reason: null, repository_url: `https://api.github.com/repos/${repo}` } } : githubNotFound());
138
+ };
139
+ return {
140
+ // the feed-URL catalog (timeline/user/security-advisories links); stateless
141
+ 'activity/get-feeds': activityGetFeeds,
142
+ // the public timeline: every modeled event
143
+ 'activity/list-public-events': activityListPublicEvents,
144
+ // the same projection scoped to one repo
145
+ 'activity/list-repo-events': activityListRepoEvents,
146
+ // the inbox: unread threads, or every thread with `all=true`
147
+ 'activity/list-notifications-for-authenticated-user': activityListNotificationsForAuthenticatedUser,
148
+ 'activity/list-repo-notifications-for-authenticated-user': activityListRepoNotificationsForAuthenticatedUser,
149
+ 'activity/get-thread': activityGetThread,
150
+ 'activity/get-thread-subscription-for-authenticated-user': activityGetThreadSubscriptionForAuthenticatedUser,
151
+ // subscribe, or ignore (mute) the thread, which also takes it out of the inbox
152
+ 'activity/set-thread-subscription': activitySetThreadSubscription,
153
+ // unsubscribe: muted until the person is next @mentioned
154
+ 'activity/delete-thread-subscription': activityDeleteThreadSubscription,
155
+ // mark a thread read (205)
156
+ 'activity/mark-thread-as-read': activityMarkThreadAsRead,
157
+ 'activity/mark-notifications-as-read': activityMarkNotificationsAsRead,
158
+ 'activity/mark-repo-notifications-as-read': activityMarkRepoNotificationsAsRead,
159
+ 'activity/list-stargazers-for-repo': repoFans(true),
160
+ 'activity/list-watchers-for-repo': repoFans(false),
161
+ // the authenticated user's starred repos, and the membership check (204 starred / 404 not)
162
+ 'activity/list-repos-starred-by-authenticated-user': activityListReposStarredByAuthenticatedUser,
163
+ 'activity/check-repo-is-starred-by-authenticated-user': activityCheckRepoIsStarredByAuthenticatedUser,
164
+ 'activity/star-repo-for-authenticated-user': star(true),
165
+ 'activity/unstar-repo-for-authenticated-user': star(false),
166
+ // the authenticated user's watch on a repo ({subscribed:true} or 404)
167
+ 'activity/get-repo-subscription': activityGetRepoSubscription,
168
+ 'activity/set-repo-subscription': watch(true),
169
+ 'activity/delete-repo-subscription': watch(false),
170
+ };
171
+ }
@@ -0,0 +1,133 @@
1
+ // GitHub's Apps family: an app by its slug, the app's installations and installation access tokens. An app
2
+ // is registered through the twin's door (GitHub's settings UI has no API) and installed by a person on its
3
+ // page (src/screens/app-installation.tsx); a token is minted deterministically from its installation.
4
+ import { Buffer } from 'node:buffer';
5
+ import { createHash } from 'node:crypto';
6
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
7
+ import { githubApps, githubNotFound, paginate, toGithubApp, toGithubAppInstallation, type GithubAppRegistration } from '../github-twin.ts';
8
+ import { gh, put, send } from './shared.ts';
9
+ import { emitGithubAppEvent, githubAppDeliveries, githubAppDelivery } from '../github-events.ts';
10
+
11
+ // An app speaks for itself with a JSON Web Token it signs with its private key: its client ID (or app ID) as `iss`,
12
+ // issued no later than now and expiring at most ten minutes after it was issued
13
+ // (docs.github.com/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app).
14
+ // The app's installations and their access tokens take only that token ("You must use a JWT to access this endpoint",
15
+ // docs.github.com/rest/apps/apps). Where the evidence stops: the private key is generated on the app's settings page
16
+ // and never leaves the app's server, so the twin holds no key to check the RS256 signature against; it reads the
17
+ // token's claims, and a token whose claims do not name a registered app, or that is expired, is refused.
18
+ const JWT_DOCS = 'https://docs.github.com/rest/apps/apps';
19
+ type Claims = { iss?: unknown; iat?: unknown; exp?: unknown };
20
+ const claimsOf = (token: string | undefined): Claims | undefined => {
21
+ const parts = (token ?? '').split('.');
22
+ try { return parts.length === 3 ? JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8')) as Claims : undefined; } catch { return undefined; }
23
+ };
24
+ /** The app a request's JWT authenticates, or nothing when the token is not a live JWT of a registered app. */
25
+ function appOfJwt(ctx: SemanticsContext): GithubAppRegistration | undefined {
26
+ const claims = claimsOf(gh(ctx).token);
27
+ const now = Math.floor(Date.parse(ctx.occurredAt) / 1000);
28
+ const live = typeof claims?.iat === 'number' && typeof claims.exp === 'number' && claims.iat <= now + 60 && claims.exp > now && claims.exp - claims.iat <= 600;
29
+ return live ? githubApps(ctx.root).find((a) => String(claims!.iss) === a.client_id || String(claims!.iss) === String(a.id)) : undefined;
30
+ }
31
+ /** GitHub's answer to a call on an app's own endpoints without a live JWT of the app. */
32
+ const jwtRefused = (ctx: SemanticsContext): Response => ctx.reply({ message: 'A JSON web token could not be decoded', documentation_url: JWT_DOCS }, 401);
33
+ /** An installation of the app the request's JWT authenticates, by its id. */
34
+ const ownInstallation = (ctx: SemanticsContext, app: GithubAppRegistration) =>
35
+ gh(ctx).state().appInstallations.find((x) => x.id === Number(decodeURIComponent(gh(ctx).seg[2]!)) && x.app_slug === app.slug);
36
+
37
+ export function appsSemantics(): Record<string, Semantics> {
38
+ const appsGetBySlug: Semantics = async (ctx) => {
39
+ const { seg, state } = gh(ctx);
40
+ const slug = decodeURIComponent(seg[1]!);
41
+ const app = githubApps(ctx.root).find((a) => a.slug === slug);
42
+ return send(ctx, app ? { status: 200, body: toGithubApp(app, state().appInstallations.filter((i) => i.app_slug === slug).length, state()) } : githubNotFound());
43
+ };
44
+ const appsGetAuthenticated: Semantics = async (ctx) => {
45
+ const app = appOfJwt(ctx);
46
+ if (!app) return jwtRefused(ctx);
47
+ const { state } = gh(ctx);
48
+ return ctx.reply(toGithubApp(app, state().appInstallations.filter((i) => i.app_slug === app.slug).length, state()));
49
+ };
50
+ const appsListInstallations: Semantics = async (ctx) => {
51
+ const app = appOfJwt(ctx);
52
+ if (!app) return jwtRefused(ctx);
53
+ const { query, state } = gh(ctx);
54
+ return ctx.reply(paginate(state().appInstallations.filter((i) => i.app_slug === app.slug), query).map(toGithubAppInstallation));
55
+ };
56
+ const appsGetInstallation: Semantics = async (ctx) => {
57
+ const app = appOfJwt(ctx);
58
+ if (!app) return jwtRefused(ctx);
59
+ const i = ownInstallation(ctx, app);
60
+ return send(ctx, i ? { status: 200, body: toGithubAppInstallation(i) } : githubNotFound());
61
+ };
62
+ return {
63
+ // a public app's page data, by its slug
64
+ 'apps/get-by-slug': appsGetBySlug,
65
+ // The application that sent a person to register it from its manifest exchanges the temporary code GitHub
66
+ // redirected back with for the app's credentials: once, within an hour, with no other authentication (201)
67
+ // (docs.github.com/rest/apps/apps#create-a-github-app-from-a-manifest,
68
+ // docs.github.com/apps/sharing-github-apps/registering-a-github-app-from-a-manifest). A code used or expired is
69
+ // Not Found. Where the documentation stops and the twin decides: the private key is a stand-in of the PEM's shape
70
+ // that nothing verifies (a JWT's signature is not checked, see appOfJwt).
71
+ 'apps/create-from-manifest': async (ctx) => {
72
+ const { seg, occurredAt, state } = gh(ctx);
73
+ const code = decodeURIComponent(seg[1]!);
74
+ const row = ctx.tree().find((r) => r.type === 'app_manifest_code' && r.code === code && r.deleted !== true);
75
+ const expired = row && Date.parse(occurredAt) - Date.parse(String(row.created_at)) > 60 * 60 * 1000;
76
+ const app = row && githubApps(ctx.root).find((a) => a.slug === row.app_slug);
77
+ if (!row || row.used === true || expired || !app) return send(ctx, githubNotFound());
78
+ await put(ctx, { operation: 'app_manifest_code.use', subjectType: 'app_manifest_code', subjectId: `manifestcode:${code}`, fields: { used: true } });
79
+ const secret = (what: string) => createHash('sha256').update(`app:${app.slug}:${what}`).digest('hex');
80
+ const pem = `-----BEGIN RSA PRIVATE KEY-----\n${Buffer.from(secret('key') + secret('key2')).toString('base64').match(/.{1,64}/g)!.join('\n')}\n-----END RSA PRIVATE KEY-----\n`;
81
+ return ctx.reply({ ...toGithubApp(app, 0, state()), client_secret: secret('client').slice(0, 40), webhook_secret: app.webhook_secret ?? null, pem }, 201);
82
+ },
83
+ // the app the JWT authenticates (docs.github.com/rest/apps/apps#get-the-authenticated-app)
84
+ 'apps/get-authenticated': appsGetAuthenticated,
85
+ // the deliveries of the app's own webhook, and one in detail with the payload it carried
86
+ // (docs.github.com/rest/apps/webhooks#list-deliveries-for-an-app-webhook)
87
+ 'apps/list-webhook-deliveries': async (ctx) => {
88
+ const app = appOfJwt(ctx);
89
+ if (!app) return jwtRefused(ctx);
90
+ return ctx.reply(paginate(githubAppDeliveries(app.id, ctx.root), gh(ctx).query));
91
+ },
92
+ 'apps/get-webhook-delivery': async (ctx) => {
93
+ const app = appOfJwt(ctx);
94
+ if (!app) return jwtRefused(ctx);
95
+ const d = githubAppDelivery(app.id, Number(decodeURIComponent(gh(ctx).seg[3]!)), ctx.root);
96
+ return send(ctx, d ? { status: 200, body: d } : githubNotFound());
97
+ },
98
+ // the app uninstalls itself from an account: the installation and its tokens are gone (204)
99
+ // (docs.github.com/rest/apps/apps#delete-an-installation-for-the-authenticated-app)
100
+ 'apps/delete-installation': async (ctx) => {
101
+ const app = appOfJwt(ctx);
102
+ if (!app) return jwtRefused(ctx);
103
+ const i = ownInstallation(ctx, app);
104
+ if (!i) return send(ctx, githubNotFound());
105
+ await put(ctx, { operation: 'app_installation.delete', subjectType: 'app_installation', subjectId: `installation:${i.id}`, fields: { deleted: true } });
106
+ for (const t of ctx.tree().filter((r) => r.type === 'app_token' && r.installation_id === i.id && r.deleted !== true)) {
107
+ await put(ctx, { operation: 'app_token.revoke', subjectType: 'app_token', subjectId: String(t.id), fields: { deleted: true } });
108
+ }
109
+ try {
110
+ await emitGithubAppEvent(app, 'installation', { action: 'deleted', installation: toGithubAppInstallation(i), sender: { login: `${app.slug}[bot]`, type: 'Bot' } }, { occurredAt: ctx.occurredAt, ...(ctx.root !== undefined ? { root: ctx.root } : {}) });
111
+ } catch { /* a delivery failure never fails the deletion */ }
112
+ return send(ctx, { status: 204, body: undefined });
113
+ },
114
+ // the installations of the app the JWT authenticates
115
+ 'apps/list-installations': appsListInstallations,
116
+ 'apps/get-installation': appsGetInstallation,
117
+ // A deterministic installation token (ghs_…) bound to the installation's permissions. Real tokens
118
+ // expire in about an hour; the twin answers a fixed far-future expiry (never the clock).
119
+ 'apps/create-installation-access-token': async (ctx) => {
120
+ const app = appOfJwt(ctx);
121
+ if (!app) return jwtRefused(ctx);
122
+ const { body } = gh(ctx);
123
+ const i = ownInstallation(ctx, app);
124
+ if (!i) return send(ctx, githubNotFound());
125
+ const id = i.id;
126
+ const token = `ghs_${createHash('sha256').update(`insttoken:${id}`).digest('hex').slice(0, 36)}`;
127
+ const perms = (body.permissions as Record<string, string>) ?? i.permissions;
128
+ // the token is the app's credential for this installation: what it may do, and on which repositories
129
+ await put(ctx, { operation: 'app_token.create', subjectType: 'app_token', subjectId: `apptoken:${token}`, fields: { token, installation_id: id, app_slug: i.app_slug, account_login: i.account_login, permissions: perms, repository_selection: i.repository_selection, repositories: i.repositories ?? [] } });
130
+ return ctx.reply({ token, expires_at: '2100-01-01T00:00:00Z', permissions: perms, repository_selection: i.repository_selection }, 201);
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,133 @@
1
+ // A repository's branches and their protection: the persisted protection config (required status
2
+ // checks, admin enforcement, required reviews, restrictions) that merge gates read back. A branch is
3
+ // created by a ref write (semantics/git.ts), a push, or its first protection write.
4
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
5
+ import { githubNotFound, isAncestorInPlane, mergeIntoBranchInPlane, normalizeRequiredStatusChecks, paginate, resolvePlaneRef, toGithubBranch, toGithubBranchProtection, updateGitRefInPlane, type GithubBranch } from '../github-twin.ts';
6
+ import { announce, gh, put, send } from './shared.ts';
7
+
8
+ const SYNC_DOCS = 'https://docs.github.com/rest/branches/branches#sync-a-fork-branch-with-the-upstream-repository';
9
+ /** A fork's branch brought up to date with the same branch of the repository it was forked from: fast-forwarded when
10
+ * it has nothing of its own, merged when it has, 409 when the merge conflicts, 422 when the repository is no fork or
11
+ * names no branch (docs.github.com/rest/branches/branches#sync-a-fork-branch-with-the-upstream-repository). */
12
+ const syncFork: Semantics = async (ctx) => {
13
+ const { repo, body, root, occurredAt, state } = gh(ctx);
14
+ const fork = state().repos.find((r) => r.full_name === repo);
15
+ if (!fork) return send(ctx, githubNotFound());
16
+ const branch = typeof body.branch === 'string' ? body.branch : '';
17
+ const upstream = fork.parent_full_name;
18
+ const theirs = upstream && branch ? await resolvePlaneRef(upstream, `refs/heads/${branch}`, branch, root) : null;
19
+ const ours = branch ? await resolvePlaneRef(repo, `refs/heads/${branch}`, branch, root) : null;
20
+ if (!upstream || !theirs || !ours) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Repository', field: 'branch', code: 'invalid' }], documentation_url: SYNC_DOCS }, 422);
21
+ const base_branch = `${upstream.split('/')[0]}:${branch}`;
22
+ if (ours === theirs || await isAncestorInPlane(repo, theirs, ours, root)) return ctx.reply({ message: `This branch is not behind the upstream ${base_branch}.`, merge_type: 'none', base_branch });
23
+ const forward = await isAncestorInPlane(repo, ours, theirs, root);
24
+ const landed = forward ? (await updateGitRefInPlane(repo, `refs/heads/${branch}`, theirs, root, ours) ? { sha: theirs } : null) : await mergeIntoBranchInPlane(repo, branch, theirs, `Merge branch '${branch}' of github.com:${upstream}`, 'merge', occurredAt, root);
25
+ if (!landed || 'conflict' in landed) return ctx.reply({ message: 'There are merge conflicts', documentation_url: SYNC_DOCS }, 409);
26
+ await put(ctx, { operation: 'git_ref.update', subjectType: 'git_ref', subjectId: `gitref:${repo}#refs/heads/${branch}`, fields: { repository: repo, ref: `refs/heads/${branch}`, object_sha: landed.sha, object_type: 'commit' } });
27
+ await put(ctx, { operation: 'branch.create', subjectType: 'branch', subjectId: `branch:${repo}#${branch}`, fields: { repository: repo, name: branch, commit_sha: landed.sha } });
28
+ await announce(ctx, [{ event: 'push', action: '', repository: repo, number: 0, ref: `refs/heads/${branch}`, before: ours, after: landed.sha }]);
29
+ return ctx.reply({ message: forward ? `Successfully fetched and fast-forwarded from upstream ${base_branch}.` : `Successfully fetched and merged from upstream ${base_branch}.`, merge_type: forward ? 'fast-forward' : 'merge', base_branch });
30
+ };
31
+
32
+ const notProtected = { status: 404, body: { message: 'Branch not protected', documentation_url: 'https://docs.github.com/rest/branches/branch-protection' } };
33
+
34
+ /** The branch a protection path names (a name may hold slashes). */
35
+ function branchOf(ctx: SemanticsContext): { name: string; b: GithubBranch | undefined } {
36
+ const { repo, seg, state } = gh(ctx);
37
+ const name = decodeURIComponent(seg[4]!);
38
+ return { name, b: state().branches.find((x) => x.repository === repo && x.name === name) };
39
+ }
40
+ const protectionUrl = (repo: string, name: string): string => `https://api.github.com/repos/${repo}/branches/${encodeURIComponent(name)}/protection`;
41
+
42
+ /** Write a protected branch's config. */
43
+ const protect = (ctx: SemanticsContext, name: string, b: GithubBranch, protection_config: unknown): Promise<Record<string, unknown>> =>
44
+ put(ctx, { operation: 'branch.protect', subjectType: 'branch', subjectId: `branch:${gh(ctx).repo}#${name}`, fields: { repository: gh(ctx).repo, name, commit_sha: b.commit_sha, protected: true, protection_config } });
45
+
46
+ export function branchesSemantics(): Record<string, Semantics> {
47
+ const reposListBranches: Semantics = async (ctx) => {
48
+ const { repo, query, state } = gh(ctx);
49
+ return ctx.reply(paginate(state().branches.filter((b) => b.repository === repo), query).map(toGithubBranch));
50
+ };
51
+ const reposGetBranchProtection: Semantics = async (ctx) => {
52
+ const { b } = branchOf(ctx);
53
+ return send(ctx, b?.protected ? { status: 200, body: toGithubBranchProtection(b) } : notProtected);
54
+ };
55
+ const reposGetStatusChecksProtection: Semantics = async (ctx) => {
56
+ const { repo } = gh(ctx);
57
+ const { name, b } = branchOf(ctx);
58
+ if (!b?.protected) return send(ctx, notProtected);
59
+ const rsc = b.protection_config?.required_status_checks;
60
+ if (!rsc) return ctx.reply({ message: 'Required status checks not enabled', documentation_url: 'https://docs.github.com/rest/branches/branch-protection' }, 404);
61
+ const base = protectionUrl(repo, name);
62
+ return ctx.reply({ url: `${base}/required_status_checks`, strict: rsc.strict, contexts: rsc.contexts, contexts_url: `${base}/required_status_checks/contexts`, checks: rsc.checks });
63
+ };
64
+ const reposGetAdminBranchProtection: Semantics = async (ctx) => {
65
+ const { repo } = gh(ctx);
66
+ const { name, b } = branchOf(ctx);
67
+ if (!b?.protected) return send(ctx, notProtected);
68
+ return ctx.reply({ url: `${protectionUrl(repo, name)}/enforce_admins`, enabled: b.protection_config?.enforce_admins ?? false });
69
+ };
70
+ const reposUpdateStatusCheckProtection: Semantics = async (ctx) => {
71
+ const { repo, body } = gh(ctx);
72
+ const { name, b } = branchOf(ctx);
73
+ if (!b?.protected) return send(ctx, githubNotFound());
74
+ const prev = b.protection_config?.required_status_checks ?? { strict: false, contexts: [], checks: [] };
75
+ const next = normalizeRequiredStatusChecks({ strict: body.strict ?? prev.strict, contexts: body.contexts ?? (body.checks === undefined ? prev.contexts : undefined), checks: body.checks })!; const base = protectionUrl(repo, name); await protect(ctx, name, b, { ...(b.protection_config ?? {}), required_status_checks: next });
76
+ return ctx.reply({ url: `${base}/required_status_checks`, strict: next.strict, contexts: next.contexts, contexts_url: `${base}/required_status_checks/contexts`, checks: next.checks });
77
+ };
78
+ return {
79
+ 'repos/merge-upstream': syncFork,
80
+ 'repos/list-branches': reposListBranches,
81
+ // a branch name may hold slashes
82
+ 'repos/get-branch': async (ctx) => {
83
+ const { repo, seg, state } = gh(ctx);
84
+ const name = decodeURIComponent(seg.slice(4).join('/'));
85
+ const b = state().branches.find((x) => x.repository === repo && x.name === name);
86
+ return send(ctx, b ? { status: 200, body: toGithubBranch(b) } : githubNotFound());
87
+ },
88
+ // the persisted config back out; 404 "Branch not protected" when protection is off
89
+ 'repos/get-branch-protection': reposGetBranchProtection,
90
+ 'repos/get-status-checks-protection': reposGetStatusChecksProtection,
91
+ 'repos/get-admin-branch-protection': reposGetAdminBranchProtection,
92
+ // Protect and persist the config. GitHub 422s unless all four keys are present (each nullable,
93
+ // never omittable). A branch the twin has not seen is created by its first protection write.
94
+ 'repos/update-branch-protection': async (ctx) => protection(ctx, true),
95
+ 'repos/delete-branch-protection': async (ctx) => protection(ctx, false),
96
+ // update the required checks of a protected branch (404 when not protected)
97
+ 'repos/update-status-check-protection': reposUpdateStatusCheckProtection,
98
+ 'repos/set-admin-branch-protection': async (ctx) => enforceAdmins(ctx, true),
99
+ 'repos/delete-admin-branch-protection': async (ctx) => enforceAdmins(ctx, false),
100
+ };
101
+ }
102
+
103
+ async function protection(ctx: SemanticsContext, protecting: boolean): Promise<Response> {
104
+ const { repo, body, state } = gh(ctx);
105
+ const { name, b } = branchOf(ctx);
106
+ // a branch must exist to be protected (docs.github.com/rest/branches/branch-protection#update-branch-protection)
107
+ if (!b) return ctx.reply({ message: 'Branch not found', documentation_url: 'https://docs.github.com/rest/branches/branch-protection#update-branch-protection' }, 404);
108
+ if (protecting) {
109
+ const missingKeys = (['required_status_checks', 'enforce_admins', 'required_pull_request_reviews', 'restrictions'] as const).filter((k) => !(k in body));
110
+ if (missingKeys.length) return ctx.reply({ message: 'Validation Failed', errors: missingKeys.map((field) => ({ resource: 'BranchProtection', field, code: 'missing_field' })) }, 422);
111
+ }
112
+ const protection_config = protecting
113
+ ? {
114
+ required_status_checks: normalizeRequiredStatusChecks(body.required_status_checks),
115
+ enforce_admins: body.enforce_admins === true,
116
+ required_pull_request_reviews: body.required_pull_request_reviews && typeof body.required_pull_request_reviews === 'object' ? (body.required_pull_request_reviews as Record<string, unknown>) : null,
117
+ restrictions: body.restrictions && typeof body.restrictions === 'object' ? (body.restrictions as Record<string, unknown>) : null,
118
+ }
119
+ : null;
120
+ await put(ctx, { operation: protecting ? 'branch.protect' : 'branch.unprotect', subjectType: 'branch', subjectId: `branch:${repo}#${name}`, fields: { repository: repo, name, commit_sha: b.commit_sha, protected: protecting, protection_config } });
121
+ if (!protecting) return send(ctx, { status: 204, body: undefined });
122
+ return ctx.reply(toGithubBranchProtection(state().branches.find((x) => x.repository === repo && x.name === name)!));
123
+ }
124
+
125
+ /** Toggle admin enforcement on a protected branch (404 when not protected). */
126
+ async function enforceAdmins(ctx: SemanticsContext, enabled: boolean): Promise<Response> {
127
+ const { repo } = gh(ctx);
128
+ const { name, b } = branchOf(ctx);
129
+ if (!b?.protected) return send(ctx, githubNotFound());
130
+ await protect(ctx, name, b, { ...(b.protection_config ?? {}), enforce_admins: enabled });
131
+ if (!enabled) return send(ctx, { status: 204, body: undefined });
132
+ return ctx.reply({ url: `${protectionUrl(repo, name)}/enforce_admins`, enabled });
133
+ }
@@ -0,0 +1,163 @@
1
+ // Commit statuses and check runs (the twin's CI surface: nothing runs; callers post the results) and
2
+ // the check suite the twin groups a commit's runs into. A success that completes a pull request's
3
+ // required checks lands it when auto-merge is armed. Anyone who can write the repository posts a commit
4
+ // status, but only a GitHub App writes check runs, through an installation token that grants checks:write
5
+ // on the repository (docs.github.com/rest/checks/runs#create-a-check-run: "OAuth apps and personal access
6
+ // tokens (classic) cannot use this endpoint").
7
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
8
+ import {
9
+ combineStatuses, githubNotFound, paginate, planeCommitBody, toGithubAnnotation, toGithubCheckRun, toGithubCheckSuite, toGithubStatus,
10
+ } from '../github-twin.ts';
11
+ import { gh, nextSeq, put, send } from './shared.ts';
12
+
13
+ const refOf = (ctx: SemanticsContext): string => decodeURIComponent(gh(ctx).seg[4]!);
14
+
15
+ /** The app a check-run write comes from, or GitHub's refusal when it is not an installed app allowed checks:write on
16
+ * this repository. */
17
+ function appWriting(ctx: SemanticsContext): { refused: Response } | { app: string } {
18
+ const { token, repo } = gh(ctx);
19
+ const docs = 'https://docs.github.com/rest/checks/runs#create-a-check-run';
20
+ const grant = token ? ctx.tree().find((r) => r.type === 'app_token' && r.token === token && r.deleted !== true) : undefined;
21
+ if (!grant) return { refused: ctx.reply({ message: 'You must authenticate via a GitHub App.', documentation_url: docs }, 403) };
22
+ const names = (grant.repositories as string[] | undefined) ?? [];
23
+ const covers = grant.repository_selection !== 'selected' ? repo.split('/')[0] === grant.account_login : names.some((n) => n === repo || `${String(grant.account_login)}/${n}` === repo);
24
+ if ((grant.permissions as Record<string, string> | undefined)?.checks !== 'write' || !covers) return { refused: ctx.reply({ message: 'Resource not accessible by integration', documentation_url: docs }, 403) };
25
+ return { app: String(grant.app_slug) };
26
+ }
27
+ /** The commit a suite checks, as GitHub's simple-commit, read from the repository's git plane when it holds it. */
28
+ async function headCommit(repo: string, sha: string, root?: string): Promise<{ head_commit?: Record<string, unknown> }> {
29
+ const c = await planeCommitBody(repo, sha, root) as { commit?: { message?: string; author?: { name?: string; email?: string; date?: string }; committer?: { name?: string; email?: string }; tree?: { sha?: string } } } | null;
30
+ if (!c?.commit) return {};
31
+ const who = (p?: { name?: string; email?: string }) => ({ name: p?.name ?? '', email: p?.email ?? '' });
32
+ return { head_commit: { id: sha, tree_id: c.commit.tree?.sha ?? '', message: c.commit.message ?? '', timestamp: c.commit.author?.date ?? '', author: who(c.commit.author), committer: who(c.commit.committer) } };
33
+ }
34
+
35
+ export function checksSemantics(): Record<string, Semantics> {
36
+ const reposGetCombinedStatusForRef: Semantics = async (ctx) => {
37
+ const { repo, state } = gh(ctx);
38
+ return ctx.reply(combineStatuses(repo, refOf(ctx), state().statuses));
39
+ };
40
+ const reposListCommitStatusesForRef: Semantics = async (ctx) => {
41
+ const { repo, query, state } = gh(ctx);
42
+ const ref = refOf(ctx);
43
+ return ctx.reply(paginate(state().statuses.filter((s) => s.repository === repo && s.sha === ref).sort((a, b) => b.id - a.id), query).map(toGithubStatus));
44
+ };
45
+ const reposCreateCommitStatus: Semantics = async (ctx) => {
46
+ const { repo, body, occurredAt } = gh(ctx);
47
+ const sha = refOf(ctx);
48
+ if (!body.state) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Status', field: 'state', code: 'missing_field' }] }, 422);
49
+ if (!['error', 'failure', 'pending', 'success'].includes(body.state)) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Status', field: 'state', code: 'invalid' }] }, 422);
50
+ const seq = nextSeq(ctx, 'status');
51
+ const fields = { repository: repo, sha, state: body.state, context: body.context ?? 'default', description: body.description, target_url: body.target_url, created_at: occurredAt, updated_at: occurredAt };
52
+ await put(ctx, { operation: 'commit_status.create', subjectType: 'commit_status', subjectId: `status:${seq}`, fields });
53
+ return ctx.reply(toGithubStatus({ id: seq, ...fields }), 201);
54
+ };
55
+ const checksListSuitesForRef: Semantics = async (ctx) => {
56
+ const { repo, state, root } = gh(ctx);
57
+ const ref = refOf(ctx);
58
+ const st = state();
59
+ const apps = [...new Set(st.checkRuns.filter((c) => c.repository === repo && c.head_sha === ref).map((c) => c.app_slug))];
60
+ const head = await headCommit(repo, ref, root);
61
+ const suites = apps.map((app) => ({ ...toGithubCheckSuite(repo, ref, st.checkRuns, app, st), ...head }));
62
+ return ctx.reply({ total_count: suites.length, check_suites: suites });
63
+ };
64
+ const checksGet: Semantics = async (ctx) => {
65
+ const { repo, state } = gh(ctx);
66
+ const cr = state().checkRuns.find((c) => c.repository === repo && c.id === Number(refOf(ctx)));
67
+ return send(ctx, cr ? { status: 200, body: toGithubCheckRun(cr, state()) } : githubNotFound());
68
+ };
69
+ const checksListAnnotations: Semantics = async (ctx) => {
70
+ const { repo, query, state } = gh(ctx);
71
+ const cr = state().checkRuns.find((c) => c.repository === repo && c.id === Number(refOf(ctx)));
72
+ if (!cr) return send(ctx, githubNotFound());
73
+ return ctx.reply(paginate(cr.annotations ?? [], query).map((an) => toGithubAnnotation(cr, an)));
74
+ };
75
+ const checksGetSuite: Semantics = async (ctx) => {
76
+ const { repo, state, root } = gh(ctx);
77
+ const st = state();
78
+ const id = Number(refOf(ctx));
79
+ const cr = st.checkRuns.find((c) => c.repository === repo && (c.check_suite_id ?? 0) === id);
80
+ return send(ctx, cr ? { status: 200, body: { ...toGithubCheckSuite(repo, cr.head_sha, st.checkRuns, cr.app_slug, st), ...(await headCommit(repo, cr.head_sha, root)) } } : githubNotFound());
81
+ };
82
+ const checksListForSuite: Semantics = async (ctx) => {
83
+ const { repo, query, state } = gh(ctx);
84
+ const st = state();
85
+ const id = Number(refOf(ctx));
86
+ if (!st.checkRuns.find((c) => c.repository === repo && (c.check_suite_id ?? 0) === id)) return send(ctx, githubNotFound());
87
+ const items = st.checkRuns.filter((c) => c.repository === repo && (c.check_suite_id ?? 0) === id).sort((a, b) => a.id - b.id);
88
+ return ctx.reply({ total_count: items.length, check_runs: paginate(items, query).map((c) => toGithubCheckRun(c, state())) });
89
+ };
90
+ const checksUpdate: Semantics = async (ctx) => {
91
+ const writer = appWriting(ctx);
92
+ if ('refused' in writer) return writer.refused;
93
+ const { repo, body, occurredAt, state } = gh(ctx);
94
+ const id = Number(refOf(ctx));
95
+ const existing = state().checkRuns.find((c) => c.repository === repo && c.id === id);
96
+ if (!existing) return send(ctx, githubNotFound());
97
+ // an app updates only the check runs it created
98
+ if (existing.app_slug && existing.app_slug !== writer.app) return ctx.reply({ message: 'Resource not accessible by integration', documentation_url: 'https://docs.github.com/rest/checks/runs#update-a-check-run' }, 403);
99
+ const fields: Record<string, unknown> = { repository: repo, head_sha: existing.head_sha, name: existing.name, app_slug: existing.app_slug ?? writer.app };
100
+ if (body.status !== undefined) fields.status = String(body.status);
101
+ if (body.conclusion !== undefined) { fields.conclusion = String(body.conclusion); fields.status = body.status ?? 'completed'; fields.completed_at = body.completed_at ?? occurredAt; }
102
+ fields.started_at = existing.started_at;
103
+ const output = body.output as Record<string, any> | undefined;
104
+ if (output) {
105
+ if (output.title !== undefined) fields.output_title = String(output.title);
106
+ if (output.summary !== undefined) fields.output_summary = String(output.summary);
107
+ if (output.text !== undefined) fields.output_text = String(output.text);
108
+ const anns = Array.isArray(output.annotations) ? output.annotations : (existing.annotations ?? []);
109
+ fields.annotations = (anns as any[]).map((an) => ({
110
+ path: String(an.path), start_line: Number(an.start_line), end_line: Number(an.end_line),
111
+ annotation_level: String(an.annotation_level), message: String(an.message),
112
+ title: an.title, raw_details: an.raw_details, start_column: an.start_column, end_column: an.end_column,
113
+ }));
114
+ } else if (existing.annotations) { fields.annotations = existing.annotations; }
115
+ await put(ctx, { operation: 'check_run.update', subjectType: 'check_run', subjectId: `check:${id}`, fields });
116
+ return ctx.reply(toGithubCheckRun(state().checkRuns.find((c) => c.id === id)!, state()));
117
+ };
118
+ const checksRerequestSuite: Semantics = async (ctx) => {
119
+ const { repo, state } = gh(ctx);
120
+ const id = Number(refOf(ctx));
121
+ if (!state().checkRuns.find((c) => c.repository === repo && (c.check_suite_id ?? 0) === id)) return send(ctx, githubNotFound());
122
+ return ctx.reply({}, 201);
123
+ };
124
+ return {
125
+ 'repos/get-combined-status-for-ref': reposGetCombinedStatusForRef,
126
+ // newest first
127
+ 'repos/list-commit-statuses-for-ref': reposListCommitStatusesForRef,
128
+ // 422 without a state or with one outside the enum
129
+ 'repos/create-commit-status': reposCreateCommitStatus,
130
+ // {total_count, check_runs}, paginated over check_runs
131
+ 'checks/list-for-ref': async (ctx) => {
132
+ const { repo, query, state } = gh(ctx);
133
+ const ref = refOf(ctx);
134
+ const items = state().checkRuns.filter((c) => c.repository === repo && c.head_sha === ref).sort((a, b) => a.id - b.id);
135
+ return ctx.reply({ total_count: items.length, check_runs: paginate(items, query).map((c) => toGithubCheckRun(c, state())) });
136
+ },
137
+ // one suite per app that ran checks on the commit
138
+ 'checks/list-suites-for-ref': checksListSuitesForRef,
139
+ 'checks/get': checksGet,
140
+ 'checks/list-annotations': checksListAnnotations,
141
+ 'checks/get-suite': checksGetSuite,
142
+ 'checks/list-for-suite': checksListForSuite,
143
+ // queued unless it carries a conclusion (then completed)
144
+ 'checks/create': async (ctx) => {
145
+ const writer = appWriting(ctx);
146
+ if ('refused' in writer) return writer.refused;
147
+ const { repo, body, occurredAt, state } = gh(ctx);
148
+ const missing = (['name', 'head_sha'] as const).filter((k) => !body[k]);
149
+ if (missing.length) return ctx.reply({ message: 'Validation Failed', errors: missing.map((field) => ({ resource: 'CheckRun', field, code: 'missing_field' })) }, 422);
150
+ const seq = nextSeq(ctx, 'check');
151
+ const status = body.status ?? (body.conclusion ? 'completed' : 'queued');
152
+ const completed_at = body.conclusion ? (body.completed_at ?? occurredAt) : undefined;
153
+ const fields: Record<string, unknown> = { repository: repo, head_sha: body.head_sha, name: body.name, status, conclusion: body.conclusion, details_url: body.details_url, started_at: body.started_at ?? occurredAt, app_slug: writer.app, ...(body.external_id !== undefined ? { external_id: String(body.external_id) } : {}) };
154
+ if (completed_at !== undefined) fields.completed_at = completed_at;
155
+ await put(ctx, { operation: 'check_run.create', subjectType: 'check_run', subjectId: `check:${seq}`, fields });
156
+ return ctx.reply(toGithubCheckRun(state().checkRuns.find((c) => c.id === seq)!, state()), 201);
157
+ },
158
+ // status, conclusion (which completes the run), and output with its annotations
159
+ 'checks/update': checksUpdate,
160
+ // 201; nothing reruns
161
+ 'checks/rerequest-suite': checksRerequestSuite,
162
+ };
163
+ }