@preventive/triage 1.0.0-alpha.51 → 1.0.0-alpha.53

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.51",
3
+ "version": "1.0.0-alpha.53",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": {
@@ -141,6 +141,7 @@
141
141
  "server-managed/github-oauth.ts",
142
142
  "server-managed/github-pulls.ts",
143
143
  "server-managed/github-requests.ts",
144
+ "server-managed/history-windows.ts",
144
145
  "server-managed/http-response.ts",
145
146
  "server-managed/http.ts",
146
147
  "server-managed/link-reports.ts",
@@ -198,6 +199,7 @@
198
199
  "server-managed/repository-pulse.ts",
199
200
  "server-managed/repository-pulls.ts",
200
201
  "server-managed/listed-activity.ts",
202
+ "server-managed/repository-stamp.ts",
201
203
  "server-managed/repository-aliases.ts",
202
204
  "server-managed/repository-discovery.ts",
203
205
  "server-managed/repository-policy.ts",
@@ -285,6 +287,10 @@
285
287
  "common/managed/comments.ts",
286
288
  "common/managed/finding-detail.d.ts",
287
289
  "common/managed/finding-detail.js",
290
+ "common/managed/finding-values.d.ts",
291
+ "common/managed/finding-values.js",
292
+ "common/managed/lite-name.d.ts",
293
+ "common/managed/lite-name.js",
288
294
  "common/managed/github-agents.ts",
289
295
  "common/managed/github-logins.ts",
290
296
  "common/managed/npm-packages.d.ts",
@@ -44,15 +44,21 @@ export async function shareGithubRead<T>(fetchImpl: typeof fetch, request: strin
44
44
  }
45
45
 
46
46
  // GitHub's answers to GETs that came with an ETag, by the request (its URL
47
- // and credential, hashed as outstanding reads are): the next asked again
48
- // If-None-Match, GitHub checking the credential as ever, and a 304, which
49
- // it doesn't count against the rate limit, answered by the body kept. Each
50
- // transport's own, as many as MAX_TAGGED_READS and MAX_TAGGED_BYTES hold,
51
- // the least recently used first to go; none larger than MAX_TAGGED_READ_BYTES.
47
+ // and credential, hashed as outstanding reads are; an installation's token,
48
+ // which the App mints anew every few minutes on every instance, as the
49
+ // installations' one credential, GitHub telling apart which may read what):
50
+ // the next asked again If-None-Match, GitHub checking the credential as
51
+ // ever, and a 304, which it doesn't count against the rate limit, answered
52
+ // by the body kept. Each transport's own, as many as MAX_TAGGED_READS and
53
+ // MAX_TAGGED_BYTES hold, the least recently used first to go; none larger
54
+ // than MAX_TAGGED_READ_BYTES. Those one this process doesn't hold, read of
55
+ // the store kept beyond it (keepTaggedReadsIn), as a serverless instance
56
+ // starting anew holds none, as many as fit it (MAX_STORED_READ_BYTES).
52
57
  const MAX_TAGGED_READS = 1000
53
58
  const MAX_TAGGED_BYTES = 32 * 1024 * 1024
54
59
  const MAX_TAGGED_READ_BYTES = 1024 * 1024
55
- type TaggedRead = { etag: string; text: string }
60
+ const MAX_STORED_READ_BYTES = 256 * 1024
61
+ export type TaggedRead = { etag: string; text: string }
56
62
  const taggedReads = new WeakMap<typeof fetch, { reads: Map<string, TaggedRead>; bytes: number }>()
57
63
  const taggedOf = (fetchImpl: typeof fetch) => {
58
64
  let tagged = taggedReads.get(fetchImpl)
@@ -60,29 +66,50 @@ const taggedOf = (fetchImpl: typeof fetch) => {
60
66
  return tagged
61
67
  }
62
68
  const requestKey = (request: string) => createHash('sha256').update(request).digest('hex')
69
+ // A GET's request as its answer is kept by: its URL and credential.
70
+ const taggedRequest = (url: string, token: string | null) => JSON.stringify([url, token?.startsWith('ghs_') ? { installation: true } : token])
63
71
 
64
- // The answer kept to `request`, its ETag and body as GitHub wrote it.
65
- export function taggedRead(fetchImpl: typeof fetch, request: string): TaggedRead | undefined {
72
+ // Where GitHub's tagged answers are kept beyond this process (http.ts
73
+ // registers the database's), by the request's key; its failures are none
74
+ // kept, or none written.
75
+ export interface TaggedStore { read(key: string): Promise<TaggedRead | null>; write(key: string, read: TaggedRead): Promise<void> }
76
+ let taggedStore: TaggedStore | null = null
77
+ export function keepTaggedReadsIn(store: TaggedStore | null): void { taggedStore = store }
78
+
79
+ // The answer kept to `request`, its ETag and body as GitHub wrote it: as
80
+ // this process holds it, else, where a store is kept beyond it, as that
81
+ // keeps it.
82
+ export function taggedRead(fetchImpl: typeof fetch, request: string): TaggedRead | undefined | Promise<TaggedRead | undefined> {
66
83
  const { reads } = taggedOf(fetchImpl)
67
84
  const key = requestKey(request)
68
85
  const read = reads.get(key)
69
- if (read) { reads.delete(key); reads.set(key, read) }
70
- return read
86
+ if (read) { reads.delete(key); reads.set(key, read); return read }
87
+ return taggedStore ? storedTaggedRead(fetchImpl, key, taggedStore) : undefined
88
+ }
89
+ async function storedTaggedRead(fetchImpl: typeof fetch, key: string, store: TaggedStore) {
90
+ const stored = await store.read(key).catch(() => null)
91
+ if (!stored || typeof stored.etag !== 'string' || typeof stored.text !== 'string') return undefined
92
+ holdTaggedRead(fetchImpl, key, stored)
93
+ return stored
71
94
  }
72
95
 
73
96
  // `text` kept as GitHub's answer to `request`, by its ETag.
74
- export function keepTaggedRead(fetchImpl: typeof fetch, request: string, etag: string, text: string): void {
75
- const tagged = taggedOf(fetchImpl)
97
+ export async function keepTaggedRead(fetchImpl: typeof fetch, request: string, etag: string, text: string): Promise<void> {
76
98
  const key = requestKey(request)
99
+ holdTaggedRead(fetchImpl, key, { etag, text })
100
+ if (text.length <= MAX_STORED_READ_BYTES) await taggedStore?.write(key, { etag, text }).catch(() => {})
101
+ }
102
+ function holdTaggedRead(fetchImpl: typeof fetch, key: string, read: TaggedRead) {
103
+ const tagged = taggedOf(fetchImpl)
77
104
  const before = tagged.reads.get(key)
78
105
  if (before) { tagged.reads.delete(key); tagged.bytes -= before.text.length }
79
- if (text.length > MAX_TAGGED_READ_BYTES) return
80
- tagged.reads.set(key, { etag, text })
81
- tagged.bytes += text.length
82
- for (const [oldest, read] of tagged.reads) {
106
+ if (read.text.length > MAX_TAGGED_READ_BYTES) return
107
+ tagged.reads.set(key, read)
108
+ tagged.bytes += read.text.length
109
+ for (const [oldest, held] of tagged.reads) {
83
110
  if (tagged.reads.size <= MAX_TAGGED_READS && tagged.bytes <= MAX_TAGGED_BYTES) break
84
111
  tagged.reads.delete(oldest)
85
- tagged.bytes -= read.text.length
112
+ tagged.bytes -= held.text.length
86
113
  }
87
114
  }
88
115
 
@@ -97,10 +124,11 @@ export function keepTaggedRead(fetchImpl: typeof fetch, request: string, etag: s
97
124
  export function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
98
125
  readOnly = method === 'GET'): Promise<unknown> {
99
126
  if (readOnly) return shareGithubRead(fetchImpl, JSON.stringify([url, token, method, body]), () => githubJson(url, token, fetchImpl, method, body, false))
100
- const request = method === 'GET' && body === undefined ? JSON.stringify([url, token]) : null
101
- const tagged = request === null ? undefined : taggedRead(fetchImpl, request)
127
+ const request = method === 'GET' && body === undefined ? taggedRequest(url, token) : null
102
128
  // Each call told in its request's Server-Timing (request-timing.ts).
103
129
  return timed('gh', async () => {
130
+ const held = request === null ? undefined : taggedRead(fetchImpl, request)
131
+ const tagged = held instanceof Promise ? await held : held
104
132
  let res: Response
105
133
  const started = Date.now()
106
134
  try {
@@ -122,15 +150,17 @@ export function githubJson(url: string, token: string | null, fetchImpl: typeof
122
150
  // The answer's body is read within the same timeout: one not read whole is
123
151
  // GitHub not reached, as one not answered is; only one that isn't JSON is
124
152
  // malformed.
125
- const etag = request === null ? null : res.headers?.get('etag') ?? null
153
+ // Kept only whole: never a 202's, GitHub still working out its answer.
154
+ const etag = request === null || res.status !== 200 ? null : res.headers?.get('etag') ?? null
155
+ let text: string | null = null, value: unknown
126
156
  try {
127
157
  if (etag === null) return await res.json()
128
- const text = await res.text()
129
- const value = JSON.parse(text) as unknown
130
- keepTaggedRead(fetchImpl, request!, etag, text)
131
- return value
158
+ text = await res.text()
159
+ value = JSON.parse(text)
132
160
  } catch (error) {
133
161
  throw error instanceof SyntaxError ? new GithubApiError(502, 'github-malformed') : githubUnreachable(error, started)
134
162
  }
163
+ await keepTaggedRead(fetchImpl, request!, etag, text)
164
+ return value
135
165
  })
136
166
  }
@@ -0,0 +1,172 @@
1
+ // A default branch's history read by when its commits were made, for the
2
+ // Pulse (repository-pulse.ts yearCommits): where the pages kept from a head
3
+ // run out, the rest of the time it needs read at once, in windows of time
4
+ // (GitHub's `history(since:, until:)`), not a page after another, each page
5
+ // waiting on the last; and the pages GitHub would list from the commits read
6
+ // (git log) kept by their tops, as reading a page at a time keeps them, so a
7
+ // walk later, from a head moved on or in the Commits tab, reads them kept.
8
+ import { linkNoreplyUsers } from './github-accounts.ts'
9
+ import { githubGraphql } from './github-graphql.ts'
10
+ import { COMMIT_FRAGMENT, type GraphCommit, HISTORY_PAGE, type HistoryCommit, type HistoryPages, PAGES_PARALLEL, PAGES_PER_QUERY, commitActors, graphCommit,
11
+ inBatches, variablesOf } from './repository-history.ts'
12
+
13
+ // Windows a read is split into at most, each meant to hold as many commits
14
+ // as WINDOW_COMMITS, a page's worth with room to spare, as often as the
15
+ // commits read before it were made, as far back as they reach, the rest left
16
+ // to the next read; pages read of them at most a read, those over going on
17
+ // where a window holds more, as many times as MAX_WAVES, each waiting on the
18
+ // last; and pages kept of what was read at most.
19
+ const MAX_WINDOWS = 40
20
+ const WINDOW_COMMITS = 70
21
+ const WINDOW_PAGES = 50
22
+ const MAX_WAVES = 5
23
+ const MAX_KEPT = 500
24
+ const KEPT_AT_ONCE = 10
25
+ const WINDOWS_AFTER = 2
26
+ const HOUR_MS = 60 * 60_000
27
+
28
+ // Each window's commits from `$head`, `w<n>` the `n`th's: those made since
29
+ // `$s<n>` (none, any time) and until `$u<n>`, after `$a<n>`.
30
+ const windowsQuery = (count: number) => `query($owner: String!, $name: String!, $head: GitObjectID!, ${variablesOf('s', count, 'GitTimestamp')}, ${
31
+ variablesOf('u', count, 'GitTimestamp!')}, ${variablesOf('a', count, 'String')}) {
32
+ repository(owner: $owner, name: $name) {
33
+ object(oid: $head) { ... on Commit {
34
+ ${Array.from({ length: count }, (_, at) => `w${at}: history(first: ${HISTORY_PAGE}, since: $s${at}, until: $u${at}, after: $a${at}) {
35
+ pageInfo { hasNextPage endCursor } nodes { ...historyCommit } }`).join('\n ')}
36
+ } }
37
+ }
38
+ }
39
+ ${COMMIT_FRAGMENT}`
40
+
41
+ type Window = { since: number | null; until: number; after: string | null; oldest?: number }
42
+ type WindowPage = { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: GraphCommit[] } | null
43
+ type Windows = { repository?: { object?: Record<string, WindowPage> | null } | null } | null
44
+ // A time as GitHub takes it, to the second: a window's start rounded down,
45
+ // its end up, so that those beside each other overlap rather than part.
46
+ const stamp = (at: number, up: boolean) => new Date((up ? Math.ceil : Math.floor)(at / 1000) * 1000).toISOString()
47
+
48
+ // The commits from `head` made from `low` to `high`, as often as `perMs` (commits
49
+ // a millisecond) says they are made, as many windows as MAX_WINDOWS reach
50
+ // down from `high`, read at once (inBatches), the latest first, each paged on
51
+ // as far as it goes, as many pages as WINDOW_PAGES; and, those reaching
52
+ // `low`, a page of those made before it besides, where a walk reading them
53
+ // goes on from. Since when every one is read (`through`: where the windows
54
+ // reach, each read whole, else, of the latest not, just after the earliest
55
+ // made of those it read, as GitHub lists them the latest made first, those
56
+ // made together left out; or its end, none read), and how many pages were
57
+ // read (`read`).
58
+ export async function readWindows(fullName: string, token: string, head: string, low: number, high: number, perMs: number, fetchImpl: typeof fetch) {
59
+ const [owner, name] = fullName.split('/') as [string, string]
60
+ const bottom = Math.max(low, high - MAX_WINDOWS * WINDOW_COMMITS / perMs)
61
+ const count = Math.min(MAX_WINDOWS, Math.max(1, Math.ceil((high - bottom) * perMs / WINDOW_COMMITS)))
62
+ const width = (high - bottom) / count
63
+ let open: Window[] = [...Array.from({ length: count }, (_, at) => ({ since: high - (at + 1) * width, until: high - at * width, after: null })),
64
+ ...bottom > low ? [] : [{ since: null, until: low - 1000, after: null }]]
65
+ const commits = new Map<string, HistoryCommit>()
66
+ let [read, waves] = [0, 0]
67
+ while (open.length > 0 && read < WINDOW_PAGES && waves++ < MAX_WAVES) {
68
+ const wave = open.slice(0, WINDOW_PAGES - read)
69
+ const next: Window[] = []
70
+ read += wave.length
71
+ await inBatches(wave, PAGES_PER_QUERY, PAGES_PARALLEL, async batch => {
72
+ const data = await githubGraphql(windowsQuery(batch.length), { owner, name, head, ...Object.fromEntries(batch.flatMap((window, at) => [
73
+ [`s${at}`, window.since === null ? null : stamp(window.since, false)], [`u${at}`, stamp(window.until, true)], [`a${at}`, window.after]])) }, token, fetchImpl) as Windows
74
+ const pages = batch.map((_, at) => data?.repository?.object?.[`w${at}`] ?? null)
75
+ await linkNoreplyUsers(pages.flatMap(page => page?.nodes ?? []).flatMap(commitActors), token, fetchImpl)
76
+ for (const [at, window] of batch.entries()) {
77
+ let oldest = window.oldest ?? Number.POSITIVE_INFINITY
78
+ for (const commit of (pages[at]?.nodes ?? []).flatMap(node => graphCommit(node) ?? [])) {
79
+ if (!commits.has(commit.sha)) commits.set(commit.sha, commit)
80
+ oldest = Math.min(oldest, commit.committedAt ?? Number.POSITIVE_INFINITY)
81
+ }
82
+ const info = pages[at]?.pageInfo
83
+ if (window.since !== null && info?.hasNextPage === true && typeof info.endCursor === 'string') next.push({ ...window, after: info.endCursor, oldest })
84
+ }
85
+ })
86
+ open = [...next, ...open.slice(wave.length)]
87
+ }
88
+ const through = Math.max(bottom, ...open.flatMap(window => window.since === null ? [] : [Number.isFinite(window.oldest) ? window.oldest! + 1 : window.until]))
89
+ return { commits: [...commits.values()], through, read }
90
+ }
91
+
92
+ // The page GitHub would list from `top` (git log: of those reached, the
93
+ // latest made first, of those made together the first reached) of `known`
94
+ // commits, every one made since `low` known; none where it would list one not
95
+ // known, or one made before `low` while one not known waits beside it.
96
+ export function gitLogPage(top: string, known: Map<string, HistoryCommit>, low: number): HistoryCommit[] | null {
97
+ if (!known.has(top)) return null
98
+ const page: HistoryCommit[] = []
99
+ const reached = new Set<string>()
100
+ const waiting: HistoryCommit[] = []
101
+ let unknown = 0
102
+ const reach = (sha: string) => {
103
+ if (reached.has(sha)) return
104
+ reached.add(sha)
105
+ const commit = known.get(sha)
106
+ if (!commit || commit.committedAt === null) { unknown++; return }
107
+ const at = waiting.findIndex(other => other.committedAt! < commit.committedAt!)
108
+ waiting.splice(at < 0 ? waiting.length : at, 0, commit)
109
+ }
110
+ reach(top)
111
+ while (page.length < HISTORY_PAGE && waiting.length > 0) {
112
+ const commit = waiting.shift()!
113
+ if (commit.committedAt! < low && unknown > 0) return null
114
+ page.push(commit)
115
+ for (const parent of commit.parents) reach(parent)
116
+ }
117
+ return page.length < HISTORY_PAGE && unknown > 0 ? null : page
118
+ }
119
+
120
+ // The pages from each of `tops` GitHub would list of `known` commits
121
+ // (gitLogPage), kept by their tops, KEPT_AT_ONCE at a time, and from the
122
+ // parents of those they list that none of `listed` is nor any kept lists, on
123
+ // till none is left, as many as MAX_KEPT: as a walk reading pages goes on to
124
+ // them.
125
+ export async function keepPages(tops: string[], known: Map<string, HistoryCommit>, listed: Set<string>, low: number, pages: HistoryPages) {
126
+ const covered = new Set(listed)
127
+ const kept: [string, HistoryCommit[]][] = []
128
+ const waiting = [...tops]
129
+ while (waiting.length > 0 && kept.length < MAX_KEPT) {
130
+ const top = waiting.shift()!
131
+ if (covered.has(top)) continue
132
+ const page = gitLogPage(top, known, low)
133
+ if (!page) continue
134
+ kept.push([top, page])
135
+ for (const commit of page) covered.add(commit.sha)
136
+ for (const commit of page) for (const parent of commit.parents) if (!covered.has(parent) && known.has(parent)) waiting.push(parent)
137
+ }
138
+ for (let at = 0; at < kept.length; at += KEPT_AT_ONCE) await Promise.all(kept.slice(at, at + KEPT_AT_ONCE).map(([top, page]) => pages.write(top, page)))
139
+ }
140
+
141
+ // What a walk from `head` (yearCommits) reads next of `starts`: those a page
142
+ // is kept from (`batch`), where any is, or all of them, a page at a time, as
143
+ // long as it has read GitHub fewer times than WINDOWS_AFTER (`queries`), a
144
+ // head moved on soon reaching the pages kept from where it was; else, none
145
+ // kept, those made from
146
+ // `low` to `high` read at once (readWindows), as often as those `found`
147
+ // were made, told it (`found`), and the pages from `starts` on kept of them,
148
+ // as far down as every one is read (keepPages), the queries that took
149
+ // (`queries`, PAGES_PER_QUERY pages each, as many at once as PAGES_PARALLEL
150
+ // a query of its) and none to read (`windowed`); where the windows can't be
151
+ // read, all of them, a page at a time (`windowed` all the same).
152
+ export async function windowsOn(fullName: string, token: string, head: string, starts: string[], found: Map<string, HistoryCommit>, low: number, high: number,
153
+ pages: HistoryPages, fetchImpl: typeof fetch, queries: number): Promise<{ batch: string[]; queries: number; windowed: boolean }> {
154
+ const kept = await pages.read(starts)
155
+ if (kept.size > 0 || queries < WINDOWS_AFTER || !(high > low) || !Number.isFinite(high)) {
156
+ return { batch: kept.size > 0 ? starts.filter(sha => kept.has(sha)) : starts, queries: 0, windowed: false }
157
+ }
158
+ let [newest, oldest, dated] = [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, 0]
159
+ for (const { committedAt } of found.values()) {
160
+ if (committedAt === null) continue
161
+ [newest, oldest, dated] = [Math.max(newest, committedAt), Math.min(oldest, committedAt), dated + 1]
162
+ }
163
+ // From the one commit the walk goes on from, where there is one, none of
164
+ // those read listed again; else from the head.
165
+ const read = await readWindows(fullName, token, starts.length === 1 ? starts[0]! : head, low, high, dated / Math.max(newest - oldest, HOUR_MS), fetchImpl)
166
+ .catch((err: unknown) => { console.warn('managed: history windows read failed:', err); return null })
167
+ if (!read) return { batch: starts, queries: 0, windowed: true }
168
+ const listed = new Set(found.keys())
169
+ for (const commit of read.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
170
+ await keepPages(starts, found, listed, read.through, pages)
171
+ return { batch: [], queries: Math.ceil(read.read / (PAGES_PER_QUERY * PAGES_PARALLEL)), windowed: true }
172
+ }