@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
|
@@ -7,8 +7,10 @@ import { createHash, createSign } from 'node:crypto'
|
|
|
7
7
|
import { createClient } from '@preventive/upstream/github.js'
|
|
8
8
|
import type { ManagedConfig } from './config.ts'
|
|
9
9
|
import { GithubApiError, githubUnreachable, throwGithubResponseError, upstreamGithub } from './github-errors.ts'
|
|
10
|
+
import { onTokenRejected, shareGithubRead, tokenRejected } from './github-requests.ts'
|
|
10
11
|
|
|
11
12
|
export { GithubApiError } from './github-errors.ts'
|
|
13
|
+
export { onTokenRejected } from './github-requests.ts'
|
|
12
14
|
|
|
13
15
|
const GITHUB_API = 'https://api.github.com'
|
|
14
16
|
const API_VERSION = '2022-11-28'
|
|
@@ -22,12 +24,12 @@ interface InstallationTokenEntry { token: string | null; expiresAt: number; pend
|
|
|
22
24
|
// cached here. Bound memory per transport and separate App/key/install scopes.
|
|
23
25
|
const installationTokens = new WeakMap<typeof fetch, Map<string, InstallationTokenEntry>>()
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
// A token GitHub rejected (401): an installation's no longer reused.
|
|
28
|
+
onTokenRejected((token, fetchImpl) => {
|
|
27
29
|
const cache = installationTokens.get(fetchImpl)
|
|
28
30
|
if (!cache) return
|
|
29
31
|
for (const [key, entry] of cache) if (entry.token === token) cache.delete(key)
|
|
30
|
-
}
|
|
32
|
+
})
|
|
31
33
|
|
|
32
34
|
// One listed repository. Carries the context to read its contents later:
|
|
33
35
|
// `installationId` mints an App installation token (Contents: Read) for a repo
|
|
@@ -69,8 +71,12 @@ export function mergeRepos(...lists: ConnectedRepo[][]): ConnectedRepo[] {
|
|
|
69
71
|
// One GitHub API call returning parsed JSON, optionally Bearer-authed by a
|
|
70
72
|
// user token, App JWT, or installation token, `body` sent as JSON (a GraphQL
|
|
71
73
|
// query's, given longer to answer). Network / non-2xx / malformed fold into a
|
|
72
|
-
// GithubApiError (401 passes through for the caller to handle).
|
|
73
|
-
|
|
74
|
+
// GithubApiError (401 passes through for the caller to handle). Identical GETs
|
|
75
|
+
// share an outstanding read through its body. POSTs only opt in for read-only
|
|
76
|
+
// GraphQL queries (repository-history.ts), never for mutations or token mints.
|
|
77
|
+
export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown,
|
|
78
|
+
readOnly = method === 'GET'): Promise<unknown> {
|
|
79
|
+
if (readOnly) return shareGithubRead(fetchImpl, JSON.stringify([url, token, method, body]), () => githubJson(url, token, fetchImpl, method, body, false))
|
|
74
80
|
let res: Response
|
|
75
81
|
const started = Date.now()
|
|
76
82
|
try {
|
|
@@ -85,7 +91,7 @@ export async function githubJson(url: string, token: string | null, fetchImpl: t
|
|
|
85
91
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
86
92
|
})
|
|
87
93
|
} catch (error) { throw githubUnreachable(error, started) }
|
|
88
|
-
if (res.status === 401)
|
|
94
|
+
if (res.status === 401 && token) tokenRejected(token, fetchImpl)
|
|
89
95
|
if (!res.ok) await throwGithubResponseError(res)
|
|
90
96
|
// The answer's body is read within the same timeout: one not read whole is
|
|
91
97
|
// GitHub not reached, as one not answered is; only one that isn't JSON is
|
|
@@ -52,6 +52,15 @@ function readPage(job: Job, item: Issue | Pull, metadata: Map<string, Metadata>)
|
|
|
52
52
|
|
|
53
53
|
function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string, Repository>, checkedAt: number, metadata: Map<string, Metadata>) {
|
|
54
54
|
const updates: IssueFixUpdate[] = []
|
|
55
|
+
// The issues each job's issue is, by repository and number, read once.
|
|
56
|
+
const byIssue = new Map<string, ManagedIssue[]>()
|
|
57
|
+
for (const issue of issues) {
|
|
58
|
+
const ref = parseGithubFixUrl(issue.issueUrl)
|
|
59
|
+
if (ref?.kind !== 'issue') continue
|
|
60
|
+
const key = `${issue.repoId}:${ref.number}`, same = byIssue.get(key)
|
|
61
|
+
if (same) same.push(issue)
|
|
62
|
+
else byIssue.set(key, [issue])
|
|
63
|
+
}
|
|
55
64
|
for (const job of jobs) {
|
|
56
65
|
if (!job.linked || !job.complete) continue
|
|
57
66
|
// "Last" means the newest PR by creation time, never its latest comment.
|
|
@@ -62,11 +71,8 @@ function issueUpdates(jobs: Job[], issues: ManagedIssue[], allowed: Map<string,
|
|
|
62
71
|
if (latest && (!repo || repo.repoId !== latest.repository.databaseId)) continue
|
|
63
72
|
const next = latest ? `https://github.com/${repo!.github}/pull/${latest.number}` : null
|
|
64
73
|
if (latest && next) metadata.set(next, { url: next, ...itemMetadata(latest, 'pull')! })
|
|
65
|
-
for (const issue of
|
|
66
|
-
|
|
67
|
-
if (ref?.kind === 'issue' && ref.number === job.number && issue.repoId === job.repo.repoId) {
|
|
68
|
-
updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
|
|
69
|
-
}
|
|
74
|
+
for (const issue of byIssue.get(`${job.repo.repoId}:${job.number}`) ?? []) {
|
|
75
|
+
updates.push({ findingId: issue.findingId, issueUrl: issue.issueUrl!, previous: issue.autoFixUrl, next, checkedAt })
|
|
70
76
|
}
|
|
71
77
|
}
|
|
72
78
|
return updates
|
|
@@ -222,6 +222,6 @@ export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot
|
|
|
222
222
|
})
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
export function storedFixUrls(
|
|
226
|
-
return [...new Set(
|
|
225
|
+
export function storedFixUrls(fixes: string[]): string[] {
|
|
226
|
+
return [...new Set(fixes.filter(fix => parseGithubFixUrl(fix)))].toSorted()
|
|
227
227
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Only outstanding reads: a completed answer (or failure) is never reused.
|
|
4
|
+
// Keep transports and credentials separate, including anonymous reads. When
|
|
5
|
+
// full, new reads proceed normally rather than waiting for an unrelated one.
|
|
6
|
+
const MAX_PENDING_READS = 1000
|
|
7
|
+
type PendingRead = { promise: Promise<unknown>; shared: boolean }
|
|
8
|
+
const pendingReads = new WeakMap<typeof fetch, Map<string, PendingRead>>()
|
|
9
|
+
|
|
10
|
+
// Invalidate installation credentials and access verdicts when GitHub rejects
|
|
11
|
+
// a token, including when several callers share the rejected request.
|
|
12
|
+
const rejectedTokenListeners = new Set<(token: string, fetchImpl: typeof fetch) => void>()
|
|
13
|
+
export function onTokenRejected(listener: (token: string, fetchImpl: typeof fetch) => void): void {
|
|
14
|
+
rejectedTokenListeners.add(listener)
|
|
15
|
+
}
|
|
16
|
+
export function tokenRejected(token: string, fetchImpl: typeof fetch): void {
|
|
17
|
+
for (const listener of rejectedTokenListeners) listener(token, fetchImpl)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function shareGithubRead<T>(fetchImpl: typeof fetch, request: string, read: () => Promise<T>): Promise<T> {
|
|
21
|
+
let pending = pendingReads.get(fetchImpl)
|
|
22
|
+
if (!pending) { pending = new Map(); pendingReads.set(fetchImpl, pending) }
|
|
23
|
+
// The request includes credentials and query variables. Retain only its hash.
|
|
24
|
+
const key = createHash('sha256').update(request).digest('hex')
|
|
25
|
+
const held = pending.get(key)
|
|
26
|
+
if (held) {
|
|
27
|
+
held.shared = true
|
|
28
|
+
return structuredClone(await held.promise) as T
|
|
29
|
+
}
|
|
30
|
+
if (pending.size >= MAX_PENDING_READS) return read()
|
|
31
|
+
const entry: PendingRead = { promise: read(), shared: false }
|
|
32
|
+
pending.set(key, entry)
|
|
33
|
+
try {
|
|
34
|
+
const value = await entry.promise
|
|
35
|
+
// Independent objects for coalesced callers, without copying large trees
|
|
36
|
+
// or histories when nobody joined the read.
|
|
37
|
+
return (entry.shared ? structuredClone(value) : value) as T
|
|
38
|
+
} finally { pending.delete(key) }
|
|
39
|
+
}
|
|
@@ -15,8 +15,16 @@ export function attachmentDisposition(filename: string): string {
|
|
|
15
15
|
// already materialized by the authorization/filtering layer before we get here.
|
|
16
16
|
export function writeResponse(res: ServerResponse, body: string | Buffer): void {
|
|
17
17
|
const bytes = typeof body === 'string' ? Buffer.from(body) : body
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
writeResponseParts(res, [bytes], bytes.length)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A body of `length` bytes held in parts, written as writeResponse writes
|
|
22
|
+
// one without first copying them into one buffer.
|
|
23
|
+
export function writeResponseParts(res: ServerResponse, parts: Buffer[], length: number): void {
|
|
24
|
+
if (length <= UPLOAD_CHUNK_BYTES) { res.end(parts.length === 1 ? parts[0] : Buffer.concat(parts, length)); return }
|
|
25
|
+
for (const part of parts) {
|
|
26
|
+
for (let offset = 0; offset < part.length; offset += 64 * 1024) res.write(part.subarray(offset, offset + 64 * 1024))
|
|
27
|
+
}
|
|
20
28
|
res.end()
|
|
21
29
|
}
|
|
22
30
|
|