@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.
Files changed (82) 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-JEAUAJVF.js +232 -0
  11. package/out/chunk-O6M574JI.js +1 -0
  12. package/out/{chunk-VL43BTSO.js → chunk-PBSOL2SL.js} +1 -1
  13. package/out/{chunk-ZCS4UYHI.js → chunk-PBZPYIG7.js} +1 -1
  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-UENXT2HK.js → chunk-ULIF7A7N.js} +1 -1
  17. package/out/{chunk-FH24VHSJ.js → chunk-WYLQWYLA.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 +38 -22
  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 +751 -273
  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-comment-reviews.ts +1 -1
  58. package/server-managed/pull-dependencies.ts +84 -36
  59. package/server-managed/pull-dependency-reviews.ts +28 -18
  60. package/server-managed/rename-diff-worker.js +1 -1
  61. package/server-managed/repository-agents.ts +9 -6
  62. package/server-managed/repository-browser.ts +13 -3
  63. package/server-managed/repository-code.ts +153 -7
  64. package/server-managed/repository-dependency-reviews.ts +12 -4
  65. package/server-managed/repository-history.ts +85 -152
  66. package/server-managed/repository-issues.ts +27 -13
  67. package/server-managed/repository-pulse.ts +31 -11
  68. package/server-managed/repository-squashes.ts +145 -0
  69. package/server-managed/request-timing.ts +63 -0
  70. package/server-managed/reviewed-dependencies.ts +3 -2
  71. package/server-managed/session.ts +34 -12
  72. package/server-managed/sql.ts +16 -8
  73. package/server-managed/storage-encryption.ts +16 -8
  74. package/server-managed/storage-maintenance.ts +5 -2
  75. package/server-managed/storage-payload.ts +18 -0
  76. package/server-managed/storage-stores.ts +3 -2
  77. package/server-managed/team-feed.ts +16 -11
  78. package/server-managed/team-npm-scopes.ts +29 -0
  79. package/server-managed/team-reports.ts +68 -16
  80. package/out/chunk-6ANSHRFQ.js +0 -6
  81. package/out/chunk-S6U2J62U.js +0 -232
  82. 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
- 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
@@ -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. A nonpublic repository's are read with
10
- // its GitHub App's installation, which reads pull requests only with their
11
- // own permission (README).
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 = 3
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 reviews.
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 (`after`).
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: (repository.page?.nodes ?? []).flatMap(node => {
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 = 9
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 (`commits`), else how many
89
- // (`read`), and whether past YEAR_COMMITS (`tooMany`).
90
- export type YearCommits = { complete: true; commits: HistoryCommit[]; passed: HistoryCommit[] } | { complete: false; read: number; tooMany: boolean }
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: [...found.values()].filter(commit => !dated(commit) && within(commit)) }
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.coauthors ?? []).some(coauthor => coauthor.agent)
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