@preventive/triage 1.0.0-alpha.45 → 1.0.0-alpha.46
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/client/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +5 -1
- package/common/line-diff.js +5 -4
- package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
- package/out/{chunk-AFLZH7WG.js → chunk-4UFAMJAX.js} +1 -1
- package/out/{chunk-HMAJDVXN.js → chunk-7XRR4OFU.js} +1 -1
- package/out/{chunk-Q6MVYGYV.js → chunk-BOIXMUU5.js} +1 -1
- package/out/{chunk-XVBEOGOS.js → chunk-CC4OM6TZ.js} +2 -2
- package/out/chunk-G24A42V6.js +2 -0
- package/out/{chunk-WI7Z5A2T.js → chunk-JKGQ3O7O.js} +1 -1
- package/out/chunk-LE5ZWLG4.js +235 -0
- package/out/chunk-MSION3G4.js +36 -0
- package/out/{chunk-6HNXNIQY.js → chunk-S6GIXWQW.js} +1 -1
- package/out/chunk-V3KWNNMY.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +25 -25
- package/out/compare.js +13 -13
- package/out/dialogs.js +40 -40
- package/out/graph.js +1 -1
- package/out/index.html +6 -6
- package/out/scan.js +2 -2
- package/out/terminal.js +1 -1
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +3 -1
- package/risk/actions/findings.js +14 -9
- package/risk/actions/scripts/shell.js +5 -1
- package/risk/index.d.ts +5 -2
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/bundle-cache.ts +7 -1
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-lockfiles.ts +21 -11
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +37 -9
- package/server-managed/db-neon.ts +61 -21
- package/server-managed/db-schema.ts +4 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/dependency-reviews.ts +13 -3
- package/server-managed/github-app.ts +12 -6
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +195 -111
- package/server-managed/index.ts +8 -7
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/npm-delta.ts +11 -6
- package/server-managed/npm-kept.ts +32 -2
- package/server-managed/npm-loads.ts +100 -30
- package/server-managed/npm-packages.ts +89 -3
- package/server-managed/npm-risk.ts +4 -1
- package/server-managed/pretty-print.ts +67 -23
- package/server-managed/pull-dependencies.ts +29 -9
- package/server-managed/pull-dependency-reviews.ts +8 -8
- package/server-managed/queue.ts +16 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +42 -9
- package/server-managed/repository-actions.ts +15 -6
- package/server-managed/repository-browser.ts +119 -31
- package/server-managed/repository-dependency-reviews.ts +41 -19
- package/server-managed/repository-history.ts +24 -8
- package/server-managed/repository-pulse.ts +8 -1
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +11 -6
- package/server-managed/repository-risk.ts +27 -20
- package/server-managed/session.ts +18 -4
- package/server-managed/sql.ts +5 -2
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-reports.ts +24 -12
- package/server-managed/upstream-cache.ts +12 -0
- package/server.ts +14 -5
- package/out/chunk-5DHL5NG3.js +0 -2
- package/out/chunk-NV7ES4EW.js +0 -1
- package/out/chunk-PTGDYLNS.js +0 -36
- package/out/chunk-QRD6GIG5.js +0 -229
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.46",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -139,6 +139,7 @@
|
|
|
139
139
|
"server-managed/github-users.ts",
|
|
140
140
|
"server-managed/github-oauth.ts",
|
|
141
141
|
"server-managed/github-pulls.ts",
|
|
142
|
+
"server-managed/github-requests.ts",
|
|
142
143
|
"server-managed/http-response.ts",
|
|
143
144
|
"server-managed/http.ts",
|
|
144
145
|
"server-managed/link-reports.ts",
|
|
@@ -156,6 +157,7 @@
|
|
|
156
157
|
"server-managed/npm-risk.ts",
|
|
157
158
|
"server-managed/npm-provenance.ts",
|
|
158
159
|
"server-managed/kept.ts",
|
|
160
|
+
"server-managed/kept-briefly.ts",
|
|
159
161
|
"server-managed/npm-kept.ts",
|
|
160
162
|
"server-managed/npm-loads.ts",
|
|
161
163
|
"server-managed/npm-delta.ts",
|
package/risk/actions/findings.js
CHANGED
|
@@ -64,16 +64,21 @@ function lockedIn(lockfiles, directory, name) {
|
|
|
64
64
|
// The inputs a text names (`${{ inputs.title }}`).
|
|
65
65
|
const inputsIn = text => typeof text === 'string' ? [...dotted(text).matchAll(/\$\{\{[^}]*?\binputs\s*\.\s*([\w-]+)/gu)].map(match => match[1]) : []
|
|
66
66
|
|
|
67
|
-
// The inputs a step's action writes into a
|
|
68
|
-
// `script
|
|
69
|
-
// `actions`) names in its `run:`s or
|
|
67
|
+
// The inputs a step's action, or a job's reusable workflow, writes into a
|
|
68
|
+
// script: github-script's `script`; those one of the repository's own
|
|
69
|
+
// (ScanOptions `actions`) names in its `run:`s or its jobs' images, or hands
|
|
70
|
+
// to such an input of a step's or a job's; and each of another reusable
|
|
71
|
+
// workflow's, or one whose text isn't told, as it may.
|
|
70
72
|
function scriptInputs(step, actions, seen = new Set()) {
|
|
71
73
|
if (step.uses?.repo?.toLowerCase() === 'actions/github-script') return ['script']
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
+
if (step.uses?.kind === 'workflow' || (step.uses?.job && !actions?.has(step.uses.path))) return Object.keys(step.with ?? {})
|
|
75
|
+
const path = step.uses?.kind === 'local' && actions?.has(step.uses.path) && !seen.has(step.uses.path) ? step.uses.path : null
|
|
76
|
+
const read = path === null ? null : readWorkflow(step.uses.job ? 'workflow' : 'action', actions.get(path))
|
|
74
77
|
if (!read || read instanceof YamlError) return []
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
const inner = new Set(seen).add(path)
|
|
79
|
+
const handed = each => scriptInputs(each, actions, inner).flatMap(input => inputsIn(each.with?.[input]))
|
|
80
|
+
return [...new Set(read.jobs.flatMap(job => [...job.uses ? handed(job) : [], ...job.images.flatMap(image => inputsIn(image.text)),
|
|
81
|
+
...job.steps.flatMap(each => [...inputsIn(each.run?.script), ...handed(each)])]))]
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
// Injections through the inputs an action writes into its scripts, and a
|
|
@@ -98,8 +103,8 @@ function jobFindings(job, triggers, { owner, lockfiles, external, actions }) {
|
|
|
98
103
|
const finding = usesFinding(job.uses, owner)
|
|
99
104
|
if (finding) found.push({ ...finding, line: job.uses.line })
|
|
100
105
|
if (job.secrets === 'inherit' && job.uses.repo && ownerKind(job.uses.repo, owner) === 'third-party') found.push({ id: 'secrets-inherited', severity: 'high', uses: job.uses.uses, key: usesKey(job.uses), line: job.uses.line })
|
|
101
|
-
// An input
|
|
102
|
-
for (const
|
|
106
|
+
// An input a reusable workflow writes into its scripts.
|
|
107
|
+
for (const input of scriptInputs(job, actions)) for (const { index: _index, ...injected } of scriptInjections(job.with?.[input], triggers)) found.push({ ...injected, line: job.withLines[input] })
|
|
103
108
|
}
|
|
104
109
|
for (const image of job.images) {
|
|
105
110
|
if (!image.read?.digest && !image.text.includes('${{')) found.push({ ...imageFinding(image.text, image.read), line: image.line })
|
|
@@ -12,7 +12,11 @@ const SHELL = new RegExp(String.raw`^(?:.*[\/\\])?${SHELLS}(?:\.exe)?$`, 'iu')
|
|
|
12
12
|
// pipe, `&&` or `||` the next line goes on from), and the line it starts
|
|
13
13
|
// on, counting from 0.
|
|
14
14
|
export function scriptLines(script) {
|
|
15
|
-
|
|
15
|
+
// An expression (`${{ … }}`), which GitHub writes in before the shell reads
|
|
16
|
+
// the script, read as a variable is: a value unknown here, each line it
|
|
17
|
+
// spans past its first continuing the one it is on; a `}}` in one of its
|
|
18
|
+
// strings (`format('{{0}}')`) not its end.
|
|
19
|
+
const lines = script.replaceAll(/\$\{\{(?:'[^']*'|[^'])*?\}\}/gu, expression => `$EXPRESSION${' \\\n'.repeat(expression.split('\n').length - 1)}`).split('\n')
|
|
16
20
|
const joined = []
|
|
17
21
|
for (let at = 0; at < lines.length; at++) {
|
|
18
22
|
const start = at
|
package/risk/index.d.ts
CHANGED
|
@@ -200,8 +200,11 @@ export interface ScanOptions {
|
|
|
200
200
|
// Its lockfiles' paths, the packages each locks, as npx runs those, by
|
|
201
201
|
// name and by `name@version`, and those only devDependencies need.
|
|
202
202
|
lockfiles?: { path: string; names: ReadonlySet<string>; dev?: ReadonlySet<string> }[]
|
|
203
|
-
// Its own actions' metadata by their directories,
|
|
204
|
-
//
|
|
203
|
+
// Its own actions' metadata by their directories, and its workflows by
|
|
204
|
+
// their paths: for where its actions install its packages, else each
|
|
205
|
+
// counts as installing them anywhere; and for the inputs its actions and
|
|
206
|
+
// reusable workflows write into their scripts, else each of a reusable
|
|
207
|
+
// workflow's counts as one.
|
|
205
208
|
actions?: ReadonlyMap<string, string> | null
|
|
206
209
|
}
|
|
207
210
|
|
package/server-e2e/app.ts
CHANGED
|
@@ -102,11 +102,12 @@ import { createBusReceiver } from './bus-receiver.ts'
|
|
|
102
102
|
import { e2eStorageLines } from '../server-common/storage-log.ts'
|
|
103
103
|
|
|
104
104
|
// Each embedding gets its own stores, transports, timers and shutdown state.
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
// `ui: false` serves only the API (see ./http.ts).
|
|
106
|
+
export async function createE2eApp(config = loadConfig(), { ui = true }: { ui?: boolean } = {}) {
|
|
107
|
+
return await initializeApp(rollback => assembleE2eApp(config, ui, rollback))
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
async function assembleE2eApp(config: Config, rollback: AsyncDisposableStack) {
|
|
110
|
+
async function assembleE2eApp(config: Config, ui: boolean, rollback: AsyncDisposableStack) {
|
|
110
111
|
// All external inputs (env vars + optional config.json) are parsed
|
|
111
112
|
// and validated in ./config.ts; destructure into the uppercase names
|
|
112
113
|
// the rest of this module uses.
|
|
@@ -388,7 +389,7 @@ async function assembleE2eApp(config: Config, rollback: AsyncDisposableStack) {
|
|
|
388
389
|
// below.
|
|
389
390
|
const httpServer = createHttpServer({
|
|
390
391
|
wss, restDeps: objstoreRestDeps, sseServer, serverInfo: SERVER_INFO, isOriginAllowed,
|
|
391
|
-
isShuttingDown, track, handleSaveRest,
|
|
392
|
+
isShuttingDown, track, handleSaveRest, ui,
|
|
392
393
|
restPutIdleTimeoutMs: REST_PUT_IDLE_TIMEOUT_MS, debug: DEBUG,
|
|
393
394
|
})
|
|
394
395
|
const requestHandler = httpServer.listeners('request')[0]!
|
package/server-e2e/http.ts
CHANGED
|
@@ -59,6 +59,9 @@ export type HttpServerDeps = {
|
|
|
59
59
|
handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
|
|
60
60
|
restPutIdleTimeoutMs: number
|
|
61
61
|
debug: boolean
|
|
62
|
+
// Serve the static UI bundle (default). False serves only the API and
|
|
63
|
+
// never reads `out/`, for hosts that serve the UI themselves.
|
|
64
|
+
ui?: boolean
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
// `POST /api/sync/save` dispatch. Returns the in-flight handler promise when
|
|
@@ -114,12 +117,12 @@ function dispatchSaveRest(
|
|
|
114
117
|
}
|
|
115
118
|
|
|
116
119
|
export function createHttpServer(deps: HttpServerDeps): Server {
|
|
117
|
-
const { wss, restDeps, sseServer, serverInfo, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug } = deps
|
|
120
|
+
const { wss, restDeps, sseServer, serverInfo, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug, ui = true } = deps
|
|
118
121
|
// Static-file plane (see ./static.ts). The directory is the
|
|
119
122
|
// `build.js build` output sibling to this file; the loader handles
|
|
120
123
|
// enumeration, pre-compression, and ETag derivation. Plugged in after
|
|
121
124
|
// the `/api/objstore/...` REST branch.
|
|
122
|
-
const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)), serverInfo.deepviewScanServer ?? null)
|
|
125
|
+
const handleStatic = ui ? loadStatic(fileURLToPath(new URL('../out', import.meta.url)), serverInfo.deepviewScanServer ?? null) : () => false
|
|
123
126
|
|
|
124
127
|
const httpServer = createServer((req: HttpRequest, res: ServerResponse) => {
|
|
125
128
|
// Static mode probe: GET /api/config → this server's `server-info` as JSON.
|
|
@@ -24,11 +24,17 @@ export type BundleCacheRecord = Pick<ManagedBundle, 'id' | 'integrity' | 'filena
|
|
|
24
24
|
export interface BundleSummary { files: number; codeFiles: number; lines: number; commit?: string; stasisVersion?: number; versionedPackages?: number }
|
|
25
25
|
|
|
26
26
|
export async function readBundleDetails(record: BundleCacheRecord, store: BundleStore) {
|
|
27
|
+
return (await readSizedBundleDetails(record, store))?.details ?? null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// As readBundleDetails, with the bytes the bundle decoded to: about what
|
|
31
|
+
// holding its details takes.
|
|
32
|
+
export async function readSizedBundleDetails(record: BundleCacheRecord, store: BundleStore) {
|
|
27
33
|
const bytes = await store.get(record.id, record.kind)
|
|
28
34
|
if (!bytes) return null
|
|
29
35
|
const decoded = record.kind === 'stasis' || record.kind === 'sourcemap' ? await decompress(bytes, { maxOutputLength: MAX_DECODED_BYTES }) : bytes
|
|
30
36
|
if (decoded.length > MAX_DECODED_BYTES) throw new Error('Decoded bundle too large')
|
|
31
|
-
return parseBundleContents(decodeUtf8(decoded), { integrity: record.integrity, kind: record.kind, size: record.byteSize })
|
|
37
|
+
return { details: parseBundleContents(decodeUtf8(decoded), { integrity: record.integrity, kind: record.kind, size: record.byteSize }), decodedBytes: decoded.length }
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface BundleCacheStorage {
|
|
@@ -10,13 +10,13 @@ import { loadManagedConfig } from './config.ts'
|
|
|
10
10
|
import { LOGIN_PATH } from './github-oauth.ts'
|
|
11
11
|
import { createManagedApp, logManagedStartup } from './index.ts'
|
|
12
12
|
|
|
13
|
-
export async function init(mode: 'managed+e2e' | 'e2e+managed'): Promise<Server> {
|
|
13
|
+
export async function init(mode: 'managed+e2e' | 'e2e+managed', { ui = true }: { ui?: boolean } = {}): Promise<Server> {
|
|
14
14
|
const config = loadManagedConfig({ combined: true })
|
|
15
15
|
const e2eConfig = loadConfig()
|
|
16
16
|
if (!e2eConfig.neonUrl && !config.neonUrl && config.dbPath !== ':memory:' && resolve(config.dbPath) === resolve(e2eConfig.dbPath)) {
|
|
17
17
|
throw new Error('MANAGED_DB_PATH must differ from e2e DB_PATH in combined mode.')
|
|
18
18
|
}
|
|
19
|
-
const e2e = await createE2eApp(e2eConfig)
|
|
19
|
+
const e2e = await createE2eApp(e2eConfig, { ui })
|
|
20
20
|
// Replace the assembled server's single
|
|
21
21
|
// request listener; adding a second would write two responses per request.
|
|
22
22
|
const handlers = e2e.httpServer.listeners('request')
|
|
@@ -25,6 +25,7 @@ export async function init(mode: 'managed+e2e' | 'e2e+managed'): Promise<Server>
|
|
|
25
25
|
const managed = await createManagedApp(config, {
|
|
26
26
|
next: e2e.handleRequest,
|
|
27
27
|
isShuttingDown: e2e.isShuttingDown,
|
|
28
|
+
ui,
|
|
28
29
|
serverInfo: {
|
|
29
30
|
mode, managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName },
|
|
30
31
|
...(e2eConfig.deepviewScanServer ? { deepviewScanServer: e2eConfig.deepviewScanServer } : {}),
|
|
@@ -4,40 +4,50 @@
|
|
|
4
4
|
// read as a pull request's are (pull-dependencies.ts readLockfile, kept in
|
|
5
5
|
// this instance by the commit and path), as many as MAX_LOCKFILES of those,
|
|
6
6
|
// each's versions (`lockfiles`), and those its reader refuses, of Bun's, or
|
|
7
|
-
// past MAX_LOCKFILES (`unread`)
|
|
8
|
-
//
|
|
9
|
-
// it is wherever it is locked.
|
|
10
|
-
// there fails the whole, as it
|
|
7
|
+
// past MAX_LOCKFILES (`unread`), as many read at once as READ_AT_ONCE; and
|
|
8
|
+
// those of some of them (lockedIn), each version once by what its reviews
|
|
9
|
+
// are held to (lockedWith), dev only where it is wherever it is locked.
|
|
10
|
+
// GitHub having none of a lockfile it lists there fails the whole, as it
|
|
11
|
+
// would for any file.
|
|
11
12
|
import { createClient } from '@preventive/upstream/github.js'
|
|
12
13
|
import { upstreamGithub } from './github-errors.ts'
|
|
13
14
|
import { GithubApiError } from './github-app.ts'
|
|
14
15
|
import type { LockedPackage } from './locked-packages.ts'
|
|
15
16
|
import { type Lru, lru } from './lru.ts'
|
|
16
|
-
import { lockedWith, lockfileFormat, readLockfile } from './pull-dependencies.ts'
|
|
17
|
+
import { eachOf, lockedWith, lockfileFormat, lockfileHeld, readLockfile } from './pull-dependencies.ts'
|
|
17
18
|
|
|
18
19
|
export const MAX_LOCKFILES = 100
|
|
20
|
+
const READ_AT_ONCE = 4
|
|
19
21
|
|
|
20
22
|
export interface CommitLockfiles { lockfiles: { path: string; packages: LockedPackage[] }[]; unread: string[] }
|
|
23
|
+
// Set where GitHub was asked for what a read reads (`github`), not all of it
|
|
24
|
+
// held in this instance.
|
|
25
|
+
export interface GithubAsked { github: boolean }
|
|
21
26
|
|
|
22
27
|
// The lockfiles at commit `sha` its review reads, by their paths, each with
|
|
23
|
-
// its format (pull-dependencies.ts lockfileFormat), kept by the commit
|
|
28
|
+
// its format (pull-dependencies.ts lockfileFormat), kept by the commit;
|
|
29
|
+
// `asked` told where GitHub was.
|
|
24
30
|
const listings: Lru<{ path: string; format: NonNullable<ReturnType<typeof lockfileFormat>> }[]> = lru(20_000, listed => listed.length + 1)
|
|
25
|
-
export async function lockfilesAt(fullName: string, token: string, sha: string) {
|
|
31
|
+
export async function lockfilesAt(fullName: string, token: string, sha: string, asked?: GithubAsked) {
|
|
26
32
|
const key = `${fullName}\0${sha}`
|
|
27
33
|
const known = listings.get(key)
|
|
28
34
|
if (known) return known
|
|
35
|
+
if (asked) asked.github = true
|
|
29
36
|
const { lockfiles } = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).findRepoLockfiles({ repo: fullName, sha }))
|
|
30
37
|
const listed = lockfiles.flatMap(({ path }) => { const format = lockfileFormat(path); return format ? [{ path, format }] : [] })
|
|
31
38
|
listings.set(key, listed)
|
|
32
39
|
return listed
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
export async function lockedAt(fullName: string, token: string, sha: string, held: (path: string) => boolean = () => true): Promise<CommitLockfiles> {
|
|
36
|
-
const listed = (await lockfilesAt(fullName, token, sha)).filter(({ path }) => held(path))
|
|
42
|
+
export async function lockedAt(fullName: string, token: string, sha: string, held: (path: string) => boolean = () => true, asked?: GithubAsked): Promise<CommitLockfiles> {
|
|
43
|
+
const listed = (await lockfilesAt(fullName, token, sha, asked)).filter(({ path }) => held(path))
|
|
37
44
|
const unread = listed.slice(MAX_LOCKFILES).map(({ path }) => path)
|
|
38
45
|
const lockfiles: CommitLockfiles['lockfiles'] = []
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
const reading = listed.slice(0, MAX_LOCKFILES)
|
|
47
|
+
if (asked && reading.some(lockfile => !lockfileHeld(fullName, sha, lockfile))) asked.github = true
|
|
48
|
+
const reads = await eachOf(reading, READ_AT_ONCE, lockfile => readLockfile(fullName, token, sha, lockfile))
|
|
49
|
+
for (const [at, lockfile] of reading.entries()) {
|
|
50
|
+
const read = reads[at]!
|
|
41
51
|
if ('error' in read && read.error === 'github-not-found') throw new GithubApiError(404, 'github-not-found')
|
|
42
52
|
if ('error' in read) unread.push(lockfile.path)
|
|
43
53
|
else lockfiles.push({ path: lockfile.path, packages: read.packages })
|
package/server-managed/config.ts
CHANGED
|
@@ -42,6 +42,11 @@ export interface ManagedConfig {
|
|
|
42
42
|
cookieSecure: boolean
|
|
43
43
|
sessionCookieName: string
|
|
44
44
|
sessionTtlMs: number
|
|
45
|
+
// How long GitHub's answer on a user's access to a repository (its
|
|
46
|
+
// visibility, their permission) is kept for that user and repository
|
|
47
|
+
// (repository-browser.ts), asked again past it; 0 asks on every request
|
|
48
|
+
// (MANAGED_REPOSITORY_ACCESS_CACHE_MS, five minutes by default).
|
|
49
|
+
repositoryAccessCacheMs: number
|
|
45
50
|
// Optional installation credentials for the same GitHub App as login.
|
|
46
51
|
// Repository permissions are approved when connecting repositories.
|
|
47
52
|
githubAppId: string | null
|
|
@@ -140,6 +145,7 @@ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
|
|
|
140
145
|
cookieSecure,
|
|
141
146
|
sessionCookieName,
|
|
142
147
|
sessionTtlMs: intEnv('SESSION_TTL_MS', 1_209_600_000, 60_000, 7_776_000_000),
|
|
148
|
+
repositoryAccessCacheMs: intEnv('MANAGED_REPOSITORY_ACCESS_CACHE_MS', 300_000, 0, 3_600_000),
|
|
143
149
|
githubAppId: env['GITHUB_APP_ID'] ?? null,
|
|
144
150
|
githubAppPrivateKey: normalizePem(env['GITHUB_APP_PRIVATE_KEY']),
|
|
145
151
|
githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
|
|
@@ -392,6 +392,10 @@ export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyRe
|
|
|
392
392
|
// A view session opened by `viewerSessionId`, while that session is current
|
|
393
393
|
// and its user is still an admin. Presence is the admin's, not the viewed user's.
|
|
394
394
|
viewSessionWithUser(id: string, viewerSessionId: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
|
|
395
|
+
// The two above as they read, recording no presence: for a read again in a
|
|
396
|
+
// request whose first read recorded it.
|
|
397
|
+
getSessionWithUser(id: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
|
|
398
|
+
getViewSessionWithUser(id: string, viewerSessionId: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
|
|
395
399
|
// Store `candidate` unless the session already has an upload key; returns
|
|
396
400
|
// the stored key, or null when the session no longer exists.
|
|
397
401
|
ensureSessionUploadKey(id: string, candidate: string): Promise<string | null>
|
|
@@ -413,6 +417,8 @@ export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyRe
|
|
|
413
417
|
deselectRepo(repoId: number): Promise<boolean>
|
|
414
418
|
listSelectedRepos(): Promise<SelectedRepo[]>
|
|
415
419
|
listAllRepos(): Promise<ManagedRepo[]>
|
|
420
|
+
// A selected repository's full name, active or not; null for none.
|
|
421
|
+
getRepoFullName(repoId: number): Promise<string | null>
|
|
416
422
|
deactivateRepo(repoId: number): Promise<boolean>
|
|
417
423
|
reactivateRepo(repoId: number): Promise<boolean>
|
|
418
424
|
deleteRepo(repoId: number): Promise<boolean>
|
|
@@ -459,6 +465,8 @@ export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyRe
|
|
|
459
465
|
// stamp nor the trail. Every change also appends to managed_finding_triage_event;
|
|
460
466
|
// listTriageHistory walks one finding's trail, newest first.
|
|
461
467
|
listTriage(findingIds: readonly string[]): Promise<TriageRow[]>
|
|
468
|
+
// The distinct Fix links of those, without the rest of their entries.
|
|
469
|
+
listTriageFixes(findingIds: readonly string[]): Promise<string[]>
|
|
462
470
|
getAnnotations(findingIds: readonly string[]): Promise<{ triage: TriageRow[]; comments: Awaited<ReturnType<CommentStore['listComments']>>; issues: Awaited<ReturnType<ManagedIssueStore['listManagedIssues']>> }>
|
|
463
471
|
applyManagedIssueFixes(sessionId: string, snapshot: TeamReportAccessSnapshot, updates: IssueFixUpdate[]): Promise<boolean>
|
|
464
472
|
// A content-free fingerprint of current annotations, read in one snapshot.
|
|
@@ -543,6 +551,15 @@ type SessionRow = {
|
|
|
543
551
|
uid: string; login: string; name: string | null; avatar: string | null; role: Role
|
|
544
552
|
}
|
|
545
553
|
type ViewSessionRow = SessionRow & { viewerId: string; viewerLogin: string; viewerName: string | null }
|
|
554
|
+
const ownSession = (row: SessionRow) => ({
|
|
555
|
+
session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey },
|
|
556
|
+
user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
|
|
557
|
+
})
|
|
558
|
+
const viewSession = (row: ViewSessionRow) => ({
|
|
559
|
+
session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey,
|
|
560
|
+
viewer: { id: row.viewerId, login: row.viewerLogin, name: row.viewerName } },
|
|
561
|
+
user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
|
|
562
|
+
})
|
|
546
563
|
|
|
547
564
|
type UserRow = { id: string; login: string; name: string | null; role: Role; created: number; lastSeen: number | null; lastActivity: number | null }
|
|
548
565
|
|
|
@@ -690,6 +707,7 @@ function prepareStatements(db: ManagedSql) {
|
|
|
690
707
|
added_by AS addedBy, added_at AS addedAt, active AS active
|
|
691
708
|
FROM managed_selected_repo WHERE active = 1 ORDER BY full_name ASC`,
|
|
692
709
|
),
|
|
710
|
+
selectRepoFullNameStmt: db.prepare(`SELECT full_name AS fullName FROM managed_selected_repo WHERE repo_id = ?`),
|
|
693
711
|
selectAllReposStmt: db.prepare(
|
|
694
712
|
`SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
|
|
695
713
|
installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
|
|
@@ -820,6 +838,8 @@ function prepareStatements(db: ManagedSql) {
|
|
|
820
838
|
WHERE t.finding_id IN (SELECT value FROM json_each(?))
|
|
821
839
|
ORDER BY t.finding_id ASC`,
|
|
822
840
|
),
|
|
841
|
+
selectTriageFixesStmt: db.prepare(`SELECT DISTINCT fix FROM managed_finding_triage
|
|
842
|
+
WHERE finding_id IN (SELECT value FROM json_each(?)) AND fix IS NOT NULL ORDER BY fix`),
|
|
823
843
|
annotationTriageRevisionStmt: db.prepare(`
|
|
824
844
|
SELECT t.finding_id AS id,
|
|
825
845
|
(SELECT e.batch_id FROM managed_finding_triage_event e
|
|
@@ -1065,6 +1085,9 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
1065
1085
|
const rows = (await selectAllReposStmt.all()) as RepoRow[]
|
|
1066
1086
|
return rows.map(readManagedRepo)
|
|
1067
1087
|
},
|
|
1088
|
+
async getRepoFullName(repoId: number): Promise<string | null> {
|
|
1089
|
+
return ((await stmts.selectRepoFullNameStmt.get(repoId)) as { fullName: string } | undefined)?.fullName ?? null
|
|
1090
|
+
},
|
|
1068
1091
|
async deactivateRepo(repoId: number): Promise<boolean> {
|
|
1069
1092
|
return Number((await deactivateRepoStmt.run(repoId)).changes) > 0
|
|
1070
1093
|
},
|
|
@@ -1289,6 +1312,10 @@ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit
|
|
|
1289
1312
|
actorId: r.actorId, actorLogin: r.actorLogin, actorName: r.actorName, at: r.at,
|
|
1290
1313
|
}))
|
|
1291
1314
|
},
|
|
1315
|
+
async listTriageFixes(findingIds: readonly string[]): Promise<string[]> {
|
|
1316
|
+
if (findingIds.length === 0) return []
|
|
1317
|
+
return ((await stmts.selectTriageFixesStmt.all(JSON.stringify(findingIds))) as { fix: string }[]).map(row => row.fix)
|
|
1318
|
+
},
|
|
1292
1319
|
async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
|
|
1293
1320
|
if (findingIds.length === 0) return []
|
|
1294
1321
|
const rows = (await selectTriageStmt.all(JSON.stringify(findingIds))) as TriageDbRow[]
|
|
@@ -1658,10 +1685,11 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1658
1685
|
const row = (await selectOwnSessionStmt.get(id, now)) as SessionRow | undefined
|
|
1659
1686
|
if (row == null) return null
|
|
1660
1687
|
await touchUserSeenStmt.run(now, row.uid, now)
|
|
1661
|
-
return
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1688
|
+
return ownSession(row)
|
|
1689
|
+
},
|
|
1690
|
+
async getSessionWithUser(id, now) {
|
|
1691
|
+
const row = (await selectOwnSessionStmt.get(id, now)) as SessionRow | undefined
|
|
1692
|
+
return row == null ? null : ownSession(row)
|
|
1665
1693
|
},
|
|
1666
1694
|
async createViewSession(view, now) {
|
|
1667
1695
|
if (Number((await insertViewSessionStmt.run(view.id, view.csrfToken, now, view.userId, view.viewerSessionId, now)).changes) === 0) return false
|
|
@@ -1672,11 +1700,11 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1672
1700
|
const row = (await selectViewSessionStmt.get(id, viewerSessionId, now, now)) as ViewSessionRow | undefined
|
|
1673
1701
|
if (row == null) return null
|
|
1674
1702
|
await touchUserSeenStmt.run(now, row.viewerId, now)
|
|
1675
|
-
return
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1703
|
+
return viewSession(row)
|
|
1704
|
+
},
|
|
1705
|
+
async getViewSessionWithUser(id, viewerSessionId, now) {
|
|
1706
|
+
const row = (await selectViewSessionStmt.get(id, viewerSessionId, now, now)) as ViewSessionRow | undefined
|
|
1707
|
+
return row == null ? null : viewSession(row)
|
|
1680
1708
|
},
|
|
1681
1709
|
async ensureSessionUploadKey(id, candidate) {
|
|
1682
1710
|
const row = (await ensureUploadKeyStmt.get(candidate, id)) as { uploadKey: string } | undefined
|
|
@@ -8,7 +8,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
|
|
|
8
8
|
import { WebSocket } from 'ws'
|
|
9
9
|
import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
|
|
10
10
|
import { revisionSchema } from './revisions.ts'
|
|
11
|
-
import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
|
|
11
|
+
import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
|
|
12
12
|
import { STORAGE_SCHEMA } from './storage-db.ts'
|
|
13
13
|
import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
14
14
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
@@ -27,7 +27,8 @@ export interface PgConnection {
|
|
|
27
27
|
rows: Record<string, unknown>[]; rowCount?: number | null
|
|
28
28
|
fields?: { name: string; dataTypeID: number }[]
|
|
29
29
|
}>
|
|
30
|
-
|
|
30
|
+
// After a failure the connection's state is unknown: discard it, never reuse it.
|
|
31
|
+
release(discard?: boolean): Promise<void>
|
|
31
32
|
}
|
|
32
33
|
export type PgConnect = () => Promise<PgConnection>
|
|
33
34
|
// Content/access mutations cooperate on this transaction-scoped lock. Reads,
|
|
@@ -38,7 +39,7 @@ async function currentSchema(db: PgConnection): Promise<boolean> {
|
|
|
38
39
|
const exists = (await db.query("SELECT to_regclass('managed_schema_version') AS name")).rows[0]?.['name']
|
|
39
40
|
if (!exists) return false
|
|
40
41
|
const versions = new Set((await db.query('SELECT version FROM managed_schema_version')).rows.map(row => Number(row['version'])))
|
|
41
|
-
return Array.from({ length:
|
|
42
|
+
return Array.from({ length: 31 }, (_, i) => i + 1).every(version => versions.has(version))
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
|
|
@@ -169,6 +170,12 @@ async function migrateRepositoryDependencyReviewLockfiles(db: PgConnection): Pro
|
|
|
169
170
|
await db.query('INSERT INTO managed_schema_version VALUES (30)')
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
async function migrateRepoUploadIndexes(db: PgConnection): Promise<void> {
|
|
174
|
+
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 31')).rows.length > 0) return
|
|
175
|
+
await db.query(REPO_UPLOAD_INDEXES)
|
|
176
|
+
await db.query('INSERT INTO managed_schema_version VALUES (31)')
|
|
177
|
+
}
|
|
178
|
+
|
|
172
179
|
async function initialize(db: PgConnection): Promise<void> {
|
|
173
180
|
if (await currentSchema(db)) return
|
|
174
181
|
await db.query('BEGIN')
|
|
@@ -243,7 +250,7 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
243
250
|
await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
|
|
244
251
|
}
|
|
245
252
|
for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews, migrateGithubLinks, migrateRepositoryDependencyReviews,
|
|
246
|
-
migrateRepositoryDependencyReviewLockfiles]) await migrate(db)
|
|
253
|
+
migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes]) await migrate(db)
|
|
247
254
|
await db.query('COMMIT')
|
|
248
255
|
} catch (err) {
|
|
249
256
|
await db.query('ROLLBACK')
|
|
@@ -276,7 +283,7 @@ function requestConnections(connect: PgConnect) {
|
|
|
276
283
|
const connection = await connect()
|
|
277
284
|
let result: T
|
|
278
285
|
try { result = await work(connection) }
|
|
279
|
-
catch (error) { await connection.release().catch(() => {}); throw error }
|
|
286
|
+
catch (error) { await connection.release(true).catch(() => {}); throw error }
|
|
280
287
|
await connection.release()
|
|
281
288
|
return result
|
|
282
289
|
}
|
|
@@ -287,7 +294,7 @@ function requestConnections(connect: PgConnect) {
|
|
|
287
294
|
try { return await work(connection) }
|
|
288
295
|
catch (error) {
|
|
289
296
|
delete request.connection
|
|
290
|
-
await connection.release().catch(() => {})
|
|
297
|
+
await connection.release(true).catch(() => {})
|
|
291
298
|
throw error
|
|
292
299
|
}
|
|
293
300
|
})
|
|
@@ -303,7 +310,7 @@ function requestConnections(connect: PgConnect) {
|
|
|
303
310
|
finally {
|
|
304
311
|
request.ended = true
|
|
305
312
|
await request.queue
|
|
306
|
-
if (failed) await request.connection?.release().catch(() => {})
|
|
313
|
+
if (failed) await request.connection?.release(true).catch(() => {})
|
|
307
314
|
else await request.connection?.release()
|
|
308
315
|
}
|
|
309
316
|
}
|
|
@@ -313,7 +320,8 @@ function requestConnections(connect: PgConnect) {
|
|
|
313
320
|
// Exposed for parity tests against real PostgreSQL semantics via PGlite.
|
|
314
321
|
export async function openPostgresManagedDb(connect: PgConnect, options: ManagedDbOptions = {}, reuseConnections = false): Promise<ManagedDb> {
|
|
315
322
|
const initial = await connect()
|
|
316
|
-
try { await initialize(initial) }
|
|
323
|
+
try { await initialize(initial) } catch (err) { await initial.release(true).catch(() => {}); throw err }
|
|
324
|
+
await initial.release()
|
|
317
325
|
const { withConnection, withRequest } = requestConnections(connect)
|
|
318
326
|
const context = new AsyncLocalStorage<PgConnection>()
|
|
319
327
|
let closed = false
|
|
@@ -376,20 +384,52 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
|
|
|
376
384
|
return methods
|
|
377
385
|
}
|
|
378
386
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
387
|
+
// Idle connections stay open this long between leases, so a warm instance's
|
|
388
|
+
// requests and feed polls (three seconds apart) skip the TLS, WebSocket and
|
|
389
|
+
// Postgres handshake a new connection repeats.
|
|
390
|
+
const NEON_POOL_IDLE_MS = 5_000
|
|
391
|
+
|
|
392
|
+
type NeonPoolClient = {
|
|
393
|
+
query: PgConnection['query']
|
|
394
|
+
release(discard?: boolean): void
|
|
395
|
+
on(event: string, listener: (...args: unknown[]) => void): void
|
|
396
|
+
}
|
|
397
|
+
export type NeonPool = {
|
|
398
|
+
connect(): Promise<NeonPoolClient>
|
|
399
|
+
end(): Promise<void>
|
|
400
|
+
on(event: string, listener: (...args: unknown[]) => void): void
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// `attachPool` is Vercel's attachDatabasePool on serverless instances: each
|
|
404
|
+
// release keeps the invocation alive until idle connections have closed, so
|
|
405
|
+
// no socket survives a frozen instance.
|
|
406
|
+
export async function openNeonManagedDb(url: string, options: ManagedDbOptions = {}, attachPool?: (pool: NeonPool) => void): Promise<ManagedDb> {
|
|
407
|
+
// Reuse the e2e optional-driver boundary.
|
|
408
|
+
const { Pool, neonConfig } = await import('../server-e2e/neon-driver.ts') as unknown as {
|
|
409
|
+
Pool: new (config: { connectionString: string; max: number; idleTimeoutMillis: number }) => NeonPool
|
|
384
410
|
neonConfig: { webSocketConstructor: typeof WebSocket }
|
|
385
411
|
}
|
|
386
412
|
neonConfig.webSocketConstructor = WebSocket
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
413
|
+
// Leases stay unbounded, one per request or operation as before: a cap would
|
|
414
|
+
// stall a request that holds one while it awaits work needing another.
|
|
415
|
+
const pool = new Pool({ connectionString: url, max: Infinity, idleTimeoutMillis: NEON_POOL_IDLE_MS })
|
|
416
|
+
// The pool evicts an idle connection the server closed. Neither that nor a
|
|
417
|
+
// remote disconnect while leased may become an unhandled EventEmitter error;
|
|
418
|
+
// a leased one must instead reject its next query, and is then discarded.
|
|
419
|
+
pool.on('error', () => {})
|
|
420
|
+
attachPool?.(pool)
|
|
421
|
+
const failed = new WeakSet<NeonPoolClient>(), watched = new WeakSet<NeonPoolClient>()
|
|
422
|
+
let ending: Promise<void> | undefined
|
|
423
|
+
const end = () => ending ??= pool.end()
|
|
424
|
+
let db: ManagedDb
|
|
425
|
+
try {
|
|
426
|
+
db = await openPostgresManagedDb(async () => {
|
|
427
|
+
const client = await pool.connect()
|
|
428
|
+
if (!watched.has(client)) { watched.add(client); client.on('error', () => { failed.add(client) }) }
|
|
429
|
+
return { query: (sql, params) => client.query(sql, params),
|
|
430
|
+
release: discard => Promise.try(() => client.release(discard === true || failed.has(client))) }
|
|
431
|
+
}, options, true)
|
|
432
|
+
} catch (err) { await end().catch(() => {}); throw err }
|
|
433
|
+
const close = db.close
|
|
434
|
+
return { ...db, async close() { try { await close() } finally { await end() } } }
|
|
395
435
|
}
|
|
@@ -7,6 +7,10 @@ import { GITHUB_LINK_SCHEMA } from './github-links.ts'
|
|
|
7
7
|
|
|
8
8
|
// Created after the column migration, which predates it on older databases.
|
|
9
9
|
export const VIEW_SESSION_INDEX = 'CREATE INDEX IF NOT EXISTS managed_session_viewer_idx ON managed_session(viewer_session);'
|
|
10
|
+
// What a team's scopes join reports and bundles on: their repository, and the
|
|
11
|
+
// directory in it. Created after the column migrations, as above.
|
|
12
|
+
export const REPO_UPLOAD_INDEXES = `CREATE INDEX IF NOT EXISTS managed_report_repo_idx ON managed_report(repo_id, repo_directory);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS managed_bundle_repo_idx ON managed_bundle(repo_id, repo_directory);`
|
|
10
14
|
|
|
11
15
|
export const MANAGED_SCHEMA = `
|
|
12
16
|
CREATE TABLE IF NOT EXISTS managed_user (
|
package/server-managed/db.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { migrateReportLocations } from './report-migration.ts'
|
|
|
7
7
|
import { initCommentMethods } from './comments.ts'
|
|
8
8
|
import { initActivityMethods } from './activity.ts'
|
|
9
9
|
import { revisionSchema } from './revisions.ts'
|
|
10
|
-
import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
|
|
10
|
+
import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
|
|
11
11
|
import { GITHUB_LINK_USER_COLUMNS } from './github-links.ts'
|
|
12
12
|
import { REPOSITORY_DEPENDENCY_REVIEW_COLUMNS } from './repository-dependency-reviews.ts'
|
|
13
13
|
import { STORAGE_SCHEMA } from './storage-db.ts'
|
|
@@ -113,6 +113,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
113
113
|
if (addedVisible) db.prepare('UPDATE managed_report SET visible = 1 WHERE visible = 0').run()
|
|
114
114
|
ensureColumn(db, 'managed_bundle', 'uploaded_by_login', 'TEXT')
|
|
115
115
|
ensureColumn(db, 'managed_bundle', 'repo_directory', "TEXT NOT NULL DEFAULT ''")
|
|
116
|
+
db.exec(REPO_UPLOAD_INDEXES)
|
|
116
117
|
ensureColumn(db, 'managed_bundle', 'visible', 'INTEGER NOT NULL DEFAULT 1')
|
|
117
118
|
// Existing rows stay NULL (unknown): some may have been built on the server.
|
|
118
119
|
ensureColumn(db, 'managed_bundle', 'provenance', 'TEXT')
|
|
@@ -134,13 +134,23 @@ async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Pro
|
|
|
134
134
|
JOIN managed_dependency_review r ON r.id = rt.review_id JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = ?
|
|
135
135
|
WHERE rt.review_id IN (${placeholders}) AND (r.user_id = u.id OR (${reviewsIn(false)}))
|
|
136
136
|
ORDER BY t.name, t.id`).all(user, ...ids) as (ReviewTeam & { reviewId: string })[]
|
|
137
|
+
// Each review's own, in their order, by its id.
|
|
138
|
+
const byReview = <T extends { reviewId: string }>(list: T[]) => {
|
|
139
|
+
const of = new Map<string, Omit<T, 'reviewId'>[]>()
|
|
140
|
+
for (const { reviewId, ...row } of list) {
|
|
141
|
+
const own = of.get(reviewId)
|
|
142
|
+
if (own) own.push(row)
|
|
143
|
+
else of.set(reviewId, [row])
|
|
144
|
+
}
|
|
145
|
+
return of
|
|
146
|
+
}
|
|
147
|
+
const [entriesOf, teamsOf] = [byReview(entries), byReview(linked)]
|
|
137
148
|
return reviews.map(review => {
|
|
138
149
|
const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
|
|
139
150
|
const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
|
|
140
151
|
const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
|
|
141
|
-
const ownEntries = fileEntries(
|
|
142
|
-
return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles),
|
|
143
|
-
teams: linked.filter(team => team.reviewId === review.id).map(({ reviewId: _reviewId, ...team }) => team) }
|
|
152
|
+
const ownEntries = fileEntries((entriesOf.get(review.id) ?? []) as ReviewEntry[])
|
|
153
|
+
return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: (teamsOf.get(review.id) ?? []) as ReviewTeam[] }
|
|
144
154
|
})
|
|
145
155
|
}
|
|
146
156
|
|