@preventive/triage 1.0.0-alpha.46 → 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.
- package/client/dependency-paths.d.ts +2 -1
- package/client/dependency-paths.js +16 -4
- package/common/managed/agent-tasks.ts +12 -12
- package/common/managed/github-agents.ts +29 -10
- package/out/activity.js +4 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C2AVODGZ.js +36 -0
- package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
- package/out/chunk-MOACFRJ4.js +3 -0
- package/out/chunk-O6M574JI.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PENGGKX7.js +233 -0
- package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
- package/out/chunk-SZD5CW5R.js +3 -0
- package/out/{chunk-G24A42V6.js → chunk-TQJDYHVE.js} +1 -1
- package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
- package/out/{chunk-7XRR4OFU.js → chunk-WYLQWYLA.js} +1 -1
- package/out/chunk-X6PJAK3T.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +26 -26
- package/out/client-sync.js +2 -2
- package/out/compare.js +14 -14
- package/out/dialogs.js +40 -40
- package/out/graph.js +11 -11
- package/out/index.html +12 -12
- package/out/scan.js +2 -2
- package/out/terminal.js +215 -215
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +9 -5
- package/risk/index.d.ts +4 -2
- package/risk/npm/assess.js +15 -4
- package/risk/npm/registry.js +6 -1
- package/risk/npm/score.js +1 -1
- package/server-managed/activity.ts +24 -11
- package/server-managed/agent-merges.ts +10 -8
- package/server-managed/app-agents.ts +8 -21
- package/server-managed/bundle-build-worker.js +7 -3
- package/server-managed/bundle-build.ts +5 -3
- package/server-managed/bundle-cache.ts +9 -3
- package/server-managed/bundle.ts +3 -4
- package/server-managed/cache-storage.ts +3 -2
- package/server-managed/commit-coauthors.ts +11 -10
- package/server-managed/commit-lockfiles.ts +22 -21
- package/server-managed/db-methods.ts +338 -187
- package/server-managed/db-neon.ts +31 -8
- package/server-managed/db.ts +2 -0
- package/server-managed/dependabot-access.ts +1 -1
- package/server-managed/dependency-review-comments.ts +7 -4
- package/server-managed/dependency-reviews.ts +71 -33
- package/server-managed/github-accounts.ts +28 -1
- package/server-managed/github-app.ts +27 -23
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +8 -1
- package/server-managed/github-metadata.ts +2 -2
- package/server-managed/http-response.ts +28 -10
- package/server-managed/http.ts +736 -247
- package/server-managed/index.ts +2 -2
- package/server-managed/link-reports.ts +5 -1
- package/server-managed/managed-issues.ts +3 -0
- package/server-managed/npm-delta.ts +16 -0
- package/server-managed/npm-kept.ts +51 -10
- package/server-managed/npm-loads.ts +48 -28
- package/server-managed/npm-manifest.ts +1 -1
- package/server-managed/npm-packages.ts +31 -15
- package/server-managed/npm-risk.ts +64 -17
- package/server-managed/object-storage.ts +4 -3
- package/server-managed/pretty-print.ts +45 -12
- package/server-managed/public-workspace.ts +3 -3
- package/server-managed/pull-dependencies.ts +84 -36
- package/server-managed/pull-dependency-reviews.ts +13 -10
- package/server-managed/rename-diff-worker.js +1 -1
- package/server-managed/report-store.ts +16 -11
- package/server-managed/repository-agents.ts +9 -6
- package/server-managed/repository-browser.ts +17 -5
- package/server-managed/repository-code.ts +180 -17
- package/server-managed/repository-dependency-reviews.ts +7 -0
- package/server-managed/repository-history.ts +85 -152
- package/server-managed/repository-issues.ts +27 -13
- package/server-managed/repository-pulse.ts +9 -3
- package/server-managed/repository-squashes.ts +145 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +3 -2
- package/server-managed/session.ts +34 -12
- package/server-managed/solidity-entry-points.ts +5 -3
- package/server-managed/sql-postgres.ts +3 -0
- package/server-managed/sql.ts +17 -8
- package/server-managed/storage-db.ts +11 -3
- package/server-managed/storage-encryption.ts +23 -12
- package/server-managed/storage-maintenance.ts +5 -2
- package/server-managed/storage-payload.ts +18 -0
- package/server-managed/storage-stores.ts +3 -2
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-feed.ts +19 -12
- package/server-managed/team-npm-scopes.ts +29 -0
- package/server-managed/team-reports.ts +74 -17
- package/server-managed/uploads.ts +29 -9
- package/out/chunk-4UFAMJAX.js +0 -1
- package/out/chunk-BOIXMUU5.js +0 -3
- package/out/chunk-CC4OM6TZ.js +0 -3
- package/out/chunk-JKGQ3O7O.js +0 -6
- package/out/chunk-LE5ZWLG4.js +0 -235
- package/out/chunk-MSION3G4.js +0 -36
- package/out/chunk-S6GIXWQW.js +0 -52
- package/out/chunk-V3KWNNMY.js +0 -1
- package/out/chunk-WQ74ZBZF.js +0 -7
|
@@ -4,27 +4,44 @@
|
|
|
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'
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
+
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
13
|
+
import type { KeptStore } from './npm-kept.ts'
|
|
14
|
+
import { plainObject } from './npm-manifest.ts'
|
|
15
|
+
import { MAX_NPM_JSON_LENGTH, NpmPackageError, npmFileRows } from './npm-packages.ts'
|
|
16
|
+
import { type NpmLoad, loadPlaces, readTarballFiles, sharedLoad } from './npm-loads.ts'
|
|
12
17
|
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
export const
|
|
18
|
+
// What a directory may hold, bounded apart from a package's (npm-packages.ts),
|
|
19
|
+
// as the viewer holds every file in memory: its files' count and bytes.
|
|
20
|
+
export const MAX_REPOSITORY_CODE_FILES = 80_000
|
|
21
|
+
export const MAX_REPOSITORY_CODE_BYTES = 256 * 1024 * 1024
|
|
22
|
+
// The tarball they come in, bounded in proportion as a package's is: its tar
|
|
23
|
+
// stream, headers and padding included, and its entries, as many again as
|
|
24
|
+
// files, as GitHub's has one for each directory too.
|
|
25
|
+
const REPOSITORY_TAR = { maxFiles: MAX_REPOSITORY_CODE_FILES, maxBytes: MAX_REPOSITORY_CODE_BYTES, maxEntries: 2 * MAX_REPOSITORY_CODE_FILES, maxTarBytes: 384 * 1024 * 1024 }
|
|
26
|
+
// What the files may take as the viewer's JSON, written as one string: short
|
|
27
|
+
// of the longest V8 holds (2^29 - 24 characters).
|
|
28
|
+
const MAX_REPOSITORY_CODE_JSON_LENGTH = 384 * 1024 * 1024
|
|
16
29
|
|
|
17
30
|
// Whether a directory's files fit, by GitHub's listing of its tree and every
|
|
18
31
|
// tree under it (repository-browser.ts repositoryTreeFiles): asked before
|
|
19
32
|
// its tarball is, which may run to hundreds of MiB. One GitHub cut short
|
|
20
33
|
// (null) has too many.
|
|
21
|
-
export function repositoryCodeFits(files:
|
|
34
|
+
export function repositoryCodeFits(files: RepositoryCodeFiles | null): files is RepositoryCodeFiles {
|
|
22
35
|
return files !== null && files.files <= MAX_REPOSITORY_CODE_FILES && files.bytes <= MAX_REPOSITORY_CODE_BYTES
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
// A directory's files' count and bytes, as GitHub lists its tree.
|
|
39
|
+
export interface RepositoryCodeFiles { files: number; bytes: number }
|
|
40
|
+
|
|
25
41
|
// The directory read: the repository by its id and name, the commit, the
|
|
26
|
-
// directory (`''` for the root)
|
|
27
|
-
|
|
42
|
+
// directory (`''` for the root), its tree's id, and its files as listed,
|
|
43
|
+
// which weigh its load among the active ones.
|
|
44
|
+
export interface RepositoryCodeInput { repoId: number; repo: string; commit: string; path: string; tree: string; listed: RepositoryCodeFiles }
|
|
28
45
|
|
|
29
46
|
// A package's refusals, as a directory's: past the bounds, or an archive
|
|
30
47
|
// @preventive/archive refuses, as it does one holding a symlink that climbs
|
|
@@ -37,16 +54,162 @@ const directoryError = (err: unknown) => {
|
|
|
37
54
|
|
|
38
55
|
// The directory as the viewer reads it, as brotli-encoded JSON: `{ repoId,
|
|
39
56
|
// repo, commit, path, tree, files }`, its files as npmFileRows gives them,
|
|
40
|
-
// by their paths in the directory, from `tarball`, the tree's
|
|
41
|
-
|
|
42
|
-
|
|
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> {
|
|
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
|
|
65
|
+
// Its places among the active loads, by its files as listed, hold their
|
|
66
|
+
// JSON too, which the listing cannot tell, as escaping may grow a text
|
|
67
|
+
// sixfold: as much as a package's may take for each.
|
|
68
|
+
const places = loadPlaces(listed)
|
|
69
|
+
const maxLength = Math.min(MAX_REPOSITORY_CODE_JSON_LENGTH, places * MAX_NPM_JSON_LENGTH)
|
|
43
70
|
return sharedLoad(`repository ${repoId} ${repo} ${commit} ${path} ${tree}`, async () => {
|
|
44
71
|
let files
|
|
45
|
-
|
|
46
|
-
// LICENSE links the root's, and no further: the directory's depth, less
|
|
47
|
-
// the level the archive's root already stands above its top directory.
|
|
48
|
-
try { files = npmFileRows(await readTarballFiles(await tarball(), { symlinksAbove: path.split('/').length - 1 })) }
|
|
72
|
+
try { files = npmFileRows(await readTarballFiles(await tarball(symlinksAbove), { ...REPOSITORY_TAR, symlinksAbove }), maxLength) }
|
|
49
73
|
catch (err) { throw directoryError(err) }
|
|
50
74
|
return encodeBrotli(Buffer.from(JSON.stringify({ repoId, repo, commit, path, tree, files })))
|
|
51
|
-
}, 'repository-busy')
|
|
75
|
+
}, 'repository-busy', { places })
|
|
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
|
+
}
|
|
52
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
|
|
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
|