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.
Files changed (45) hide show
  1. package/js/src/server/aggregate.js +16 -2
  2. package/js/src/server/github-service.js +383 -0
  3. package/js/src/server/index.js +13 -9
  4. package/js/src/server/routes-github.js +98 -0
  5. package/js/src/server/routes-messages.js +8 -6
  6. package/js/src/server/routes-mutating.js +15 -32
  7. package/js/src/server/routes-pull.js +163 -0
  8. package/js/src/sources/auto-import/files.js +5 -1
  9. package/js/src/sources/auto-import/recipes.js +18 -1
  10. package/js/src/sources/github-actions.js +162 -0
  11. package/js/src/sources/github-http.js +266 -0
  12. package/js/src/sources/github-inbox.js +312 -0
  13. package/js/src/sources/github-ref.js +106 -0
  14. package/js/src/sources/github-sync.js +124 -0
  15. package/js/src/sources/github.js +79 -193
  16. package/js/src/sources/pull-runner.js +180 -0
  17. package/js/src/sources/vk-html-archive.js +268 -0
  18. package/js/src/sources/vk-limits.js +119 -0
  19. package/js/src/sources/vk-longpoll.js +333 -0
  20. package/js/src/sources/vk-sync.js +138 -0
  21. package/js/src/sources/vk.js +224 -71
  22. package/js/src/storage/migrations/0003-vk-reactions-docs.js +95 -0
  23. package/js/src/storage/migrations/0004-github-external-ids.js +115 -0
  24. package/js/src/storage/migrations/index.js +4 -2
  25. package/js/src/web/app.css +43 -0
  26. package/js/src/web/chat-media.js +3 -0
  27. package/js/src/web/chat-remote-media.js +111 -0
  28. package/js/src/web/connections/fetch-now.js +81 -0
  29. package/js/src/web/connections/github-inbox.js +236 -0
  30. package/js/src/web/connections/index.js +6 -0
  31. package/js/src/web/dom.js +17 -0
  32. package/js/src/web/locales/en-chat-account.js +17 -0
  33. package/js/src/web/locales/en-github.js +30 -0
  34. package/js/src/web/locales/en.js +2 -0
  35. package/js/src/web/locales/hi-chat-account.js +17 -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-chat-account.js +17 -0
  39. package/js/src/web/locales/ru-github.js +30 -0
  40. package/js/src/web/locales/ru.js +2 -0
  41. package/js/src/web/locales/zh-chat-account.js +16 -0
  42. package/js/src/web/locales/zh-github.js +28 -0
  43. package/js/src/web/locales/zh.js +2 -0
  44. package/js/src/web/message-parts.js +34 -9
  45. 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
- const archive = toArchive(recipe, name, data.toString('utf8'), context);
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
- files: /messages.*\.json$/i,
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
+ };