@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.
- package/common/managed/finding-detail.d.ts +4 -2
- package/common/managed/finding-detail.js +29 -20
- package/common/managed/finding-values.d.ts +2 -0
- package/common/managed/finding-values.js +44 -0
- package/common/managed/lite-name.d.ts +2 -0
- package/common/managed/lite-name.js +20 -0
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +32 -3
- package/common/workspace-app.js +26 -6
- package/out/activity.js +4 -4
- package/out/chunk-JEFZTVNX.js +3 -0
- package/out/chunk-LJ7XRXFU.js +52 -0
- package/out/{chunk-PBZPYIG7.js → chunk-MYGJBYYJ.js} +1 -1
- package/out/{chunk-7JBSSRD7.js → chunk-NE2UGKRJ.js} +1 -1
- package/out/chunk-TSIFIFBX.js +232 -0
- package/out/{chunk-WRAJVD35.js → chunk-YA4ETCFU.js} +1 -1
- package/out/client-managed.js +26 -26
- package/out/compare.js +5 -5
- package/out/dialogs.js +41 -41
- package/out/index.html +3 -3
- package/out/scan.js +2 -2
- package/out/view.js +11 -11
- package/package.json +7 -1
- package/server-managed/github-requests.ts +54 -24
- package/server-managed/history-windows.ts +172 -0
- package/server-managed/http.ts +192 -62
- package/server-managed/npm-kept.ts +4 -0
- package/server-managed/repository-history.ts +2 -2
- package/server-managed/repository-pulse.ts +15 -4
- package/server-managed/repository-stamp.ts +41 -0
- package/server-managed/team-app.ts +3 -1
- package/server-managed/team-reports.ts +21 -13
- package/out/chunk-6LNQTKAV.js +0 -52
- package/out/chunk-IPAH4LKF.js +0 -232
- package/out/chunk-VDRXHIHN.js +0 -3
package/server-managed/http.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
2064
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
2458
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
2834
|
-
|
|
2835
|
-
|
|
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
|
-
|
|
2838
|
-
|
|
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 =
|
|
3043
|
+
const lastKey = pulseLastKey(repoId)
|
|
2928
3044
|
const mergesKey = pulseMergesKey(repoId)
|
|
2929
|
-
const last =
|
|
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,
|
|
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
|
|
2951
|
-
//
|
|
2952
|
-
//
|
|
2953
|
-
|
|
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
|
-
|
|
2985
|
-
|
|
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
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
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(
|
|
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
|
-
|
|
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 ?? []
|