meta-sovereign 0.49.2 → 0.49.4
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/aggregate.js +16 -2
- package/js/src/server/github-service.js +383 -0
- package/js/src/server/index.js +13 -9
- 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 +163 -0
- package/js/src/sources/auto-import/files.js +5 -1
- package/js/src/sources/auto-import/recipes.js +18 -1
- 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/pull-runner.js +180 -0
- package/js/src/sources/vk-html-archive.js +268 -0
- package/js/src/sources/vk-limits.js +119 -0
- package/js/src/sources/vk-longpoll.js +333 -0
- package/js/src/sources/vk-sync.js +138 -0
- package/js/src/sources/vk.js +224 -71
- package/js/src/storage/migrations/0003-vk-reactions-docs.js +95 -0
- package/js/src/storage/migrations/0004-github-external-ids.js +115 -0
- package/js/src/storage/migrations/index.js +4 -2
- package/js/src/web/app.css +43 -0
- package/js/src/web/chat-media.js +3 -0
- package/js/src/web/chat-remote-media.js +111 -0
- package/js/src/web/connections/fetch-now.js +81 -0
- package/js/src/web/connections/github-inbox.js +236 -0
- package/js/src/web/connections/index.js +6 -0
- package/js/src/web/dom.js +17 -0
- package/js/src/web/locales/en-chat-account.js +17 -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-chat-account.js +17 -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-chat-account.js +17 -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-chat-account.js +16 -0
- package/js/src/web/locales/zh-github.js +28 -0
- package/js/src/web/locales/zh.js +2 -0
- package/js/src/web/message-parts.js +34 -9
- package/package.json +5 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbox pulls for the networks that have no live connection of their own
|
|
3
|
+
* (R-Z8): the Connections card's "Fetch now" button and the background
|
|
4
|
+
* schedule behind it.
|
|
5
|
+
*
|
|
6
|
+
* Its routes:
|
|
7
|
+
* POST /api/pull {source} -> { ok, source, imported, lastOkAt }
|
|
8
|
+
* or { ok: false, error, code } with `code` one of
|
|
9
|
+
* unsupported | not-connected | rate-limited
|
|
10
|
+
* (HTTP 429, at most one fetch per 15 s per
|
|
11
|
+
* network, `retryAfterMs`) | the network's own code
|
|
12
|
+
* GET /api/pull/status -> { ok, sources: { <source>: { connected,
|
|
13
|
+
* lastOkAt, lastError } } }
|
|
14
|
+
*
|
|
15
|
+
* The same `createPullRunner` (../sources/pull-runner.js) serves every
|
|
16
|
+
* network: add a job to `pullJobs` to give another network a button and a
|
|
17
|
+
* schedule. VK also keeps a Long Poll running (../sources/vk-sync.js).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createPullRunner, loadPullState } from '../sources/pull-runner.js';
|
|
21
|
+
import { createVkInbox } from '../sources/vk-sync.js';
|
|
22
|
+
import { liveOptionsFrom, pullLiveInto } from '../sources/index.js';
|
|
23
|
+
import { createUpworkLive } from '../sources/upwork.js';
|
|
24
|
+
import { createPeoplePerHourLive } from '../sources/peopleperhour.js';
|
|
25
|
+
import { createGithubService } from './github-service.js';
|
|
26
|
+
import { fail, json, readBody } from './util.js';
|
|
27
|
+
|
|
28
|
+
const MINUTE = 60_000;
|
|
29
|
+
|
|
30
|
+
const connected = (store, source) => async () =>
|
|
31
|
+
Boolean((await liveOptionsFrom(store, source)).token);
|
|
32
|
+
|
|
33
|
+
// `live` for a network whose test double or host is given; the saved token
|
|
34
|
+
// still comes from the store.
|
|
35
|
+
const liveOf = (make, given) => (given ? make(given) : undefined);
|
|
36
|
+
|
|
37
|
+
/** The jobs: network -> { ready, run, intervalMs }. */
|
|
38
|
+
export const pullJobs = (store, { vkInbox, github, options = {} }) => ({
|
|
39
|
+
...(github ? { github: github.pullJob() } : {}),
|
|
40
|
+
vk: {
|
|
41
|
+
ready: vkInbox.ready,
|
|
42
|
+
run: vkInbox.fetch,
|
|
43
|
+
intervalMs: 10 * MINUTE,
|
|
44
|
+
firstDelayMs: 30_000,
|
|
45
|
+
},
|
|
46
|
+
upwork: {
|
|
47
|
+
ready: connected(store, 'upwork'),
|
|
48
|
+
run: () =>
|
|
49
|
+
pullLiveInto(store, 'upwork', {
|
|
50
|
+
live: liveOf(createUpworkLive, options.upwork),
|
|
51
|
+
}),
|
|
52
|
+
intervalMs: 5 * MINUTE,
|
|
53
|
+
firstDelayMs: 30_000,
|
|
54
|
+
},
|
|
55
|
+
peopleperhour: {
|
|
56
|
+
ready: connected(store, 'peopleperhour'),
|
|
57
|
+
run: () =>
|
|
58
|
+
pullLiveInto(store, 'peopleperhour', {
|
|
59
|
+
live: liveOf(createPeoplePerHourLive, options.peopleperhour),
|
|
60
|
+
}),
|
|
61
|
+
intervalMs: 5 * MINUTE,
|
|
62
|
+
firstDelayMs: 30_000,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The pull services for a server: `{ pullRunner, vkInbox, startAll, stopAll }`.
|
|
68
|
+
* `options` (tests): `{ vk: {baseUrl, fetchImpl, wait}, upwork: {baseUrl},
|
|
69
|
+
* peopleperhour: {baseUrl}, minGapMs, intervals }`; `false` turns it all off.
|
|
70
|
+
*/
|
|
71
|
+
export const createPullServices = (store, log, options = {}, github = null) => {
|
|
72
|
+
const vkInbox = createVkInbox({ store, log, ...options.vk });
|
|
73
|
+
const jobs = pullJobs(store, { vkInbox, github, options });
|
|
74
|
+
for (const [source, intervalMs] of Object.entries(options.intervals ?? {})) {
|
|
75
|
+
jobs[source].intervalMs = intervalMs;
|
|
76
|
+
jobs[source].firstDelayMs = intervalMs;
|
|
77
|
+
}
|
|
78
|
+
const pullRunner = createPullRunner({
|
|
79
|
+
store,
|
|
80
|
+
jobs,
|
|
81
|
+
log,
|
|
82
|
+
minGapMs: options.minGapMs,
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
pullRunner,
|
|
86
|
+
vkInbox,
|
|
87
|
+
start: () => {
|
|
88
|
+
pullRunner.start();
|
|
89
|
+
vkInbox.start();
|
|
90
|
+
},
|
|
91
|
+
stop: () => {
|
|
92
|
+
pullRunner.stop();
|
|
93
|
+
vkInbox.stop();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const statusOf = async (store, ctx) => {
|
|
99
|
+
const sources = {};
|
|
100
|
+
for (const source of ctx.pullRunner.sources()) {
|
|
101
|
+
const state = await loadPullState(store, source);
|
|
102
|
+
sources[source] = {
|
|
103
|
+
connected: await ctx.pullRunner.connected(source),
|
|
104
|
+
lastOkAt: state.lastOkAt ?? null,
|
|
105
|
+
lastError: state.lastError ?? null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, sources };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const sourceOf = (body) =>
|
|
112
|
+
typeof body.source === 'string' && /^[a-z]{2,30}$/.test(body.source)
|
|
113
|
+
? body.source
|
|
114
|
+
: null;
|
|
115
|
+
|
|
116
|
+
export const handlePullRoutes = async (store, req, res, p, ctx) => {
|
|
117
|
+
if (!ctx.pullRunner) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (p === '/api/pull/status' && req.method === 'GET') {
|
|
121
|
+
return json(res, 200, await statusOf(store, ctx)) ?? true;
|
|
122
|
+
}
|
|
123
|
+
if (p !== '/api/pull' || req.method !== 'POST') {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const source = sourceOf(await readBody(req));
|
|
127
|
+
if (!source) {
|
|
128
|
+
return fail(res, 400, 'bad-request', 'a "source" is needed') ?? true;
|
|
129
|
+
}
|
|
130
|
+
const outcome = await ctx.pullRunner.fetchNow(source);
|
|
131
|
+
if (outcome.ok) {
|
|
132
|
+
return json(res, 200, outcome) ?? true;
|
|
133
|
+
}
|
|
134
|
+
const status =
|
|
135
|
+
{ 'rate-limited': 429, unsupported: 404, 'not-connected': 409 }[
|
|
136
|
+
outcome.code
|
|
137
|
+
] ?? 502;
|
|
138
|
+
return json(res, status, outcome) ?? true;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** What a server keeps for the pulls; they start with the other `later` services. */
|
|
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 };
|
|
153
|
+
if (options === false) {
|
|
154
|
+
return { ...given, pullRunner: null };
|
|
155
|
+
}
|
|
156
|
+
const services = createPullServices(store, log, options ?? {}, github);
|
|
157
|
+
later.push(services.start);
|
|
158
|
+
return {
|
|
159
|
+
...given,
|
|
160
|
+
pullRunner: services.pullRunner,
|
|
161
|
+
stopPulls: services.stop,
|
|
162
|
+
};
|
|
163
|
+
};
|
|
@@ -25,7 +25,11 @@ const toArchive = (recipe, name, text, context = {}) => {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
const parseEntry = async (provider, recipe, name, data, context) => {
|
|
28
|
-
|
|
28
|
+
// A recipe may decode its own bytes (VK's Windows-1251 pages, R-Z8).
|
|
29
|
+
const text = recipe.decode
|
|
30
|
+
? recipe.decode(name, data)
|
|
31
|
+
: data.toString('utf8');
|
|
32
|
+
const archive = toArchive(recipe, name, text, context);
|
|
29
33
|
return (await getSource(provider).parseArchive(archive)) ?? [];
|
|
30
34
|
};
|
|
31
35
|
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
import { parseCsv } from './archive.js';
|
|
25
25
|
import { chatNameFromFile } from '../whatsapp-ids.js';
|
|
26
|
+
import {
|
|
27
|
+
decodeVkHtml,
|
|
28
|
+
parseVkHtmlPage,
|
|
29
|
+
peerFromArchivePath,
|
|
30
|
+
} from '../vk-html-archive.js';
|
|
26
31
|
|
|
27
32
|
// X archives ship data as `window.YTD.<name>.part0 = [ ... ]`.
|
|
28
33
|
const ytdPayload = (text) => JSON.parse(text.replace(/^[^=[]*=\s*/, ''));
|
|
@@ -64,6 +69,14 @@ const linkedinToArchive = (_name, text) => ({
|
|
|
64
69
|
),
|
|
65
70
|
});
|
|
66
71
|
|
|
72
|
+
const vkDecode = (name, bytes) =>
|
|
73
|
+
/\.html$/i.test(name) ? decodeVkHtml(bytes) : bytes.toString('utf8');
|
|
74
|
+
|
|
75
|
+
const vkToArchive = (name, text) =>
|
|
76
|
+
/\.html$/i.test(name)
|
|
77
|
+
? parseVkHtmlPage(text, { peer: peerFromArchivePath(name) })
|
|
78
|
+
: JSON.parse(text);
|
|
79
|
+
|
|
67
80
|
export const autoImportRecipes = {
|
|
68
81
|
email: {
|
|
69
82
|
url: 'https://takeout.google.com/settings/takeout/custom/gmail',
|
|
@@ -87,7 +100,11 @@ export const autoImportRecipes = {
|
|
|
87
100
|
vk: {
|
|
88
101
|
url: 'https://vk.com/data_protection?section=rules',
|
|
89
102
|
domains: ['vk.com', 'vk.ru'],
|
|
90
|
-
|
|
103
|
+
// The JSON of konard/vk-export, or the pages of VK's own archive:
|
|
104
|
+
// messages/<peer>/messages<N>.html in Windows-1251 (R-Z8).
|
|
105
|
+
files: /(messages.*\.json|messages\/[^/]+\/messages\d*\.html)$/i,
|
|
106
|
+
decode: vkDecode,
|
|
107
|
+
toArchive: vkToArchive,
|
|
91
108
|
},
|
|
92
109
|
x: {
|
|
93
110
|
url: 'https://x.com/settings/download_your_data',
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the user does to a GitHub item: reply, edit, delete, react (R-Z11).
|
|
3
|
+
*
|
|
4
|
+
* Every action names the object by the reference kept at pull time
|
|
5
|
+
* (`link.externalId`, ./github-ref.js) -- never by parsing the link id --
|
|
6
|
+
* so an issue comment, a review comment and an issue body each reach their
|
|
7
|
+
* own endpoint. An item pulled before the reference existed is refused with
|
|
8
|
+
* `no-external-id`; nothing is sent and the browser is not tried.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { codedError, requestJson } from './http.js';
|
|
12
|
+
import { requireRef } from './github-ref.js';
|
|
13
|
+
import { replyToDiscussion } from './github-inbox.js';
|
|
14
|
+
|
|
15
|
+
export const GITHUB_EMOJI_TO_REACTION = {
|
|
16
|
+
'👍': '+1',
|
|
17
|
+
'👎': '-1',
|
|
18
|
+
'😄': 'laugh',
|
|
19
|
+
'🎉': 'hooray',
|
|
20
|
+
'😕': 'confused',
|
|
21
|
+
'❤️': 'heart',
|
|
22
|
+
'🚀': 'rocket',
|
|
23
|
+
'👀': 'eyes',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const api = (ctx, path) => `${ctx.baseUrl}${path}`;
|
|
27
|
+
const repoPath = (ref) => `/repos/${ref.owner}/${ref.repo}`;
|
|
28
|
+
// Issue and pull request conversation comments live under /issues, the
|
|
29
|
+
// inline comments on the diff under /pulls.
|
|
30
|
+
const commentPath = (ref) =>
|
|
31
|
+
`${repoPath(ref)}/${ref.kind === 'review-comment' ? 'pulls' : 'issues'}/comments/${ref.id}`;
|
|
32
|
+
|
|
33
|
+
const send = (ctx, method, path, options, body) =>
|
|
34
|
+
requestJson(ctx.fetchImpl, api(ctx, path), {
|
|
35
|
+
method,
|
|
36
|
+
headers: ctx.headers(options.token),
|
|
37
|
+
...(body === undefined ? {} : { body }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// A bare numeric id with an owner and repo is the old programmatic call.
|
|
41
|
+
const legacyRef = (ctx, id, options) => {
|
|
42
|
+
const { owner, repo } = ctx.requireRepo(options);
|
|
43
|
+
return { kind: 'issue-comment', id, owner, repo };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const refFor = (ctx, { id, link }, options, action) =>
|
|
47
|
+
link ? requireRef(link, action) : legacyRef(ctx, id, options);
|
|
48
|
+
|
|
49
|
+
// An issue or pull request is shown as "title, blank line, body".
|
|
50
|
+
const titleAndBody = (text) => {
|
|
51
|
+
const [title, ...rest] = String(text).split(/\n\s*\n/);
|
|
52
|
+
return { title: title.trim(), body: rest.join('\n\n') };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const editGithubComment = async (ctx, params = {}, options = {}) => {
|
|
56
|
+
const ref = refFor(ctx, params, options, 'edit');
|
|
57
|
+
if (ref.kind === 'issue' || ref.kind === 'pr') {
|
|
58
|
+
return send(
|
|
59
|
+
ctx,
|
|
60
|
+
'PATCH',
|
|
61
|
+
`${repoPath(ref)}/issues/${ref.number}`,
|
|
62
|
+
options,
|
|
63
|
+
titleAndBody(params.text)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return send(ctx, 'PATCH', commentPath(ref), options, { body: params.text });
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const deleteGithubComments = async (ctx, params = {}, options = {}) => {
|
|
70
|
+
const refs = params.links
|
|
71
|
+
? params.links.map((link) => requireRef(link, 'delete'))
|
|
72
|
+
: (params.ids ?? []).map((id) => legacyRef(ctx, id, options));
|
|
73
|
+
let deleted = 0;
|
|
74
|
+
for (const ref of refs) {
|
|
75
|
+
await send(ctx, 'DELETE', commentPath(ref), options);
|
|
76
|
+
deleted += 1;
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, deleted };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const reactGithubComment = async (ctx, params = {}, options = {}) => {
|
|
82
|
+
const content = GITHUB_EMOJI_TO_REACTION[params.emoji];
|
|
83
|
+
if (!content) {
|
|
84
|
+
throw Object.assign(
|
|
85
|
+
codedError(
|
|
86
|
+
params.emoji
|
|
87
|
+
? `github: unsupported reaction emoji: ${params.emoji}`
|
|
88
|
+
: 'github: taking a reaction back needs its id and is not supported',
|
|
89
|
+
'unsupported-action'
|
|
90
|
+
),
|
|
91
|
+
{ fallback: false }
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const ref = refFor(ctx, params, options, 'react');
|
|
95
|
+
return send(ctx, 'POST', `${commentPath(ref)}/reactions`, options, {
|
|
96
|
+
content,
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// `owner/repo#12` -- a chat of an issue or pull request.
|
|
101
|
+
const CHAT = /^([^/#\s]+)\/([^#\s]+)#(\d+)$/;
|
|
102
|
+
const DISCUSSION_CHAT = /^([^/#\s]+)\/([^#\s]+)#discussion:(\d+)$/;
|
|
103
|
+
|
|
104
|
+
const textOf = (content) =>
|
|
105
|
+
typeof content === 'string'
|
|
106
|
+
? content
|
|
107
|
+
: (content?.text ?? content?.body ?? '');
|
|
108
|
+
|
|
109
|
+
const chatOf = (content) =>
|
|
110
|
+
content && typeof content === 'object' ? String(content.chat ?? '') : '';
|
|
111
|
+
|
|
112
|
+
const issueNumberOf = (content, options, parsed, reply) => {
|
|
113
|
+
const number =
|
|
114
|
+
options.issueNumber ?? content?.issueNumber ?? parsed?.[3] ?? reply?.number;
|
|
115
|
+
if (!number) {
|
|
116
|
+
throw codedError(
|
|
117
|
+
'github post requires an issue or pull request number (options.issueNumber, or a chat like owner/repo#12)',
|
|
118
|
+
'unsupported-chat'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return number;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// The issue or pull request a post goes to: options, the reply's saved
|
|
125
|
+
// reference, or the chat `owner/repo#12`, in that order.
|
|
126
|
+
const issueEndpoint = (ctx, content, options) => {
|
|
127
|
+
const parsed = chatOf(content).match(CHAT);
|
|
128
|
+
const reply = content?.replyToLink?.externalId;
|
|
129
|
+
const { owner, repo } = ctx.requireRepo({
|
|
130
|
+
owner: options.owner ?? parsed?.[1] ?? reply?.owner,
|
|
131
|
+
repo: options.repo ?? parsed?.[2] ?? reply?.repo,
|
|
132
|
+
});
|
|
133
|
+
const number = issueNumberOf(content, options, parsed, reply);
|
|
134
|
+
return `/repos/${owner}/${repo}/issues/${number}/comments`;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Where a post goes: a review comment's reply endpoint, or the issue.
|
|
138
|
+
const targetOf = (ctx, content, options) => {
|
|
139
|
+
if (content?.replyToLink?.externalId?.kind === 'review-comment') {
|
|
140
|
+
const ref = requireRef(content.replyToLink, 'reply');
|
|
141
|
+
return `${repoPath(ref)}/pulls/${ref.number}/comments/${ref.id}/replies`;
|
|
142
|
+
}
|
|
143
|
+
return issueEndpoint(ctx, content, options);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const isDiscussionPost = (content) =>
|
|
147
|
+
DISCUSSION_CHAT.test(chatOf(content)) ||
|
|
148
|
+
Boolean(content?.replyToLink?.externalId?.kind?.startsWith('discussion'));
|
|
149
|
+
|
|
150
|
+
export const postGithubComment = async (ctx, content, options = {}) => {
|
|
151
|
+
const text = textOf(content);
|
|
152
|
+
if (!text) {
|
|
153
|
+
throw new Error('github post text is required');
|
|
154
|
+
}
|
|
155
|
+
if (isDiscussionPost(content)) {
|
|
156
|
+
const discussion = chatOf(content).match(DISCUSSION_CHAT);
|
|
157
|
+
return replyToDiscussion(ctx, { content, text, discussion }, options);
|
|
158
|
+
}
|
|
159
|
+
return send(ctx, 'POST', targetOf(ctx, content, options), options, {
|
|
160
|
+
body: text,
|
|
161
|
+
});
|
|
162
|
+
};
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading from the GitHub API politely (R-Z11).
|
|
3
|
+
*
|
|
4
|
+
* - Every list follows the `Link: rel="next"` header until there is no
|
|
5
|
+
* next page; a visited-url guard and a very high safety cap stop a
|
|
6
|
+
* server that loops (no page counting).
|
|
7
|
+
* - `ETag` / `Last-Modified` come back so the caller can ask again with
|
|
8
|
+
* `If-None-Match` / `If-Modified-Since`; a `304` costs no rate limit.
|
|
9
|
+
* - `x-ratelimit-remaining` / `x-ratelimit-reset` and the secondary limit's
|
|
10
|
+
* `retry-after` are respected: a short wait is slept (and the request
|
|
11
|
+
* repeated, a read is safe to repeat), a long one is reported as a
|
|
12
|
+
* `rate-limited` error carrying `retryAfter` seconds. Never a crash and
|
|
13
|
+
* never a busy loop.
|
|
14
|
+
*
|
|
15
|
+
* Only reads go through here; writes keep `requestJson` (./http.js), which
|
|
16
|
+
* marks an ambiguous outcome and is never repeated.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { codedError, timedRequest } from './http.js';
|
|
20
|
+
|
|
21
|
+
const CODES = { 401: 'unauthorized', 404: 'not-found' };
|
|
22
|
+
const SAFETY_MAX_PAGES = 2000;
|
|
23
|
+
const DEFAULT_MAX_WAIT_MS = 60_000;
|
|
24
|
+
const DEFAULT_SECONDARY_WAIT_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
export const parseLinkHeader = (header) => {
|
|
27
|
+
const out = {};
|
|
28
|
+
if (!header) {
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
for (const part of String(header).split(',')) {
|
|
32
|
+
const match = part.match(/<([^>]+)>;\s*rel="?([^";\s]+)"?/);
|
|
33
|
+
if (match) {
|
|
34
|
+
out[match[2]] = match[1];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const headerValue = (headers, name) => {
|
|
41
|
+
if (!headers) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (typeof headers.get === 'function') {
|
|
45
|
+
return headers.get(name) ?? headers.get(name.toLowerCase()) ?? null;
|
|
46
|
+
}
|
|
47
|
+
return headers[name] ?? headers[name.toLowerCase()] ?? null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const numberHeader = (headers, name) => {
|
|
51
|
+
const raw = headerValue(headers, name);
|
|
52
|
+
const value = raw === null ? NaN : Number(raw);
|
|
53
|
+
return Number.isFinite(value) ? value : null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const parseBody = (text) => {
|
|
57
|
+
if (!text) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(text);
|
|
62
|
+
} catch {
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const itemsOf = (body) => {
|
|
68
|
+
if (Array.isArray(body)) {
|
|
69
|
+
return body;
|
|
70
|
+
}
|
|
71
|
+
return body ? [body] : [];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Add a page's items; true when the sorted list reached the cursor.
|
|
75
|
+
const takeItems = (page, items, until) => {
|
|
76
|
+
for (const item of itemsOf(page.body)) {
|
|
77
|
+
if (until?.(item)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
items.push(item);
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isRateLimited = (res, text) => {
|
|
86
|
+
if (res.status === 429) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (res.status !== 403) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
headerValue(res.headers, 'retry-after') !== null ||
|
|
94
|
+
headerValue(res.headers, 'x-ratelimit-remaining') === '0' ||
|
|
95
|
+
/rate limit|abuse/i.test(text)
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const rateLimitedError = (seconds, scope) =>
|
|
100
|
+
Object.assign(
|
|
101
|
+
codedError(
|
|
102
|
+
`GitHub rate limit reached (${scope}); try again in ${seconds} s`,
|
|
103
|
+
'rate-limited'
|
|
104
|
+
),
|
|
105
|
+
{ retryAfter: seconds, status: 429 }
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {object} options
|
|
110
|
+
* @param {Function} options.fetchImpl
|
|
111
|
+
* @param {() => number} [options.now] milliseconds
|
|
112
|
+
* @param {(ms: number) => Promise<void>} [options.sleep]
|
|
113
|
+
* @param {number} [options.maxWaitMs] the longest wait slept; a longer one
|
|
114
|
+
* is reported to the caller instead
|
|
115
|
+
* @param {number} [options.retries] repeats after a rate-limit wait
|
|
116
|
+
*/
|
|
117
|
+
export const createGithubHttp = ({
|
|
118
|
+
fetchImpl,
|
|
119
|
+
now = () => Date.now(),
|
|
120
|
+
sleep = (ms) => new Promise((resolve) => globalThis.setTimeout(resolve, ms)),
|
|
121
|
+
maxWaitMs = DEFAULT_MAX_WAIT_MS,
|
|
122
|
+
retries = 2,
|
|
123
|
+
timeoutMs,
|
|
124
|
+
} = {}) => {
|
|
125
|
+
const limit = { remaining: null, resetAt: 0 };
|
|
126
|
+
|
|
127
|
+
const learn = (res) => {
|
|
128
|
+
const remaining = numberHeader(res.headers, 'x-ratelimit-remaining');
|
|
129
|
+
const reset = numberHeader(res.headers, 'x-ratelimit-reset');
|
|
130
|
+
if (remaining !== null) {
|
|
131
|
+
limit.remaining = remaining;
|
|
132
|
+
}
|
|
133
|
+
if (reset !== null) {
|
|
134
|
+
limit.resetAt = reset * 1000;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// How long to stay quiet after a refusal.
|
|
139
|
+
const waitAfter = (res) => {
|
|
140
|
+
const retry = numberHeader(res.headers, 'retry-after');
|
|
141
|
+
if (retry !== null) {
|
|
142
|
+
return { ms: retry * 1000, scope: 'secondary' };
|
|
143
|
+
}
|
|
144
|
+
if (limit.remaining === 0 && limit.resetAt > now()) {
|
|
145
|
+
return { ms: limit.resetAt - now(), scope: 'primary' };
|
|
146
|
+
}
|
|
147
|
+
return { ms: DEFAULT_SECONDARY_WAIT_MS, scope: 'secondary' };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const pauseOrThrow = async (ms, scope) => {
|
|
151
|
+
if (ms > maxWaitMs) {
|
|
152
|
+
throw rateLimitedError(Math.ceil(ms / 1000), scope);
|
|
153
|
+
}
|
|
154
|
+
await sleep(Math.max(ms, 0));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Spent budget: wait for the reset (or say how long) before asking.
|
|
158
|
+
const beforeRequest = async () => {
|
|
159
|
+
if (limit.remaining === 0 && limit.resetAt > now()) {
|
|
160
|
+
await pauseOrThrow(limit.resetAt - now() + 1000, 'primary');
|
|
161
|
+
limit.remaining = null;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const once = (url, { headers, method, body, etag, lastModified }) => {
|
|
166
|
+
const conditional = {
|
|
167
|
+
...headers,
|
|
168
|
+
...(etag ? { 'If-None-Match': etag } : {}),
|
|
169
|
+
...(lastModified ? { 'If-Modified-Since': lastModified } : {}),
|
|
170
|
+
};
|
|
171
|
+
return timedRequest(
|
|
172
|
+
fetchImpl,
|
|
173
|
+
url,
|
|
174
|
+
{
|
|
175
|
+
method,
|
|
176
|
+
headers: conditional,
|
|
177
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
178
|
+
},
|
|
179
|
+
{ timeoutMs, write: false },
|
|
180
|
+
async (res) => ({ res, text: res.status === 304 ? '' : await res.text() })
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// What a good answer carries: the body and the headers worth keeping.
|
|
185
|
+
const answer = (res, text, settings) => ({
|
|
186
|
+
status: res.status,
|
|
187
|
+
notModified: res.status === 304,
|
|
188
|
+
body: res.status === 304 ? null : parseBody(text),
|
|
189
|
+
etag: headerValue(res.headers, 'ETag') ?? settings.etag ?? null,
|
|
190
|
+
lastModified:
|
|
191
|
+
headerValue(res.headers, 'Last-Modified') ??
|
|
192
|
+
(res.status === 304 ? (settings.lastModified ?? null) : null),
|
|
193
|
+
next:
|
|
194
|
+
res.status === 304
|
|
195
|
+
? null
|
|
196
|
+
: (parseLinkHeader(headerValue(res.headers, 'Link')).next ?? null),
|
|
197
|
+
pollInterval: numberHeader(res.headers, 'X-Poll-Interval'),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* One read. Resolves `{ status, body, notModified, etag, lastModified,
|
|
202
|
+
* next, pollInterval }`; throws on a refusal (`rate-limited` carries
|
|
203
|
+
* `retryAfter`).
|
|
204
|
+
*/
|
|
205
|
+
const request = async (url, options = {}) => {
|
|
206
|
+
const settings = { method: 'GET', ...options };
|
|
207
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
208
|
+
await beforeRequest();
|
|
209
|
+
const { res, text } = await once(url, settings);
|
|
210
|
+
learn(res);
|
|
211
|
+
if (res.status === 304 || res.ok) {
|
|
212
|
+
return answer(res, text, settings);
|
|
213
|
+
}
|
|
214
|
+
if (isRateLimited(res, text)) {
|
|
215
|
+
const { ms, scope } = waitAfter(res);
|
|
216
|
+
if (attempt >= retries || ms > maxWaitMs) {
|
|
217
|
+
throw rateLimitedError(Math.ceil(ms / 1000), scope);
|
|
218
|
+
}
|
|
219
|
+
await sleep(ms);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
throw Object.assign(
|
|
223
|
+
new Error(`HTTP ${res.status} ${res.statusText}: ${text}`),
|
|
224
|
+
{ status: res.status, code: CODES[res.status] ?? 'github-error' }
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Every page of a list. `until(item)` true on a sorted list ends the walk
|
|
231
|
+
* (the rest is older than the cursor). The first page may be conditional
|
|
232
|
+
* (`etag`): then `notModified` is true and `items` is empty.
|
|
233
|
+
*/
|
|
234
|
+
const pages = async (url, options = {}) => {
|
|
235
|
+
const { until, maxPages = SAFETY_MAX_PAGES, ...first } = options;
|
|
236
|
+
const items = [];
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
let target = url;
|
|
239
|
+
let head = null;
|
|
240
|
+
let count = 0;
|
|
241
|
+
while (target && !seen.has(target) && count < maxPages) {
|
|
242
|
+
seen.add(target);
|
|
243
|
+
const page = await request(
|
|
244
|
+
target,
|
|
245
|
+
count === 0
|
|
246
|
+
? first
|
|
247
|
+
: { headers: first.headers, method: first.method ?? 'GET' }
|
|
248
|
+
);
|
|
249
|
+
head ??= page;
|
|
250
|
+
count += 1;
|
|
251
|
+
if (page.notModified) {
|
|
252
|
+
return { items, notModified: true, etag: head.etag, head };
|
|
253
|
+
}
|
|
254
|
+
target = takeItems(page, items, until) ? null : page.next;
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
items,
|
|
258
|
+
notModified: false,
|
|
259
|
+
etag: head?.etag ?? null,
|
|
260
|
+
lastModified: head?.lastModified ?? null,
|
|
261
|
+
head,
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return { request, pages, limit };
|
|
266
|
+
};
|