@preventive/triage 1.0.0-alpha.51 → 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.
@@ -138,7 +138,8 @@ import { mergeLinkGroups } from './link-reports.ts'
138
138
  import { backfillFindingIds, reportEntries } from '@preventive/report'
139
139
  import type { IncomingMessage, ServerResponse } from 'node:http'
140
140
  import { pipeline } from 'node:stream/promises'
141
- import { type KeptStore, REPOSITORY_READ_KEPT_MS, authoredCommitStore, branchTipStore, codeHeadStore, codeTreeStore, commitPullStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore,
141
+ import { setTimeout as delay } from 'node:timers/promises'
142
+ import { type KeptStore, REPOSITORY_READ_KEPT_MS, authoredCommitStore, branchTipStore, codeHeadStore, codeTreeStore, commitPullStore, githubTagStore, historyChangeStore, historyPageStore, historySquashStore, historyTreeStore,
142
143
  issueListStore, lockfileReadStore,
143
144
  npmDeltaStore, npmPublishTimeStore, npmVersionStore, ownKeptPrunes, pullDependencyStore, pullListStore, repositoryReadStore, reviewBaselineStore, riskLineStore, scanReadStore } from './npm-kept.ts'
144
145
  import { plainObject } from './npm-manifest.ts'
@@ -177,6 +178,7 @@ import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/manag
177
178
  import { CONFIG_PATH, type ManagedSessionInfo, type ServerInfo } from '../common/server-info.ts'
178
179
  import { bundleCommitHash } from '../common/bundle-commit.js'
179
180
  import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
181
+ import { type TaggedRead, keepTaggedReadsIn } from './github-requests.ts'
180
182
  import { upstreamGithub } from './github-errors.ts'
181
183
  import { createClient } from '@preventive/upstream/github.js'
182
184
  import type { ConnectedRepo } from './github-app.ts'
@@ -239,6 +241,7 @@ import { PULSE_WEEKS, PULSE_YEAR_VERSION, type PulseNarrowing, type PulseStats,
239
241
  repositoryStats, withLines, yearCommits } from './repository-pulse.ts'
240
242
  import { type AgentMerges, agentCommits, agentMerges, directCommits, sameMerges } from './agent-merges.ts'
241
243
  import { listedActivity } from './listed-activity.ts'
244
+ import { STAMP_READ, repositoryStamp } from './repository-stamp.ts'
242
245
  import { ISSUES_LIST_READ_VERSION, type IssueList, type Listed, PULLS_READ_VERSION, type PullList, pullsReadTo, readIssues, readPulls } from './repository-pulls.ts'
243
246
  import { AGENT_MARKS_READ } from './app-agents.ts'
244
247
  import { releaseCommits } from './release-commits.ts'
@@ -1869,7 +1872,8 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1869
1872
  // Open pull heads do not depend on the commit history. Start them with
1870
1873
  // it, handling failures immediately even if the history fails first.
1871
1874
  if (token && path === '' && page === 1) {
1872
- readingPulls = keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh).then(answer => answer.value.pulls, quiet(null))
1875
+ readingPulls = keptAnswer(deps, `${repoId}\0openPulls`, () => openPullHeads(repo, token), undefined, fresh, fresh ? null : stampOf(deps, repoId, repo, token))
1876
+ .then(answer => answer.value.pulls, quiet(null))
1873
1877
  }
1874
1878
  let body: object
1875
1879
  if (all) {
@@ -1878,7 +1882,8 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1878
1882
  // follow, the deepest page says.
1879
1883
  // Asked for afresh, those kept answered as just read: how long till
1880
1884
  // they may be read again (`retryIn`), for the client to ask then.
1881
- const heads = starts ? null : await keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token), undefined, fresh)
1885
+ const heads = starts ? null : await keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token), undefined, fresh,
1886
+ fresh ? null : stampOf(deps, repoId, repo, token))
1882
1887
  const retryIn = heads && fresh && heads.kept ? heads.at + REPOSITORY_READ_FLOOR_MS - Date.now() : 0
1883
1888
  body = starts ? { all: true, page, commits: (await historyFrom(repo, token, starts, pages)).commits, more: page < MAX_HISTORY_PAGE }
1884
1889
  : { all: true, ...await repositoryAllHistory(repo, token, heads!.value, pages), ...retryIn > 0 ? { retryIn } : {} }
@@ -1888,7 +1893,7 @@ async function handleRepositoryHistory(res: ServerResponse, deps: ManagedHttpDep
1888
1893
  // branches reads and the latest tags, kept for both; without, GitHub's
1889
1894
  // first page of each.
1890
1895
  const naming = () => token
1891
- ? keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token)).then(({ value }) => ({
1896
+ ? keptAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(repo, token), undefined, false, stampOf(deps, repoId, repo, token)).then(({ value }) => ({
1892
1897
  branches: value.heads.map(({ name, sha }) => ({ name, sha })), tags: value.tags }))
1893
1898
  : keptAnswer(deps, `${repoId}\0refs`, () => repositoryRefTips(repo, token)).then(answer => answer.value)
1894
1899
  const tips = page === 1 ? naming() : null
@@ -2052,33 +2057,58 @@ const refusedRead = (err: unknown) => err instanceof GithubApiError
2052
2057
  // answered where that read fails. Those asking for one not kept while it is
2053
2058
  // read, as a tab's first readers do together, are answered by that read. One GitHub refuses to read again is no
2054
2059
  // longer kept: the next to ask is refused as GitHub refused it. `at` is when
2055
- // what it answers was read.
2060
+ // what it answers was read. With `stamp`, the repository's change stamp
2061
+ // (stampOf), one kept is read again, behind it or afresh, only where the
2062
+ // stamp moved since it was read whole again (the stamp read before it
2063
+ // was), or that was STAMP_TRUSTED_MS ago or longer; else it is kept as read
2064
+ // now, as GitHub would answer it. One read the first time, none's stamp
2065
+ // asked for, is read whole once more before its stamp keeps it.
2066
+ const STAMP_TRUSTED_MS = 60 * 60_000
2056
2067
  async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>,
2057
- keepThrough: (err: unknown) => boolean = () => false, fresh = false): Promise<{ value: T; kept: boolean; at: number }> {
2068
+ keepThrough: (err: unknown) => boolean = () => false, fresh = false, stamp: Stamp | null = null): Promise<{ value: T; kept: boolean; at: number }> {
2058
2069
  const store = repositoryReadStore(deps.db, deps.config.debug)
2059
2070
  // One kept longer than a day, not yet removed (pruneKept removes them as
2060
2071
  // others are written), is none.
2061
2072
  const stored = await store.read(key)
2062
2073
  const held = stored && Date.now() - stored.at < REPOSITORY_READ_KEPT_MS ? stored : null
2063
- // Read, and kept as read when it was, which it is said read at.
2064
- const readKept = async () => {
2074
+ // The stamp what is kept was read whole at, and when (`read`).
2075
+ const stampKey = `${key}\0stamp`
2076
+ // Read, and kept as read when it was, which it is said read at, with the
2077
+ // stamp read before it (`began`), where one was.
2078
+ const readKept = async (began: string | null = null) => {
2065
2079
  try {
2066
2080
  const value = await read()
2067
2081
  const at = Date.now()
2068
2082
  await store.write(key, value, at)
2069
- return { value, at }
2083
+ if (began !== null) await store.write(stampKey, { stamp: began, read: at }, at)
2084
+ return { value, at, kept: false }
2070
2085
  } catch (err) {
2071
2086
  if (refusedRead(err)) await store.remove(key)
2072
2087
  throw err
2073
2088
  }
2074
2089
  }
2090
+ // Read whole again, or kept as read now where the repository's stamp
2091
+ // hasn't moved since it was.
2092
+ const refresh = async () => {
2093
+ if (held && stamp) {
2094
+ const [told, current] = await Promise.all([store.read(stampKey), stamp().catch(() => null)])
2095
+ const was = told?.value as { stamp?: unknown; read?: unknown } | undefined
2096
+ if (current !== null && was?.stamp === current && typeof was.read === 'number' && Date.now() - was.read < STAMP_TRUSTED_MS) {
2097
+ const at = Date.now()
2098
+ await store.write(key, held.value, at)
2099
+ return { value: held.value as T, at, kept: true }
2100
+ }
2101
+ return readKept(current)
2102
+ }
2103
+ return readKept()
2104
+ }
2075
2105
  const reread = () => {
2076
2106
  let reading = repositoryRereads.get(key)
2077
2107
  if (!reading) {
2078
- reading = readKept().finally(() => repositoryRereads.delete(key))
2108
+ reading = refresh().finally(() => repositoryRereads.delete(key))
2079
2109
  repositoryRereads.set(key, reading)
2080
2110
  }
2081
- return reading as Promise<{ value: T; at: number }>
2111
+ return reading as Promise<{ value: T; at: number; kept: boolean }>
2082
2112
  }
2083
2113
  const age = held ? Date.now() - held.at : Infinity
2084
2114
  const now = fresh && age >= REPOSITORY_READ_FLOOR_MS
@@ -2092,13 +2122,23 @@ async function keptAnswer<T extends object>(deps: ManagedHttpDeps, key: string,
2092
2122
  return { value: held.value as T, kept: true, at: held.at }
2093
2123
  }
2094
2124
  try {
2095
- return { ...await reread(), kept: false }
2125
+ return await reread()
2096
2126
  } catch (err) {
2097
2127
  if (held && ((now && !refusedRead(err)) || keepThrough(err))) return { value: held.value as T, kept: true, at: held.at }
2098
2128
  throw err
2099
2129
  }
2100
2130
  }
2101
2131
 
2132
+ // Repository `repoId`'s change stamp (repository-stamp.ts), read with `token`
2133
+ // at most every REPOSITORY_READ_FLOOR_MS by every reader together, for its
2134
+ // kept answers to be read again only where it moved (keptAnswer); `also`
2135
+ // what else such an answer moves with, as a period's days do.
2136
+ type Stamp = () => Promise<string>
2137
+ const stampOf = (deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, also = ''): Stamp => async () =>
2138
+ `${(await keptAnswer(deps, `${repoId}\0stamp\0${STAMP_READ}`, () => repositoryStamp(fullName, token), undefined, true)).value.stamp}${also}`
2139
+ // The day it is (UTC), as a period's answer moves with it.
2140
+ const today = () => `\0${Math.floor(Date.now() / (24 * 60 * 60_000))}`
2141
+
2102
2142
  // The pages of repository `repoId`'s history kept (historyPageStore), each by
2103
2143
  // the commit it starts at (repository-history.ts historyFrom) and the rules
2104
2144
  // its co-authors were read by (commit-coauthors.ts COAUTHORS_READ), those
@@ -2221,9 +2261,9 @@ const keptIssues = (deps: ManagedHttpDeps, repoId: number, fullName: string, tok
2221
2261
  // What is kept by `key` (keptAnswer), read now where it was read
2222
2262
  // REPOSITORY_READ_FRESH_MS ago or longer, not behind: for an answer that is
2223
2263
  // kept as fresh itself, as told of it (the Agents tab's).
2224
- async function currentAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>) {
2264
+ async function currentAnswer<T extends object>(deps: ManagedHttpDeps, key: string, read: () => Promise<T>, stamp: Stamp | null = null) {
2225
2265
  const stored = await repositoryReadStore(deps.db, deps.config.debug).read(key)
2226
- return keptAnswer(deps, key, read, undefined, !stored || Date.now() - stored.at >= REPOSITORY_READ_FRESH_MS)
2266
+ return keptAnswer(deps, key, read, undefined, !stored || Date.now() - stored.at >= REPOSITORY_READ_FRESH_MS, stamp)
2227
2267
  }
2228
2268
 
2229
2269
  // Repository `repoId`'s default branch's head, as its tabs read it: as the
@@ -2237,7 +2277,8 @@ async function defaultTip(deps: ManagedHttpDeps, repoId: number, fullName: strin
2237
2277
  const tip = all?.heads.find(head => head.name === all.defaultBranch)?.sha
2238
2278
  if (typeof tip === 'string' && /^[a-f\d]{40}$/u.test(tip)) return { sha: tip, kept: true }
2239
2279
  const head = async () => ({ sha: await defaultHead(fullName, token) })
2240
- const read = await (current ? currentAnswer(deps, `${repoId}\0pulse-head`, head) : keptAnswer(deps, `${repoId}\0pulse-head`, head))
2280
+ const stamp = stampOf(deps, repoId, fullName, token)
2281
+ const read = await (current ? currentAnswer(deps, `${repoId}\0pulse-head`, head, stamp) : keptAnswer(deps, `${repoId}\0pulse-head`, head, undefined, false, stamp))
2241
2282
  return { sha: read.value.sha, kept: read.kept }
2242
2283
  }
2243
2284
 
@@ -2287,10 +2328,10 @@ function historySquashes(deps: ManagedHttpDeps, repoId: number): Squashes {
2287
2328
  // `readAt`, when it was read from GitHub, one kept as old as it was kept, so
2288
2329
  // that a reader holding two reads of the same can tell the later.
2289
2330
  async function readWholeRepository(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, repoId: number,
2290
- read: (fullName: string, token: string) => Promise<object>, key: string | null = null, readAt = false): Promise<void> {
2331
+ read: (fullName: string, token: string) => Promise<object>, key: string | null = null, readAt = false, stamped = false): Promise<void> {
2291
2332
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2292
2333
  const { value, kept, at } = key === null ? { value: await read(fullName, token), kept: false, at: Date.now() }
2293
- : await keptAnswer(deps, `${repoId}\0${key}`, () => read(fullName, token))
2334
+ : await keptAnswer(deps, `${repoId}\0${key}`, () => read(fullName, token), undefined, false, stamped ? stampOf(deps, repoId, fullName, token) : null)
2294
2335
  return { value: readAt ? { ...value, readAt: at } : value, kept }
2295
2336
  })
2296
2337
  }
@@ -2387,7 +2428,10 @@ async function handleRepositoryIssues(res: ServerResponse, deps: ManagedHttpDeps
2387
2428
  || (after !== null && !ISSUES_CURSOR.test(after))) { sendJson(res, 400, { error: 'bad-request' }); return }
2388
2429
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2389
2430
  const { value: { commits: _commits, ...page }, kept, at } = await keptAnswer(deps, `${repoId}\0issues\0${ISSUES_READ_VERSION}\0${kind}\0${state}\0${after ?? ''}`,
2390
- async () => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }))
2431
+ async () => ({ kind, state, ...await repositoryIssues(fullName, token, kind, state, after) }), undefined, false,
2432
+ // An issue's tile tells nothing its update time doesn't move with; a
2433
+ // pull request's, its checks.
2434
+ kind === 'issues' ? stampOf(deps, repoId, fullName, token) : null)
2391
2435
  return { value: { ...page, readAt: at }, kept }
2392
2436
  })
2393
2437
  }
@@ -2408,9 +2452,9 @@ async function handleRepositoryAgents(res: ServerResponse, deps: ManagedHttpDeps
2408
2452
  await readWholeRepository(res, deps, cookie, repoId, (fullName, token) => repositoryAgents(fullName, token, cursors, historyPages(deps, repoId, true, true),
2409
2453
  commitPulls(deps, repoId), { pulls: () => keptPulls(deps, repoId, fullName, token, null), issues: () => keptIssues(deps, repoId, fullName, token, null),
2410
2454
  head: async () => (await defaultTip(deps, repoId, fullName, token, true)).sha,
2411
- heads: async () => (await currentAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(fullName, token))).value,
2455
+ heads: async () => (await currentAnswer<AllHeads>(deps, `${repoId}\0heads`, () => repositoryAllHeads(fullName, token), stampOf(deps, repoId, fullName, token))).value,
2412
2456
  tips: branchTips(deps, repoId), authored: authoredCommits(deps, repoId) }),
2413
- `agents\0${AGENTS_READ_VERSION}\0${cursors.pulls ?? ''}\0${cursors.commits ?? ''}`)
2457
+ `agents\0${AGENTS_READ_VERSION}\0${cursors.pulls ?? ''}\0${cursors.commits ?? ''}`, false, true)
2414
2458
  }
2415
2459
 
2416
2460
  // GET /api/admin/repositories/item: the issue or pull request numbered
@@ -2449,13 +2493,19 @@ const PULLS_AT_ONCE = 3
2449
2493
  async function handleRepositoryPullDependencies(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
2450
2494
  const repoId = Number(query.get('repoId'))
2451
2495
  const review = query.get('review') === '1'
2496
+ // As an open one's tile on the Pull requests tab asks (`listed=1`): each
2497
+ // by the commits the page it is of names, where one kept names them
2498
+ // (keptPullCommits); else by its number, kept a while, as a popup's.
2499
+ const tiles = query.get('listed') === '1'
2452
2500
  const listed = query.get('numbers')
2453
2501
  const numbers = listed === null ? [itemNumber(query)] : listed.split(',').map(text => itemNumber(new URLSearchParams({ number: text })))
2454
2502
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || numbers.length > MAX_ASKED_PULLS || numbers.some(number => number === null) || new Set(numbers).size < numbers.length
2455
- || !['1', null].includes(query.get('review'))) { sendJson(res, 400, { error: 'bad-request' }); return }
2503
+ || !['1', null].includes(query.get('review')) || !['1', null].includes(query.get('listed'))) { sendJson(res, 400, { error: 'bad-request' }); return }
2456
2504
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token, user) => {
2457
- if (listed === null) return pullDependencyAnswer(deps, fullName, token, user, repoId, numbers[0]!, review)
2458
- const answers = await eachOf(numbers as number[], PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, review).catch((err: unknown) => {
2505
+ const commits = tiles ? await keptPullCommits(deps, repoId, numbers as number[]) : new Map<number, PullCommits>()
2506
+ if (listed === null) return pullDependencyAnswer(deps, fullName, token, user, repoId, numbers[0]!, review, commits.get(numbers[0]!))
2507
+ const answers = await eachOf(numbers as number[], PULLS_AT_ONCE, number => pullDependencyAnswer(deps, fullName, token, user, repoId, number, review, commits.get(number))
2508
+ .catch((err: unknown) => {
2459
2509
  // GitHub asked, though it failed: its access asked again after.
2460
2510
  if (err instanceof GithubApiError) return { value: { number, error: err.message }, kept: false }
2461
2511
  throw err
@@ -2477,7 +2527,7 @@ async function pullDependencyAnswer(deps: ManagedHttpDeps, fullName: string, tok
2477
2527
  const read = await pullDependenciesAt(fullName, token, number, kept)
2478
2528
  reading.told = read.told
2479
2529
  return { commits: read.commits }
2480
- })
2530
+ }, undefined, false, stampOf(deps, repoId, fullName, token))
2481
2531
  // What its commits tell, as read, else as kept by them, else, no longer
2482
2532
  // kept, read again: by those given, without asking GitHub for them.
2483
2533
  const held = reading.told ?? (typeof answered.value.commits === 'string' ? await kept.read(answered.value.commits) : null)
@@ -2696,22 +2746,48 @@ async function teamOpenPulls(deps: ManagedHttpDeps, repoId: number, fullName: st
2696
2746
  let after: string | null = null, kept = true
2697
2747
  for (let page = 0; page < TEAM_PULL_PAGES; page++) {
2698
2748
  const cursor = after
2699
- const answer = await keptAnswer(deps, `${repoId}\0issues\0${ISSUES_READ_VERSION}\0pulls\0open\0${cursor ?? ''}`,
2700
- async () => ({ kind: 'pulls', state: 'open', ...await repositoryIssues(fullName, token, 'pulls', 'open', cursor) }))
2749
+ const answer = await keptAnswer(deps, openPullsKey(repoId, cursor), async () => ({ kind: 'pulls', state: 'open', ...await repositoryIssues(fullName, token, 'pulls', 'open', cursor) }))
2701
2750
  kept &&= answer.kept
2702
- const value = answer.value as { items?: unknown; more?: unknown; after?: unknown; commits?: unknown }
2751
+ const value = answer.value as OpenPullsPage
2703
2752
  items.push(...(Array.isArray(value.items) ? value.items : []).filter(item => Number.isSafeInteger(item?.number) && item.number > 0))
2704
- for (const pull of Array.isArray(value.commits) ? value.commits as { number?: unknown; base?: unknown; head?: unknown }[] : []) {
2705
- const { number, base, head } = pull ?? {}
2706
- if (Number.isSafeInteger(number) && typeof base === 'string' && OBJECT_ID.test(base) && typeof head === 'string' && OBJECT_ID.test(head)) {
2707
- commits.set(number as number, { base, head })
2708
- }
2709
- }
2753
+ pageCommits(value, commits)
2710
2754
  if (value.more !== true || typeof value.after !== 'string') return { items, commits, more: false, kept }
2711
2755
  after = value.after
2712
2756
  }
2713
2757
  return { items, commits, more: true, kept }
2714
2758
  }
2759
+ type OpenPullsPage = { items?: unknown; more?: unknown; after?: unknown; commits?: unknown }
2760
+ const openPullsKey = (repoId: number, cursor: string | null) => `${repoId}\0issues\0${ISSUES_READ_VERSION}\0pulls\0open\0${cursor ?? ''}`
2761
+ // Each open pull request's base's and head's commits a page of them names,
2762
+ // into `commits` by its number.
2763
+ function pageCommits(page: OpenPullsPage, commits: Map<number, PullCommits>) {
2764
+ for (const pull of Array.isArray(page.commits) ? page.commits as { number?: unknown; base?: unknown; head?: unknown }[] : []) {
2765
+ const { number, base, head } = pull ?? {}
2766
+ if (Number.isSafeInteger(number) && typeof base === 'string' && OBJECT_ID.test(base) && typeof head === 'string' && OBJECT_ID.test(head)) {
2767
+ commits.set(number as number, { base, head })
2768
+ }
2769
+ }
2770
+ }
2771
+
2772
+ // Open pull requests `numbers`' base's and head's commits, as the pages of
2773
+ // them the Pull requests tab read were kept with them (GET issues), as many
2774
+ // of those as TEAM_PULL_PAGES on from its first, while all are kept: GitHub
2775
+ // not asked, nor pages read where none is kept. A tile of a page kept, even
2776
+ // one read again behind it, is of the commits it names.
2777
+ async function keptPullCommits(deps: ManagedHttpDeps, repoId: number, numbers: number[]) {
2778
+ const store = repositoryReadStore(deps.db, deps.config.debug)
2779
+ const commits = new Map<number, PullCommits>()
2780
+ let after: string | null = null
2781
+ for (let page = 0; page < TEAM_PULL_PAGES && numbers.some(number => !commits.has(number)); page++) {
2782
+ const stored = await store.read(openPullsKey(repoId, after))
2783
+ if (!stored || Date.now() - stored.at >= REPOSITORY_READ_KEPT_MS) break
2784
+ const value = stored.value as OpenPullsPage
2785
+ pageCommits(value, commits)
2786
+ if (value.more !== true || typeof value.after !== 'string') break
2787
+ after = value.after
2788
+ }
2789
+ return commits
2790
+ }
2715
2791
 
2716
2792
  // The lines the versions wanting reviews of `answers` change, as their rows
2717
2793
  // show them (npm-delta.js deltaBase), a patched one's none, where they are
@@ -2830,15 +2906,55 @@ async function handleRepositoryPulse(res: ServerResponse, deps: ManagedHttpDeps,
2830
2906
  const weeks = Number(query.get('weeks') ?? '4')
2831
2907
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || !PULSE_WEEKS.includes(weeks)) { sendJson(res, 400, { error: 'bad-request' }); return }
2832
2908
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2833
- const [period, year] = await Promise.all([
2834
- keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => pulseActivity(deps, repoId, fullName, token, weeks)),
2835
- pulseCommits(deps, repoId, fullName, token, weeks),
2909
+ const year = pulseYearRead(deps, repoId, fullName, token, weeks)
2910
+ const [period, counted] = await Promise.all([
2911
+ keptAnswer(deps, `${repoId}\0pulse\0${weeks}`, () => pulseActivity(deps, repoId, fullName, token, weeks), undefined, false, stampOf(deps, repoId, fullName, token, today())),
2912
+ Promise.race([year.reading, delay(PULSE_YEAR_WAIT_MS, null, { ref: false })]),
2836
2913
  ])
2837
- return { value: { weeks, ...period.value, stats: year.stats && periodStats(year.stats, weeks), reading: year.reading, pulls: year.pulls,
2838
- pending: year.pending }, kept: period.kept && year.kept }
2914
+ const told = counted ?? { stats: await lastPulseYear(deps, repoId, pulseSince(52, Date.now())), reading: year.progress.commits, pulls: null, pending: false,
2915
+ kept: false }
2916
+ return { value: { weeks, ...period.value, stats: told.stats && periodStats(told.stats, weeks), reading: told.reading, pulls: told.pulls,
2917
+ pending: told.pending, ...counted ? {} : { behind: true } }, kept: period.kept && told.kept }
2839
2918
  })
2840
2919
  }
2841
2920
 
2921
+ // How long a Pulse answer waits on its year (pulseCommits) before it is
2922
+ // sent without it: the period's pull requests and issues told, and how many
2923
+ // of the year's commits are read so far (`reading`, beside the year last
2924
+ // counted), the year read on behind it (`behind`, for the client to ask
2925
+ // again till it is, however long a page of it takes to read); those
2926
+ // asking meanwhile, by the database, the repository and the period, waiting
2927
+ // on that read, not one of their own, unless it began PULSE_YEAR_HELD_MS ago
2928
+ // or longer.
2929
+ const PULSE_YEAR_WAIT_MS = 1500
2930
+ const PULSE_YEAR_HELD_MS = 60_000
2931
+ type PulseYear = Awaited<ReturnType<typeof pulseCommits>>
2932
+ type PulseYearRead = { reading: Promise<PulseYear>; progress: { commits: number }; began: number }
2933
+ const pulseYearReads = new WeakMap<ManagedDb, Map<string, PulseYearRead>>()
2934
+ function pulseYearRead(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, weeks: number) {
2935
+ let reads = pulseYearReads.get(deps.db)
2936
+ if (!reads) pulseYearReads.set(deps.db, reads = new Map())
2937
+ const key = `${repoId}\0${weeks}`
2938
+ const held = reads.get(key)
2939
+ if (held && Date.now() - held.began < PULSE_YEAR_HELD_MS) return held
2940
+ const progress = { commits: 0 }
2941
+ const year = { reading: pulseCommits(deps, repoId, fullName, token, weeks, progress), progress, began: Date.now() }
2942
+ reads.set(key, year)
2943
+ const settled = year.reading.catch((err: unknown) => { console.warn('managed: pulse year read failed:', err) })
2944
+ .finally(() => { if (reads.get(key) === year) reads.delete(key) })
2945
+ deps.track(settled)
2946
+ deps.waitUntil?.(settled)
2947
+ return year
2948
+ }
2949
+
2950
+ // The year last counted of repository `repoId` (pulseCommits), where it is
2951
+ // no more than PULSE_LAST_MS behind the year from `first`; else none.
2952
+ const pulseLastKey = (repoId: number) => `${repoId}\0pulse-year-last`
2953
+ async function lastPulseYear(deps: ManagedHttpDeps, repoId: number, first: number) {
2954
+ const kept = (await repositoryReadStore(deps.db, deps.config.debug).read(pulseLastKey(repoId)))?.value as { first: number; stats: PulseStats } | undefined
2955
+ return kept && first - kept.first <= PULSE_LAST_MS ? kept.stats : null
2956
+ }
2957
+
2842
2958
  // The default branch's year (repository-pulse.ts commitStats), counted from
2843
2959
  // its commits as the Commits tab keeps them, with their lines
2844
2960
  // (yearCommits): kept by the head it was read from (repositoryReadStore),
@@ -2908,12 +3024,12 @@ async function handleRepositoryPulseItems(res: ServerResponse, deps: ManagedHttp
2908
3024
  await answerWholeRepository(res, deps, cookie, repoId, async (fullName, token) => {
2909
3025
  const narrowing = { from, to, authors }
2910
3026
  const items = await keptAnswer(deps, `${repoId}\0pulse-items\0${weeks}\0${from}\0${to ?? ''}\0${narrowing.authors.join(',')}`,
2911
- () => pulseActivity(deps, repoId, fullName, token, weeks, narrowing))
3027
+ () => pulseActivity(deps, repoId, fullName, token, weeks, narrowing), undefined, false, stampOf(deps, repoId, fullName, token, today()))
2912
3028
  return { value: { weeks, ...items.value }, kept: items.kept }
2913
3029
  })
2914
3030
  }
2915
3031
 
2916
- async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, weeks = 52) {
3032
+ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: string, token: string, weeks = 52, progress: { commits: number } | null = null) {
2917
3033
  const now = Date.now()
2918
3034
  const first = pulseSince(52, now)
2919
3035
  // As far back as a period of `weeks` weeks needs the year read whole: its
@@ -2924,22 +3040,21 @@ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: str
2924
3040
  if (!head.sha) return { stats: commitStats([], first), reading: null, pulls: null, pending: false, kept: head.kept }
2925
3041
  const store = repositoryReadStore(deps.db, deps.config.debug)
2926
3042
  const key = `${repoId}\0pulse-year\0${PULSE_YEAR_VERSION}\0${head.sha}\0${first}`
2927
- const lastKey = `${repoId}\0pulse-year-last`
3043
+ const lastKey = pulseLastKey(repoId)
2928
3044
  const mergesKey = pulseMergesKey(repoId)
2929
- const last = async () => {
2930
- const kept = (await store.read(lastKey))?.value as { first: number; stats: PulseStats } | undefined
2931
- return kept && first - kept.first <= PULSE_LAST_MS ? kept.stats : null
2932
- }
3045
+ const last = () => lastPulseYear(deps, repoId, first)
2933
3046
  const sha = head.sha
2934
3047
  // What tells whose the year's commits are, kept as read: the agents' pull
2935
3048
  // requests merged (agentMerges), as the pull requests kept tell, read as
2936
3049
  // far back as `whole` (keptPulls), and the commits that release the
2937
- // repository (`releases`, release-commits.ts releaseCommits).
3050
+ // repository since then (`releases`, release-commits.ts releaseCommits),
3051
+ // as far back as what is counted whole, not the year, a week's reading a
3052
+ // week's releases' commits.
2938
3053
  type Attribution = AgentMerges & { releases?: string[] }
2939
3054
  const keptAttribution = async () => ((await store.read(mergesKey))?.value as Attribution | undefined) ?? null
2940
3055
  const attribution = async (whole: number): Promise<Attribution> => {
2941
3056
  const [pulls, releases] = await Promise.all([keptPulls(deps, repoId, fullName, token, whole),
2942
- releaseCommits(fullName, token, first, historyChanges(deps, repoId))])
3057
+ releaseCommits(fullName, token, whole, historyChanges(deps, repoId))])
2943
3058
  return { ...agentMerges(pulls, first), releases }
2944
3059
  }
2945
3060
  // Since when every pull request merged is read (repository-pulls.ts
@@ -2947,12 +3062,13 @@ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: str
2947
3062
  const readTo = (read: Attribution) => pullsReadTo(read)
2948
3063
  // The year counted from its `commits` as `read` tells whose they are and
2949
3064
  // which were pushed directly, the branch followed through those it was
2950
- // read on past (`passed`, yearCommits) too, whole from when both the
2951
- // commits and the pull requests merged are read (`from`, none where all
2952
- // are); kept by its head and as the year last counted.
2953
- const count = async ({ commits, passed, from }: { commits: HistoryCommit[]; passed: HistoryCommit[]; from: number | null }, read: Attribution) => {
3065
+ // read on past (`passed`, yearCommits) too, whole from when the commits,
3066
+ // the pull requests merged and, where it read them since later than the
3067
+ // year, the releases (`since`) are all read (`from`, none where all are);
3068
+ // kept by its head and as the year last counted.
3069
+ const count = async ({ commits, passed, from }: { commits: HistoryCommit[]; passed: HistoryCommit[]; from: number | null }, read: Attribution, since: number) => {
2954
3070
  const walked = [...commits, ...passed]
2955
- const whole = Math.max(from ?? Number.NEGATIVE_INFINITY, readTo(read))
3071
+ const whole = Math.max(from ?? Number.NEGATIVE_INFINITY, readTo(read), since > first ? since : Number.NEGATIVE_INFINITY)
2956
3072
  const stats = { ...commitStats(commits, first, agentCommits(read, walked), new Set(read.releases), directCommits(read, walked, sha)),
2957
3073
  ...Number.isFinite(whole) ? { from: whole } : {} }
2958
3074
  await Promise.all([store.write(key, stats, Date.now()), store.write(lastKey, { first, stats }, Date.now())])
@@ -2973,28 +3089,38 @@ async function pulseCommits(deps: ManagedHttpDeps, repoId: number, fullName: str
2973
3089
  const same = kept && sameMerges(read, kept) && read.releases!.join() === (kept.releases ?? []).join() && readTo(kept) <= readTo(read)
2974
3090
  if (readTo(read) >= whole || same) return read
2975
3091
  const year = await yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true), globalThis.fetch, whole)
2976
- if (year.complete) recounted.stats = await count(year, read)
3092
+ if (year.complete) recounted.stats = await count(year, read, whole)
2977
3093
  return read
2978
- }, () => true).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
3094
+ }, () => true, false, stampOf(deps, repoId, fullName, token, today())).catch((err: unknown) => { console.warn('managed: pulse attribution read failed:', err) })
2979
3095
  return recounted.stats ?? stats
2980
3096
  }
2981
3097
  // A year kept, read whole as far back as the period needs, as it was;
2982
3098
  // else its commits and the pull requests merged read on together as far
2983
- // as it needs, and counted once both are.
2984
- const held = (await store.read(key))?.value as PulseStats | { tooMany: true } | undefined
2985
- if (held && !('tooMany' in held) && (held.from === undefined || held.from < need)) {
3099
+ // as it needs, and counted once both are, how many of its commits are read
3100
+ // told as they are (`progress`); but where they are too many to read
3101
+ // since no later than it needs (kept apart, `within`, one kept before
3102
+ // with the year's own the year's), GitHub's statistics, a week's commits
3103
+ // read where the year's are too many.
3104
+ const tooManyKey = `${key}\0too-many`
3105
+ const [held, tooMany] = await Promise.all([store.read(key), store.read(tooManyKey)]).then(([year, marked]) => [
3106
+ year?.value as PulseStats | { tooMany: true } | undefined, marked?.value as { within?: unknown } | undefined] as const)
3107
+ if (held && !('tooMany' in held) && (held.from === undefined || held.from <= need)) {
2986
3108
  return { stats: await relabelled(held), reading: null, pulls: null, pending: false, kept: head.kept }
2987
3109
  }
2988
- if (!held || !('tooMany' in held)) {
2989
- const [year, read] = await Promise.all([yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true), globalThis.fetch, need),
2990
- attribution(need)])
3110
+ const tooManyWithin = typeof tooMany?.within === 'number' ? tooMany.within : held && 'tooMany' in held ? first : Number.NEGATIVE_INFINITY
3111
+ // One kept with the year's own before, kept apart now, before a shorter
3112
+ // period's count takes its place.
3113
+ if (!tooMany && held && 'tooMany' in held) await store.write(tooManyKey, { within: first }, Date.now())
3114
+ if (need > tooManyWithin) {
3115
+ const [year, read] = await Promise.all([yearCommits(fullName, token, sha, first, historyPages(deps, repoId, true), globalThis.fetch, need,
3116
+ commits => { if (progress) progress.commits = commits }), attribution(need)])
2991
3117
  await store.write(mergesKey, read, Date.now())
2992
3118
  if (year.complete) {
2993
3119
  if (readTo(read) >= need) return { stats: await last(), reading: null, pulls: read.next?.read ?? 0, pending: false, kept: false }
2994
- return { stats: await count(year, read), reading: null, pulls: null, pending: false, kept: false }
3120
+ return { stats: await count(year, read, need), reading: null, pulls: null, pending: false, kept: false }
2995
3121
  }
2996
3122
  if (!year.tooMany) return { stats: await last(), reading: year.read, pulls: null, pending: false, kept: false }
2997
- await store.write(key, { tooMany: true }, Date.now())
3123
+ await store.write(tooManyKey, { within: need }, Date.now())
2998
3124
  }
2999
3125
  // Its commits and lines read together, kept as one: never the one GitHub
3000
3126
  // has counted since a push beside the other kept from before it.
@@ -5000,6 +5126,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
5000
5126
  // Kept reads' prunes run behind their writes: tracked, so the database
5001
5127
  // outlives them, and on serverless kept alive past the answer.
5002
5128
  ownKeptPrunes(db, prune => { track(prune); deps.waitUntil?.(prune) })
5129
+ // GitHub's tagged answers kept beyond this process, for an instance
5130
+ // starting anew to ask again If-None-Match (github-requests.ts).
5131
+ const tags = githubTagStore<TaggedRead>(db, config.debug)
5132
+ keepTaggedReadsIn({ read: async key => (await tags.read(key))?.value ?? null, write: (key, read) => tags.write(key, read, Date.now()) })
5003
5133
 
5004
5134
  async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
5005
5135
  const url = new URL(req.url ?? '/', 'http://localhost')
@@ -153,6 +153,10 @@ export const scanReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'loc
153
153
  export const REPOSITORY_READ_KEPT_MS = 24 * 60 * 60_000
154
154
  export const repositoryReadStore = (db: UpstreamCacheStore, debug = false): KeptStore<object> =>
155
155
  keptStore<object>(db, 'repository/reads', REPOSITORY_READ_KEPT_MS, debug)
156
+ // GitHub's answers to GETs that came with an ETag (github-requests.ts
157
+ // taggedRead), by their request's key, for any instance to ask again
158
+ // If-None-Match, kept as long as what was read of a repository is.
159
+ export const githubTagStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'github/tags', REPOSITORY_READ_KEPT_MS, debug)
156
160
  // Pages of a repository's history (repository-history.ts historyFrom), by its
157
161
  // id and the commit each starts at, which never change: kept a month after
158
162
  // they were last read from GitHub.
@@ -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]
@@ -211,9 +212,10 @@ export async function defaultHead(fullName: string, token: string, fetchImpl: ty
211
212
  // before it, a clock behind, the branch's commits since beneath them. Read
212
213
  // no further than `need` asks, those since it read whole (`complete`, `from`
213
214
  // the newest of those read whose parents are not, every commit after it
214
- // read; null, the year read whole).
215
+ // read; null, the year read whole). How many are read so far `told` as they
216
+ // are.
215
217
  export async function yearCommits(fullName: string, token: string, head: string, since: number, pages: HistoryPages,
216
- fetchImpl: typeof fetch = globalThis.fetch, need = since): Promise<YearCommits> {
218
+ fetchImpl: typeof fetch = globalThis.fetch, need = since, told: (read: number) => void = () => {}): Promise<YearCommits> {
217
219
  const found = new Map<string, HistoryCommit>()
218
220
  const tried = new Set<string>()
219
221
  const within = (commit: HistoryCommit) => commit.committedAt === null || commit.committedAt >= since - YEAR_SKEW_MS
@@ -221,6 +223,7 @@ export async function yearCommits(fullName: string, token: string, head: string,
221
223
  const read = () => [...found.values()].filter(dated).length
222
224
  const passed = () => [...found.values()].filter(commit => !dated(commit) && within(commit))
223
225
  let queries = 0
226
+ let windowed = false
224
227
  let starts = [head]
225
228
  while (starts.length > 0) {
226
229
  // Those not read yet, the head before any, else the parents of those
@@ -231,11 +234,19 @@ export async function yearCommits(fullName: string, token: string, head: string,
231
234
  .filter(commit => commit.parents.some(sha => unread.has(sha))).map(commit => commit.committedAt ?? Number.POSITIVE_INFINITY))
232
235
  if (need > since && from < need) return { complete: true, commits: [...found.values()].filter(dated), passed: passed(), from }
233
236
  if (queries >= YEAR_QUERIES) return { complete: false, read: read(), tooMany: false }
234
- 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)
235
245
  for (const sha of batch) tried.add(sha)
236
- 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 }
237
247
  if (got.read > 0) queries++
238
248
  for (const commit of got.commits) if (!found.has(commit.sha)) found.set(commit.sha, commit)
249
+ told(read())
239
250
  if (read() > YEAR_COMMITS) return { complete: false, read: read(), tooMany: true }
240
251
  starts = [...new Set([...found.values()].filter(within).flatMap(commit => commit.parents))].filter(sha => !found.has(sha) && !tried.has(sha))
241
252
  }
@@ -0,0 +1,41 @@
1
+ // A repository's change stamp: what moves as anything its tabs read of it
2
+ // changes, but the checks its pull requests run, which move no pull
3
+ // request's update time, read in one cheap GraphQL query: when it was last
4
+ // pushed to, which branch is its default, how many branches and tags it
5
+ // has, and how many pull requests and issues it has and when the one
6
+ // updated last was. An answer kept of it
7
+ // is read again behind its readers only where its stamp moved since the
8
+ // answer was read (http.ts keptAnswer).
9
+ import { GithubApiError } from './github-errors.ts'
10
+ import { githubGraphql } from './github-graphql.ts'
11
+
12
+ // The rules a stamp is read by: its key's, so that a stamp read by others is
13
+ // none's (http.ts stampOf).
14
+ export const STAMP_READ = 2
15
+
16
+ const STAMP_QUERY = `query($owner: String!, $name: String!) {
17
+ repository(owner: $owner, name: $name) {
18
+ pushedAt
19
+ defaultBranchRef { name }
20
+ heads: refs(refPrefix: "refs/heads/", first: 1) { totalCount }
21
+ tags: refs(refPrefix: "refs/tags/", first: 1) { totalCount }
22
+ pullRequests(first: 1, orderBy: { field: UPDATED_AT, direction: DESC }) { totalCount nodes { updatedAt } }
23
+ issues(first: 1, orderBy: { field: UPDATED_AT, direction: DESC }) { totalCount nodes { updatedAt } }
24
+ }
25
+ }`
26
+ type Counted = { totalCount?: unknown; nodes?: ({ updatedAt?: unknown } | null)[] | null } | null
27
+ type Stamped = { repository?: { pushedAt?: unknown; defaultBranchRef?: { name?: unknown } | null; heads?: Counted; tags?: Counted; pullRequests?: Counted; issues?: Counted } | null }
28
+ | null
29
+
30
+ // Repository `fullName`'s stamp, read with `token`, as text to compare.
31
+ export async function repositoryStamp(fullName: string, token: string, fetchImpl: typeof fetch = globalThis.fetch): Promise<{ stamp: string }> {
32
+ const [owner, name] = fullName.split('/') as [string, string]
33
+ const repository = (await githubGraphql(STAMP_QUERY, { owner, name }, token, fetchImpl) as Stamped)?.repository
34
+ if (!repository) throw new GithubApiError(404, 'github-not-found')
35
+ // One missing a count tells nothing: none, never one that would move with nothing.
36
+ const { heads, tags, pullRequests, issues } = repository
37
+ if (![heads, tags, pullRequests, issues].every(connection => Number.isSafeInteger(connection?.totalCount))) throw new GithubApiError(502, 'github-malformed')
38
+ const latest = (connection: Counted | undefined) => [connection?.totalCount, connection?.nodes?.[0]?.updatedAt ?? null]
39
+ return { stamp: JSON.stringify([repository.pushedAt ?? null, repository.defaultBranchRef?.name ?? null, heads?.totalCount, tags?.totalCount, ...latest(pullRequests),
40
+ ...latest(issues)]) }
41
+ }
@@ -17,6 +17,7 @@
17
17
  // findings, and only expanded does it show that they fail to combine.
18
18
  import { reportEntries } from '@preventive/report'
19
19
  import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
20
+ import { expandFindingValues } from '../common/managed/finding-values.js'
20
21
  import type { BlobStore } from './blob-store.ts'
21
22
  import type { ManagedDb, TeamReportAccessSnapshot, UserTeam } from './db.ts'
22
23
  import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
@@ -86,8 +87,9 @@ async function classify(db: ManagedDb, store: BlobStore, key: string, team: User
86
87
  // A catalog read before a concurrent publication names another report set.
87
88
  if (!access?.teamId || access.reports.length !== ids.size || access.reports.some(report => !ids.has(report.id))) return null
88
89
  const body = JSON.parse((await loadTeamReportsResponse(db, store, access)).toString('utf8')) as {
89
- reports: { id: string; filename: string; data: unknown }[]; links?: string[][]
90
+ reports: { id: string; filename: string; data: unknown }[]; links?: string[][]; values?: unknown[]
90
91
  }
92
+ for (const report of body.reports) expandFindingValues(report.data, body.values)
91
93
  const restricted = access.reports.some(report => !report.permissions.dependencies || !report.permissions.security)
92
94
  const empty = restricted ? body.reports.filter(report => !hasContent(report.data)).map(report => report.id) : []
93
95
  const links = body.links ?? []