@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,139 @@
1
+ // GitHub's platform meta family: the service root, the rate limit, and the stateless reference
2
+ // catalogs (gitignore templates, licenses, emojis, service metadata) and the Markdown renderer.
3
+ // None of it is account state; every answer is fixed data or computed from the request.
4
+ import { createHash } from 'node:crypto';
5
+ import type { Semantics } from '@volter/world-core';
6
+ import { githubNotFound } from '../github-twin.ts';
7
+ import { gh, send } from './shared.ts';
8
+
9
+ // ── Static meta catalogs (the platform's stateless reference endpoints) ───────────────────
10
+ // GitHub serves these from a fixed, well-known catalog (they are not per-account state). The
11
+ // twin returns a faithful representative subset — vendor-shaped objects, deterministic, never
12
+ // fabricated per-request. (The real catalogs are long; a representative slice is honest here.)
13
+ const GITIGNORE_TEMPLATES = ['Node', 'Python', 'Go', 'Rust', 'Java', 'Ruby', 'C', 'C++', 'VisualStudio'];
14
+ const GITIGNORE_SOURCE: Record<string, string> = {
15
+ Node: 'node_modules\nnpm-debug.log\n.env\ndist\n',
16
+ Python: '__pycache__/\n*.py[cod]\n.venv\n.env\n',
17
+ Go: '*.exe\n*.test\n/vendor/\n',
18
+ };
19
+ const LICENSES = [
20
+ { key: 'mit', name: 'MIT License', spdx_id: 'MIT' },
21
+ { key: 'apache-2.0', name: 'Apache License 2.0', spdx_id: 'Apache-2.0' },
22
+ { key: 'gpl-3.0', name: 'GNU General Public License v3.0', spdx_id: 'GPL-3.0' },
23
+ { key: 'bsd-3-clause', name: 'BSD 3-Clause "New" or "Revised" License', spdx_id: 'BSD-3-Clause' },
24
+ { key: 'unlicense', name: 'The Unlicense', spdx_id: 'Unlicense' },
25
+ ];
26
+ const EMOJIS: Record<string, string> = {
27
+ '+1': 'https://github.githubassets.com/images/icons/emoji/unicode/1f44d.png',
28
+ '-1': 'https://github.githubassets.com/images/icons/emoji/unicode/1f44e.png',
29
+ smile: 'https://github.githubassets.com/images/icons/emoji/unicode/1f604.png',
30
+ rocket: 'https://github.githubassets.com/images/icons/emoji/unicode/1f680.png',
31
+ tada: 'https://github.githubassets.com/images/icons/emoji/unicode/1f389.png',
32
+ eyes: 'https://github.githubassets.com/images/icons/emoji/unicode/1f440.png',
33
+ heart: 'https://github.githubassets.com/images/icons/emoji/unicode/2764.png',
34
+ };
35
+ /** A .gitignore template's text, by its name (any case); undefined for a name the catalog lacks. */
36
+ export function gitignoreTemplate(name: string): string | undefined {
37
+ const found = GITIGNORE_TEMPLATES.find((t) => t.toLowerCase() === name.toLowerCase());
38
+ return found && (GITIGNORE_SOURCE[found] ?? `# ${found} .gitignore\n`);
39
+ }
40
+ // The license texts GitHub serves, [year] and [fullname] left for whoever copies them in: MIT's is the body
41
+ // docs.github.com/rest/licenses/licenses#get-a-license shows (its example's leading blank lines dropped). Where the
42
+ // documentation stops: the other catalog licenses keep their name alone.
43
+ const LICENSE_TEXTS: Record<string, string> = {
44
+ mit: 'The MIT License (MIT)\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the "Software"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n',
45
+ };
46
+ /** A license's text, by its key (any case); undefined for a key the catalog lacks. Given a holder, its placeholders are
47
+ * filled as the license's implementation note says: [year] with the year and [fullname] with the copyright holder. */
48
+ export function licenseTemplate(key: string, fill?: { year: string; fullname: string }): string | undefined {
49
+ const found = LICENSES.find((l) => l.key === key.toLowerCase());
50
+ if (!found) return undefined;
51
+ const text = LICENSE_TEXTS[found.key] ?? `${found.name}\n`;
52
+ return fill ? text.replaceAll('[year]', fill.year).replaceAll('[fullname]', fill.fullname) : text;
53
+ }
54
+ function licenseToRest(l: (typeof LICENSES)[number]): Record<string, unknown> {
55
+ return { key: l.key, name: l.name, spdx_id: l.spdx_id, url: `https://api.github.com/licenses/${l.key}`, node_id: createHash('sha256').update(`license:${l.key}`).digest('base64').slice(0, 16), html_url: `https://choosealicense.com/licenses/${l.key}/` };
56
+ }
57
+ // A minimal-but-faithful GitHub-Flavored-Markdown renderer for POST /markdown. GitHub returns
58
+ // rendered HTML; the twin supports the common inline/block forms (headings, bold, italic,
59
+ // inline code, links) deterministically. Not a full CommonMark engine — a faithful subset.
60
+ function renderMarkdown(text: string): string {
61
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
62
+ const inline = (s: string) =>
63
+ esc(s)
64
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
65
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
66
+ .replace(/\*([^*]+)\*/g, '<em>$1</em>')
67
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
68
+ const out: string[] = [];
69
+ for (const raw of text.split(/\r?\n/)) {
70
+ const line = raw.trimEnd();
71
+ if (!line) continue;
72
+ const h = /^(#{1,6})\s+(.*)$/.exec(line);
73
+ if (h) { const lvl = h[1]!.length; out.push(`<h${lvl}>${inline(h[2]!)}</h${lvl}>`); continue; }
74
+ out.push(`<p>${inline(line)}</p>`);
75
+ }
76
+ return out.join('\n');
77
+ }
78
+
79
+ export function metaSemantics(_opts: { readOnly?: boolean }): Record<string, Semantics> {
80
+ const metaRoot: Semantics = async (ctx) => {
81
+ const a = 'https://api.github.com';
82
+ return ctx.reply({
83
+ current_user_url: `${a}/user`, current_user_authorizations_html_url: 'https://github.com/settings/connections/applications{/client_id}',
84
+ authorizations_url: `${a}/authorizations`, code_search_url: `${a}/search/code?q={query}{&page,per_page,sort,order}`,
85
+ commit_search_url: `${a}/search/commits?q={query}{&page,per_page,sort,order}`, emails_url: `${a}/user/emails`, emojis_url: `${a}/emojis`,
86
+ events_url: `${a}/events`, feeds_url: `${a}/feeds`, followers_url: `${a}/user/followers`, following_url: `${a}/user/following{/target}`,
87
+ gists_url: `${a}/gists{/gist_id}`, hub_url: `${a}/hub`, issue_search_url: `${a}/search/issues?q={query}{&page,per_page,sort,order}`,
88
+ issues_url: `${a}/issues`, keys_url: `${a}/user/keys`, label_search_url: `${a}/search/labels?q={query}&repository_id={repository_id}{&page,per_page}`,
89
+ notifications_url: `${a}/notifications`, organization_url: `${a}/orgs/{org}`, organization_repositories_url: `${a}/orgs/{org}/repos{?type,page,per_page,sort}`,
90
+ organization_teams_url: `${a}/orgs/{org}/teams`, public_gists_url: `${a}/gists/public`, rate_limit_url: `${a}/rate_limit`,
91
+ repository_url: `${a}/repos/{owner}/{repo}`, repository_search_url: `${a}/search/repositories?q={query}{&page,per_page,sort,order}`,
92
+ current_user_repositories_url: `${a}/user/repos{?type,page,per_page,sort}`, starred_url: `${a}/user/starred{/owner}{/repo}`,
93
+ starred_gists_url: `${a}/gists/starred`, topic_search_url: `${a}/search/topics?q={query}{&page,per_page}`, user_url: `${a}/users/{user}`,
94
+ user_organizations_url: `${a}/user/orgs`, user_repositories_url: `${a}/users/{user}/repos{?type,page,per_page,sort}`,
95
+ user_search_url: `${a}/search/users?q={query}{&page,per_page,sort,order}`,
96
+ });
97
+ };
98
+ const rateLimitGet: Semantics = async (ctx) => {
99
+ const reset = 4102444800; // 2100-01-01T00:00:00Z — a stable, far-future sentinel.
100
+ const bucket = (limit: number) => ({ limit, used: 0, remaining: limit, reset });
101
+ const resources = { core: bucket(5000), search: bucket(30), graphql: bucket(5000), integration_manifest: bucket(5000), code_scanning_upload: bucket(500) };
102
+ return ctx.reply({ resources, rate: resources.core });
103
+ };
104
+ const gitignoreGetTemplate: Semantics = async (ctx) => {
105
+ const name = GITIGNORE_TEMPLATES.find((t) => t.toLowerCase() === decodeURIComponent(gh(ctx).seg[2]!).toLowerCase());
106
+ if (!name) return send(ctx, githubNotFound());
107
+ return ctx.reply({ name, source: gitignoreTemplate(name) });
108
+ };
109
+ const licensesGet: Semantics = async (ctx) => {
110
+ const l = LICENSES.find((x) => x.key === decodeURIComponent(gh(ctx).seg[1]!).toLowerCase());
111
+ if (!l) return send(ctx, githubNotFound());
112
+ return ctx.reply({ ...licenseToRest(l), description: `${l.name}.`, permissions: [], conditions: [], limitations: [], body: licenseTemplate(l.key), featured: l.key === 'mit' });
113
+ };
114
+ const markdownRender: Semantics = async (ctx) => {
115
+ const { body } = gh(ctx);
116
+ if (typeof body.text !== 'string') return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Markdown', field: 'text', code: 'missing_field' }] }, 422);
117
+ return ctx.reply(renderMarkdown(body.text));
118
+ };
119
+ return {
120
+ // the twin's own root: which service this is and whether it takes writes
121
+ // the API's root: the URL templates of its resources, as api.github.com answers (docs.github.com/rest/meta/meta#github-api-root)
122
+ 'meta/root': metaRoot,
123
+ // Rate limit: the twin is local + unmetered, so it reports a faithful full-budget envelope
124
+ // in GitHub's exact {resources:{core,search,...}, rate} shape. The reset is a fixed sentinel
125
+ // (deterministic — never Date.now()).
126
+ 'rate-limit/get': rateLimitGet,
127
+ 'gitignore/get-all-templates': async (ctx) => ctx.reply(GITIGNORE_TEMPLATES),
128
+ 'gitignore/get-template': gitignoreGetTemplate,
129
+ 'licenses/get-all-commonly-used': async (ctx) => ctx.reply(LICENSES.map(licenseToRest)),
130
+ 'licenses/get': licensesGet,
131
+ // the named-emoji → asset-URL map
132
+ 'emojis/get': async (ctx) => ctx.reply(EMOJIS),
133
+ // the platform's service metadata envelope
134
+ 'meta/get': async (ctx) => ctx.reply({ verifiable_password_authentication: false, ssh_key_fingerprints: {}, hooks: [], web: [], api: [], git: [], packages: [], pages: [], importer: [], actions: [], dependabot: [] }),
135
+ // Render Markdown to HTML (GitHub returns text/html). The twin renders a faithful GFM subset
136
+ // deterministically; `text` is required (422 when absent, like GitHub).
137
+ 'markdown/render': markdownRender,
138
+ };
139
+ }
@@ -0,0 +1,58 @@
1
+ // The notifications GitHub sends the signed-in person (the World's user): when someone else @mentions them, assigns
2
+ // them, asks for their review, or comments on or reviews their issue or pull request, the thread for that issue or pull
3
+ // request is unread in their inbox with the reason (docs.github.com/account-and-profile/managing-subscriptions-and-
4
+ // notifications-on-github/setting-up-notifications/about-notifications#reasons-for-receiving-notifications). One
5
+ // thread per issue or pull request: a new reason on it marks it unread again. The person's own acts notify no one.
6
+ import { createHash } from 'node:crypto';
7
+ import type { SemanticsContext } from '@volter/world-core';
8
+ import { GITHUB_WORLD_USER, type GithubWriteEvent } from '../github-twin.ts';
9
+ import { gh, put } from './shared.ts';
10
+
11
+ const loginOf = (u: unknown): string | undefined => (u && typeof u === 'object' ? ((u as { login?: unknown }).login as string | undefined) : undefined);
12
+ const mentions = (text: unknown, login: string): boolean => typeof text === 'string' && new RegExp(`(^|[^\\w-])@${login}\\b`, 'i').test(text);
13
+
14
+ /** Whether a review request names the World's user. */
15
+ function reviewRequestedOf(event: GithubWriteEvent, me: string): boolean {
16
+ const requested = (event.pr?.requested_reviewers as unknown[] | undefined) ?? [];
17
+ return requested.some((r) => (typeof r === 'string' ? r : loginOf(r)) === me);
18
+ }
19
+ /** Why an event notifies the World's user, or nothing. */
20
+ function reasonFor(event: GithubWriteEvent, me: string, author: string | null | undefined): string | undefined {
21
+ const text = event.comment?.body ?? event.review?.body ?? event.issue?.body ?? event.body;
22
+ if (mentions(text, me)) return 'mention';
23
+ if (event.event === 'pull_request' && event.action === 'review_requested' && reviewRequestedOf(event, me)) return 'review_requested';
24
+ if (event.event === 'issues' && (event.action === 'opened' || event.action === 'assigned' || event.action === 'edited')) {
25
+ const assignees = (event.issue?.assignees as unknown[] | undefined) ?? [];
26
+ if (assignees.some((a) => loginOf(a) === me)) return 'assign';
27
+ }
28
+ if ((event.event === 'issue_comment' || event.event === 'pull_request_review') && author === me) return 'author';
29
+ return undefined;
30
+ }
31
+
32
+ /** The thread an event puts in the World's user's inbox, when it concerns them and someone else did it. */
33
+ export async function notify(ctx: SemanticsContext, event: GithubWriteEvent): Promise<void> {
34
+ const { caller, state } = gh(ctx);
35
+ const me = GITHUB_WORLD_USER;
36
+ if (!caller || caller === me || !event.number) return;
37
+ const st = state();
38
+ const pr = st.prs.find((p) => p.repository === event.repository && Number(p.number) === event.number);
39
+ const issue = st.issues.find((i) => i.repository === event.repository && Number(i.number) === event.number);
40
+ const author = pr?.user_login ?? (issue as { user_login?: string | null } | undefined)?.user_login ?? undefined;
41
+ const reason = reasonFor(event, me, author);
42
+ if (!reason) return;
43
+ const isPull = Boolean(pr) || event.event.startsWith('pull_request');
44
+ const id = String(parseInt(createHash('sha256').update(`${event.repository}#${event.number}`).digest('hex').slice(0, 8), 16));
45
+ // an ignored thread stays quiet; an unsubscribed one until the next @mention, which subscribes the person again
46
+ const thread = st.notifications.find((n) => n.id === id);
47
+ if (thread?.subscription?.ignored) return;
48
+ if (thread?.subscription === null && reason !== 'mention') return;
49
+ await put(ctx, {
50
+ operation: 'notification.create', subjectType: 'notification', subjectId: `notification:${id}`,
51
+ fields: {
52
+ id, repository: event.repository, subject_title: String(pr?.title ?? issue?.title ?? `#${event.number}`),
53
+ subject_type: isPull ? 'PullRequest' : 'Issue', subject_url: `https://api.github.com/repos/${event.repository}/${isPull ? 'pulls' : 'issues'}/${event.number}`,
54
+ reason, unread: true, updated_at: ctx.occurredAt,
55
+ ...(thread?.subscription === null ? { subscription: { subscribed: true, ignored: false, created_at: ctx.occurredAt } } : {}),
56
+ },
57
+ });
58
+ }
@@ -0,0 +1,383 @@
1
+ // GitHub's organizations family: an organization's profile, members and memberships, outside
2
+ // collaborators, organization webhooks, issue types, its repositories, and teams (their members and
3
+ // repository grants). A bare organization is auto-vivified by its first edit (the twin cannot observe
4
+ // the enterprise flow that creates one). Team discussions, which GitHub no longer publishes, stay on
5
+ // the hand-written route.
6
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
7
+ import {
8
+ GITHUB_WORLD_USER, githubNotFound, paginate, seedUser, toGithubIssueType, toGithubOrg, toGithubOrgMembership, toGithubOrgWebhook, toGithubRepo, toGithubTeam, toGithubTeamMembership, toGithubUser, twinUserId,
9
+ type GithubRepo, type GithubTeam, type GithubState } from '../github-twin.ts';
10
+ import { asView, gh, nextSeq, put, send, type Gh } from './shared.ts';
11
+
12
+ const invalid = (ctx: SemanticsContext, resource: string, field: string, code: string, documentation_url?: string): Response =>
13
+ ctx.reply({ message: 'Validation Failed', errors: [{ resource, field, code }], ...(documentation_url ? { documentation_url } : {}) }, 422);
14
+
15
+ // GitHub's team-name → slug derivation (lowercase, non-alphanum → '-', collapse/trim dashes).
16
+ function slugifyTeamName(name: string): string {
17
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/-+/g, '-');
18
+ }
19
+
20
+ /** The team a path under /orgs/:org/teams/:slug names, or nothing. */
21
+ function teamOf(g: Gh): { org: string; slug: string; team: GithubTeam | undefined } {
22
+ const org = decodeURIComponent(g.seg[1]!);
23
+ const slug = decodeURIComponent(g.seg[3]!);
24
+ return { org, slug, team: g.state().teams.find((t) => t.org === org && t.slug === slug) };
25
+ }
26
+
27
+ /** A read or write under a team: Not Found when the team does not exist. */
28
+ const underTeam = (serve: (ctx: SemanticsContext, g: Gh, org: string, slug: string, team: GithubTeam) => Promise<Response>): Semantics => async (ctx) => {
29
+ const g = gh(ctx);
30
+ const { org, slug, team } = teamOf(g);
31
+ return team ? serve(ctx, g, org, slug, team) : send(ctx, githubNotFound());
32
+ };
33
+
34
+ async function removeOrgMember(ctx: SemanticsContext): Promise<Response> {
35
+ const { seg, state } = gh(ctx);
36
+ const org = decodeURIComponent(seg[1]!); const login = decodeURIComponent(seg[3]!);
37
+ if (!state().orgMembers.some((m) => m.org === org && m.login === login)) return send(ctx, githubNotFound());
38
+ await put(ctx, { operation: 'org_membership.remove', subjectType: 'org_membership', subjectId: `orgmember:${org}#${login}`, fields: { deleted: true, org, login } });
39
+ return send(ctx, { status: 204, body: undefined });
40
+ }
41
+
42
+ /** Edit an issue type (GitHub's PUT; the hand-written route keeps the PATCH it also answered). */
43
+ async function updateIssueType(ctx: SemanticsContext): Promise<Response> {
44
+ const { seg, body, occurredAt, state } = gh(ctx);
45
+ const org = decodeURIComponent(seg[1]!); const id = Number(decodeURIComponent(seg[3]!));
46
+ const existing = state().issueTypes.find((t) => t.org === org && t.id === id);
47
+ if (!existing) return send(ctx, githubNotFound());
48
+ // the update is a PUT: name and is_enabled are required, as on create (docs.github.com/rest/orgs/issue-types)
49
+ if (!body.name) return invalid(ctx, 'IssueType', 'name', 'missing_field');
50
+ if (typeof body.is_enabled !== 'boolean') return invalid(ctx, 'IssueType', 'is_enabled', 'missing_field');
51
+ const fields: Record<string, unknown> = { org, name: existing.name, description: existing.description, color: existing.color, is_enabled: existing.is_enabled, created_at: existing.created_at, updated_at: occurredAt };
52
+ for (const k of ['name', 'description', 'color'] as const) if (body[k] !== undefined) fields[k] = body[k];
53
+ if (body.is_enabled !== undefined) fields.is_enabled = body.is_enabled !== false;
54
+ await put(ctx, { operation: 'issue_type.update', subjectType: 'issue_type', subjectId: `issuetype:${id}`, fields });
55
+ return ctx.reply(toGithubIssueType(state().issueTypes.find((x) => x.org === org && x.id === id)!));
56
+ }
57
+
58
+ /** An organization's outside collaborators: people with access to one of its repositories who are not its members,
59
+ * and members it converted (docs.github.com/organizations/managing-user-access-to-your-organizations-repositories). */
60
+ function outsideOf(st: GithubState, org: string): string[] {
61
+ const members = new Set(st.orgMembers.filter((m) => m.org === org && m.state === 'active').map((m) => m.login));
62
+ const logins = new Set([...st.outsideCollaborators.filter((c) => c.org === org).map((c) => c.login), ...st.collaborators.filter((c) => c.repository.startsWith(`${org}/`)).map((c) => c.login)]);
63
+ return [...logins].filter((l) => !members.has(l)).sort();
64
+ }
65
+
66
+ export function orgsSemantics(): Record<string, Semantics> {
67
+ const orgsGet: Semantics = async (ctx) => {
68
+ const { seg, caller, state } = gh(ctx);
69
+ const login = decodeURIComponent(seg[1]!);
70
+ const st = state();
71
+ const o = st.orgs.find((x) => x.login === login);
72
+ if (!o) return send(ctx, githubNotFound());
73
+ // GitHub reports the two-factor requirement only to the organization's owners.
74
+ const owner = !!caller && st.orgMembers.some((m) => m.org === login && m.login === caller && m.role === 'admin' && (m.state ?? 'active') === 'active');
75
+ return ctx.reply({ ...toGithubOrg(o, st), ...(owner ? { two_factor_requirement_enabled: o.two_factor_requirement_enabled === true } : {}) });
76
+ };
77
+ const orgsUpdate: Semantics = async (ctx) => {
78
+ const { seg, body, occurredAt, state } = gh(ctx);
79
+ const login = decodeURIComponent(seg[1]!);
80
+ const prev = state().orgs.find((o) => o.login === login);
81
+ // an organization is created on github.com (the twin's door, POST /_twin/orgs), never by an edit
82
+ if (!prev) return send(ctx, githubNotFound());
83
+ const fields: Record<string, unknown> = { login, updated_at: occurredAt, created_at: prev.created_at ?? occurredAt };
84
+ for (const k of ['description', 'name', 'company', 'blog', 'location', 'email', 'billing_email'] as const) if (body[k] !== undefined) fields[k] = body[k];
85
+ await put(ctx, { operation: 'organization.update', subjectType: 'organization', subjectId: `org:${login}`, fields });
86
+ return ctx.reply(toGithubOrg(state().orgs.find((x) => x.login === login)!, state()));
87
+ };
88
+ const orgsListMembers: Semantics = async (ctx) => {
89
+ const { seg, query, state } = gh(ctx);
90
+ const org = decodeURIComponent(seg[1]!);
91
+ const role = new URLSearchParams((query ?? '').replace(/^\?/, '')).get('role') ?? 'all';
92
+ if (!['all', 'admin', 'member'].includes(role)) return invalid(ctx, 'Organization', 'role', 'invalid', 'https://docs.github.com/rest/orgs/members#list-organization-members');
93
+ const members = state().orgMembers.filter((m) => m.org === org && m.state === 'active' && (role === 'all' || m.role === role));
94
+ return ctx.reply(paginate(members, query).map((m) => asView('simple-user', toGithubUser(seedUser(m.login)))));
95
+ };
96
+ const orgsCheckMembershipForUser: Semantics = async (ctx) => {
97
+ const { seg, state } = gh(ctx);
98
+ const org = decodeURIComponent(seg[1]!);
99
+ const m = state().orgMembers.find((x) => x.org === org && x.login === decodeURIComponent(seg[3]!) && x.state === 'active');
100
+ return send(ctx, m ? { status: 204, body: undefined } : githubNotFound());
101
+ };
102
+ const orgsGetMembershipForUser: Semantics = async (ctx) => {
103
+ const { seg, state } = gh(ctx);
104
+ const org = decodeURIComponent(seg[1]!); const login = decodeURIComponent(seg[3]!);
105
+ const m = state().orgMembers.find((x) => x.org === org && x.login === login);
106
+ return send(ctx, m ? { status: 200, body: toGithubOrgMembership(m) } : githubNotFound());
107
+ };
108
+ const orgsListPendingInvitations: Semantics = async (ctx) => {
109
+ const { seg, query, state } = gh(ctx);
110
+ const org = decodeURIComponent(seg[1]!);
111
+ const pending = state().orgMembers.filter((m) => m.org === org && m.state === 'pending');
112
+ return ctx.reply(paginate(pending, query).map((m) => ({
113
+ id: twinUserId(`${org}#${m.login}`), node_id: null, login: m.login, email: null, role: m.role === 'admin' ? 'admin' : 'direct_member',
114
+ created_at: m.created_at ?? null, failed_at: null, failed_reason: null, inviter: toGithubUser(seedUser(GITHUB_WORLD_USER)), team_count: 0,
115
+ invitation_teams_url: `https://api.github.com/organizations/${org}/invitations/${m.login}/teams`, invitation_source: 'member',
116
+ })));
117
+ };
118
+ const orgsListMembershipsForAuthenticatedUser: Semantics = async (ctx) => {
119
+ const { caller, query, state } = gh(ctx);
120
+ const me = caller ?? GITHUB_WORLD_USER;
121
+ const wanted = new URLSearchParams((query ?? '').replace(/^\?/, '')).get('state');
122
+ return ctx.reply(paginate(state().orgMembers.filter((m) => m.login === me && (!wanted || m.state === wanted)), query).map(toGithubOrgMembership));
123
+ };
124
+ const orgsGetMembershipForAuthenticatedUser: Semantics = async (ctx) => {
125
+ const { caller, seg, state } = gh(ctx);
126
+ const org = decodeURIComponent(seg[3]!);
127
+ const m = state().orgMembers.find((x) => x.org === org && x.login === (caller ?? GITHUB_WORLD_USER));
128
+ return send(ctx, m ? { status: 200, body: toGithubOrgMembership(m) } : githubNotFound());
129
+ };
130
+ const orgsListOutsideCollaborators: Semantics = async (ctx) => {
131
+ const { seg, query, state } = gh(ctx);
132
+ const org = decodeURIComponent(seg[1]!);
133
+ return ctx.reply(paginate(outsideOf(state(), org), query).map((login) => asView('simple-user', toGithubUser(seedUser(login)))));
134
+ };
135
+ const orgsConvertMemberToOutsideCollaborator: Semantics = async (ctx) => {
136
+ const { seg } = gh(ctx);
137
+ const org = decodeURIComponent(seg[1]!); const login = decodeURIComponent(seg[3]!);
138
+ await put(ctx, { operation: 'outside_collaborator.add', subjectType: 'outside_collaborator', subjectId: `outsidecollab:${org}#${login}`, fields: { org, login } });
139
+ // they are no longer a member, nor on its teams; the repositories they worked in stay theirs
140
+ const st = gh(ctx).state();
141
+ const membership = st.orgMembers.find((m) => m.org === org && m.login === login);
142
+ if (membership) await put(ctx, { operation: 'org_membership.remove', subjectType: 'org_membership', subjectId: `orgmember:${org}#${login}`, fields: { ...membership, deleted: true } });
143
+ for (const t of st.teamMembers.filter((m) => m.org === org && m.login === login)) {
144
+ await put(ctx, { operation: 'team_membership.remove', subjectType: 'team_membership', subjectId: `teammember:${org}#${t.team_slug}#${login}`, fields: { ...t, deleted: true } });
145
+ }
146
+ return send(ctx, { status: 204, body: undefined });
147
+ };
148
+ const orgsRemoveOutsideCollaborator: Semantics = async (ctx) => {
149
+ const { seg, state } = gh(ctx);
150
+ const org = decodeURIComponent(seg[1]!); const login = decodeURIComponent(seg[3]!);
151
+ const st = state();
152
+ if (!outsideOf(st, org).includes(login)) return send(ctx, githubNotFound());
153
+ if (st.outsideCollaborators.some((c) => c.org === org && c.login === login)) await put(ctx, { operation: 'outside_collaborator.remove', subjectType: 'outside_collaborator', subjectId: `outsidecollab:${org}#${login}`, fields: { deleted: true, org, login } });
154
+ for (const c of st.collaborators.filter((x) => x.login === login && x.repository.startsWith(`${org}/`))) {
155
+ await put(ctx, { operation: 'collaborator.remove', subjectType: 'collaborator', subjectId: `collab:${c.repository}#${login}`, fields: { deleted: true, repository: c.repository, login } });
156
+ }
157
+ return send(ctx, { status: 204, body: undefined });
158
+ };
159
+ const orgsListWebhooks: Semantics = async (ctx) => {
160
+ const { seg, query, state } = gh(ctx);
161
+ const org = decodeURIComponent(seg[1]!);
162
+ return ctx.reply(paginate(state().orgWebhooks.filter((h) => h.org === org), query).map(toGithubOrgWebhook));
163
+ };
164
+ const orgsGetWebhook: Semantics = async (ctx) => {
165
+ const { seg, state } = gh(ctx);
166
+ const org = decodeURIComponent(seg[1]!);
167
+ const h = state().orgWebhooks.find((x) => x.org === org && x.id === Number(decodeURIComponent(seg[3]!)));
168
+ return send(ctx, h ? { status: 200, body: toGithubOrgWebhook(h) } : githubNotFound());
169
+ };
170
+ const orgsCreateWebhook: Semantics = async (ctx) => {
171
+ const { seg, body, occurredAt, state } = gh(ctx);
172
+ const org = decodeURIComponent(seg[1]!);
173
+ const cfg = (body.config ?? {}) as Record<string, any>;
174
+ if (!cfg.url) return invalid(ctx, 'Hook', 'config', 'missing_field');
175
+ const seq = nextSeq(ctx, 'orghook');
176
+ const fields = { org, active: body.active !== false, events: Array.isArray(body.events) ? body.events : ['push'], url: String(cfg.url), content_type: cfg.content_type ?? 'form', created_at: occurredAt, updated_at: occurredAt };
177
+ await put(ctx, { operation: 'org_webhook.create', subjectType: 'org_webhook', subjectId: `orghook:${seq}`, fields });
178
+ return ctx.reply(toGithubOrgWebhook(state().orgWebhooks.find((x) => x.org === org && x.id === seq)!), 201);
179
+ };
180
+ const orgsUpdateWebhook: Semantics = async (ctx) => {
181
+ const { seg, body, occurredAt, state } = gh(ctx);
182
+ const org = decodeURIComponent(seg[1]!); const id = Number(decodeURIComponent(seg[3]!));
183
+ const existing = state().orgWebhooks.find((h) => h.org === org && h.id === id);
184
+ if (!existing) return send(ctx, githubNotFound());
185
+ const cfg = (body.config ?? {}) as Record<string, any>;
186
+ const fields: Record<string, unknown> = { org, created_at: existing.created_at, updated_at: occurredAt, url: existing.url, content_type: existing.content_type, events: existing.events, active: existing.active };
187
+ if (body.active !== undefined) fields.active = body.active !== false;
188
+ if (Array.isArray(body.events)) fields.events = body.events;
189
+ if (cfg.url !== undefined) fields.url = String(cfg.url);
190
+ if (cfg.content_type !== undefined) fields.content_type = cfg.content_type;
191
+ await put(ctx, { operation: 'org_webhook.update', subjectType: 'org_webhook', subjectId: `orghook:${id}`, fields });
192
+ return ctx.reply(toGithubOrgWebhook(state().orgWebhooks.find((x) => x.org === org && x.id === id)!));
193
+ };
194
+ const orgsDeleteWebhook: Semantics = async (ctx) => {
195
+ const { seg, state } = gh(ctx);
196
+ const org = decodeURIComponent(seg[1]!); const id = Number(decodeURIComponent(seg[3]!));
197
+ if (!state().orgWebhooks.find((h) => h.org === org && h.id === id)) return send(ctx, githubNotFound());
198
+ await put(ctx, { operation: 'org_webhook.delete', subjectType: 'org_webhook', subjectId: `orghook:${id}`, fields: { deleted: true, org } });
199
+ return send(ctx, { status: 204, body: undefined });
200
+ };
201
+ const orgsListIssueTypes: Semantics = async (ctx) => {
202
+ const { seg, query, state } = gh(ctx);
203
+ const org = decodeURIComponent(seg[1]!);
204
+ return ctx.reply(paginate(state().issueTypes.filter((t) => t.org === org), query).map(toGithubIssueType));
205
+ };
206
+ const orgsCreateIssueType: Semantics = async (ctx) => {
207
+ const { seg, body, occurredAt, state } = gh(ctx);
208
+ const org = decodeURIComponent(seg[1]!);
209
+ // name and is_enabled are required (docs.github.com/rest/orgs/issue-types#create-issue-type-for-an-organization)
210
+ if (!body.name) return invalid(ctx, 'IssueType', 'name', 'missing_field');
211
+ if (typeof body.is_enabled !== 'boolean') return invalid(ctx, 'IssueType', 'is_enabled', 'missing_field');
212
+ if (state().issueTypes.some((t) => t.org === org && t.name === String(body.name))) return invalid(ctx, 'IssueType', 'name', 'already_exists');
213
+ const seq = nextSeq(ctx, 'issuetype');
214
+ const fields = { org, name: String(body.name), description: body.description ?? null, color: body.color ?? null, is_enabled: body.is_enabled, created_at: occurredAt, updated_at: occurredAt };
215
+ await put(ctx, { operation: 'issue_type.create', subjectType: 'issue_type', subjectId: `issuetype:${seq}`, fields });
216
+ return ctx.reply(toGithubIssueType(state().issueTypes.find((x) => x.org === org && x.name === String(body.name))!));
217
+ };
218
+ const orgsDeleteIssueType: Semantics = async (ctx) => {
219
+ const { seg, state } = gh(ctx);
220
+ const org = decodeURIComponent(seg[1]!); const id = Number(decodeURIComponent(seg[3]!));
221
+ if (!state().issueTypes.find((t) => t.org === org && t.id === id)) return send(ctx, githubNotFound());
222
+ await put(ctx, { operation: 'issue_type.delete', subjectType: 'issue_type', subjectId: `issuetype:${id}`, fields: { deleted: true, org } });
223
+ return send(ctx, { status: 204, body: undefined });
224
+ };
225
+ const reposListForOrg: Semantics = async (ctx) => {
226
+ const { seg, query, state } = gh(ctx);
227
+ const org = decodeURIComponent(seg[1]!);
228
+ const params = new URLSearchParams((query ?? '').replace(/^\?/, ''));
229
+ let items = state().repos.filter((r) => r.owner === org);
230
+ const type = params.get('type');
231
+ if (type === 'public') items = items.filter((r) => !(r.private ?? false));
232
+ else if (type === 'private') items = items.filter((r) => r.private ?? false);
233
+ else if (type === 'forks') items = items.filter((r) => r.fork ?? false);
234
+ else if (type === 'sources') items = items.filter((r) => !(r.fork ?? false));
235
+ const sort = params.get('sort');
236
+ if (sort === 'full_name') items = [...items].sort((a, b) => (a.full_name < b.full_name ? -1 : 1));
237
+ else if (sort === 'created') items = [...items].sort((a, b) => ((a.created_at ?? '') < (b.created_at ?? '') ? 1 : -1));
238
+ else if (sort === 'updated') items = [...items].sort((a, b) => ((a.updated_at ?? '') < (b.updated_at ?? '') ? 1 : -1));
239
+ return ctx.reply(paginate(items, query).map(toGithubRepo));
240
+ };
241
+ const teamsList: Semantics = async (ctx) => {
242
+ const { seg, query, state } = gh(ctx);
243
+ const org = decodeURIComponent(seg[1]!);
244
+ return ctx.reply(paginate(state().teams.filter((t) => t.org === org), query).map((t) => asView('team', toGithubTeam(t))));
245
+ };
246
+ const teamsUpdateInOrg: Semantics = (ctx) => (underTeam(async (ctx, g, org, slug, existing) => {
247
+ const fields: Record<string, unknown> = { org, slug, name: existing.name, description: existing.description, privacy: existing.privacy, permission: existing.permission, created_at: existing.created_at, updated_at: g.occurredAt };
248
+ for (const k of ['name', 'description', 'privacy', 'permission'] as const) if (g.body[k] !== undefined) fields[k] = g.body[k];
249
+ await put(ctx, { operation: 'team.update', subjectType: 'team', subjectId: `team:${existing.id}`, fields });
250
+ return ctx.reply(toGithubTeam(g.state().teams.find((x) => x.org === org && x.slug === slug)!, g.state()));
251
+ }))(ctx);
252
+ const teamsDeleteInOrg: Semantics = (ctx) => (underTeam(async (ctx, _g, org, _slug, existing) => {
253
+ await put(ctx, { operation: 'team.delete', subjectType: 'team', subjectId: `team:${existing.id}`, fields: { deleted: true, org } });
254
+ return send(ctx, { status: 204, body: undefined });
255
+ }))(ctx);
256
+ const teamsListMembersInOrg: Semantics = (ctx) => (underTeam(async (ctx, g, org, slug) => {
257
+ const role = new URLSearchParams((g.query ?? '').replace(/^\?/, '')).get('role') ?? 'all';
258
+ if (!['all', 'member', 'maintainer'].includes(role)) return invalid(ctx, 'Team', 'role', 'invalid', 'https://docs.github.com/rest/teams/members#list-team-members');
259
+ const members = g.state().teamMembers.filter((m) => m.org === org && m.team_slug === slug && (m.state ?? 'active') === 'active' && (role === 'all' || m.role === role));
260
+ return ctx.reply(paginate(members, g.query).map((m) => asView('team-member', toGithubUser(seedUser(m.login)))));
261
+ }))(ctx);
262
+ const teamsRemoveMembershipForUserInOrg: Semantics = (ctx) => (underTeam(async (ctx, g, org, slug) => {
263
+ const login = decodeURIComponent(g.seg[5]!);
264
+ if (!g.state().teamMembers.some((m) => m.org === org && m.team_slug === slug && m.login === login)) return send(ctx, githubNotFound());
265
+ await put(ctx, { operation: 'team_membership.remove', subjectType: 'team_membership', subjectId: `teammember:${org}#${slug}#${login}`, fields: { deleted: true, org, team_slug: slug, login } });
266
+ return send(ctx, { status: 204, body: undefined });
267
+ }))(ctx);
268
+ const teamsListReposInOrg: Semantics = (ctx) => (underTeam(async (ctx, g, org, slug) => {
269
+ const st = g.state();
270
+ const items = st.teamRepos.filter((x) => x.org === org && x.team_slug === slug).map((t) => st.repos.find((r) => r.full_name === t.repository)).filter((r): r is GithubRepo => !!r);
271
+ return ctx.reply(paginate(items, g.query).map(toGithubRepo));
272
+ }))(ctx);
273
+ const teamsCheckPermissionsForRepoInOrg: Semantics = (ctx) => (underTeam(async (ctx, g, org, slug) => {
274
+ const repo = `${decodeURIComponent(g.seg[5]!)}/${decodeURIComponent(g.seg[6]!)}`;
275
+ return send(ctx, g.state().teamRepos.find((x) => x.org === org && x.team_slug === slug && x.repository === repo) ? { status: 204, body: undefined } : githubNotFound());
276
+ }))(ctx);
277
+ const teamsRemoveRepoInOrg: Semantics = (ctx) => (underTeam(async (ctx, g, org, slug) => {
278
+ const repo = `${decodeURIComponent(g.seg[5]!)}/${decodeURIComponent(g.seg[6]!)}`;
279
+ if (!g.state().teamRepos.some((x) => x.org === org && x.team_slug === slug && x.repository === repo)) return send(ctx, githubNotFound());
280
+ await put(ctx, { operation: 'team_repo.remove', subjectType: 'team_repo', subjectId: `teamrepo:${org}#${slug}#${repo}`, fields: { deleted: true, org, team_slug: slug, repository: repo } });
281
+ return send(ctx, { status: 204, body: undefined });
282
+ }))(ctx);
283
+ return {
284
+ 'orgs/get': orgsGet,
285
+ 'orgs/update': orgsUpdate,
286
+ // `role` filters as GitHub does: all (the default), admin or member; any other value is a validation error.
287
+ 'orgs/list-members': orgsListMembers,
288
+ // 204 member / 404 not
289
+ 'orgs/check-membership-for-user': orgsCheckMembershipForUser,
290
+ 'orgs/remove-member': removeOrgMember,
291
+ 'orgs/get-membership-for-user': orgsGetMembershipForUser,
292
+ // Invite or set a role (docs.github.com/rest/orgs/members#set-organization-membership-for-a-user): someone not
293
+ // yet a member is invited, pending until they accept (PATCH /user/memberships/orgs/{org}); a member's role
294
+ // changes in place.
295
+ 'orgs/set-membership-for-user': async (ctx) => {
296
+ const { seg, body, occurredAt, state } = gh(ctx);
297
+ const org = decodeURIComponent(seg[1]!); const login = decodeURIComponent(seg[3]!);
298
+ if (body.role !== undefined && body.role !== 'admin' && body.role !== 'member') return invalid(ctx, 'Membership', 'role', 'invalid', 'https://docs.github.com/rest/orgs/members#set-organization-membership-for-a-user');
299
+ const role = body.role === 'admin' ? 'admin' : 'member';
300
+ const prior = state().orgMembers.find((x) => x.org === org && x.login === login);
301
+ await put(ctx, { operation: 'org_membership.upsert', subjectType: 'org_membership', subjectId: `orgmember:${org}#${login}`, fields: { org, login, role, state: prior?.state ?? 'pending', created_at: prior?.created_at ?? occurredAt } });
302
+ return ctx.reply(toGithubOrgMembership(state().orgMembers.find((x) => x.org === org && x.login === login)!));
303
+ },
304
+ // the invitations waiting on their invitee (docs.github.com/rest/orgs/members#list-pending-organization-invitations)
305
+ 'orgs/list-pending-invitations': orgsListPendingInvitations,
306
+ // the signed-in person's own memberships, and accepting an invitation: state 'active' is the only one accepted
307
+ // (docs.github.com/rest/orgs/members#update-an-organization-membership-for-the-authenticated-user)
308
+ 'orgs/list-memberships-for-authenticated-user': orgsListMembershipsForAuthenticatedUser,
309
+ 'orgs/get-membership-for-authenticated-user': orgsGetMembershipForAuthenticatedUser,
310
+ 'orgs/update-membership-for-authenticated-user': async (ctx) => {
311
+ const { caller, seg, body, state } = gh(ctx);
312
+ const org = decodeURIComponent(seg[3]!); const me = caller ?? GITHUB_WORLD_USER;
313
+ const m = state().orgMembers.find((x) => x.org === org && x.login === me);
314
+ if (!m) return send(ctx, githubNotFound());
315
+ if (body.state !== 'active') return invalid(ctx, 'Membership', 'state', 'invalid', 'https://docs.github.com/rest/orgs/members#update-an-organization-membership-for-the-authenticated-user');
316
+ await put(ctx, { operation: 'org_membership.upsert', subjectType: 'org_membership', subjectId: `orgmember:${org}#${me}`, fields: { ...m, state: 'active' } });
317
+ // the teams they were invited to take them in with the organization
318
+ for (const t of state().teamMembers.filter((x) => x.org === org && x.login === me && x.state === 'pending')) {
319
+ await put(ctx, { operation: 'team_membership.upsert', subjectType: 'team_membership', subjectId: `teammember:${org}#${t.team_slug}#${me}`, fields: { ...t, state: 'active' } });
320
+ }
321
+ return ctx.reply(toGithubOrgMembership(state().orgMembers.find((x) => x.org === org && x.login === me)!));
322
+ },
323
+ 'orgs/remove-membership-for-user': removeOrgMember,
324
+ 'orgs/list-outside-collaborators': orgsListOutsideCollaborators,
325
+ 'orgs/convert-member-to-outside-collaborator': orgsConvertMemberToOutsideCollaborator,
326
+ // removing an outside collaborator takes away their access to every repository of the organization
327
+ 'orgs/remove-outside-collaborator': orgsRemoveOutsideCollaborator,
328
+ 'orgs/list-webhooks': orgsListWebhooks,
329
+ 'orgs/get-webhook': orgsGetWebhook,
330
+ 'orgs/create-webhook': orgsCreateWebhook,
331
+ 'orgs/update-webhook': orgsUpdateWebhook,
332
+ 'orgs/delete-webhook': orgsDeleteWebhook,
333
+ 'orgs/list-issue-types': orgsListIssueTypes,
334
+ 'orgs/create-issue-type': orgsCreateIssueType,
335
+ 'orgs/update-issue-type': updateIssueType,
336
+ 'orgs/delete-issue-type': orgsDeleteIssueType,
337
+ // repositories an org owns; ?type=(all|public|private|forks|sources) and ?sort=(created|updated|full_name)
338
+ 'repos/list-for-org': reposListForOrg,
339
+ // ── teams ──
340
+ 'teams/list': teamsList,
341
+ 'teams/get-by-name': underTeam(async (ctx, g, _org, _slug, team) => ctx.reply(toGithubTeam(team, g.state()))),
342
+ 'teams/create': async (ctx) => {
343
+ const { seg, body, occurredAt, state } = gh(ctx);
344
+ const org = decodeURIComponent(seg[1]!);
345
+ if (!body.name) return invalid(ctx, 'Team', 'name', 'missing_field');
346
+ const slug = slugifyTeamName(String(body.name));
347
+ if (state().teams.some((t) => t.org === org && t.slug === slug)) return invalid(ctx, 'Team', 'name', 'already_exists');
348
+ const seq = nextSeq(ctx, 'team');
349
+ const fields = { org, slug, name: String(body.name), description: body.description ?? null, privacy: body.privacy ?? 'secret', permission: body.permission ?? 'pull', created_at: occurredAt, updated_at: occurredAt };
350
+ await put(ctx, { operation: 'team.create', subjectType: 'team', subjectId: `team:${seq}`, fields });
351
+ return ctx.reply(toGithubTeam(state().teams.find((x) => x.org === org && x.slug === slug)!, state()), 201);
352
+ },
353
+ 'teams/update-in-org': teamsUpdateInOrg,
354
+ 'teams/delete-in-org': teamsDeleteInOrg,
355
+ // `role` filters as GitHub does: all (the default), member or maintainer; only active memberships are listed.
356
+ 'teams/list-members-in-org': teamsListMembersInOrg,
357
+ 'teams/get-membership-for-user-in-org': underTeam(async (ctx, g, org, slug) => {
358
+ const m = g.state().teamMembers.find((x) => x.org === org && x.team_slug === slug && x.login === decodeURIComponent(g.seg[5]!));
359
+ return send(ctx, m ? { status: 200, body: toGithubTeamMembership(m, g.state()) } : githubNotFound());
360
+ }),
361
+ 'teams/add-or-update-membership-for-user-in-org': underTeam(async (ctx, g, org, slug) => {
362
+ const login = decodeURIComponent(g.seg[5]!);
363
+ const role = g.body.role === 'maintainer' ? 'maintainer' : 'member';
364
+ // someone not yet in the organization is invited to it, and their team membership is pending until they accept
365
+ // (docs.github.com/rest/teams/members#add-or-update-team-membership-for-a-user)
366
+ const membership = g.state().orgMembers.find((m) => m.org === org && m.login === login);
367
+ if (!membership) await put(ctx, { operation: 'org_membership.upsert', subjectType: 'org_membership', subjectId: `orgmember:${org}#${login}`, fields: { org, login, role: 'member', state: 'pending', created_at: g.occurredAt } });
368
+ const state = membership?.state === 'active' ? 'active' : 'pending';
369
+ await put(ctx, { operation: 'team_membership.upsert', subjectType: 'team_membership', subjectId: `teammember:${org}#${slug}#${login}`, fields: { org, team_slug: slug, login, role, state } });
370
+ return ctx.reply(toGithubTeamMembership(g.state().teamMembers.find((x) => x.org === org && x.team_slug === slug && x.login === login)!, g.state()));
371
+ }),
372
+ 'teams/remove-membership-for-user-in-org': teamsRemoveMembershipForUserInOrg,
373
+ 'teams/list-repos-in-org': teamsListReposInOrg,
374
+ // 204 when the team holds a grant on the repo, else 404
375
+ 'teams/check-permissions-for-repo-in-org': teamsCheckPermissionsForRepoInOrg,
376
+ 'teams/add-or-update-repo-permissions-in-org': underTeam(async (ctx, g, org, slug) => {
377
+ const repo = `${decodeURIComponent(g.seg[5]!)}/${decodeURIComponent(g.seg[6]!)}`;
378
+ await put(ctx, { operation: 'team_repo.grant', subjectType: 'team_repo', subjectId: `teamrepo:${org}#${slug}#${repo}`, fields: { org, team_slug: slug, repository: repo, permission: g.body.permission ?? 'pull' } });
379
+ return send(ctx, { status: 204, body: undefined });
380
+ }),
381
+ 'teams/remove-repo-in-org': teamsRemoveRepoInOrg,
382
+ };
383
+ }