@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.
- package/common/managed/finding-detail.d.ts +4 -2
- package/common/managed/finding-detail.js +26 -14
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +32 -3
- package/common/workspace-app.js +26 -6
- package/out/activity.js +2 -2
- package/out/{chunk-NCN3M5EP.js → chunk-JEFZTVNX.js} +1 -1
- package/out/{chunk-IMM274IW.js → chunk-NE2UGKRJ.js} +1 -1
- package/out/{chunk-OGAYMEER.js → chunk-TSIFIFBX.js} +41 -41
- package/out/{chunk-CHRBUGVN.js → chunk-YA4ETCFU.js} +1 -1
- package/out/client-managed.js +1 -1
- package/out/compare.js +1 -1
- package/out/dialogs.js +1 -1
- package/out/index.html +2 -2
- package/out/scan.js +1 -1
- package/out/view.js +1 -1
- package/package.json +2 -1
- package/server-managed/history-windows.ts +172 -0
- package/server-managed/repository-history.ts +2 -2
- package/server-managed/repository-pulse.ts +11 -2
- package/server-managed/team-reports.ts +7 -6
|
@@ -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
|
-
|
|
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
|
|
206
|
-
// App mode does not show (common/managed/finding-detail.js).
|
|
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,
|
|
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)
|