@yolk-sdk/emulators 0.1.0-canary.96

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 (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
@@ -0,0 +1,986 @@
1
+ /**
2
+ * GitHub emulator API: the route table (evidence, raw parameter patterns, request-shape checks, and
3
+ * stateful handlers), the fixture error bodies, object rendering, and `Link` paging (internal;
4
+ * re-exported by `src/github.ts`).
5
+ *
6
+ * Only the REST routes the seven GitHub conformance cases send are emulated, on
7
+ * `https://api.github.com`, with the wire shapes of their synthetic fixtures: the label listing
8
+ * (with the paging fixture's `Link` header), issue read, create, rename, and close, the issue
9
+ * search the validation fixture refuses, file contents (folded base64), comment create, listing
10
+ * `since`, and delete, and issue label add and remove. Every route plans first (reading the state,
11
+ * writing nothing) and commits only an eligible request: anything no fixture shows is not emulated
12
+ * (400, nothing written, no fault used up).
13
+ *
14
+ * @experimental
15
+ */
16
+ import { Predicate } from 'effect'
17
+ import type * as Schema from 'effect/Schema'
18
+ import {
19
+ exactBodyKeys,
20
+ exactQuery,
21
+ isJsonObject,
22
+ isNotEmulated,
23
+ notEmulated,
24
+ statefulRoute,
25
+ type Commit,
26
+ type EmulatedRequest,
27
+ type NotEmulated,
28
+ type StatefulRoute
29
+ } from '../stateful-emulator.ts'
30
+ import {
31
+ githubFilePathSource,
32
+ githubLabelNamePattern,
33
+ githubMaxCommentId,
34
+ githubMaxIssueNumber,
35
+ githubOwnerPattern,
36
+ githubRepoPattern,
37
+ githubTimestampPattern,
38
+ mintedCommentNodeId,
39
+ mintedIssueIdBase,
40
+ mintedIssueNodeId,
41
+ type GithubEmulatorComment,
42
+ type GithubEmulatorIssue,
43
+ type GithubEmulatorLabel,
44
+ type GithubEmulatorState,
45
+ type GithubEmulatorUser
46
+ } from './state.ts'
47
+
48
+ /** The origin every GitHub fixture records; another origin is not emulated. */
49
+ export const githubEmulatorOrigin = 'https://api.github.com'
50
+
51
+ /** The only `X-GitHub-Api-Version` the emulator answers (every fixture sends it). */
52
+ export const githubEmulatorApiVersion = '2026-03-10'
53
+
54
+ /** The only `Accept` the emulator answers (every fixture sends it). */
55
+ export const githubEmulatorAccept = 'application/vnd.github+json'
56
+
57
+ /** Content type of every GitHub JSON response, as the fixtures record it. */
58
+ const githubContentType = 'application/json; charset=utf-8'
59
+
60
+ /** The origin of the raw file URLs the contents fixture records (`download_url`). */
61
+ const rawOrigin = 'https://raw.githubusercontent.com'
62
+
63
+ /** Drill knobs (tests only): each makes the emulator disagree with one conformance claim. */
64
+ export type GithubEmulatorDrills = {
65
+ /** The first label page's `Link` lists no `rel="next"` while labels remain. */
66
+ readonly linkOmitsNext?: boolean
67
+ /** The not-found body of an unreached issue number lacks `documentation_url`. */
68
+ readonly notFoundOmitsDocumentationUrl?: boolean
69
+ /** The overlong search's validation body carries an empty `errors` array. */
70
+ readonly validationWithoutErrors?: boolean
71
+ /** File contents answer base64 on one line (not folded). */
72
+ readonly contentUnfolded?: boolean
73
+ /** A comment listing `since` excludes a comment updated at exactly that instant. */
74
+ readonly sinceExcludesEqual?: boolean
75
+ /** The label add answers the issue labels without the added label. */
76
+ readonly addAnswerOmitsLabel?: boolean
77
+ /** Closing an issue answers `closed_at: null`. */
78
+ readonly closeWithoutClosedAt?: boolean
79
+ }
80
+
81
+ export const githubEmulatorDrillKnobs: ReadonlyArray<keyof GithubEmulatorDrills> = [
82
+ 'linkOmitsNext',
83
+ 'notFoundOmitsDocumentationUrl',
84
+ 'validationWithoutErrors',
85
+ 'contentUnfolded',
86
+ 'sinceExcludesEqual',
87
+ 'addAnswerOmitsLabel',
88
+ 'closeWithoutClosedAt'
89
+ ]
90
+
91
+ export type GithubApiEnv = {
92
+ /** Clock in epoch milliseconds (created issue and comment timestamps, close times). */
93
+ readonly now: () => number
94
+ readonly drills: Readonly<Record<keyof GithubEmulatorDrills, boolean>>
95
+ }
96
+
97
+ /**
98
+ * The fixtures' error bodies, byte for byte (`JSON.stringify` keeps this key order): the issue
99
+ * and comment 404s, the label-not-on-issue 404, and the overlong search's 422.
100
+ */
101
+ export const githubEmulatorErrorBodies = {
102
+ issueNotFound: {
103
+ message: 'Not Found',
104
+ documentation_url: 'https://docs.github.com/rest/issues/issues#get-an-issue',
105
+ status: '404'
106
+ },
107
+ commentNotFound: {
108
+ message: 'Not Found',
109
+ documentation_url: 'https://docs.github.com/rest/issues/comments#delete-an-issue-comment',
110
+ status: '404'
111
+ },
112
+ labelNotOnIssue: {
113
+ message: 'Label does not exist',
114
+ documentation_url: 'https://docs.github.com/rest/issues/labels#remove-a-label-from-an-issue',
115
+ status: '404'
116
+ },
117
+ searchTooLong: {
118
+ message: 'Validation Failed',
119
+ errors: [
120
+ {
121
+ message: 'The search is longer than 256 characters.',
122
+ resource: 'Search',
123
+ field: 'q',
124
+ code: 'invalid'
125
+ }
126
+ ],
127
+ documentation_url: 'https://docs.github.com/v3/search/',
128
+ status: '422'
129
+ }
130
+ } as const satisfies Readonly<Record<string, Schema.Json>>
131
+
132
+ type Route = StatefulRoute<GithubEmulatorState, GithubApiEnv>
133
+
134
+ const pagingCase = 'github.labels.list-link-paging'
135
+
136
+ const notFoundCase = 'github.errors.not-found-envelope'
137
+
138
+ const validationCase = 'github.errors.validation-envelope'
139
+
140
+ const contentsCase = 'github.contents.base64-file'
141
+
142
+ const commentCase = 'github.comments.create-delete'
143
+
144
+ const labelCase = 'github.labels.add-remove'
145
+
146
+ const lifecycleCase = 'github.issues.lifecycle-close'
147
+
148
+ // Raw (still percent-encoded) path parameter patterns: a request whose raw path is not exactly one
149
+ // of these shapes is unrecognised (ledgered with constant text only).
150
+
151
+ const repoParams = { owner: githubOwnerPattern, repo: githubRepoPattern }
152
+
153
+ const issueNumberPattern = /^[1-9][0-9]{0,9}$/
154
+
155
+ /** Fifteen digits at most: every id up to `githubMaxCommentId`. */
156
+ const commentIdPattern = new RegExp(`^[1-9][0-9]{0,${String(githubMaxCommentId).length - 1}}$`)
157
+
158
+ /** A plain file path of at most 200 characters (the seed's path pattern and length bound). */
159
+ const filePathParam = new RegExp(`^(?=.{1,200}$)${githubFilePathSource}$`)
160
+
161
+ const evidence = (
162
+ method: string,
163
+ path: string,
164
+ write: boolean,
165
+ caseIds: ReadonlyArray<string>,
166
+ params: Readonly<Record<string, RegExp>> = {}
167
+ ) => ({
168
+ method,
169
+ path,
170
+ kind: 'connector' as const,
171
+ write,
172
+ caseIds,
173
+ evidence: 'unverified' as const,
174
+ origin: githubEmulatorOrigin,
175
+ params
176
+ })
177
+
178
+ const repoEvidence = (
179
+ method: string,
180
+ path: string,
181
+ write: boolean,
182
+ caseIds: ReadonlyArray<string>,
183
+ params: Readonly<Record<string, RegExp>> = {}
184
+ ) => evidence(method, `/repos/{owner}/{repo}${path}`, write, caseIds, { ...repoParams, ...params })
185
+
186
+ // Responses.
187
+
188
+ const json = (status: number, body: Schema.Json, headers: Record<string, string> = {}): Response =>
189
+ new Response(JSON.stringify(body), {
190
+ status,
191
+ headers: { 'content-type': githubContentType, ...headers }
192
+ })
193
+
194
+ /** A read-only commit. */
195
+ const answer =
196
+ (response: () => Response): Commit =>
197
+ () =>
198
+ response()
199
+
200
+ /** The current instant as GitHub writes it (whole seconds); throws for an unusable clock. */
201
+ const timestamp = (env: GithubApiEnv): string => {
202
+ const text = new Date(env.now()).toISOString().replace(/\.\d{3}Z$/, 'Z')
203
+
204
+ if (!githubTimestampPattern.test(text)) throw new Error('the clock is out of range')
205
+
206
+ return text
207
+ }
208
+
209
+ // Requests.
210
+
211
+ type RepoInput = { readonly owner: string; readonly repo: string }
212
+
213
+ const repoOf = (request: EmulatedRequest): RepoInput => ({
214
+ owner: request.params.owner ?? '',
215
+ repo: request.params.repo ?? ''
216
+ })
217
+
218
+ /** Not emulated unless the request names the seeded repository (exactly, as stored). */
219
+ const otherRepository = (state: GithubEmulatorState, input: RepoInput): NotEmulated | undefined =>
220
+ state.repository.owner === input.owner && state.repository.repo === input.repo
221
+ ? undefined
222
+ : notEmulated('a repository other than the seeded one is not emulated')
223
+
224
+ const withoutQuery = (request: EmulatedRequest): NotEmulated | undefined => {
225
+ const query = exactQuery(request, [])
226
+
227
+ return isNotEmulated(query) ? query : undefined
228
+ }
229
+
230
+ const issueNumberOf = (request: EmulatedRequest): number => Number(request.params.issueNumber)
231
+
232
+ const nonEmptyString = (value: Schema.Json | undefined, label: string): string | NotEmulated =>
233
+ Predicate.isString(value) && value.length > 0
234
+ ? value
235
+ : notEmulated(`${label} must be a non-empty string`)
236
+
237
+ // Rendering, in the fixture key order.
238
+
239
+ const repoApi = (state: GithubEmulatorState): string =>
240
+ `${githubEmulatorOrigin}/repos/${state.repository.owner}/${state.repository.repo}`
241
+
242
+ const repoHtml = (state: GithubEmulatorState): string =>
243
+ `https://github.com/${state.repository.owner}/${state.repository.repo}`
244
+
245
+ const renderUser = (user: GithubEmulatorUser): Schema.JsonObject => ({
246
+ login: user.login,
247
+ id: user.id,
248
+ type: user.type,
249
+ site_admin: user.siteAdmin
250
+ })
251
+
252
+ const renderLabel = (
253
+ state: GithubEmulatorState,
254
+ label: GithubEmulatorLabel
255
+ ): Schema.JsonObject => ({
256
+ id: label.id,
257
+ node_id: label.nodeId,
258
+ url: `${repoApi(state)}/labels/${encodeURIComponent(label.name)}`,
259
+ name: label.name,
260
+ color: label.color,
261
+ default: label.default,
262
+ description: label.description
263
+ })
264
+
265
+ const labelNamed = (state: GithubEmulatorState, name: string): GithubEmulatorLabel | undefined =>
266
+ state.labels.find(label => label.name === name)
267
+
268
+ /** Labels by name, rendered (names the seed validated, or the add route checked). */
269
+ const renderLabels = (
270
+ state: GithubEmulatorState,
271
+ names: ReadonlyArray<string>
272
+ ): ReadonlyArray<Schema.JsonObject> =>
273
+ names.flatMap(name => {
274
+ const label = labelNamed(state, name)
275
+
276
+ return label === undefined ? [] : [renderLabel(state, label)]
277
+ })
278
+
279
+ const renderIssue = (
280
+ state: GithubEmulatorState,
281
+ issue: GithubEmulatorIssue
282
+ ): Schema.JsonObject => ({
283
+ url: `${repoApi(state)}/issues/${issue.number}`,
284
+ repository_url: repoApi(state),
285
+ html_url: `${repoHtml(state)}/issues/${issue.number}`,
286
+ id: issue.id,
287
+ node_id: issue.nodeId,
288
+ number: issue.number,
289
+ title: issue.title,
290
+ user: renderUser(issue.user),
291
+ labels: [...renderLabels(state, issue.labels)],
292
+ state: issue.state,
293
+ locked: false,
294
+ assignees: [],
295
+ milestone: null,
296
+ comments: 0,
297
+ created_at: issue.createdAt,
298
+ updated_at: issue.updatedAt,
299
+ closed_at: issue.closedAt,
300
+ author_association: issue.authorAssociation,
301
+ active_lock_reason: null,
302
+ body: issue.body,
303
+ state_reason: issue.stateReason
304
+ })
305
+
306
+ /** Every fixture's issue answers `comments: 0`: an issue holding comments is not rendered. */
307
+ const commentsRefusal = (
308
+ state: GithubEmulatorState,
309
+ issue: GithubEmulatorIssue
310
+ ): NotEmulated | undefined =>
311
+ state.comments.some(comment => comment.issueNumber === issue.number)
312
+ ? notEmulated(
313
+ 'an issue answer while the issue holds comments is not emulated (no fixture records one)'
314
+ )
315
+ : undefined
316
+
317
+ const renderComment = (
318
+ state: GithubEmulatorState,
319
+ comment: GithubEmulatorComment
320
+ ): Schema.JsonObject => ({
321
+ url: `${repoApi(state)}/issues/comments/${comment.id}`,
322
+ html_url: `${repoHtml(state)}/issues/${comment.issueNumber}#issuecomment-${comment.id}`,
323
+ issue_url: `${repoApi(state)}/issues/${comment.issueNumber}`,
324
+ id: comment.id,
325
+ node_id: mintedCommentNodeId(comment.id),
326
+ user: renderUser(comment.user),
327
+ created_at: comment.createdAt,
328
+ updated_at: comment.updatedAt,
329
+ author_association: comment.authorAssociation,
330
+ body: comment.body
331
+ })
332
+
333
+ /** Base64 of the UTF-8 text, folded every 60 characters with a trailing line break. */
334
+ const foldedBase64 = (text: string, folded: boolean): string => {
335
+ let binary = ''
336
+
337
+ for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte)
338
+
339
+ const encoded = btoa(binary)
340
+
341
+ return folded ? `${(encoded.match(/.{1,60}/g) ?? []).join('\n')}\n` : encoded
342
+ }
343
+
344
+ const issueHeld = (state: GithubEmulatorState, number: number): GithubEmulatorIssue | undefined =>
345
+ state.issues.find(issue => issue.number === number)
346
+
347
+ const missingIssue = (): NotEmulated =>
348
+ notEmulated('an issue the state does not hold is not emulated on this route')
349
+
350
+ // Routes.
351
+
352
+ type LabelsInput = RepoInput & { readonly perPage: number; readonly page: number }
353
+
354
+ const listLabels: Route = statefulRoute(
355
+ repoEvidence('GET', '/labels', false, [pagingCase, labelCase]),
356
+ 'none',
357
+ (request): LabelsInput | NotEmulated => {
358
+ const query = exactQuery(request, ['per_page'], ['page'])
359
+
360
+ if (isNotEmulated(query)) return query
361
+
362
+ const perPage = query.per_page ?? ''
363
+
364
+ if (!/^[1-9][0-9]{0,2}$/.test(perPage) || Number(perPage) > 100) {
365
+ return notEmulated('per_page must be an integer from 1 to 100')
366
+ }
367
+
368
+ const page = query.page
369
+
370
+ // The fixtures ask for the first page without `page`.
371
+ if (page !== undefined && (!/^[1-9][0-9]{0,5}$/.test(page) || Number(page) < 2)) {
372
+ return notEmulated('page must be an integer of 2 or more (the first page omits it)')
373
+ }
374
+
375
+ return {
376
+ ...repoOf(request),
377
+ perPage: Number(perPage),
378
+ page: page === undefined ? 1 : Number(page)
379
+ }
380
+ },
381
+ (state, input, { env }) => {
382
+ const other = otherRepository(state, input)
383
+
384
+ if (other !== undefined) return other
385
+
386
+ const count = state.labels.length
387
+
388
+ if (count === 0) {
389
+ return notEmulated('a label listing without labels is not emulated (no fixture records one)')
390
+ }
391
+
392
+ const last = Math.ceil(count / input.perPage)
393
+
394
+ // The paging fixture records the page after the last (empty), never one further.
395
+ if (input.page > last + 1) {
396
+ return notEmulated('a label page beyond the one after the last is not emulated')
397
+ }
398
+
399
+ return answer(() => {
400
+ const target = (page: number) =>
401
+ `<${githubEmulatorOrigin}/repositories/${state.repository.id}/labels` +
402
+ `?per_page=${input.perPage}&page=${page}>`
403
+
404
+ // The paging fixture's relations and order: prev, next, last, first.
405
+ const relations = [
406
+ input.page > 1 ? `${target(input.page - 1)}; rel="prev"` : undefined,
407
+ input.page < last && !(env.drills.linkOmitsNext && input.page === 1)
408
+ ? `${target(input.page + 1)}; rel="next"`
409
+ : undefined,
410
+ input.page === last ? undefined : `${target(last)}; rel="last"`,
411
+ input.page > 1 ? `${target(1)}; rel="first"` : undefined
412
+ ].filter(Predicate.isString)
413
+
414
+ const start = (input.page - 1) * input.perPage
415
+
416
+ return json(
417
+ 200,
418
+ state.labels.slice(start, start + input.perPage).map(label => renderLabel(state, label)),
419
+ // A listing that fits one page carries no Link, as the label fixture records.
420
+ relations.length === 0 ? {} : { link: relations.join(', ') }
421
+ )
422
+ })
423
+ }
424
+ )
425
+
426
+ type IssueInput = RepoInput & { readonly number: number }
427
+
428
+ const issueInput = (request: EmulatedRequest): IssueInput | NotEmulated =>
429
+ withoutQuery(request) ?? { ...repoOf(request), number: issueNumberOf(request) }
430
+
431
+ const getIssue: Route = statefulRoute(
432
+ repoEvidence('GET', '/issues/{issueNumber}', false, [notFoundCase, labelCase, lifecycleCase], {
433
+ issueNumber: issueNumberPattern
434
+ }),
435
+ 'none',
436
+ issueInput,
437
+ (state, input, { env }) => {
438
+ const other = otherRepository(state, input)
439
+
440
+ if (other !== undefined) return other
441
+
442
+ const issue = issueHeld(state, input.number)
443
+
444
+ if (issue !== undefined) {
445
+ return commentsRefusal(state, issue) ?? answer(() => json(200, renderIssue(state, issue)))
446
+ }
447
+
448
+ // A number the repository has not reached answers the not-found fixture's 404.
449
+ if (input.number >= state.counters.nextIssueNumber) {
450
+ return answer(() => {
451
+ const { documentation_url: _url, ...withoutUrl } = githubEmulatorErrorBodies.issueNotFound
452
+
453
+ return json(
454
+ 404,
455
+ env.drills.notFoundOmitsDocumentationUrl
456
+ ? withoutUrl
457
+ : githubEmulatorErrorBodies.issueNotFound
458
+ )
459
+ })
460
+ }
461
+
462
+ return notEmulated('an issue below nextIssueNumber that the state does not hold is implied')
463
+ }
464
+ )
465
+
466
+ /** The connector's repository qualifier, then the case's query. */
467
+ const scopedQueryPattern = /^repo:([^/\s]+)\/([^/\s]+) ([\s\S]*)$/
468
+
469
+ /** GitHub's documented search length limit, which the validation fixture refuses past. */
470
+ const searchLimit = 256
471
+
472
+ const searchIssues: Route = statefulRoute(
473
+ evidence('GET', '/search/issues', false, [validationCase]),
474
+ 'none',
475
+ (request): RepoInput | NotEmulated => {
476
+ const query = exactQuery(request, ['q'])
477
+
478
+ if (isNotEmulated(query)) return query
479
+
480
+ const scoped = scopedQueryPattern.exec(query.q ?? '')
481
+
482
+ if (scoped === null) {
483
+ return notEmulated(
484
+ 'a search without the leading repo:<owner>/<repo> qualifier is not emulated'
485
+ )
486
+ }
487
+
488
+ // Only a refusal is recorded: a query after the qualifier of at most 256 characters would
489
+ // answer results, which no fixture shows.
490
+ return (scoped[3] ?? '').length > searchLimit
491
+ ? { owner: scoped[1] ?? '', repo: scoped[2] ?? '' }
492
+ : notEmulated(
493
+ 'a search of at most 256 characters is not emulated (no fixture records results)'
494
+ )
495
+ },
496
+ (state, input, { env }) =>
497
+ otherRepository(state, input) ??
498
+ answer(() =>
499
+ json(
500
+ 422,
501
+ env.drills.validationWithoutErrors
502
+ ? { ...githubEmulatorErrorBodies.searchTooLong, errors: [] }
503
+ : githubEmulatorErrorBodies.searchTooLong
504
+ )
505
+ )
506
+ )
507
+
508
+ type FileInput = RepoInput & { readonly path: string }
509
+
510
+ const getContents: Route = statefulRoute(
511
+ repoEvidence('GET', '/contents/{path+}', false, [contentsCase], { path: filePathParam }),
512
+ 'none',
513
+ (request): FileInput | NotEmulated =>
514
+ withoutQuery(request) ?? { ...repoOf(request), path: request.params.path ?? '' },
515
+ (state, input, { env }) => {
516
+ const other = otherRepository(state, input)
517
+
518
+ if (other !== undefined) return other
519
+
520
+ const file = state.files.find(candidate => candidate.path === input.path)
521
+
522
+ if (file === undefined) {
523
+ return notEmulated('a path naming no seeded file is not emulated (no fixture records one)')
524
+ }
525
+
526
+ return answer(() => {
527
+ const { owner, repo, defaultBranch } = state.repository
528
+ const name = file.path.split('/').at(-1) ?? file.path
529
+
530
+ return json(200, {
531
+ name,
532
+ path: file.path,
533
+ sha: file.sha,
534
+ size: new TextEncoder().encode(file.text).byteLength,
535
+ url: `${repoApi(state)}/contents/${file.path}?ref=${defaultBranch}`,
536
+ html_url: `${repoHtml(state)}/blob/${defaultBranch}/${file.path}`,
537
+ git_url: `${repoApi(state)}/git/blobs/${file.sha}`,
538
+ download_url: `${rawOrigin}/${owner}/${repo}/${defaultBranch}/${file.path}`,
539
+ type: 'file',
540
+ content: foldedBase64(file.text, !env.drills.contentUnfolded),
541
+ encoding: 'base64'
542
+ })
543
+ })
544
+ }
545
+ )
546
+
547
+ type CommentInput = IssueInput & { readonly body: string }
548
+
549
+ const createComment: Route = statefulRoute(
550
+ repoEvidence('POST', '/issues/{issueNumber}/comments', true, [commentCase], {
551
+ issueNumber: issueNumberPattern
552
+ }),
553
+ 'json',
554
+ (request): CommentInput | NotEmulated => {
555
+ const issue = issueInput(request)
556
+
557
+ if (isNotEmulated(issue)) return issue
558
+
559
+ const body = exactBodyKeys(request.json, 'the comment body', ['body'])
560
+
561
+ if (isNotEmulated(body)) return body
562
+
563
+ const text = nonEmptyString(body.body, 'body')
564
+
565
+ return isNotEmulated(text) ? text : { ...issue, body: text }
566
+ },
567
+ (state, input, { env }) => {
568
+ const other = otherRepository(state, input)
569
+
570
+ if (other !== undefined) return other
571
+
572
+ const issue = issueHeld(state, input.number)
573
+
574
+ if (issue === undefined) return missingIssue()
575
+
576
+ if (issue.state !== 'open') {
577
+ return notEmulated('commenting on a closed issue is not emulated (no fixture records one)')
578
+ }
579
+
580
+ // Refused before any fault: a comment past the maximum could never be deleted again.
581
+ if (state.counters.nextCommentId > githubMaxCommentId) {
582
+ return notEmulated('the emulator has run out of comment ids')
583
+ }
584
+
585
+ return () => {
586
+ // Read the clock before any write: a failing clock writes nothing.
587
+ const at = timestamp(env)
588
+ const id = state.counters.nextCommentId
589
+
590
+ const comment: GithubEmulatorComment = {
591
+ id,
592
+ issueNumber: issue.number,
593
+ body: input.body,
594
+ user: state.viewer,
595
+ authorAssociation: 'OWNER',
596
+ createdAt: at,
597
+ updatedAt: at
598
+ }
599
+
600
+ state.counters = { ...state.counters, nextCommentId: id + 1 }
601
+ state.comments = [...state.comments, comment]
602
+
603
+ return json(201, renderComment(state, comment))
604
+ }
605
+ }
606
+ )
607
+
608
+ type CommentsInput = IssueInput & { readonly since: number }
609
+
610
+ const listComments: Route = statefulRoute(
611
+ repoEvidence('GET', '/issues/{issueNumber}/comments', false, [commentCase], {
612
+ issueNumber: issueNumberPattern
613
+ }),
614
+ 'none',
615
+ (request): CommentsInput | NotEmulated => {
616
+ const query = exactQuery(request, ['per_page', 'since'])
617
+
618
+ if (isNotEmulated(query)) return query
619
+
620
+ // The fixture records one page of 100, without `page`.
621
+ if (query.per_page !== '100') {
622
+ return notEmulated('a comment listing per_page other than 100 is not emulated')
623
+ }
624
+
625
+ const since = query.since ?? ''
626
+ const instant = githubTimestampPattern.test(since) ? Date.parse(since) : Number.NaN
627
+
628
+ if (!Number.isFinite(instant)) {
629
+ return notEmulated('since must be a timestamp of the form YYYY-MM-DDTHH:MM:SSZ')
630
+ }
631
+
632
+ return { ...repoOf(request), number: issueNumberOf(request), since: instant }
633
+ },
634
+ (state, input, { env }) => {
635
+ const other = otherRepository(state, input)
636
+
637
+ if (other !== undefined) return other
638
+
639
+ if (issueHeld(state, input.number) === undefined) return missingIssue()
640
+
641
+ const listed = state.comments.filter(comment => {
642
+ if (comment.issueNumber !== input.number) return false
643
+
644
+ const updated = Date.parse(comment.updatedAt)
645
+
646
+ return env.drills.sinceExcludesEqual ? updated > input.since : updated >= input.since
647
+ })
648
+
649
+ // The fixture records a listing of one comment and an empty one; the order of several is not
650
+ // recorded.
651
+ if (listed.length > 1) {
652
+ return notEmulated('a comment listing of more than one comment is not emulated')
653
+ }
654
+
655
+ return answer(() =>
656
+ json(
657
+ 200,
658
+ listed.map(comment => renderComment(state, comment))
659
+ )
660
+ )
661
+ }
662
+ )
663
+
664
+ type CommentIdInput = RepoInput & { readonly id: number }
665
+
666
+ const deleteComment: Route = statefulRoute(
667
+ repoEvidence('DELETE', '/issues/comments/{commentId}', true, [commentCase], {
668
+ commentId: commentIdPattern
669
+ }),
670
+ 'none',
671
+ (request): CommentIdInput | NotEmulated =>
672
+ withoutQuery(request) ?? { ...repoOf(request), id: Number(request.params.commentId) },
673
+ (state, input) => {
674
+ const other = otherRepository(state, input)
675
+
676
+ if (other !== undefined) return other
677
+
678
+ const comment = state.comments.find(candidate => candidate.id === input.id)
679
+
680
+ if (comment !== undefined) {
681
+ return () => {
682
+ state.comments = state.comments.filter(candidate => candidate.id !== input.id)
683
+ state.deletedComments = [...state.deletedComments, input.id]
684
+
685
+ // The fixture's bodiless 204.
686
+ return new Response(null, { status: 204 })
687
+ }
688
+ }
689
+
690
+ // A comment deleted here answers the fixture's second-delete 404.
691
+ return state.deletedComments.includes(input.id)
692
+ ? answer(() => json(404, githubEmulatorErrorBodies.commentNotFound))
693
+ : notEmulated('a comment this emulator never held is not emulated')
694
+ }
695
+ )
696
+
697
+ type AddLabelInput = IssueInput & { readonly label: string }
698
+
699
+ /** GitHub orders label names case-insensitively; the fixture's answer is in that order. */
700
+ const sortsAfter = (name: string, other: string): boolean =>
701
+ name.toLowerCase() > other.toLowerCase()
702
+
703
+ const addLabels: Route = statefulRoute(
704
+ repoEvidence('POST', '/issues/{issueNumber}/labels', true, [labelCase], {
705
+ issueNumber: issueNumberPattern
706
+ }),
707
+ 'json',
708
+ (request): AddLabelInput | NotEmulated => {
709
+ const issue = issueInput(request)
710
+
711
+ if (isNotEmulated(issue)) return issue
712
+
713
+ const body = exactBodyKeys(request.json, 'the labels body', ['labels'])
714
+
715
+ if (isNotEmulated(body)) return body
716
+
717
+ const labels = body.labels
718
+
719
+ if (!Array.isArray(labels) || labels.length !== 1) {
720
+ return notEmulated('adding other than exactly one label is not emulated')
721
+ }
722
+
723
+ const [label] = labels
724
+
725
+ return Predicate.isString(label) && githubLabelNamePattern.test(label)
726
+ ? { ...issue, label }
727
+ : notEmulated('the label must be a plain label name')
728
+ },
729
+ (state, input, { env }) => {
730
+ const other = otherRepository(state, input)
731
+
732
+ if (other !== undefined) return other
733
+
734
+ const issue = issueHeld(state, input.number)
735
+
736
+ if (issue === undefined) return missingIssue()
737
+
738
+ if (issue.state !== 'open') {
739
+ return notEmulated('labelling a closed issue is not emulated (no fixture records one)')
740
+ }
741
+
742
+ // Adding a label the repository lacks creates it, which no fixture records.
743
+ if (labelNamed(state, input.label) === undefined) {
744
+ return notEmulated('adding a label the repository does not have is not emulated')
745
+ }
746
+
747
+ if (issue.labels.includes(input.label)) {
748
+ return notEmulated('adding a label the issue already has is not emulated')
749
+ }
750
+
751
+ // The fixture's answer is both the issue's labels with the new one appended and name order.
752
+ if (!issue.labels.every(name => sortsAfter(input.label, name))) {
753
+ return notEmulated('adding a label that does not sort after the issue labels is not emulated')
754
+ }
755
+
756
+ return () => {
757
+ const labels = [...issue.labels, input.label]
758
+
759
+ state.issues = state.issues.map(candidate =>
760
+ candidate.number === issue.number ? { ...candidate, labels } : candidate
761
+ )
762
+
763
+ return json(200, [
764
+ ...renderLabels(state, env.drills.addAnswerOmitsLabel ? issue.labels : labels)
765
+ ])
766
+ }
767
+ }
768
+ )
769
+
770
+ const removeLabel: Route = statefulRoute(
771
+ repoEvidence('DELETE', '/issues/{issueNumber}/labels/{name}', true, [labelCase], {
772
+ issueNumber: issueNumberPattern,
773
+ name: githubLabelNamePattern
774
+ }),
775
+ 'none',
776
+ (request): AddLabelInput | NotEmulated => {
777
+ const issue = issueInput(request)
778
+
779
+ return isNotEmulated(issue) ? issue : { ...issue, label: request.params.name ?? '' }
780
+ },
781
+ (state, input) => {
782
+ const other = otherRepository(state, input)
783
+
784
+ if (other !== undefined) return other
785
+
786
+ const issue = issueHeld(state, input.number)
787
+
788
+ if (issue === undefined) return missingIssue()
789
+
790
+ if (issue.state !== 'open') {
791
+ return notEmulated('unlabelling a closed issue is not emulated (no fixture records one)')
792
+ }
793
+
794
+ if (labelNamed(state, input.label) === undefined) {
795
+ return notEmulated('removing a label the repository does not have is not emulated')
796
+ }
797
+
798
+ // A repository label that is not on the issue answers the fixture's 404.
799
+ if (!issue.labels.includes(input.label)) {
800
+ return answer(() => json(404, githubEmulatorErrorBodies.labelNotOnIssue))
801
+ }
802
+
803
+ const labels = issue.labels.filter(name => name !== input.label)
804
+
805
+ // The fixture's removal answers the remaining labels; no fixture records an empty answer.
806
+ if (labels.length === 0) {
807
+ return notEmulated(
808
+ 'removing the last label of an issue is not emulated (no fixture records it)'
809
+ )
810
+ }
811
+
812
+ return () => {
813
+ state.issues = state.issues.map(candidate =>
814
+ candidate.number === issue.number ? { ...candidate, labels } : candidate
815
+ )
816
+
817
+ return json(200, [...renderLabels(state, labels)])
818
+ }
819
+ }
820
+ )
821
+
822
+ type CreateIssueInput = RepoInput & { readonly title: string; readonly body: string }
823
+
824
+ const createIssue: Route = statefulRoute(
825
+ repoEvidence('POST', '/issues', true, [lifecycleCase]),
826
+ 'json',
827
+ (request): CreateIssueInput | NotEmulated => {
828
+ const query = withoutQuery(request)
829
+
830
+ if (query !== undefined) return query
831
+
832
+ const body = exactBodyKeys(request.json, 'the issue body', ['title', 'body'])
833
+
834
+ if (isNotEmulated(body)) return body
835
+
836
+ const title = nonEmptyString(body.title, 'title')
837
+
838
+ if (isNotEmulated(title)) return title
839
+
840
+ return Predicate.isString(body.body)
841
+ ? { ...repoOf(request), title, body: body.body }
842
+ : notEmulated('body must be a string')
843
+ },
844
+ (state, input, { env }) => {
845
+ const other = otherRepository(state, input)
846
+
847
+ if (other !== undefined) return other
848
+
849
+ const number = state.counters.nextIssueNumber
850
+
851
+ if (number > githubMaxIssueNumber) {
852
+ return notEmulated('the repository has run out of emulated issue numbers')
853
+ }
854
+
855
+ return () => {
856
+ // Read the clock before any write: a failing clock writes nothing.
857
+ const at = timestamp(env)
858
+
859
+ const issue: GithubEmulatorIssue = {
860
+ number,
861
+ id: mintedIssueIdBase + number,
862
+ nodeId: mintedIssueNodeId(number),
863
+ title: input.title,
864
+ body: input.body,
865
+ state: 'open',
866
+ stateReason: null,
867
+ labels: [],
868
+ user: state.viewer,
869
+ authorAssociation: 'OWNER',
870
+ createdAt: at,
871
+ updatedAt: at,
872
+ closedAt: null,
873
+ createdHere: true
874
+ }
875
+
876
+ state.counters = { ...state.counters, nextIssueNumber: number + 1 }
877
+ state.issues = [...state.issues, issue]
878
+
879
+ return json(201, renderIssue(state, issue))
880
+ }
881
+ }
882
+ )
883
+
884
+ type UpdateIssueInput = IssueInput &
885
+ ({ readonly change: 'rename'; readonly title: string } | { readonly change: 'close' })
886
+
887
+ const updateIssue: Route = statefulRoute(
888
+ repoEvidence('PATCH', '/issues/{issueNumber}', true, [lifecycleCase], {
889
+ issueNumber: issueNumberPattern
890
+ }),
891
+ 'json',
892
+ (request): UpdateIssueInput | NotEmulated => {
893
+ const issue = issueInput(request)
894
+
895
+ if (isNotEmulated(issue)) return issue
896
+
897
+ const body = request.json
898
+
899
+ if (!isJsonObject(body)) return notEmulated('the update body must be a JSON object')
900
+
901
+ const keys = Object.keys(body).sort().join(',')
902
+
903
+ // The two recorded updates: a rename, and closing as completed.
904
+ if (keys === 'title') {
905
+ const title = nonEmptyString(body.title, 'title')
906
+
907
+ return isNotEmulated(title) ? title : { ...issue, change: 'rename', title }
908
+ }
909
+
910
+ if (keys === 'state,state_reason') {
911
+ return body.state === 'closed' && body.state_reason === 'completed'
912
+ ? { ...issue, change: 'close' }
913
+ : notEmulated('state changes other than closed as completed are not emulated')
914
+ }
915
+
916
+ return notEmulated(
917
+ 'issue updates other than { title } or { state, state_reason } are not emulated'
918
+ )
919
+ },
920
+ (state, input, { env }) => {
921
+ const other = otherRepository(state, input)
922
+
923
+ if (other !== undefined) return other
924
+
925
+ const issue = issueHeld(state, input.number)
926
+
927
+ if (issue === undefined || !issue.createdHere) {
928
+ return notEmulated('updating an issue not created here is not emulated')
929
+ }
930
+
931
+ if (issue.state !== 'open') {
932
+ return notEmulated('updating a closed issue is not emulated (no fixture records one)')
933
+ }
934
+
935
+ const refusal = commentsRefusal(state, issue)
936
+
937
+ if (refusal !== undefined) return refusal
938
+
939
+ return () => {
940
+ // Read the clock before any write: a failing clock writes nothing. A rename keeps
941
+ // `updated_at`, as the lifecycle fixture records.
942
+ const updated: GithubEmulatorIssue =
943
+ input.change === 'rename'
944
+ ? { ...issue, title: input.title }
945
+ : (() => {
946
+ const at = timestamp(env)
947
+
948
+ return {
949
+ ...issue,
950
+ state: 'closed',
951
+ stateReason: 'completed',
952
+ closedAt: at,
953
+ updatedAt: at
954
+ }
955
+ })()
956
+
957
+ state.issues = state.issues.map(candidate =>
958
+ candidate.number === issue.number ? updated : candidate
959
+ )
960
+
961
+ const rendered = renderIssue(state, updated)
962
+
963
+ return json(
964
+ 200,
965
+ input.change === 'close' && env.drills.closeWithoutClosedAt
966
+ ? { ...rendered, closed_at: null }
967
+ : rendered
968
+ )
969
+ }
970
+ }
971
+ )
972
+
973
+ /** The route table: evidence plus handlers. `githubEmulatorRoutes` is its evidence part. */
974
+ export const githubApiRoutes: ReadonlyArray<Route> = [
975
+ listLabels,
976
+ getIssue,
977
+ searchIssues,
978
+ getContents,
979
+ createComment,
980
+ listComments,
981
+ deleteComment,
982
+ addLabels,
983
+ removeLabel,
984
+ createIssue,
985
+ updateIssue
986
+ ]