meta-sovereign 0.49.3 → 0.49.5

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 (42) hide show
  1. package/js/src/server/github-service.js +383 -0
  2. package/js/src/server/routes-github.js +98 -0
  3. package/js/src/server/routes-messages.js +8 -6
  4. package/js/src/server/routes-mutating.js +15 -32
  5. package/js/src/server/routes-pull.js +22 -6
  6. package/js/src/sources/archive/context.js +99 -0
  7. package/js/src/sources/archive/legacy.js +64 -0
  8. package/js/src/sources/archive/media-store.js +60 -0
  9. package/js/src/sources/archive/meta-thread.js +278 -0
  10. package/js/src/sources/archive/text.js +207 -0
  11. package/js/src/sources/archive/x-dms.js +196 -0
  12. package/js/src/sources/archive/zip-file.js +200 -0
  13. package/js/src/sources/auto-import/files.js +146 -43
  14. package/js/src/sources/auto-import/manager.js +32 -5
  15. package/js/src/sources/auto-import/recipes.js +6 -29
  16. package/js/src/sources/facebook.js +23 -22
  17. package/js/src/sources/github-actions.js +162 -0
  18. package/js/src/sources/github-http.js +266 -0
  19. package/js/src/sources/github-inbox.js +312 -0
  20. package/js/src/sources/github-ref.js +106 -0
  21. package/js/src/sources/github-sync.js +124 -0
  22. package/js/src/sources/github.js +79 -193
  23. package/js/src/sources/link.js +2 -0
  24. package/js/src/sources/linkedin.js +68 -17
  25. package/js/src/sources/pull-runner.js +10 -1
  26. package/js/src/sources/x.js +9 -14
  27. package/js/src/storage/migrations/0004-github-external-ids.js +115 -0
  28. package/js/src/storage/migrations/0005-archive-ids.js +162 -0
  29. package/js/src/storage/migrations/index.js +4 -2
  30. package/js/src/web/connections/fetch-now.js +4 -2
  31. package/js/src/web/connections/github-inbox.js +236 -0
  32. package/js/src/web/connections/index.js +2 -0
  33. package/js/src/web/dom.js +10 -0
  34. package/js/src/web/locales/en-github.js +30 -0
  35. package/js/src/web/locales/en.js +2 -0
  36. package/js/src/web/locales/hi-github.js +29 -0
  37. package/js/src/web/locales/hi.js +2 -0
  38. package/js/src/web/locales/ru-github.js +30 -0
  39. package/js/src/web/locales/ru.js +2 -0
  40. package/js/src/web/locales/zh-github.js +28 -0
  41. package/js/src/web/locales/zh.js +2 -0
  42. package/package.json +3 -1
@@ -0,0 +1,383 @@
1
+ /**
2
+ * GitHub as a full inbox, server side (R-Z11).
3
+ *
4
+ * Which repositories to pull and how often is a saved setting
5
+ * (`settings:github`); where each pull stopped is a cursor link
6
+ * (`cursor:github:<owner/repo>`) written only after the pulled links were
7
+ * stored (R-Z14), so a failed write repeats the same window. A repository
8
+ * that fails does not stop the others; a rate limit stops the run and says
9
+ * when to come back. Notifications are fetched into `notification:github:*`
10
+ * links and marked read only by an explicit call.
11
+ *
12
+ * There is no timer here: `pullJob()` is the job the shared pull runner
13
+ * (../sources/pull-runner.js, R-Z8) registers as `github`, so "Fetch now" and
14
+ * the schedule are the runner's. The schedule is off until the user picks
15
+ * repositories and an interval.
16
+ */
17
+
18
+ import { createGithubLive } from '../sources/github.js';
19
+ import { stampSourceLink } from '../sources/index.js';
20
+ import { loadPullState, savePullState } from '../sources/pull-runner.js';
21
+ import { safeMessage } from './redact.js';
22
+
23
+ const SETTINGS_ID = 'settings:github';
24
+ const cursorId = (key) => `cursor:github:${key}`;
25
+ const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
26
+ const MAX_REPOS = 200;
27
+ const MIN_INTERVAL_MINUTES = 5;
28
+ const MAX_INTERVAL_MINUTES = 24 * 60;
29
+
30
+ export const DEFAULT_SETTINGS = Object.freeze({
31
+ repos: [],
32
+ intervalMinutes: 0,
33
+ notifications: false,
34
+ discussions: false,
35
+ });
36
+
37
+ const secretValue = (link) =>
38
+ link?.token ?? link?.value ?? link?.body ?? link?.text ?? null;
39
+
40
+ export const readGithubToken = async (store, { secretId } = {}) => {
41
+ const ids = [
42
+ secretId,
43
+ 'secret:github:access-token',
44
+ 'secret:github:token',
45
+ ].filter(Boolean);
46
+ for (const id of ids) {
47
+ const token = secretValue(await store.get(id));
48
+ if (token) {
49
+ return token;
50
+ }
51
+ }
52
+ return null;
53
+ };
54
+
55
+ export const githubLive = async (store, body, ctx) => {
56
+ const token = body.token ?? (await readGithubToken(store, body));
57
+ const factory = ctx?.githubLiveFactory ?? createGithubLive;
58
+ const live = factory({
59
+ token,
60
+ owner: body.owner ?? null,
61
+ repo: body.repo ?? null,
62
+ });
63
+ return { token, live };
64
+ };
65
+
66
+ const invalid = (message) =>
67
+ Object.assign(new Error(message), { code: 'bad-request' });
68
+
69
+ /** The settings a request may save, or a `bad-request` error. */
70
+ export const checkSettings = (input = {}) => {
71
+ const repos = input.repos ?? [];
72
+ if (
73
+ !Array.isArray(repos) ||
74
+ repos.length > MAX_REPOS ||
75
+ !repos.every((r) => typeof r === 'string' && REPO.test(r))
76
+ ) {
77
+ throw invalid('repos must be a list of owner/repo names');
78
+ }
79
+ const interval = Number(input.intervalMinutes ?? 0);
80
+ if (
81
+ !Number.isInteger(interval) ||
82
+ (interval !== 0 &&
83
+ (interval < MIN_INTERVAL_MINUTES || interval > MAX_INTERVAL_MINUTES))
84
+ ) {
85
+ throw invalid(
86
+ `intervalMinutes must be 0 (off) or ${MIN_INTERVAL_MINUTES}..${MAX_INTERVAL_MINUTES}`
87
+ );
88
+ }
89
+ return {
90
+ repos: [...new Set(repos)],
91
+ intervalMinutes: interval,
92
+ notifications: input.notifications === true,
93
+ discussions: input.discussions === true,
94
+ };
95
+ };
96
+
97
+ const errorEntry = (error) => ({
98
+ error: safeMessage(error),
99
+ code: error.code ?? 'github-error',
100
+ ...(error.retryAfter ? { retryAfter: error.retryAfter } : {}),
101
+ });
102
+
103
+ const putAll = async (store, links) => {
104
+ for (const link of links) {
105
+ await store.put(stampSourceLink(link, 'github'));
106
+ }
107
+ };
108
+
109
+ // One repository: pull, store, then (only then) move its cursor.
110
+ const fetchRepo = async (env, live, token, key, options) => {
111
+ const { store, now } = env;
112
+ const [owner, repo] = key.split('/');
113
+ const cursor = (await store.get(cursorId(key))) ?? {};
114
+ const result = await live.pullMessages({
115
+ owner,
116
+ repo,
117
+ token,
118
+ since: cursor.since ?? null,
119
+ etags: cursor.etags ?? {},
120
+ });
121
+ const found = options.discussions
122
+ ? await fetchDiscussionsOf(env, live, { owner, repo, token, cursor })
123
+ : { links: [], since: cursor.discussionsSince ?? null, note: null };
124
+ await putAll(store, [...result.links, ...found.links]);
125
+ // After the store write: a failed write above leaves the cursor as it was.
126
+ await store.put({
127
+ id: cursorId(key),
128
+ source: 'github',
129
+ since: result.since,
130
+ etags: result.etags,
131
+ discussionsSince: found.since,
132
+ fetchedAt: new Date(now()).toISOString(),
133
+ });
134
+ return {
135
+ repo: key,
136
+ imported: result.links.length + found.links.length,
137
+ rawCount: result.rawCount,
138
+ notModified: result.notModified,
139
+ discussions: options.discussions ? found.links.length : null,
140
+ ...(found.note ? { discussionsNote: found.note } : {}),
141
+ };
142
+ };
143
+
144
+ // Discussions need a token with access; without it the repository still
145
+ // pulls and the reason is reported (a rate limit still stops the run).
146
+ const fetchDiscussionsOf = async (
147
+ env,
148
+ live,
149
+ { owner, repo, token, cursor }
150
+ ) => {
151
+ const startedAt = new Date(env.now()).toISOString();
152
+ try {
153
+ const found = await live.fetchDiscussions({
154
+ owner,
155
+ repo,
156
+ token,
157
+ since: cursor.discussionsSince ?? null,
158
+ });
159
+ return { links: found.links, since: startedAt, note: null };
160
+ } catch (error) {
161
+ if (error.code === 'rate-limited') {
162
+ throw error;
163
+ }
164
+ return {
165
+ links: [],
166
+ since: cursor.discussionsSince ?? null,
167
+ note: errorEntry(error),
168
+ };
169
+ }
170
+ };
171
+
172
+ // The notification inbox, never faster than the interval GitHub asked for.
173
+ const fetchNotes = async ({ store, now }, live, token) => {
174
+ const id = cursorId('notifications');
175
+ const cursor = (await store.get(id)) ?? {};
176
+ const next = Number(cursor.nextPollAt ?? 0);
177
+ if (next > now()) {
178
+ return { skipped: 'poll-interval', nextPollAt: next };
179
+ }
180
+ const startedAt = new Date(now()).toISOString();
181
+ const found = await live.fetchNotifications({
182
+ token,
183
+ since: cursor.since ?? null,
184
+ lastModified: cursor.lastModified ?? null,
185
+ });
186
+ await putAll(store, found.links);
187
+ const pollInterval = found.pollInterval ?? cursor.pollInterval ?? 60;
188
+ await store.put({
189
+ id,
190
+ source: 'github',
191
+ since: found.notModified ? (cursor.since ?? startedAt) : startedAt,
192
+ lastModified: found.lastModified,
193
+ pollInterval,
194
+ nextPollAt: now() + pollInterval * 1000,
195
+ });
196
+ return { imported: found.links.length, notModified: found.notModified };
197
+ };
198
+
199
+ const storedNotifications = async (store) => {
200
+ const all = await store.query(
201
+ (l) => typeof l?.id === 'string' && l.id.startsWith('notification:github:')
202
+ );
203
+ return all
204
+ .filter((l) => !l.deleted)
205
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
206
+ };
207
+
208
+ // A repository that fails does not stop the others; a rate limit does.
209
+ const runAll = async (env, saved, wanted, { token, live }, log) => {
210
+ const out = { repos: [], notifications: null, rateLimited: null };
211
+ const limited = (error) => {
212
+ if (error.code === 'rate-limited') {
213
+ out.rateLimited = { retryAfter: error.retryAfter };
214
+ }
215
+ };
216
+ for (const key of wanted) {
217
+ if (out.rateLimited) {
218
+ out.repos.push({ repo: key, skipped: 'rate-limited' });
219
+ continue;
220
+ }
221
+ try {
222
+ out.repos.push(await fetchRepo(env, live, token, key, saved));
223
+ } catch (error) {
224
+ out.repos.push({ repo: key, ...errorEntry(error) });
225
+ limited(error);
226
+ log?.({ level: 'warn', event: 'github_fetch_failed', repo: key });
227
+ }
228
+ }
229
+ if (saved.notifications && !out.rateLimited) {
230
+ out.notifications = await fetchNotes(env, live, token).catch((error) => {
231
+ limited(error);
232
+ return errorEntry(error);
233
+ });
234
+ }
235
+ out.imported = out.repos.reduce((n, r) => n + (r.imported ?? 0), 0);
236
+ return out;
237
+ };
238
+
239
+ /**
240
+ * @param {object} options
241
+ * @param {object} options.store
242
+ * @param {object} [options.ctx] server context (`githubLiveFactory`)
243
+ * @param {() => number} [options.now]
244
+ * @param {Function} [options.log]
245
+ */
246
+ export const createGithubService = ({
247
+ store,
248
+ ctx = {},
249
+ now = () => Date.now(),
250
+ log = null,
251
+ }) => {
252
+ const env = { store, now };
253
+ let running = null;
254
+
255
+ const settings = async () => {
256
+ const { repos, intervalMinutes, notifications, discussions } = {
257
+ ...DEFAULT_SETTINGS,
258
+ ...((await store.get(SETTINGS_ID)) ?? {}),
259
+ };
260
+ return { repos, intervalMinutes, notifications, discussions };
261
+ };
262
+
263
+ const saveSettings = async (input) => {
264
+ const next = checkSettings(input);
265
+ await store.put({ id: SETTINGS_ID, source: 'github', ...next });
266
+ return next;
267
+ };
268
+
269
+ const withLive = async () => {
270
+ const { token, live } = await githubLive(store, {}, ctx);
271
+ if (!token) {
272
+ throw Object.assign(new Error('save a GitHub access token first'), {
273
+ code: 'no-token',
274
+ });
275
+ }
276
+ return { token, live };
277
+ };
278
+
279
+ const listRepos = async () => {
280
+ const { token, live } = await withLive();
281
+ const repos = await live.listRepos({ token });
282
+ const chosen = new Set((await settings()).repos);
283
+ return repos.map((r) => ({
284
+ fullName: r.full_name,
285
+ private: Boolean(r.private),
286
+ description: r.description ?? '',
287
+ updatedAt: r.pushed_at ?? r.updated_at ?? null,
288
+ selected: chosen.has(r.full_name),
289
+ }));
290
+ };
291
+
292
+ const run = async (options = {}) => {
293
+ const saved = await settings();
294
+ const access = await withLive();
295
+ const wanted = options.repos?.length ? options.repos : saved.repos;
296
+ return runAll(env, saved, wanted, access, log);
297
+ };
298
+
299
+ // One run at a time: a second request waits for and shares the first.
300
+ const fetchNow = (options) => {
301
+ running ??= run(options).finally(() => {
302
+ running = null;
303
+ });
304
+ return running;
305
+ };
306
+
307
+ const notifications = () => storedNotifications(store);
308
+
309
+ // Marking read changes GitHub: only on an explicit call (never by fetching).
310
+ const markRead = async ({ threadId = null, all = false } = {}) => {
311
+ const { token, live } = await withLive();
312
+ if (all) {
313
+ await live.markAllRead({ token });
314
+ for (const link of await notifications()) {
315
+ await store.put({ ...link, unread: false });
316
+ }
317
+ return { ok: true, all: true };
318
+ }
319
+ await live.markThreadRead(threadId, { token });
320
+ const link = await store.get(`notification:github:${threadId}`);
321
+ if (link) {
322
+ await store.put({ ...link, unread: false });
323
+ }
324
+ return { ok: true, threadId: String(threadId) };
325
+ };
326
+
327
+ // The runner's job. A rate limit is remembered (`notBefore` in the pull
328
+ // state), so neither the schedule nor a click asks GitHub again before it
329
+ // allows, and the wait is reported instead of looped on.
330
+ const waitLeft = async () =>
331
+ Math.ceil(
332
+ (Number((await loadPullState(store, 'github')).notBefore ?? 0) - now()) /
333
+ 1000
334
+ );
335
+ const limitError = (seconds) =>
336
+ Object.assign(new Error('GitHub rate limit reached; try again later'), {
337
+ code: 'rate-limited',
338
+ retryAfter: Math.max(1, seconds),
339
+ });
340
+ const pullJob = () => ({
341
+ ready: async () => Boolean(await readGithubToken(store)),
342
+ intervalMs: 60_000,
343
+ firstDelayMs: 30_000,
344
+ due: async () => {
345
+ const saved = await settings();
346
+ if (!saved.intervalMinutes || saved.repos.length === 0) {
347
+ return false;
348
+ }
349
+ const state = await loadPullState(store, 'github');
350
+ const last = Date.parse(state.lastOkAt ?? '') || 0;
351
+ return (
352
+ now() >=
353
+ Math.max(last + saved.intervalMinutes * 60_000, state.notBefore ?? 0)
354
+ );
355
+ },
356
+ run: async () => {
357
+ const left = await waitLeft();
358
+ if (left > 0) {
359
+ throw limitError(left);
360
+ }
361
+ const out = await fetchNow({});
362
+ if (out.rateLimited) {
363
+ const seconds = out.rateLimited.retryAfter ?? 60;
364
+ await savePullState(store, 'github', {
365
+ notBefore: now() + seconds * 1000,
366
+ });
367
+ throw limitError(seconds);
368
+ }
369
+ await savePullState(store, 'github', { notBefore: 0 });
370
+ return { imported: out.imported };
371
+ },
372
+ });
373
+
374
+ return {
375
+ settings,
376
+ saveSettings,
377
+ listRepos,
378
+ fetchNow,
379
+ notifications,
380
+ markRead,
381
+ pullJob,
382
+ };
383
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The GitHub inbox routes (R-Z11), all under /api/github/:
3
+ *
4
+ * GET repos the user's repositories, `selected` marked
5
+ * GET settings { repos, intervalMinutes, notifications,
6
+ * discussions }
7
+ * POST settings the same, saved
8
+ * GET notifications the stored notification inbox
9
+ * POST notifications/read { threadId } | { all: true } -- the only
10
+ * thing that marks anything read on GitHub
11
+ *
12
+ * "Fetch now" and the schedule are the shared pull runner's: POST /api/pull
13
+ * {source: "github"} (./routes-pull.js). Pull, clone and post-comment stay in
14
+ * ./routes-mutating.js. Answers are
15
+ * `{ ok, ... }` or the uniform `{ ok: false, error, code }`.
16
+ */
17
+
18
+ import { fail, json, readBody } from './util.js';
19
+ import { isTrustedMutation } from './routes-auto-import.js';
20
+ import { safeMessage } from './redact.js';
21
+
22
+ const STATUS = {
23
+ 'bad-request': 400,
24
+ 'no-token': 400,
25
+ 'rate-limited': 429,
26
+ 'discussions-unavailable': 403,
27
+ };
28
+
29
+ const failure = (res, error) => {
30
+ const code = error.code ?? 'github-error';
31
+ const status = STATUS[code] ?? (error.status === 401 ? 401 : 502);
32
+ return (
33
+ json(res, status, {
34
+ ok: false,
35
+ error: safeMessage(error),
36
+ code,
37
+ ...(error.retryAfter ? { retryAfter: error.retryAfter } : {}),
38
+ }) ?? true
39
+ );
40
+ };
41
+
42
+ const guarded = async (res, work) => {
43
+ try {
44
+ return (await work()) ?? true;
45
+ } catch (error) {
46
+ return failure(res, error);
47
+ }
48
+ };
49
+
50
+ const mutation = async (req, res, ctx, work) => {
51
+ if (!isTrustedMutation(req, ctx.allowOrigins)) {
52
+ return fail(
53
+ res,
54
+ 403,
55
+ 'forbidden',
56
+ 'GitHub settings must be changed from the meta-sovereign app'
57
+ );
58
+ }
59
+ return guarded(res, async () => work(await readBody(req)));
60
+ };
61
+
62
+ const READ = {
63
+ repos: async (service) => ({ repos: await service.listRepos() }),
64
+ settings: async (service) => ({ settings: await service.settings() }),
65
+ notifications: async (service) => ({
66
+ notifications: await service.notifications(),
67
+ }),
68
+ };
69
+
70
+ const WRITE = {
71
+ settings: async (service, body) => ({
72
+ settings: await service.saveSettings(body),
73
+ }),
74
+ 'notifications/read': (service, body) =>
75
+ service.markRead({
76
+ threadId: body.threadId ?? null,
77
+ all: body.all === true,
78
+ }),
79
+ };
80
+
81
+ export const handleGithubRoutes = async (req, res, p, ctx) => {
82
+ const prefix = '/api/github/';
83
+ if (!p.startsWith(prefix) || !ctx.github) {
84
+ return false;
85
+ }
86
+ const name = p.slice(prefix.length);
87
+ if (req.method === 'GET' && Object.hasOwn(READ, name)) {
88
+ return guarded(res, async () =>
89
+ json(res, 200, { ok: true, ...(await READ[name](ctx.github)) })
90
+ );
91
+ }
92
+ if (req.method === 'POST' && Object.hasOwn(WRITE, name)) {
93
+ return mutation(req, res, ctx, async (body) =>
94
+ json(res, 200, { ok: true, ...(await WRITE[name](ctx.github, body)) })
95
+ );
96
+ }
97
+ return false;
98
+ };
@@ -170,6 +170,13 @@ const ACTIONS = {
170
170
  },
171
171
  };
172
172
 
173
+ // An item pulled before its network id was kept is refused (R-Z11).
174
+ const FAILURE_STATUS = {
175
+ 'flood-wait': 429,
176
+ 'unsupported-action': 501,
177
+ 'no-external-id': 409,
178
+ };
179
+
173
180
  const handleAction = async (store, req, res, ctx, name) => {
174
181
  const action = ACTIONS[name];
175
182
  const body = await readBody(req);
@@ -197,12 +204,7 @@ const handleAction = async (store, req, res, ctx, name) => {
197
204
  source: target.source,
198
205
  code: error.code ?? null,
199
206
  });
200
- const status =
201
- error.code === 'flood-wait'
202
- ? 429
203
- : error.code === 'unsupported-action'
204
- ? 501
205
- : 502;
207
+ const status = FAILURE_STATUS[error.code] ?? 502;
206
208
  return (
207
209
  json(res, status, {
208
210
  error: error.message,
@@ -17,7 +17,8 @@ import {
17
17
  stampSourceLink,
18
18
  } from '../sources/index.js';
19
19
  import { emailFailure, handleEmailPull, sendEmail } from './routes-email.js';
20
- import { createGithubLive } from '../sources/github.js';
20
+ import { githubLive } from './github-service.js';
21
+ import { handleGithubRoutes } from './routes-github.js';
21
22
  import { createUpworkLive } from '../sources/upwork.js';
22
23
  import { createPeoplePerHourLive } from '../sources/peopleperhour.js';
23
24
  import {
@@ -523,33 +524,6 @@ const handleEmail = async (store, req, res, p, ctx) => {
523
524
  return false;
524
525
  };
525
526
 
526
- const readGithubToken = async (store, { secretId } = {}) => {
527
- const ids = [
528
- secretId,
529
- 'secret:github:access-token',
530
- 'secret:github:token',
531
- ].filter(Boolean);
532
- for (const id of ids) {
533
- const link = await store.get(id);
534
- const token = link?.token ?? link?.value ?? link?.body ?? link?.text;
535
- if (token) {
536
- return token;
537
- }
538
- }
539
- return null;
540
- };
541
-
542
- const githubLive = async (store, body, ctx) => {
543
- const token = body.token ?? (await readGithubToken(store, body));
544
- const factory = ctx?.githubLiveFactory ?? createGithubLive;
545
- const live = factory({
546
- token,
547
- owner: body.owner ?? null,
548
- repo: body.repo ?? null,
549
- });
550
- return { token, live };
551
- };
552
-
553
527
  const pullGithub = async (store, body, ctx) => {
554
528
  const { token, live } = await githubLive(store, body, ctx);
555
529
  const result = await live.pullMessages({ ...body, token });
@@ -584,10 +558,18 @@ const cloneGithub = async (store, body, ctx) => {
584
558
 
585
559
  const postGithubComment = async (store, body, ctx) => {
586
560
  const { token, live } = await githubLive(store, body, ctx);
587
- const result = await live.post(body.content ?? body.message ?? body, {
588
- ...body,
589
- token,
590
- });
561
+ let content = body.content ?? body.message ?? body;
562
+ // A reply names the message it answers; its saved GitHub id picks the
563
+ // endpoint (a review comment has its own reply route) (R-Z11).
564
+ if (typeof body.replyTo === 'string' && body.replyTo.startsWith('msg:')) {
565
+ const replyToLink = await store.get(body.replyTo);
566
+ content = {
567
+ ...(typeof content === 'string' ? { text: content } : content),
568
+ chat: body.chat ?? replyToLink?.chat,
569
+ replyToLink,
570
+ };
571
+ }
572
+ const result = await live.post(content, { ...body, token });
591
573
  return { source: 'github', result };
592
574
  };
593
575
 
@@ -805,6 +787,7 @@ export const handleMutatingRoutes = async (store, req, res, p, url, ctx) =>
805
787
  (await handleOutreach(store, req, res, p, ctx)) ||
806
788
  (await handleEmail(store, req, res, p, ctx)) ||
807
789
  (await handleGithub(store, req, res, p, ctx)) ||
790
+ (await handleGithubRoutes(req, res, p, ctx)) ||
808
791
  (await handleUpwork(store, req, res, p, ctx)) ||
809
792
  (await handlePeoplePerHour(store, req, res, p, ctx)) ||
810
793
  (await handleHardening(store, req, res, p, ctx));
@@ -22,6 +22,7 @@ import { createVkInbox } from '../sources/vk-sync.js';
22
22
  import { liveOptionsFrom, pullLiveInto } from '../sources/index.js';
23
23
  import { createUpworkLive } from '../sources/upwork.js';
24
24
  import { createPeoplePerHourLive } from '../sources/peopleperhour.js';
25
+ import { createGithubService } from './github-service.js';
25
26
  import { fail, json, readBody } from './util.js';
26
27
 
27
28
  const MINUTE = 60_000;
@@ -34,7 +35,8 @@ const connected = (store, source) => async () =>
34
35
  const liveOf = (make, given) => (given ? make(given) : undefined);
35
36
 
36
37
  /** The jobs: network -> { ready, run, intervalMs }. */
37
- export const pullJobs = (store, { vkInbox, options = {} }) => ({
38
+ export const pullJobs = (store, { vkInbox, github, options = {} }) => ({
39
+ ...(github ? { github: github.pullJob() } : {}),
38
40
  vk: {
39
41
  ready: vkInbox.ready,
40
42
  run: vkInbox.fetch,
@@ -66,9 +68,9 @@ export const pullJobs = (store, { vkInbox, options = {} }) => ({
66
68
  * `options` (tests): `{ vk: {baseUrl, fetchImpl, wait}, upwork: {baseUrl},
67
69
  * peopleperhour: {baseUrl}, minGapMs, intervals }`; `false` turns it all off.
68
70
  */
69
- export const createPullServices = (store, log, options = {}) => {
71
+ export const createPullServices = (store, log, options = {}, github = null) => {
70
72
  const vkInbox = createVkInbox({ store, log, ...options.vk });
71
- const jobs = pullJobs(store, { vkInbox, options });
73
+ const jobs = pullJobs(store, { vkInbox, github, options });
72
74
  for (const [source, intervalMs] of Object.entries(options.intervals ?? {})) {
73
75
  jobs[source].intervalMs = intervalMs;
74
76
  jobs[source].firstDelayMs = intervalMs;
@@ -138,10 +140,24 @@ export const handlePullRoutes = async (store, req, res, p, ctx) => {
138
140
 
139
141
  /** What a server keeps for the pulls; they start with the other `later` services. */
140
142
  export const pullContext = (store, log, options, later) => {
143
+ // GitHub's picker routes work even when the pulls are off; its schedule is
144
+ // one more job of the shared runner (R-Z11).
145
+ // Tests give `pulls.githubLiveFactory`, a fake createGithubLive.
146
+ const githubLiveFactory = options?.githubLiveFactory ?? null;
147
+ const github = createGithubService({
148
+ store,
149
+ ctx: { githubLiveFactory },
150
+ log,
151
+ });
152
+ const given = { githubLiveFactory, github };
141
153
  if (options === false) {
142
- return { pullRunner: null };
154
+ return { ...given, pullRunner: null };
143
155
  }
144
- const services = createPullServices(store, log, options ?? {});
156
+ const services = createPullServices(store, log, options ?? {}, github);
145
157
  later.push(services.start);
146
- return { pullRunner: services.pullRunner, stopPulls: services.stop };
158
+ return {
159
+ ...given,
160
+ pullRunner: services.pullRunner,
161
+ stopPulls: services.stop,
162
+ };
147
163
  };