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.
- package/js/src/server/github-service.js +383 -0
- package/js/src/server/routes-github.js +98 -0
- package/js/src/server/routes-messages.js +8 -6
- package/js/src/server/routes-mutating.js +15 -32
- package/js/src/server/routes-pull.js +22 -6
- package/js/src/sources/archive/context.js +99 -0
- package/js/src/sources/archive/legacy.js +64 -0
- package/js/src/sources/archive/media-store.js +60 -0
- package/js/src/sources/archive/meta-thread.js +278 -0
- package/js/src/sources/archive/text.js +207 -0
- package/js/src/sources/archive/x-dms.js +196 -0
- package/js/src/sources/archive/zip-file.js +200 -0
- package/js/src/sources/auto-import/files.js +146 -43
- package/js/src/sources/auto-import/manager.js +32 -5
- package/js/src/sources/auto-import/recipes.js +6 -29
- package/js/src/sources/facebook.js +23 -22
- package/js/src/sources/github-actions.js +162 -0
- package/js/src/sources/github-http.js +266 -0
- package/js/src/sources/github-inbox.js +312 -0
- package/js/src/sources/github-ref.js +106 -0
- package/js/src/sources/github-sync.js +124 -0
- package/js/src/sources/github.js +79 -193
- package/js/src/sources/link.js +2 -0
- package/js/src/sources/linkedin.js +68 -17
- package/js/src/sources/pull-runner.js +10 -1
- package/js/src/sources/x.js +9 -14
- package/js/src/storage/migrations/0004-github-external-ids.js +115 -0
- package/js/src/storage/migrations/0005-archive-ids.js +162 -0
- package/js/src/storage/migrations/index.js +4 -2
- package/js/src/web/connections/fetch-now.js +4 -2
- package/js/src/web/connections/github-inbox.js +236 -0
- package/js/src/web/connections/index.js +2 -0
- package/js/src/web/dom.js +10 -0
- package/js/src/web/locales/en-github.js +30 -0
- package/js/src/web/locales/en.js +2 -0
- package/js/src/web/locales/hi-github.js +29 -0
- package/js/src/web/locales/hi.js +2 -0
- package/js/src/web/locales/ru-github.js +30 -0
- package/js/src/web/locales/ru.js +2 -0
- package/js/src/web/locales/zh-github.js +28 -0
- package/js/src/web/locales/zh.js +2 -0
- 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 {
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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 {
|
|
158
|
+
return {
|
|
159
|
+
...given,
|
|
160
|
+
pullRunner: services.pullRunner,
|
|
161
|
+
stopPulls: services.stop,
|
|
162
|
+
};
|
|
147
163
|
};
|