@preventive/triage 1.0.0-alpha.47 → 1.0.0-alpha.49
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 -14
- package/common/managed/github-agents.ts +7 -6
- package/out/activity.js +4 -4
- package/out/{chunk-NOZJULWS.js → chunk-6LNQTKAV.js} +1 -1
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/{chunk-5AHIQFFG.js → chunk-C2AVODGZ.js} +1 -1
- package/out/{chunk-I2MDGSQX.js → chunk-IO7JTZ6O.js} +1 -1
- package/out/chunk-JEAUAJVF.js +232 -0
- package/out/chunk-O6M574JI.js +1 -0
- package/out/{chunk-VL43BTSO.js → chunk-PBSOL2SL.js} +1 -1
- package/out/{chunk-ZCS4UYHI.js → chunk-PBZPYIG7.js} +1 -1
- package/out/{chunk-6WF3KNTL.js → chunk-SZD5CW5R.js} +2 -2
- package/out/{chunk-SPEFPML6.js → chunk-TQJDYHVE.js} +1 -1
- package/out/{chunk-UENXT2HK.js → chunk-ULIF7A7N.js} +1 -1
- package/out/{chunk-FH24VHSJ.js → chunk-WYLQWYLA.js} +1 -1
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +26 -26
- package/out/compare.js +13 -13
- package/out/dialogs.js +17 -17
- package/out/graph.js +1 -1
- package/out/index.html +7 -7
- package/out/scan.js +1 -1
- package/out/terminal.js +214 -214
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +8 -5
- package/risk/npm/score.js +1 -1
- package/server-managed/agent-merges.ts +38 -22
- package/server-managed/app-agents.ts +6 -8
- package/server-managed/cache-storage.ts +3 -2
- package/server-managed/commit-coauthors.ts +7 -2
- package/server-managed/commit-lockfiles.ts +22 -21
- package/server-managed/db-methods.ts +52 -38
- package/server-managed/db-neon.ts +21 -5
- 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 +751 -273
- package/server-managed/index.ts +2 -2
- package/server-managed/managed-issues.ts +3 -0
- package/server-managed/npm-delta.ts +16 -0
- package/server-managed/npm-kept.ts +32 -5
- package/server-managed/npm-loads.ts +21 -13
- package/server-managed/npm-manifest.ts +1 -1
- package/server-managed/npm-packages.ts +13 -4
- package/server-managed/object-storage.ts +4 -3
- package/server-managed/public-workspace.ts +3 -3
- package/server-managed/pull-comment-reviews.ts +1 -1
- package/server-managed/pull-dependencies.ts +84 -36
- package/server-managed/pull-dependency-reviews.ts +28 -18
- package/server-managed/rename-diff-worker.js +1 -1
- package/server-managed/repository-agents.ts +9 -6
- package/server-managed/repository-browser.ts +13 -3
- package/server-managed/repository-code.ts +153 -7
- package/server-managed/repository-dependency-reviews.ts +12 -4
- package/server-managed/repository-history.ts +85 -152
- package/server-managed/repository-issues.ts +27 -13
- package/server-managed/repository-pulse.ts +31 -11
- 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/sql.ts +16 -8
- package/server-managed/storage-encryption.ts +16 -8
- 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/team-feed.ts +16 -11
- package/server-managed/team-npm-scopes.ts +29 -0
- package/server-managed/team-reports.ts +68 -16
- package/out/chunk-6ANSHRFQ.js +0 -6
- package/out/chunk-S6U2J62U.js +0 -232
- package/out/chunk-ZGNMDGSB.js +0 -1
|
@@ -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
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
// review and its checks, which GraphQL alone gives, so it needs a token; an
|
|
7
7
|
// open one's, and its popup's, whether it changes a lockfile whose new
|
|
8
8
|
// packages are told (pull-dependencies.ts touchesLockfile), and its reviews
|
|
9
|
-
// (pull-reviews.ts), each reviewer's
|
|
10
|
-
// its
|
|
11
|
-
//
|
|
9
|
+
// (pull-reviews.ts), each reviewer's; and an open one's base's and head's
|
|
10
|
+
// commits, kept with its page but never told, by which what its lockfiles
|
|
11
|
+
// add is read (http.ts teamRepositoryDependencies). A nonpublic repository's
|
|
12
|
+
// are read with its GitHub App's installation, which reads pull requests only
|
|
13
|
+
// with their own permission (doc/code-viewer.md).
|
|
12
14
|
import { GithubApiError } from './github-app.ts'
|
|
13
15
|
import { touchesLockfile } from './pull-dependencies.ts'
|
|
14
16
|
import { ACTOR, type GraphAccount, type GraphqlError, actorOf, githubGraphql } from './repository-history.ts'
|
|
@@ -16,11 +18,12 @@ import { ITEM_THREADS, type ReviewNode, TILE_THREADS, pullReviews, reviewFields,
|
|
|
16
18
|
|
|
17
19
|
export const ISSUES_PAGE = 50
|
|
18
20
|
// What a kept page or item was read as: kept ones read before differ.
|
|
19
|
-
export const ISSUES_READ_VERSION =
|
|
21
|
+
export const ISSUES_READ_VERSION = 4
|
|
20
22
|
const MAX_LABELS = 20
|
|
21
23
|
const MAX_TITLE = 1000
|
|
22
24
|
const MAX_NAME = 256
|
|
23
25
|
const COLOR = /^[a-f\d]{6}$/iu
|
|
26
|
+
const SHA = /^[a-f\d]{40}$/u
|
|
24
27
|
// A page's cursor as GitHub gives it, to read on from: opaque, base64 or
|
|
25
28
|
// base64url as it is now (`…rOrK_u7Q==`), passed back to GitHub alone, so any
|
|
26
29
|
// printable one is, but none with spaces or control characters.
|
|
@@ -43,21 +46,22 @@ const connection = { issues: 'issues', pulls: 'pullRequests' }
|
|
|
43
46
|
const states = { issues: { open: '[OPEN]', closed: '[CLOSED]' }, pulls: { open: '[OPEN]', closed: '[CLOSED, MERGED]' } }
|
|
44
47
|
|
|
45
48
|
// A page of `kind`, those in `state`, after the page `after` ends, and how
|
|
46
|
-
// many of each state there are; open pull requests with their
|
|
49
|
+
// many of each state there are; open pull requests with their commits and
|
|
50
|
+
// their reviews.
|
|
47
51
|
const pageQuery = (kind: IssuesKind, state: IssuesState) => `query($owner: String!, $name: String!, $after: String) {
|
|
48
52
|
repository(owner: $owner, name: $name) {
|
|
49
53
|
open: ${connection[kind]}(states: ${states[kind].open}) { totalCount }
|
|
50
54
|
closed: ${connection[kind]}(states: ${states[kind].closed}) { totalCount }
|
|
51
55
|
page: ${connection[kind]}(states: ${states[kind][state]}, first: ${ISSUES_PAGE}, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) {
|
|
52
56
|
pageInfo { hasNextPage endCursor }
|
|
53
|
-
nodes { ${fields[kind]}${kind === 'pulls' && state === 'open' ? ` ${files} ${reviewFields(TILE_THREADS, false)}` : ''} }
|
|
57
|
+
nodes { ${fields[kind]}${kind === 'pulls' && state === 'open' ? ` baseRefOid headRefOid ${files} ${reviewFields(TILE_THREADS, false)}` : ''} }
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
}`
|
|
57
61
|
|
|
58
62
|
type Node = ReviewNode & { number?: unknown; title?: unknown; createdAt?: unknown; updatedAt?: unknown; closedAt?: unknown; mergedAt?: unknown; author?: GraphAccount
|
|
59
63
|
comments?: { totalCount?: unknown }; labels?: { totalCount?: unknown; nodes?: { name?: unknown; color?: unknown }[] }; state?: unknown; stateReason?: unknown
|
|
60
|
-
isDraft?: unknown; reviewDecision?: unknown; additions?: unknown; deletions?: unknown; headRefName?: unknown; baseRefName?: unknown
|
|
64
|
+
isDraft?: unknown; reviewDecision?: unknown; additions?: unknown; deletions?: unknown; headRefName?: unknown; baseRefName?: unknown; baseRefOid?: unknown
|
|
61
65
|
commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: unknown } | null } | null }[] }
|
|
62
66
|
files?: { totalCount?: unknown; nodes?: ({ path?: unknown } | null)[] } | null }
|
|
63
67
|
type Page = { repository?: { open?: { totalCount?: unknown }; closed?: { totalCount?: unknown }
|
|
@@ -159,9 +163,17 @@ export async function repositoryItem(fullName: string, token: string, number: nu
|
|
|
159
163
|
mergedBy: merger.login, mergedById: merger.id, reviews: pullReviews(node, tile.author, true) }
|
|
160
164
|
}
|
|
161
165
|
|
|
166
|
+
// An open pull request's base's and head's commits, by its number, as GitHub
|
|
167
|
+
// has them now: those its REST API names `base.sha` and `head.sha`
|
|
168
|
+
// (pull-dependencies.ts pullCommits). None where either isn't a commit's id.
|
|
169
|
+
const commitsOf = (number: number, { baseRefOid: base, headRefOid: head }: Node) =>
|
|
170
|
+
typeof base === 'string' && SHA.test(base) && typeof head === 'string' && SHA.test(head) ? [{ number, base, head }] : []
|
|
171
|
+
|
|
162
172
|
// A page of the repository's issues or pull requests in `state`, after the
|
|
163
173
|
// page `after` ended (null for the first): its `items`, how many are open and
|
|
164
|
-
// closed (`counts`), and, where more follow, the cursor reading them
|
|
174
|
+
// closed (`counts`), and, where more follow, the cursor reading them
|
|
175
|
+
// (`after`); open pull requests' commits (`commits`, commitsOf) too, which
|
|
176
|
+
// the code viewer is never told (http.ts handleRepositoryIssues).
|
|
165
177
|
export async function repositoryIssues(fullName: string, token: string, kind: IssuesKind, state: IssuesState, after: string | null, fetchImpl: typeof fetch = globalThis.fetch) {
|
|
166
178
|
const [owner, name] = fullName.split('/') as [string, string]
|
|
167
179
|
const data = await githubGraphql(pageQuery(kind, state), { owner, name, after }, token, fetchImpl, partRefused) as Page
|
|
@@ -169,13 +181,15 @@ export async function repositoryIssues(fullName: string, token: string, kind: Is
|
|
|
169
181
|
if (!repository) throw new GithubApiError(404, 'github-not-found')
|
|
170
182
|
const info = repository.page?.pageInfo
|
|
171
183
|
const next = info?.hasNextPage === true && typeof info.endCursor === 'string' && ISSUES_CURSOR.test(info.endCursor) ? info.endCursor : null
|
|
184
|
+
const open = kind === 'pulls' && state === 'open'
|
|
185
|
+
const tiles = (repository.page?.nodes ?? []).flatMap(node => {
|
|
186
|
+
const tile = item(kind, node)
|
|
187
|
+
return tile && node ? [{ node, tile }] : []
|
|
188
|
+
})
|
|
172
189
|
return {
|
|
173
|
-
items: (
|
|
174
|
-
const tile = item(kind, node)
|
|
175
|
-
if (!tile) return []
|
|
176
|
-
return kind === 'pulls' && state === 'open' && node ? [{ ...tile, reviews: pullReviews(node, tile.author) }] : [tile]
|
|
177
|
-
}),
|
|
190
|
+
items: tiles.map(({ node, tile }) => open ? { ...tile, reviews: pullReviews(node, tile.author) } : tile),
|
|
178
191
|
counts: { open: count(repository.open?.totalCount), closed: count(repository.closed?.totalCount) },
|
|
179
192
|
more: next !== null, after: next,
|
|
193
|
+
...open ? { commits: tiles.flatMap(({ node, tile }) => commitsOf(tile.number, node)) } : {},
|
|
180
194
|
}
|
|
181
195
|
}
|
|
@@ -20,7 +20,7 @@ import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accoun
|
|
|
20
20
|
export const PULSE_WEEKS = [1, 4, 13, 52]
|
|
21
21
|
// What a year counted from its commits holds, its version: kept years of
|
|
22
22
|
// another are counted again.
|
|
23
|
-
export const PULSE_YEAR_VERSION =
|
|
23
|
+
export const PULSE_YEAR_VERSION = 11
|
|
24
24
|
const WEEK_MS = 7 * 24 * 60 * 60_000
|
|
25
25
|
const DAY_MS = 24 * 60 * 60_000
|
|
26
26
|
const MAX_CONTRIBUTORS = 100
|
|
@@ -76,18 +76,21 @@ export interface PulseCommit { sha: string; at: number; subject: string; login:
|
|
|
76
76
|
// each commit's own time zone, and, counted from its commits, each period's
|
|
77
77
|
// (`punches`, by its weeks); `lines` false where GitHub counts none, null
|
|
78
78
|
// while it is counting them; counted from its commits, the latest pushed to
|
|
79
|
-
// the branch directly (`directCommits`, as many as DIRECT_LISTED)
|
|
79
|
+
// the branch directly (`directCommits`, as many as DIRECT_LISTED); and,
|
|
80
|
+
// counted from its commits read no further back than a period needed, when
|
|
81
|
+
// from it is counted whole (`from`), absent for the year whole.
|
|
80
82
|
export interface PulseStats { weeks: PulseWeek[]; contributors: PulseContributor[]; punch: number[][]; punches?: Record<number, number[][]>; lines: boolean | null
|
|
81
|
-
directCommits?: PulseCommit[] }
|
|
83
|
+
directCommits?: PulseCommit[]; from?: number }
|
|
82
84
|
// The lines added and removed a week, and each contributor's, as GitHub's
|
|
83
85
|
// statistics count them, `lines` false where it counts none.
|
|
84
86
|
export interface PulseLines { weeks: [number, number, number][]; contributors: { login: string; weeks: [number, number, number][] }[]; lines: boolean }
|
|
85
87
|
// The days (`from`, `to`, both days, UTC; `to` none, on) and the authors (GitHub
|
|
86
88
|
// logins, any of them) a period's pull requests and issues are narrowed to.
|
|
87
89
|
export interface PulseNarrowing { from: number; to: number | null; authors: string[] }
|
|
88
|
-
// The year's commits read so far: all of them
|
|
89
|
-
// (`
|
|
90
|
-
|
|
90
|
+
// The year's commits read so far: all of them, or as far back as asked
|
|
91
|
+
// (`commits`, `from`), else how many (`read`), and whether past
|
|
92
|
+
// YEAR_COMMITS (`tooMany`).
|
|
93
|
+
export type YearCommits = { complete: true; commits: HistoryCommit[]; passed: HistoryCommit[]; from: number | null } | { complete: false; read: number; tooMany: boolean }
|
|
91
94
|
|
|
92
95
|
const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
|
|
93
96
|
const timeOf = (value: unknown) => {
|
|
@@ -204,17 +207,28 @@ export async function defaultHead(fullName: string, token: string, fetchImpl: ty
|
|
|
204
207
|
// YEAR_QUERIES, the rest left for the next to ask (`complete` false, how
|
|
205
208
|
// many are `read`); more than YEAR_COMMITS, too many (`tooMany`). Read
|
|
206
209
|
// whole, those it was read on past besides (`passed`): undated, or dated
|
|
207
|
-
// before it, a clock behind, the branch's commits since beneath them.
|
|
210
|
+
// before it, a clock behind, the branch's commits since beneath them. Read
|
|
211
|
+
// no further than `need` asks, those since it read whole (`complete`, `from`
|
|
212
|
+
// the newest of those read whose parents are not, every commit after it
|
|
213
|
+
// read; null, the year read whole).
|
|
208
214
|
export async function yearCommits(fullName: string, token: string, head: string, since: number, pages: HistoryPages,
|
|
209
|
-
fetchImpl: typeof fetch = globalThis.fetch): Promise<YearCommits> {
|
|
215
|
+
fetchImpl: typeof fetch = globalThis.fetch, need = since): Promise<YearCommits> {
|
|
210
216
|
const found = new Map<string, HistoryCommit>()
|
|
211
217
|
const tried = new Set<string>()
|
|
212
218
|
const within = (commit: HistoryCommit) => commit.committedAt === null || commit.committedAt >= since - YEAR_SKEW_MS
|
|
213
219
|
const dated = (commit: HistoryCommit) => commit.committedAt !== null && commit.committedAt >= since
|
|
214
220
|
const read = () => [...found.values()].filter(dated).length
|
|
221
|
+
const passed = () => [...found.values()].filter(commit => !dated(commit) && within(commit))
|
|
215
222
|
let queries = 0
|
|
216
223
|
let starts = [head]
|
|
217
224
|
while (starts.length > 0) {
|
|
225
|
+
// Those not read yet, the head before any, else the parents of those
|
|
226
|
+
// read, no later than the newest of them (but for a clock behind): read
|
|
227
|
+
// whole after it.
|
|
228
|
+
const unread = new Set(starts)
|
|
229
|
+
const from = found.size === 0 ? Number.POSITIVE_INFINITY : Math.max(Number.NEGATIVE_INFINITY, ...[...found.values()]
|
|
230
|
+
.filter(commit => commit.parents.some(sha => unread.has(sha))).map(commit => commit.committedAt ?? Number.POSITIVE_INFINITY))
|
|
231
|
+
if (need > since && from < need) return { complete: true, commits: [...found.values()].filter(dated), passed: passed(), from }
|
|
218
232
|
if (queries >= YEAR_QUERIES) return { complete: false, read: read(), tooMany: false }
|
|
219
233
|
const batch = starts.slice(0, YEAR_STARTS)
|
|
220
234
|
for (const sha of batch) tried.add(sha)
|
|
@@ -224,18 +238,24 @@ export async function yearCommits(fullName: string, token: string, head: string,
|
|
|
224
238
|
if (read() > YEAR_COMMITS) return { complete: false, read: read(), tooMany: true }
|
|
225
239
|
starts = [...new Set([...found.values()].filter(within).flatMap(commit => commit.parents))].filter(sha => !found.has(sha) && !tried.has(sha))
|
|
226
240
|
}
|
|
227
|
-
return { complete: true, commits: [...found.values()].filter(dated), passed:
|
|
241
|
+
return { complete: true, commits: [...found.values()].filter(dated), passed: passed(), from: null }
|
|
228
242
|
}
|
|
229
243
|
|
|
244
|
+
// Whether an author is a GitHub App's bot: by its login (`<app>[bot]`, as a
|
|
245
|
+
// commit's no-reply address names it, github-accounts.ts commitAccount),
|
|
246
|
+
// else, for one GitHub names no account for, by the name it commits as.
|
|
247
|
+
const bot = (login: string | null | undefined, name: string | null | undefined) => /\[bot\]$/iu.test(login ?? name ?? '')
|
|
248
|
+
|
|
230
249
|
// Whether an agent made `commit` or co-authored it: its author one of
|
|
231
250
|
// GitHub's agents (common/managed/github-agents.ts githubAgent), or any of
|
|
232
251
|
// its co-authors an agent (commit-coauthors.ts coauthorsOf), as Commits
|
|
233
|
-
// marks them; or one an agent's pull request brought (`merges`,
|
|
252
|
+
// marks them, or any bot, whichever GitHub App's; or one an agent's pull request brought (`merges`,
|
|
234
253
|
// agent-merges.ts agentCommits); or one an account agents work as pushed to
|
|
235
254
|
// the branch directly (`direct`, github-agents.ts agentOperated).
|
|
236
255
|
const withAgent = (commit: HistoryCommit, merges: ReadonlySet<string>, direct: ReadonlySet<string>) => merges.has(commit.sha)
|
|
237
256
|
|| (direct.has(commit.sha) && agentOperated(commit.authorLogin, commit.authorId ?? null))
|
|
238
|
-
|| githubAgent(commit.authorLogin, commit.authorId ?? null, commit.authorName) !== null || (commit.
|
|
257
|
+
|| githubAgent(commit.authorLogin, commit.authorId ?? null, commit.authorName) !== null || bot(commit.authorLogin, commit.authorName)
|
|
258
|
+
|| (commit.coauthors ?? []).some(coauthor => Boolean(coauthor.agent) || bot(coauthor.login, coauthor.name))
|
|
239
259
|
|
|
240
260
|
// The year from `first` (pulseSince(52, …)) of `commits`, as GitHub's
|
|
241
261
|
// statistics count them, merges left out: a week a row, its commits each day
|