@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.
Files changed (79) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +5 -1
  5. package/common/line-diff.js +5 -4
  6. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  7. package/out/{chunk-AFLZH7WG.js → chunk-4UFAMJAX.js} +1 -1
  8. package/out/{chunk-HMAJDVXN.js → chunk-7XRR4OFU.js} +1 -1
  9. package/out/{chunk-Q6MVYGYV.js → chunk-BOIXMUU5.js} +1 -1
  10. package/out/{chunk-XVBEOGOS.js → chunk-CC4OM6TZ.js} +2 -2
  11. package/out/chunk-G24A42V6.js +2 -0
  12. package/out/{chunk-WI7Z5A2T.js → chunk-JKGQ3O7O.js} +1 -1
  13. package/out/chunk-LE5ZWLG4.js +235 -0
  14. package/out/chunk-MSION3G4.js +36 -0
  15. package/out/{chunk-6HNXNIQY.js → chunk-S6GIXWQW.js} +1 -1
  16. package/out/chunk-V3KWNNMY.js +1 -0
  17. package/out/client-managed-import.js +1 -1
  18. package/out/client-managed.js +25 -25
  19. package/out/compare.js +13 -13
  20. package/out/dialogs.js +40 -40
  21. package/out/graph.js +1 -1
  22. package/out/index.html +6 -6
  23. package/out/scan.js +2 -2
  24. package/out/terminal.js +1 -1
  25. package/out/view.css +1 -1
  26. package/out/view.js +15 -15
  27. package/package.json +3 -1
  28. package/risk/actions/findings.js +14 -9
  29. package/risk/actions/scripts/shell.js +5 -1
  30. package/risk/index.d.ts +5 -2
  31. package/server-e2e/app.ts +5 -4
  32. package/server-e2e/http.ts +5 -2
  33. package/server-managed/bundle-cache.ts +7 -1
  34. package/server-managed/combined.ts +3 -2
  35. package/server-managed/commit-lockfiles.ts +21 -11
  36. package/server-managed/config.ts +6 -0
  37. package/server-managed/db-methods.ts +37 -9
  38. package/server-managed/db-neon.ts +61 -21
  39. package/server-managed/db-schema.ts +4 -0
  40. package/server-managed/db.ts +2 -1
  41. package/server-managed/dependency-reviews.ts +13 -3
  42. package/server-managed/github-app.ts +12 -6
  43. package/server-managed/github-issue-links.ts +11 -5
  44. package/server-managed/github-pulls.ts +2 -2
  45. package/server-managed/github-requests.ts +39 -0
  46. package/server-managed/http-response.ts +10 -2
  47. package/server-managed/http.ts +195 -111
  48. package/server-managed/index.ts +8 -7
  49. package/server-managed/kept-briefly.ts +37 -0
  50. package/server-managed/npm-delta.ts +11 -6
  51. package/server-managed/npm-kept.ts +32 -2
  52. package/server-managed/npm-loads.ts +100 -30
  53. package/server-managed/npm-packages.ts +89 -3
  54. package/server-managed/npm-risk.ts +4 -1
  55. package/server-managed/pretty-print.ts +67 -23
  56. package/server-managed/pull-dependencies.ts +29 -9
  57. package/server-managed/pull-dependency-reviews.ts +8 -8
  58. package/server-managed/queue.ts +16 -0
  59. package/server-managed/rename-diff.ts +22 -13
  60. package/server-managed/report-sources.ts +1 -1
  61. package/server-managed/report-store.ts +42 -9
  62. package/server-managed/repository-actions.ts +15 -6
  63. package/server-managed/repository-browser.ts +119 -31
  64. package/server-managed/repository-dependency-reviews.ts +41 -19
  65. package/server-managed/repository-history.ts +24 -8
  66. package/server-managed/repository-pulse.ts +8 -1
  67. package/server-managed/repository-risk-audit.ts +13 -0
  68. package/server-managed/repository-risk-read.ts +11 -6
  69. package/server-managed/repository-risk.ts +27 -20
  70. package/server-managed/session.ts +18 -4
  71. package/server-managed/sql.ts +5 -2
  72. package/server-managed/storage.ts +4 -2
  73. package/server-managed/team-reports.ts +24 -12
  74. package/server-managed/upstream-cache.ts +12 -0
  75. package/server.ts +14 -5
  76. package/out/chunk-5DHL5NG3.js +0 -2
  77. package/out/chunk-NV7ES4EW.js +0 -1
  78. package/out/chunk-PTGDYLNS.js +0 -36
  79. 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
- function discardInstallationToken(token: string | null, fetchImpl: typeof fetch): void {
26
- if (!token) return
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
- export async function githubJson(url: string, token: string | null, fetchImpl: typeof fetch, method: 'GET' | 'POST' = 'GET', body?: unknown): Promise<unknown> {
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) discardInstallationToken(token, fetchImpl)
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 issues) {
66
- const ref = parseGithubFixUrl(issue.issueUrl)
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(entries: { fix: string | null }[]): string[] {
226
- return [...new Set(entries.flatMap(entry => entry.fix && parseGithubFixUrl(entry.fix) ? [entry.fix] : []))].toSorted()
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
- if (bytes.length <= UPLOAD_CHUNK_BYTES) { res.end(body); return }
19
- for (let offset = 0; offset < bytes.length; offset += 64 * 1024) res.write(bytes.subarray(offset, offset + 64 * 1024))
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