@preventive/triage 1.0.0-alpha.47 → 1.0.0-alpha.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/client/dependency-paths.d.ts +2 -1
  2. package/client/dependency-paths.js +16 -4
  3. package/common/managed/agent-tasks.ts +12 -14
  4. package/common/managed/github-agents.ts +7 -6
  5. package/out/activity.js +4 -4
  6. package/out/{chunk-NOZJULWS.js → chunk-6LNQTKAV.js} +1 -1
  7. package/out/chunk-7VAWINQF.js +6 -0
  8. package/out/{chunk-5AHIQFFG.js → chunk-C2AVODGZ.js} +1 -1
  9. package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
  10. package/out/{chunk-UENXT2HK.js → chunk-MOACFRJ4.js} +1 -1
  11. package/out/chunk-O6M574JI.js +1 -0
  12. package/out/{chunk-ZCS4UYHI.js → chunk-PBZPYIG7.js} +1 -1
  13. package/out/{chunk-S6U2J62U.js → chunk-PENGGKX7.js} +42 -41
  14. package/out/{chunk-6WF3KNTL.js → chunk-SZD5CW5R.js} +2 -2
  15. package/out/{chunk-SPEFPML6.js → chunk-TQJDYHVE.js} +1 -1
  16. package/out/{chunk-FH24VHSJ.js → chunk-WYLQWYLA.js} +1 -1
  17. package/out/{chunk-VL43BTSO.js → chunk-X6PJAK3T.js} +1 -1
  18. package/out/client-managed-import.js +1 -1
  19. package/out/client-managed.js +26 -26
  20. package/out/compare.js +13 -13
  21. package/out/dialogs.js +17 -17
  22. package/out/graph.js +1 -1
  23. package/out/index.html +7 -7
  24. package/out/scan.js +1 -1
  25. package/out/terminal.js +214 -214
  26. package/out/view.css +1 -1
  27. package/out/view.js +15 -15
  28. package/package.json +8 -5
  29. package/risk/npm/score.js +1 -1
  30. package/server-managed/agent-merges.ts +10 -8
  31. package/server-managed/app-agents.ts +6 -8
  32. package/server-managed/cache-storage.ts +3 -2
  33. package/server-managed/commit-coauthors.ts +7 -2
  34. package/server-managed/commit-lockfiles.ts +22 -21
  35. package/server-managed/db-methods.ts +52 -38
  36. package/server-managed/db-neon.ts +21 -5
  37. package/server-managed/db.ts +2 -0
  38. package/server-managed/dependabot-access.ts +1 -1
  39. package/server-managed/dependency-review-comments.ts +7 -4
  40. package/server-managed/dependency-reviews.ts +71 -33
  41. package/server-managed/github-accounts.ts +28 -1
  42. package/server-managed/github-app.ts +27 -23
  43. package/server-managed/github-graphql.ts +39 -0
  44. package/server-managed/github-issue-links.ts +8 -1
  45. package/server-managed/github-metadata.ts +2 -2
  46. package/server-managed/http-response.ts +28 -10
  47. package/server-managed/http.ts +677 -230
  48. package/server-managed/index.ts +2 -2
  49. package/server-managed/managed-issues.ts +3 -0
  50. package/server-managed/npm-delta.ts +16 -0
  51. package/server-managed/npm-kept.ts +32 -5
  52. package/server-managed/npm-loads.ts +21 -13
  53. package/server-managed/npm-manifest.ts +1 -1
  54. package/server-managed/npm-packages.ts +13 -4
  55. package/server-managed/object-storage.ts +4 -3
  56. package/server-managed/public-workspace.ts +3 -3
  57. package/server-managed/pull-dependencies.ts +84 -36
  58. package/server-managed/pull-dependency-reviews.ts +13 -10
  59. package/server-managed/rename-diff-worker.js +1 -1
  60. package/server-managed/repository-agents.ts +9 -6
  61. package/server-managed/repository-browser.ts +13 -3
  62. package/server-managed/repository-code.ts +153 -7
  63. package/server-managed/repository-dependency-reviews.ts +7 -0
  64. package/server-managed/repository-history.ts +85 -152
  65. package/server-managed/repository-issues.ts +27 -13
  66. package/server-managed/repository-pulse.ts +9 -3
  67. package/server-managed/repository-squashes.ts +145 -0
  68. package/server-managed/request-timing.ts +63 -0
  69. package/server-managed/reviewed-dependencies.ts +3 -2
  70. package/server-managed/session.ts +34 -12
  71. package/server-managed/sql.ts +16 -8
  72. package/server-managed/storage-encryption.ts +16 -8
  73. package/server-managed/storage-maintenance.ts +5 -2
  74. package/server-managed/storage-payload.ts +18 -0
  75. package/server-managed/storage-stores.ts +3 -2
  76. package/server-managed/team-feed.ts +16 -11
  77. package/server-managed/team-npm-scopes.ts +29 -0
  78. package/server-managed/team-reports.ts +68 -16
  79. package/out/chunk-6ANSHRFQ.js +0 -6
  80. package/out/chunk-ZGNMDGSB.js +0 -1
@@ -171,9 +171,16 @@ async function askAccess(repo: SelectedRepo, start: string | null, userToken: st
171
171
  return githubJson(base, token, fetchImpl)
172
172
  }
173
173
  }
174
- // A repository last known private asks who the user is as it asks for its
175
- // metadata, not after: its permission is asked of them.
176
- if (repo.private && userToken) getIdentity().catch(() => {})
174
+ // A repository last known private asks who the user is, and mints the
175
+ // installation token its permission is asked with, as it asks for its
176
+ // metadata, not after. Each is asked once a request (the token once for
177
+ // concurrent requests too, github-app.ts), so a failure is answered where
178
+ // it is awaited. One made public since mints a token it never waits for,
179
+ // kept for later requests.
180
+ if (repo.private && userToken) {
181
+ getIdentity().catch(() => {})
182
+ if (repo.installationId != null) installationToken().catch(() => {})
183
+ }
177
184
  // Stored visibility can be stale (or internal with private=false). Require
178
185
  // current, explicit public visibility or the caller's effective permission.
179
186
  const data = await metadata() as RepoMetadata
@@ -232,6 +239,9 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
232
239
  // The user's permission level on it where the gate asked GitHub of it, as
233
240
  // for a private repository; null for a public one.
234
241
  permission: () => level,
242
+ // Its default branch as GitHub named it when access was first checked,
243
+ // `''` where it named none: the one whose head `commit('')` reads.
244
+ defaultBranch,
235
245
  async refs() {
236
246
  // Suggestions are bounded; the input also accepts any branch or tag name.
237
247
  const list = async (kind: string) => {
@@ -4,9 +4,14 @@
4
4
  // bundle builds keep theirs (getRepoTreeTarball), and its files as the npm
5
5
  // viewer reads a package's (npm-packages.ts npmFileRows). Readers asking for
6
6
  // the same directory meanwhile share its body, a load among the npm viewer's
7
- // active ones (npm-loads.ts sharedLoad).
7
+ // active ones (npm-loads.ts sharedLoad). The default branch's head's bodies
8
+ // are kept in byte storage, for the next to open them (repositoryCodeKept).
8
9
  import { Buffer } from 'node:buffer'
10
+ import { createHash } from 'node:crypto'
9
11
  import { encodeBrotli } from './brotli.ts'
12
+ import { CacheMissError, type CacheStorage } from './cache-storage.ts'
13
+ import type { KeptStore } from './npm-kept.ts'
14
+ import { plainObject } from './npm-manifest.ts'
10
15
  import { MAX_NPM_JSON_LENGTH, NpmPackageError, npmFileRows } from './npm-packages.ts'
11
16
  import { type NpmLoad, loadPlaces, readTarballFiles, sharedLoad } from './npm-loads.ts'
12
17
 
@@ -49,9 +54,14 @@ const directoryError = (err: unknown) => {
49
54
 
50
55
  // The directory as the viewer reads it, as brotli-encoded JSON: `{ repoId,
51
56
  // repo, commit, path, tree, files }`, its files as npmFileRows gives them,
52
- // by their paths in the directory, from `tarball`, the tree's.
53
- export function loadRepositoryCodeBody(input: RepositoryCodeInput, tarball: () => Promise<Uint8Array>): NpmLoad<Buffer> {
57
+ // by their paths in the directory, from `tarball`, the tree's, as upstream
58
+ // verifies it with its symlinks let climb `symlinksAbove` levels.
59
+ export function loadRepositoryCodeBody(input: RepositoryCodeInput, tarball: (symlinksAbove: number) => Promise<Uint8Array>): NpmLoad<Buffer> {
54
60
  const { repoId, repo, commit, path, tree, listed } = input
61
+ // A symlink may reach the repository's root, as a monorepo package's
62
+ // LICENSE links the root's, and no further: the directory's depth, less
63
+ // the level the archive's root already stands above its top directory.
64
+ const symlinksAbove = path.split('/').length - 1
55
65
  // Its places among the active loads, by its files as listed, hold their
56
66
  // JSON too, which the listing cannot tell, as escaping may grow a text
57
67
  // sixfold: as much as a package's may take for each.
@@ -59,11 +69,147 @@ export function loadRepositoryCodeBody(input: RepositoryCodeInput, tarball: () =
59
69
  const maxLength = Math.min(MAX_REPOSITORY_CODE_JSON_LENGTH, places * MAX_NPM_JSON_LENGTH)
60
70
  return sharedLoad(`repository ${repoId} ${repo} ${commit} ${path} ${tree}`, async () => {
61
71
  let files
62
- // A symlink may reach the repository's root, as a monorepo package's
63
- // LICENSE links the root's, and no further: the directory's depth, less
64
- // the level the archive's root already stands above its top directory.
65
- try { files = npmFileRows(await readTarballFiles(await tarball(), { ...REPOSITORY_TAR, symlinksAbove: path.split('/').length - 1 }), maxLength) }
72
+ try { files = npmFileRows(await readTarballFiles(await tarball(symlinksAbove), { ...REPOSITORY_TAR, symlinksAbove }), maxLength) }
66
73
  catch (err) { throw directoryError(err) }
67
74
  return encodeBrotli(Buffer.from(JSON.stringify({ repoId, repo, commit, path, tree, files })))
68
75
  }, 'repository-busy', { places })
69
76
  }
77
+
78
+ // The form of the bodies kept, bumped as what a body holds changes
79
+ // (npmFileRows): one kept in another is none to read.
80
+ const KEPT_CODE_VERSION = 1
81
+ // The largest body kept. Source text runs, as brotli's JSON, to a third of
82
+ // its bytes or less, and a directory's binary files are named, not held, so
83
+ // this keeps one of some 200 MiB of text: nearly every directory that opens
84
+ // at all (MAX_REPOSITORY_CODE_BYTES). One past it would take as much byte
85
+ // storage as hundreds of ordinary directories, and hold a serverless
86
+ // invocation a while uploading it: it is sent as ever, and read again the
87
+ // next time.
88
+ export const MAX_KEPT_CODE_BYTES = 64 * 1024 * 1024
89
+ const COMMIT = /^[a-f\d]{40}$/u
90
+
91
+ // A directory as kept: the repository by its id and name, the commit and the
92
+ // directory, as its body names them; and the head they are kept at.
93
+ export type KeptCodeAt = Pick<RepositoryCodeInput, 'repoId' | 'repo' | 'commit' | 'path'>
94
+ type KeptCodeHead = Pick<KeptCodeAt, 'commit' | 'repo'>
95
+ // Under the repository, then the commit, so either goes by its prefix; the
96
+ // repository's name and the directory hashed, as a key's parts are names.
97
+ const keptKey = ({ repoId, repo, commit, path }: KeptCodeAt) =>
98
+ `${repoId}/${commit}/v${KEPT_CODE_VERSION}-${createHash('sha256').update(JSON.stringify([repo, path])).digest('hex')}.json.br`
99
+
100
+ // Bodies being kept, by their storage and key, each kept once; and each
101
+ // storage's own name among the active loads, as its reads are one of them.
102
+ const keeping = new WeakMap<CacheStorage, Set<string>>()
103
+ const storageNames = new WeakMap<CacheStorage, number>()
104
+ let storagesNamed = 0
105
+
106
+ // A body kept, read whole, where it is one MAX_KEPT_CODE_BYTES keeps.
107
+ async function readKept(storage: CacheStorage, key: string): Promise<Buffer | null> {
108
+ const { size, stream } = await storage.open(key)
109
+ try {
110
+ if (size !== null && size > MAX_KEPT_CODE_BYTES) return null
111
+ const parts: Buffer[] = []
112
+ let length = 0
113
+ for await (const part of stream) {
114
+ length += (part as Buffer).byteLength
115
+ if (length > MAX_KEPT_CODE_BYTES) return null
116
+ parts.push(Buffer.from(part as Buffer))
117
+ }
118
+ return Buffer.concat(parts, length)
119
+ } finally { stream.destroy() }
120
+ }
121
+
122
+ // The directories of repositories' default branches' heads, as
123
+ // loadRepositoryCodeBody encodes them, kept in the managed byte storage
124
+ // (`storage`: Vercel Blob, else the filesystem, encrypted at rest where
125
+ // storage is: storage-payload.ts REPOSITORY_CODE_CACHE), so that one opened
126
+ // again, on any instance, asks GitHub for no tarball, and is neither unpacked
127
+ // nor encoded again; by the commit `heads` keeps for each repository, the
128
+ // head as last read (npm-kept.ts codeHeadStore). Most opens read the head:
129
+ // `/repo/<repo id>` opens it, and its route then names its commit, as do its
130
+ // directories opened from it and a reload. A read naming the default branch
131
+ // (`''`, or `heads/` and its name) takes its head as GitHub resolves it for
132
+ // the commit kept, the directories kept at any other removed (`own`ed,
133
+ // behind its answer); any other read, of a commit, a tag or another branch,
134
+ // reads and keeps a directory only where what it resolves to is the commit
135
+ // kept. Nothing kept is per reader, and each is read only once the reader's
136
+ // own access has named its commit. A body names what it was read of, so
137
+ // instances disagreeing on the head meanwhile, as at a push, only cost a
138
+ // directory read again: one kept for a head since moved on removes itself.
139
+ // The head is kept with the repository's name, which each body's key holds,
140
+ // so a repository renamed has what was kept under its name before removed
141
+ // as a read next names its head. A head is written only for a repository
142
+ // still `active` after it, as one removed meanwhile has its head removed
143
+ // after (remove). Byte storage, as the database, is an optimization: its
144
+ // failures read as a directory kept nowhere, and keep nothing.
145
+ export function repositoryCodeKept(storage: CacheStorage, heads: KeptStore<KeptCodeHead>, active: (repoId: number) => Promise<boolean>,
146
+ own: (work: Promise<unknown>) => void, debug = false) {
147
+ const warn = (what: string) => (error: unknown) => { if (debug) console.warn(`managed: kept repository code ${what} failed:`, error) }
148
+ const headOf = async (repoId: number): Promise<KeptCodeHead | null> => {
149
+ const kept: unknown = (await heads.read(String(repoId)))?.value
150
+ return plainObject(kept) && typeof kept['commit'] === 'string' && typeof kept['repo'] === 'string' ? { commit: kept['commit'], repo: kept['repo'] } : null
151
+ }
152
+ // What is kept of repository `repoId`: its head first, so that a body being
153
+ // kept meanwhile removes itself.
154
+ const remove = async (repoId: number) => {
155
+ await heads.remove(String(repoId))
156
+ await storage.delete(String(repoId))
157
+ }
158
+ return {
159
+ // Whether a read of `ref` at repository `repoId`, by its name `repo`,
160
+ // resolved to `commit` with `defaultBranch` GitHub's, reads and keeps
161
+ // its directories.
162
+ async at({ repoId, repo, commit }: Omit<KeptCodeAt, 'path'>, ref: string, defaultBranch: string): Promise<boolean> {
163
+ if (!COMMIT.test(commit)) return false
164
+ const kept = await headOf(repoId)
165
+ const atKept = kept?.commit === commit && kept.repo === repo
166
+ if (ref !== '' && (defaultBranch === '' || ref !== `heads/${defaultBranch}`)) return atKept
167
+ if (!atKept) {
168
+ await heads.write(String(repoId), { commit, repo }, Date.now())
169
+ // One removed as its commit was read, its head removed before this
170
+ // wrote it again, keeps nothing, and has what is kept removed again.
171
+ if (!await active(repoId).catch(() => false)) {
172
+ await remove(repoId).catch(warn('removal'))
173
+ return false
174
+ }
175
+ // Those kept at the head before, or, renamed, every one kept under
176
+ // its name before.
177
+ own(storage.delete(String(repoId), kept?.repo === repo ? commit : undefined).catch(warn('removal')))
178
+ }
179
+ return true
180
+ },
181
+ // The body kept of the directory `at` names, or null: a load among the
182
+ // active ones (npm-loads.ts sharedLoad), as one read from GitHub is, a
183
+ // place of them, as it holds no more than a package may
184
+ // (MAX_KEPT_CODE_BYTES), till each reader has released it; readers
185
+ // asking for it meanwhile share it, and none is read where every place
186
+ // is taken (`repository-busy`).
187
+ read(at: KeptCodeAt): NpmLoad<Buffer | null> {
188
+ const key = keptKey(at)
189
+ const name = storageNames.get(storage) ?? storageNames.set(storage, ++storagesNamed).get(storage)!
190
+ return sharedLoad(`repository kept ${name} ${key}`, () => readKept(storage, key).catch((error: unknown) => {
191
+ if (!(error instanceof CacheMissError)) warn('read')(error)
192
+ return null
193
+ }), 'repository-busy')
194
+ },
195
+ // The directory's body kept, behind its answer, where it is no larger
196
+ // than MAX_KEPT_CODE_BYTES; and removed again, with the rest of its
197
+ // commit's, where the head kept is another by then, as when it moved on
198
+ // or the repository was renamed or removed meanwhile.
199
+ keep(at: KeptCodeAt, body: Buffer): void {
200
+ const key = keptKey(at)
201
+ const kept = keeping.get(storage) ?? keeping.set(storage, new Set()).get(storage)!
202
+ if (body.byteLength > MAX_KEPT_CODE_BYTES || kept.has(key)) return
203
+ kept.add(key)
204
+ own((async () => {
205
+ await storage.put(key, body)
206
+ const head = await headOf(at.repoId)
207
+ if (head?.commit !== at.commit || head.repo !== at.repo) await storage.delete(`${at.repoId}/${at.commit}`)
208
+ })().catch(warn('write')).finally(() => kept.delete(key)))
209
+ },
210
+ // What is kept of repository `repoId`, removed once the repository is
211
+ // no longer active.
212
+ remove,
213
+ }
214
+ }
215
+ export type RepositoryCodeKept = ReturnType<typeof repositoryCodeKept>
@@ -45,6 +45,8 @@ export interface RepositoryDependencyReviewStore {
45
45
  listRepoDependencyReviews(): Promise<Map<number, RepoDependencyReview>>
46
46
  // One repository's, none where it isn't connected.
47
47
  repoDependencyReview(repoId: number): Promise<RepoDependencyReview | null>
48
+ // Those of `repoIds` connected, by their ids, in one statement.
49
+ getRepoDependencyReviews(repoIds: number[]): Promise<Map<number, RepoDependencyReview>>
48
50
  // The teams whose published reviews count for each repository (COUNTING_TEAM),
49
51
  // by name.
50
52
  listRepoReviewTeams(): Promise<Map<number, { id: string; name: string }[]>>
@@ -122,6 +124,11 @@ export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDep
122
124
  return new Map(rows.map(row => [row.repoId, setting(row)]))
123
125
  },
124
126
  repoDependencyReview: readSetting,
127
+ async getRepoDependencyReviews(repoIds) {
128
+ if (repoIds.length === 0) return new Map()
129
+ const rows = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id IN (${repoIds.map(() => '?').join(',')})`).all(...repoIds) as SettingRow[]
130
+ return new Map(rows.map(row => [row.repoId, setting(row)]))
131
+ },
125
132
  async listRepoReviewTeams() {
126
133
  const rows = await db.prepare(`SELECT DISTINCT tr.repo_id AS repoId, t.id AS id, t.name AS name FROM managed_team_repo tr
127
134
  JOIN managed_team t ON t.id = tr.team_id WHERE t.hidden = 0 AND t.dependency_review = 1 ORDER BY t.name, t.id`).all() as { repoId: number; id: string; name: string }[]
@@ -9,11 +9,13 @@
9
9
  // read a page from a commit at a time, each page kept by the commit it starts
10
10
  // at (historyFrom): a commit's history never changes, and where a commit read
11
11
  // starts a page kept, that page glues on, and those after it.
12
- import { type Coauthor, MAX_COAUTHORS, type Maker, coauthorsOf } from './commit-coauthors.ts'
12
+ import { type Coauthor, MAX_COAUTHORS, type Maker, coauthorTrailers, coauthorsOf } from './commit-coauthors.ts'
13
13
  import { GithubApiError, githubJson } from './github-app.ts'
14
- import { type GraphActor, accountOf, commitAccount } from './github-accounts.ts'
14
+ import { type GraphActor, accountOf, commitAccount, linkNoreplyUsers } from './github-accounts.ts'
15
+ import { githubGraphql } from './github-graphql.ts'
15
16
 
16
- export { ACTOR, type GraphAccount, type GraphActor, type NamedAccount, accountOf, actorOf, commitAccount } from './github-accounts.ts'
17
+ export { ACTOR, type GraphAccount, type GraphActor, type NamedAccount, accountOf, actorOf, commitAccount, linkNoreplyUsers } from './github-accounts.ts'
18
+ export { type GraphqlError, githubGraphql } from './github-graphql.ts'
17
19
 
18
20
  export const HISTORY_PAGE = 100
19
21
  // The deepest page read, as far as Older commits goes.
@@ -79,19 +81,27 @@ function historyCommit(sha: unknown, parents: unknown[], message: unknown, autho
79
81
 
80
82
  type RestCommit = { sha?: unknown; parents?: { sha?: unknown }[]; author?: { login?: unknown; id?: unknown } | null
81
83
  commit?: { message?: unknown; author?: { name?: unknown; email?: unknown } | null; committer?: { date?: unknown } | null } }
82
- const restCommit = (item: RestCommit) => historyCommit(item?.sha, Array.isArray(item?.parents) ? item.parents.map(parent => parent?.sha) : [],
83
- item?.commit?.message, { name: item?.commit?.author?.name, email: item?.commit?.author?.email, account: accountOf(item?.author) }, item?.commit?.committer?.date)
84
+ // Its co-authors (`listed`) by its trailers, with the accounts of their addresses.
85
+ const restCommit = (item: RestCommit, listed: Maker[] | null = null) => historyCommit(item?.sha, Array.isArray(item?.parents) ? item.parents.map(parent => parent?.sha) : [],
86
+ item?.commit?.message, { name: item?.commit?.author?.name, email: item?.commit?.author?.email, account: accountOf(item?.author) }, item?.commit?.committer?.date,
87
+ null, listed)
84
88
 
85
89
  const repoApi = (fullName: string) => `https://api.github.com/repos/${fullName.split('/').map(encodeURIComponent).join('/')}`
86
90
 
87
91
  // A page of the commits `commit` reaches, newest first as GitHub lists them,
88
92
  // those touching `path` alone where it names a directory; `more` where a
89
- // full page leaves some for the next, one that may be read.
93
+ // full page leaves some for the next, one that may be read. GitHub links
94
+ // each's author to its account; with a token, its co-authors by GitHub's older
95
+ // no-reply address too (linkNoreplyUsers).
90
96
  export async function repositoryHistory(fullName: string, token: string | null, commit: string, path: string, page: number, fetchImpl: typeof fetch = globalThis.fetch) {
91
97
  const params = new URLSearchParams({ sha: commit, per_page: String(HISTORY_PAGE), page: String(page), ...path ? { path } : {} })
92
98
  const data = await githubJson(`${repoApi(fullName)}/commits?${params}`, token, fetchImpl)
93
99
  if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
94
- return { commits: data.flatMap(item => restCommit(item as RestCommit) ?? []), more: data.length >= HISTORY_PAGE && page < MAX_HISTORY_PAGE }
100
+ const items = data as RestCommit[]
101
+ const trailers = items.map(item => coauthorTrailers(item?.commit?.message).map(({ name, email }): GraphActor & { name: string } => ({ name, email, user: null })))
102
+ if (token) await linkNoreplyUsers(trailers.flat(), token, fetchImpl)
103
+ return { commits: items.flatMap((item, at) => restCommit(item, token ? trailers[at]!.map(actor => ({ name: actor.name, email: actor.email, account: commitAccount(actor) })) : null) ?? []),
104
+ more: data.length >= HISTORY_PAGE && page < MAX_HISTORY_PAGE }
95
105
  }
96
106
 
97
107
  // The commits the repository's branches and tags point at, as GitHub's
@@ -104,41 +114,8 @@ export async function repositoryRefTips(fullName: string, token: string | null,
104
114
  return { branches: tips(branches), tags: tips(tags) }
105
115
  }
106
116
 
107
- export type GraphqlError = { type?: unknown; path?: unknown; message?: unknown }
108
-
109
- const NAMED_ERRORS = new Set(['RATE_LIMITED', 'NOT_FOUND', 'FORBIDDEN'])
110
- // GitHub's own words for a query it stopped past its time (ten seconds):
111
- // "Something went wrong while executing your query. This may be the result
112
- // of a timeout, …", with no type.
113
- const TIMED_OUT = /\btimeout\b/iu
114
-
115
- // One GraphQL query with `token`, answered with its `data`: one GitHub answers
116
- // with errors, but for those the caller reads past (`tolerated`, the fields
117
- // they name then null), is rate limited, names what isn't there, is refused,
118
- // as one a GitHub App lacks the permission for is, or a token the scope for
119
- // (`INSUFFICIENT_SCOPES`, a sign-in's having none), took GitHub too long, or
120
- // else is malformed, as one GitHub rejects unread is (a field or type its API
121
- // doesn't know). GitHub's words for errors of none of those first three types
122
- // are logged, those read past too.
123
- export async function githubGraphql(query: string, variables: Record<string, unknown>, token: string, fetchImpl: typeof fetch,
124
- tolerated: (error: GraphqlError) => boolean = () => false) {
125
- const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }, true) as { data?: unknown; errors?: GraphqlError[] } | null
126
- const all = Array.isArray(body?.errors) ? body.errors : []
127
- const unnamed = all.filter(error => !NAMED_ERRORS.has(String(error?.type)))
128
- if (unnamed.length > 0) {
129
- console.warn('managed: GitHub answered a query with errors:', JSON.stringify(unnamed.slice(0, 3)
130
- .map(error => ({ type: error?.type, message: String(error?.message).slice(0, 300), path: error?.path }))))
131
- }
132
- const errors = all.filter(error => !tolerated(error))
133
- if (errors.length > 0) {
134
- if (errors.some(error => error?.type === 'RATE_LIMITED')) throw new GithubApiError(429, 'github-rate-limited', 60)
135
- if (errors.some(error => error?.type === 'NOT_FOUND')) throw new GithubApiError(404, 'github-not-found')
136
- if (errors.some(error => error?.type === 'FORBIDDEN' || error?.type === 'INSUFFICIENT_SCOPES')) throw new GithubApiError(502, 'github-status-403')
137
- if (errors.some(error => typeof error?.message === 'string' && TIMED_OUT.test(error.message))) throw new GithubApiError(502, 'github-timeout')
138
- throw new GithubApiError(502, 'github-malformed')
139
- }
140
- return body?.data
141
- }
117
+ // A commit's author and those GitHub lists beside it.
118
+ export const commitActors = (node: GraphCommit | null | undefined) => [node?.author, ...node?.authors?.nodes ?? []]
142
119
 
143
120
  // A tag's target, an annotated tag's own target in turn, through as many as
144
121
  // MAX_TAG_DEPTH annotated tags, each object's type naming what it is.
@@ -179,10 +156,10 @@ export interface HistoryPages {
179
156
  }
180
157
 
181
158
  // What a commit is read by in GraphQL.
182
- const COMMIT_FRAGMENT = `fragment historyCommit on Commit { oid message committedDate additions deletions author { name email date user { login databaseId } }
159
+ export const COMMIT_FRAGMENT = `fragment historyCommit on Commit { oid message committedDate additions deletions author { name email date user { login databaseId } }
183
160
  authors(first: ${MAX_COAUTHORS + 1}) { nodes { name email user { login databaseId } } } parents(first: ${MAX_PARENTS}) { nodes { oid } } }`
184
161
  // A query's variables `$<prefix><n>` of `type`, as many as `count`.
185
- const variablesOf = (prefix: string, count: number, type: string) => Array.from({ length: count }, (_, at) => `$${prefix}${at}: ${type}`).join(', ')
162
+ export const variablesOf = (prefix: string, count: number, type: string) => Array.from({ length: count }, (_, at) => `$${prefix}${at}: ${type}`).join(', ')
186
163
 
187
164
  // The latest commits of each tip, by its id, `b<n>` the `n`th's.
188
165
  const historiesQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
@@ -192,7 +169,7 @@ const historiesQuery = (count: number) => `query($owner: String!, $name: String!
192
169
  }
193
170
  ${COMMIT_FRAGMENT}`
194
171
 
195
- type GraphCommit = { oid?: unknown; message?: unknown; committedDate?: unknown; additions?: unknown; deletions?: unknown
172
+ export type GraphCommit = { oid?: unknown; message?: unknown; committedDate?: unknown; additions?: unknown; deletions?: unknown
196
173
  author?: ({ name?: unknown; date?: unknown } & GraphActor) | null; authors?: { nodes?: (({ name?: unknown } & GraphActor) | null)[] } | null
197
174
  parents?: { nodes?: { oid?: unknown }[] } }
198
175
  type GraphTarget = { __typename?: unknown; oid?: unknown; committedDate?: unknown; target?: GraphTarget | null }
@@ -224,20 +201,37 @@ async function repositoryHeads(owner: string, name: string, token: string, fetch
224
201
  return { heads, repository: first, cut: true }
225
202
  }
226
203
 
227
- // The first page of the history from each of `tops`, by its top: with a
228
- // token, in one GraphQL query; without, a REST list each.
229
- async function readPages(fullName: string, token: string | null, tops: string[], fetchImpl: typeof fetch) {
204
+ // Histories read in a GraphQL query, and queries at once: each counts its
205
+ // commits' lines, which GitHub works out as it answers, stopping a query
206
+ // past ten seconds.
207
+ const PAGES_PER_QUERY = 5
208
+ const PAGES_PARALLEL = 5
209
+
210
+ // The first page of the history from each of `tops`, by its top, each told
211
+ // to `keep` as it is read, so those read stay kept where another fails: with
212
+ // a token, in GraphQL queries of PAGES_PER_QUERY (inBatches); without, a
213
+ // REST list each.
214
+ async function readPages(fullName: string, token: string | null, tops: string[], fetchImpl: typeof fetch,
215
+ keep: (top: string, commits: HistoryCommit[]) => Promise<void>) {
216
+ const read = new Map<string, HistoryCommit[]>()
217
+ const got = (top: string, commits: HistoryCommit[]) => { read.set(top, commits); return keep(top, commits) }
230
218
  if (!token) {
231
- return new Map(await Promise.all(tops.map(async top => [top, (await repositoryHistory(fullName, null, top, '', 1, fetchImpl)).commits] as const)))
219
+ await Promise.all(tops.map(async top => got(top, (await repositoryHistory(fullName, null, top, '', 1, fetchImpl)).commits)))
220
+ return read
232
221
  }
233
222
  const [owner, name] = fullName.split('/') as [string, string]
234
- const data = await githubGraphql(historiesQuery(tops.length), { owner, name, ...Object.fromEntries(tops.map((sha, at) => [`o${at}`, sha])) }, token, fetchImpl) as Histories
235
- return new Map(tops.map((top, at) => [top, (data?.repository?.[`b${at}`]?.history?.nodes ?? []).flatMap(node => graphCommit(node) ?? [])]))
223
+ await inBatches(tops, PAGES_PER_QUERY, PAGES_PARALLEL, async batch => {
224
+ const data = await githubGraphql(historiesQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map((sha, at) => [`o${at}`, sha])) }, token, fetchImpl) as Histories
225
+ const nodes = batch.map((_, at) => data?.repository?.[`b${at}`]?.history?.nodes ?? [])
226
+ await linkNoreplyUsers(nodes.flat().flatMap(commitActors), token, fetchImpl)
227
+ await Promise.all(batch.map((top, at) => got(top, nodes[at]!.flatMap(node => graphCommit(node) ?? []))))
228
+ })
229
+ return read
236
230
  }
237
231
 
238
232
  // Its authors past the first, the git author, GitHub lists from its
239
233
  // `Co-authored-by:` trailers.
240
- const graphCommit = (node: GraphCommit | null | undefined) => historyCommit(node?.oid, (node?.parents?.nodes ?? []).map(parent => parent?.oid),
234
+ export const graphCommit = (node: GraphCommit | null | undefined) => historyCommit(node?.oid, (node?.parents?.nodes ?? []).map(parent => parent?.oid),
241
235
  node?.message, { name: node?.author?.name, email: node?.author?.email, account: commitAccount(node?.author) }, node?.committedDate,
242
236
  { additions: node?.additions, deletions: node?.deletions, authoredAt: node?.author?.date },
243
237
  Array.isArray(node?.authors?.nodes) ? node.authors.nodes.slice(1).map(actor => ({ name: actor?.name, email: actor?.email, account: commitAccount(actor) })) : null)
@@ -257,9 +251,9 @@ const graphCommit = (node: GraphCommit | null | undefined) => historyCommit(node
257
251
  export async function historyFrom(fullName: string, token: string | null, starts: string[], pages: HistoryPages, fetchImpl: typeof fetch = globalThis.fetch) {
258
252
  const kept = await pages.read(starts)
259
253
  const unread = starts.filter(top => !kept.has(top))
260
- const read = unread.length > 0 ? await readPages(fullName, token, unread, fetchImpl) : new Map<string, HistoryCommit[]>()
261
254
  // Kept only as GitHub listed it from its top: none for a commit it named nothing of.
262
- await Promise.all([...read].flatMap(([top, commits]) => commits[0]?.sha === top ? [pages.write(top, commits)] : []))
255
+ const keep = (top: string, commits: HistoryCommit[]) => commits[0]?.sha === top ? pages.write(top, commits) : Promise.resolve()
256
+ const read = unread.length > 0 ? await readPages(fullName, token, unread, fetchImpl, keep) : new Map<string, HistoryCommit[]>()
263
257
  const listed = new Map<string, HistoryCommit>()
264
258
  const add = (commits: HistoryCommit[]) => { for (const commit of commits) if (!listed.has(commit.sha)) listed.set(commit.sha, commit) }
265
259
  for (const top of starts) add(kept.get(top) ?? read.get(top) ?? [])
@@ -334,106 +328,45 @@ export async function repositoryAllHistory(fullName: string, token: string, { he
334
328
  return { commits: read.commits, branches, tags, defaultBranch, unread, cut, more }
335
329
  }
336
330
 
337
- // A pull request squashed into a commit of the default branch's history
338
- // (`merge`): its number, the commit its head was at when merged (`head`,
339
- // GitHub's own of it, which a branch reused or pushed over since never
340
- // moves), and its own commits, the latest MAX_SQUASHED of them, `more`
341
- // where it had more. Drawn as the merge it was, its commits on a lane of
342
- // their own (repository-history.js).
343
- export interface SquashedPull { merge: string; number: number; head: string; commits: HistoryCommit[]; more: boolean }
344
- // The pull requests squashed into commits, kept by each commit (a merged pull
345
- // request's commits never change); null for a commit none was.
346
- export interface Squashes {
347
- read(shas: string[]): Promise<Map<string, SquashedPull | null>>
348
- write(sha: string, pull: SquashedPull | null): Promise<void>
349
- }
350
-
351
- const MAX_SQUASHED = 100
352
- // Looked up of an answer's commits not yet kept, at most, the newest first;
353
- // in queries of as many commits, or pull requests, as many at once.
354
- export const MAX_SQUASH_LOOKUPS = 500
355
- const SQUASH_COMMITS = 100
356
- const SQUASH_PULLS = 20
357
- const SQUASH_PARALLEL = 4
358
-
359
- // The merged pull request that brought each commit in, `c<n>` the `n`th's.
360
- const mergedPullsQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('o', count, 'GitObjectID!')}) {
361
- repository(owner: $owner, name: $name) {
362
- ${Array.from({ length: count }, (_, at) => `c${at}: object(oid: $o${at}) { ... on Commit { associatedPullRequests(first: 1) { nodes { number mergeCommit { oid } } } } }`).join('\n ')}
331
+ const chunked = <T>(items: T[], size: number) => Array.from({ length: Math.ceil(items.length / size) }, (_, at) => items.slice(at * size, (at + 1) * size))
332
+ // GitHub stopping a query past its time: in its answer's errors, or as a 502
333
+ // or 504 of its own.
334
+ const SLOW = new Set(['github-timeout', 'github-status-502', 'github-status-504'])
335
+ // `run` for `items` in batches of `size`, as many at once as `parallel`; a
336
+ // batch GitHub stops past its time run again in halves, down to one alone,
337
+ // so that one too slow to read fails alone, the others read. The halves
338
+ // wait their turn with the rest, and as many batches are halved as run at
339
+ // once twice over at most, GitHub slow throughout past that. Where one
340
+ // fails, none more is run, those running done before its error is thrown,
341
+ // so that what they read is kept.
342
+ export async function inBatches<T>(items: T[], size: number, parallel: number, run: (batch: T[]) => Promise<void>) {
343
+ const failed: unknown[] = []
344
+ const waiting: (() => void)[] = []
345
+ let halved = 0, running = 0
346
+ const turn = () => {
347
+ if (running < parallel) { running++; return Promise.resolve() }
348
+ return new Promise<void>(resolve => { waiting.push(resolve) })
363
349
  }
364
- }`
365
- // Each pull request's head when merged and its latest commits, `p<n>` the `n`th's.
366
- const squashedQuery = (count: number) => `query($owner: String!, $name: String!, ${variablesOf('n', count, 'Int!')}) {
367
- repository(owner: $owner, name: $name) {
368
- ${Array.from({ length: count }, (_, at) => `p${at}: pullRequest(number: $n${at}) { headRefOid commits(last: ${MAX_SQUASHED}) { totalCount nodes { commit { ...historyCommit } } } }`).join('\n ')}
350
+ // A turn done, handed to the batch waiting longest.
351
+ const done = () => {
352
+ const next = waiting.shift()
353
+ if (next) next()
354
+ else running--
369
355
  }
370
- }
371
- ${COMMIT_FRAGMENT}`
372
-
373
- type MergedPulls = { repository?: Record<string, { associatedPullRequests?: { nodes?: ({ number?: unknown; mergeCommit?: { oid?: unknown } | null } | null)[] } } | null> | null }
374
- type SquashedPage = { repository?: Record<string, { headRefOid?: unknown; commits?: { totalCount?: unknown; nodes?: ({ commit?: GraphCommit | null } | null)[] } } | null> | null }
375
-
376
- const chunked = <T>(items: T[], size: number) => Array.from({ length: Math.ceil(items.length / size) }, (_, at) => items.slice(at * size, (at + 1) * size))
377
- // `run` for each of `items`, as many at once as `parallel`.
378
- async function eachOf<T>(items: T[], parallel: number, run: (item: T) => Promise<void>) {
379
- let next = 0
380
- await Promise.all(Array.from({ length: Math.min(parallel, items.length) }, async () => { while (next < items.length) await run(items[next++]!) }))
381
- }
382
- // A pull request or commit GitHub has none of, as one deleted: its field null.
383
- const notFound = (error: GraphqlError) => error?.type === 'NOT_FOUND'
384
-
385
- // The pull requests squashed into `commits`, a merged pull request's merge
386
- // commit of one parent, not one rebased (its commits copied onto the branch,
387
- // told by their subjects), kept by each commit (`squashes`), those not yet
388
- // kept looked up with GraphQL, as many as MAX_SQUASH_LOOKUPS an answer.
389
- export async function squashedPulls(fullName: string, token: string, commits: HistoryCommit[], squashes: Squashes,
390
- fetchImpl: typeof fetch = globalThis.fetch): Promise<SquashedPull[]> {
391
- const [owner, name] = fullName.split('/') as [string, string]
392
- const single = commits.filter(commit => commit.parents.length === 1)
393
- const listed = new Map(commits.map(commit => [commit.sha, commit]))
394
- const kept = await squashes.read(single.map(commit => commit.sha))
395
- const unknown = single.filter(commit => !kept.has(commit.sha)).slice(0, MAX_SQUASH_LOOKUPS)
396
- const merged: { commit: HistoryCommit; number: number }[] = []
397
- const found = new Map<string, SquashedPull | null>()
398
- await eachOf(chunked(unknown, SQUASH_COMMITS), SQUASH_PARALLEL, async batch => {
399
- const data = await githubGraphql(mergedPullsQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map((commit, at) => [`o${at}`, commit.sha])) },
400
- token, fetchImpl, notFound) as MergedPulls
401
- for (const [at, commit] of batch.entries()) {
402
- const node = data?.repository?.[`c${at}`]
403
- if (!node) continue
404
- const pull = node.associatedPullRequests?.nodes?.[0]
405
- const number = pull?.mergeCommit?.oid === commit.sha && Number.isSafeInteger(pull.number) && (pull.number as number) > 0 ? pull.number as number : null
406
- if (number === null) { found.set(commit.sha, null); await squashes.write(commit.sha, null) }
407
- else merged.push({ commit, number })
408
- }
409
- })
410
- await eachOf(chunked(merged, SQUASH_PULLS), SQUASH_PARALLEL, async batch => {
411
- const data = await githubGraphql(squashedQuery(batch.length), { owner, name, ...Object.fromEntries(batch.map(({ number }, at) => [`n${at}`, number])) },
412
- token, fetchImpl, notFound) as SquashedPage
413
- for (const [at, { commit, number }] of batch.entries()) {
414
- const node = data?.repository?.[`p${at}`]
415
- if (!node) continue
416
- const own = (node.commits?.nodes ?? []).flatMap(item => graphCommit(item?.commit) ?? [])
417
- const head = typeof node.headRefOid === 'string' && SHA.test(node.headRefOid) ? node.headRefOid : own.at(-1)?.sha
418
- // Fast-forwarded, its last commit the merge's own; or rebased, its
419
- // commits, more than one, copied onto the branch: the merge and those
420
- // below it on their first parents, by their subjects, its commits'
421
- // (one squashed is told by its commit's title too, by default, and
422
- // drawn as it was either way).
423
- const below = [commit]
424
- for (let next = listed.get(commit.parents[0] ?? ''); next && below.length < own.length; next = listed.get(next.parents[0] ?? '')) below.push(next)
425
- const copies = own.toReversed().slice(0, below.length).every((item, step) => below[step]!.subject === item.subject)
426
- // Its commits' copies as far as those listed go, but not all of them
427
- // listed: told, and kept, once they are.
428
- if (own.length > 1 && copies && below.length < own.length) continue
429
- const rebased = own.length > 1 && copies
430
- const squashed = head !== undefined && head !== commit.sha && own.length > 0 && !own.some(item => item.sha === commit.sha) && !rebased
431
- const pull = squashed ? { merge: commit.sha, number, head, commits: own, more: Number(node.commits?.totalCount) > own.length } : null
432
- found.set(commit.sha, pull)
433
- await squashes.write(commit.sha, pull)
434
- }
435
- })
436
- return single.flatMap(commit => kept.get(commit.sha) ?? found.get(commit.sha) ?? [])
356
+ const attempt = async (batch: T[]): Promise<void> => {
357
+ await turn()
358
+ try {
359
+ if (failed.length === 0) await run(batch)
360
+ return
361
+ } catch (err) {
362
+ if (batch.length === 1 || halved >= 2 * parallel || !(err instanceof GithubApiError && SLOW.has(err.message))) { failed.push(err); return }
363
+ halved++
364
+ } finally { done() }
365
+ const half = Math.ceil(batch.length / 2)
366
+ await Promise.all([attempt(batch.slice(0, half)), attempt(batch.slice(half))])
367
+ }
368
+ await Promise.all(chunked(items, size).map(attempt))
369
+ if (failed.length > 0) throw failed[0]
437
370
  }
438
371
 
439
372
  // The open pull requests most recently updated, each by the commit its head