@preventive/triage 1.0.0-alpha.52 → 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.
@@ -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
+ }
@@ -242,8 +242,8 @@ async function repositoryHeads(owner: string, name: string, token: string, fetch
242
242
  // Histories read in a GraphQL query, and queries at once: each counts its
243
243
  // commits' lines, which GitHub works out as it answers, stopping a query
244
244
  // past ten seconds.
245
- const PAGES_PER_QUERY = 5
246
- const PAGES_PARALLEL = 5
245
+ export const PAGES_PER_QUERY = 5
246
+ export const PAGES_PARALLEL = 5
247
247
 
248
248
  // The first page of the history from each of `tops`, by its top, each told
249
249
  // to `keep` as it is read, so those read stay kept where another fails: with
@@ -16,6 +16,7 @@ import { agentOperated, githubAgent } from '../common/managed/github-agents.ts'
16
16
  import { GithubApiError, githubJson } from './github-app.ts'
17
17
  import { shareGithubRead } from './github-requests.ts'
18
18
  import { coauthorsOf } from './commit-coauthors.ts'
19
+ import { windowsOn } from './history-windows.ts'
19
20
  import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accountOf, actorOf, githubGraphql, historyFrom } from './repository-history.ts'
20
21
 
21
22
  export const PULSE_WEEKS = [1, 4, 13, 52]
@@ -222,6 +223,7 @@ export async function yearCommits(fullName: string, token: string, head: string,
222
223
  const read = () => [...found.values()].filter(dated).length
223
224
  const passed = () => [...found.values()].filter(commit => !dated(commit) && within(commit))
224
225
  let queries = 0
226
+ let windowed = false
225
227
  let starts = [head]
226
228
  while (starts.length > 0) {
227
229
  // Those not read yet, the head before any, else the parents of those
@@ -232,9 +234,16 @@ export async function yearCommits(fullName: string, token: string, head: string,
232
234
  .filter(commit => commit.parents.some(sha => unread.has(sha))).map(commit => commit.committedAt ?? Number.POSITIVE_INFINITY))
233
235
  if (need > since && from < need) return { complete: true, commits: [...found.values()].filter(dated), passed: passed(), from }
234
236
  if (queries >= YEAR_QUERIES) return { complete: false, read: read(), tooMany: false }
235
- const batch = starts.slice(0, YEAR_STARTS)
237
+ // The pages kept read first; once past them, the rest read at once by
238
+ // when its commits were made (history-windows.ts windowsOn), as far back
239
+ // as it needs, the year's as far as its commits are followed.
240
+ const next: Awaited<ReturnType<typeof windowsOn>> | null = windowed || found.size === 0 ? null
241
+ : await windowsOn(fullName, token, head, starts, found, need > since ? need : since - YEAR_SKEW_MS, from, pages, fetchImpl, queries)
242
+ windowed ||= next?.windowed === true
243
+ queries += next?.queries ?? 0
244
+ const batch = (next?.batch ?? starts).slice(0, YEAR_STARTS)
236
245
  for (const sha of batch) tried.add(sha)
237
- const got = await historyFrom(fullName, token, batch, pages, fetchImpl)
246
+ const got = batch.length > 0 ? await historyFrom(fullName, token, batch, pages, fetchImpl) : { commits: [], read: 0 }
238
247
  if (got.read > 0) queries++
239
248
  for (const commit of got.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
240
249
  told(read())
@@ -194,7 +194,7 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
194
194
  const encoded = {
195
195
  // Bounded together, as when the trees were sent with their reports.
196
196
  reports: encodeReports(reports, links, trees.length),
197
- lite: encodeReports(lite.reports, links, 0, lite.folded),
197
+ lite: encodeReports(lite.reports, links, 0, { folded: lite.folded, lens: lite.lens }),
198
198
  trees,
199
199
  }
200
200
  retainVisibility(db, workspaceKey(snapshot), visible)
@@ -202,11 +202,12 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
202
202
  return { ...encoded, visible }
203
203
  }
204
204
  // `{ reports, links }` as a response sends them, `spent` bytes already
205
- // counted against the bound; `folded` where the board's copy left out rows
206
- // App mode does not show (common/managed/finding-detail.js). Its findings'
207
- // packages and repositories are sent once each, in `values`
205
+ // counted against the bound; `folded` and its `lens` where the board's copy
206
+ // left out rows App mode does not show (common/managed/finding-detail.js).
207
+ // Its findings' packages and repositories are sent once each, in `values`
208
208
  // (common/managed/finding-values.js).
209
- function encodeReports(reports: readonly TeamReport[], links: readonly string[][], spent: number, folded = false): Buffer {
209
+ function encodeReports(reports: readonly TeamReport[], links: readonly string[][], spent: number,
210
+ { folded = false, lens = null }: { folded?: false | 'rows' | 'app'; lens?: unknown } = {}): Buffer {
210
211
  let outputBytes = spent + 14
211
212
  const parts = [Buffer.from('{"reports":[')]
212
213
  const values = findingValueIndex()
@@ -217,7 +218,7 @@ function encodeReports(reports: readonly TeamReport[], links: readonly string[][
217
218
  if (index) parts.push(Buffer.from(','))
218
219
  parts.push(bytes)
219
220
  }
220
- const tail = JSON.stringify({ ...links.length > 0 ? { links } : {}, ...folded ? { folded } : {}, ...values.values.length > 0 ? { values: values.values } : {} })
221
+ const tail = JSON.stringify({ ...links.length > 0 ? { links } : {}, ...folded ? { folded, lens } : {}, ...values.values.length > 0 ? { values: values.values } : {} })
221
222
  const suffix = Buffer.from(tail === '{}' ? ']}' : `],${tail.slice(1)}`)
222
223
  if (outputBytes + suffix.length > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
223
224
  parts.push(suffix)