@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.46

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 (207) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +25 -2
  5. package/common/default-scan-models.ts +1 -1
  6. package/common/dependency-reviews.d.ts +0 -5
  7. package/common/dependency-reviews.js +0 -17
  8. package/common/line-diff.js +5 -4
  9. package/common/managed/agent-tasks.ts +292 -0
  10. package/common/managed/github-agents.ts +92 -0
  11. package/common/managed/github-logins.ts +49 -0
  12. package/common/managed/roles.ts +7 -0
  13. package/common/managed/routes.js +88 -7
  14. package/common/managed/team-app.d.ts +5 -2
  15. package/common/managed/team-app.js +2 -2
  16. package/common/server-info.ts +3 -0
  17. package/common/workspace-app.js +6 -3
  18. package/out/brotli-fallback.js +3 -3
  19. package/out/chunk-2XUEZHIP.js +18 -18
  20. package/out/chunk-2ZDZWLFY.js +1 -0
  21. package/out/chunk-3PS4KWD2.js +10 -0
  22. package/out/chunk-4HI2MSAV.js +17 -0
  23. package/out/chunk-4UFAMJAX.js +1 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-7XRR4OFU.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-BOIXMUU5.js +3 -0
  28. package/out/chunk-C4Z6ZBXV.js +6 -6
  29. package/out/chunk-CC4OM6TZ.js +3 -0
  30. package/out/chunk-G24A42V6.js +2 -0
  31. package/out/chunk-GDPCLSOS.js +2 -0
  32. package/out/chunk-GO7FT4OD.js +1 -0
  33. package/out/chunk-I2MDGSQX.js +3 -0
  34. package/out/chunk-IYPY47MC.js +1 -0
  35. package/out/chunk-IZ7POWI3.js +18 -0
  36. package/out/chunk-JCIIBLKR.js +1 -1
  37. package/out/chunk-JKGQ3O7O.js +6 -0
  38. package/out/chunk-LE5ZWLG4.js +235 -0
  39. package/out/chunk-MSION3G4.js +36 -0
  40. package/out/chunk-NRPC74AM.js +1 -1
  41. package/out/chunk-OKRLLE5I.js +17 -0
  42. package/out/chunk-OOJM4CTU.js +1 -1
  43. package/out/chunk-PNHBZGDD.js +1 -1
  44. package/out/chunk-PXK7EU4V.js +1 -0
  45. package/out/chunk-QB4CBOX2.js +1 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-S6GIXWQW.js +52 -0
  49. package/out/chunk-SXV2T5C4.js +80 -0
  50. package/out/chunk-TUII34K2.js +1 -1
  51. package/out/chunk-V3KWNNMY.js +1 -0
  52. package/out/chunk-VFWMQ3LG.js +2 -0
  53. package/out/chunk-VH4XMZIW.js +2 -0
  54. package/out/chunk-WFYLMF6Z.js +10 -0
  55. package/out/chunk-WQ74ZBZF.js +7 -0
  56. package/out/client-managed-import.js +3 -3
  57. package/out/client-managed.js +31 -35
  58. package/out/client-sync.js +11 -11
  59. package/out/compare.js +35 -0
  60. package/out/dialogs.js +51 -0
  61. package/out/github-issue-authorized.html +1 -1
  62. package/out/github-issue-authorized.js +1 -1
  63. package/out/graph.js +17 -17
  64. package/out/index.html +30 -34
  65. package/out/prism.js +14 -14
  66. package/out/scan.js +2 -0
  67. package/out/terminal.js +294 -293
  68. package/out/view.css +1 -1
  69. package/out/view.js +16 -107
  70. package/package.json +53 -6
  71. package/risk/actions/findings.js +161 -0
  72. package/risk/actions/resolve.js +219 -0
  73. package/risk/actions/scripts/commands.js +182 -0
  74. package/risk/actions/scripts/grade.js +113 -0
  75. package/risk/actions/scripts/packages.js +123 -0
  76. package/risk/actions/scripts/shell.js +169 -0
  77. package/risk/actions/uses.js +76 -0
  78. package/risk/actions/workflow.js +110 -0
  79. package/risk/files/hosts.js +6 -6
  80. package/risk/files/readability.js +23 -17
  81. package/risk/files/runs-code.js +18 -6
  82. package/risk/files/script.js +7 -3
  83. package/risk/files/stamps.js +8 -7
  84. package/risk/index.d.ts +71 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +1 -1
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/files.js +413 -2
  89. package/risk/npm/tarball.js +56 -73
  90. package/server-e2e/app.ts +5 -4
  91. package/server-e2e/http.ts +5 -2
  92. package/server-managed/agent-merges.ts +198 -0
  93. package/server-managed/app-agents.ts +30 -0
  94. package/server-managed/avatar-store.ts +1 -0
  95. package/server-managed/bundle-cache.ts +7 -1
  96. package/server-managed/combined.ts +3 -2
  97. package/server-managed/commit-coauthors.ts +83 -0
  98. package/server-managed/commit-lockfiles.ts +72 -0
  99. package/server-managed/config.ts +6 -0
  100. package/server-managed/db-methods.ts +66 -19
  101. package/server-managed/db-neon.ts +89 -21
  102. package/server-managed/db-schema.ts +13 -2
  103. package/server-managed/db.ts +9 -1
  104. package/server-managed/dependabot-access.ts +60 -0
  105. package/server-managed/dependabot-alerts.ts +185 -0
  106. package/server-managed/dependency-review-comments.ts +97 -0
  107. package/server-managed/dependency-reviews.ts +78 -18
  108. package/server-managed/github-accounts.ts +33 -0
  109. package/server-managed/github-actions.ts +71 -0
  110. package/server-managed/github-app.ts +33 -13
  111. package/server-managed/github-errors.ts +2 -0
  112. package/server-managed/github-issue-links.ts +11 -5
  113. package/server-managed/github-link-oauth.ts +69 -0
  114. package/server-managed/github-links.ts +204 -0
  115. package/server-managed/github-oauth.ts +5 -2
  116. package/server-managed/github-pulls.ts +2 -2
  117. package/server-managed/github-requests.ts +39 -0
  118. package/server-managed/github-users.ts +30 -0
  119. package/server-managed/http-response.ts +10 -2
  120. package/server-managed/http.ts +1251 -97
  121. package/server-managed/index.ts +13 -8
  122. package/server-managed/kept-briefly.ts +37 -0
  123. package/server-managed/locked-packages.ts +37 -16
  124. package/server-managed/lockfile-scope.ts +49 -0
  125. package/server-managed/npm-delta-worker.js +23 -0
  126. package/server-managed/npm-delta.ts +178 -0
  127. package/server-managed/npm-insights.ts +20 -11
  128. package/server-managed/npm-kept.ts +83 -6
  129. package/server-managed/npm-loads.ts +114 -37
  130. package/server-managed/npm-packages.ts +110 -64
  131. package/server-managed/npm-risk.ts +4 -1
  132. package/server-managed/pretty-print.ts +67 -23
  133. package/server-managed/pull-comment-reviews.ts +161 -0
  134. package/server-managed/pull-dependencies.ts +300 -0
  135. package/server-managed/pull-dependency-reviews.ts +318 -0
  136. package/server-managed/pull-reviews.ts +205 -0
  137. package/server-managed/queue.ts +16 -0
  138. package/server-managed/release-commits.ts +84 -0
  139. package/server-managed/rename-diff.ts +22 -13
  140. package/server-managed/report-sources.ts +1 -1
  141. package/server-managed/report-store.ts +42 -9
  142. package/server-managed/repository-actions.ts +218 -0
  143. package/server-managed/repository-agents.ts +220 -0
  144. package/server-managed/repository-browser.ts +166 -37
  145. package/server-managed/repository-changes.ts +284 -0
  146. package/server-managed/repository-code.ts +52 -0
  147. package/server-managed/repository-dependency-reviews.ts +157 -0
  148. package/server-managed/repository-history.ts +461 -0
  149. package/server-managed/repository-issues.ts +181 -0
  150. package/server-managed/repository-pulse.ts +439 -0
  151. package/server-managed/repository-risk-audit.ts +13 -0
  152. package/server-managed/repository-risk-read.ts +170 -0
  153. package/server-managed/repository-risk.ts +133 -118
  154. package/server-managed/reviewed-dependencies.ts +149 -0
  155. package/server-managed/session.ts +27 -9
  156. package/server-managed/sql.ts +6 -3
  157. package/server-managed/storage-stores.ts +5 -0
  158. package/server-managed/storage.ts +4 -2
  159. package/server-managed/team-app.ts +19 -3
  160. package/server-managed/team-reports.ts +24 -12
  161. package/server-managed/upstream-cache.ts +12 -0
  162. package/server-managed/workspace-shares.ts +4 -1
  163. package/server.ts +25 -5
  164. package/out/chunk-2ABLKCOY.js +0 -46
  165. package/out/chunk-2KBF5TZ7.js +0 -2
  166. package/out/chunk-35VE6CDH.js +0 -2
  167. package/out/chunk-3BZ7JERB.js +0 -1
  168. package/out/chunk-4XCDFGDP.js +0 -10
  169. package/out/chunk-5LSXOLUS.js +0 -1
  170. package/out/chunk-5RCM2J63.js +0 -1
  171. package/out/chunk-7B6HB4D7.js +0 -1
  172. package/out/chunk-AJ4GTATZ.js +0 -3
  173. package/out/chunk-CH4UN6WK.js +0 -225
  174. package/out/chunk-CMQA4DM6.js +0 -1
  175. package/out/chunk-CVVJAXVY.js +0 -2
  176. package/out/chunk-DIJJNTRH.js +0 -10
  177. package/out/chunk-EDKLBQQN.js +0 -17
  178. package/out/chunk-F47DXCEV.js +0 -33
  179. package/out/chunk-FK3DZEKL.js +0 -1
  180. package/out/chunk-G6AJWENO.js +0 -1
  181. package/out/chunk-GJ4IUCNS.js +0 -1
  182. package/out/chunk-GNXOKIGP.js +0 -80
  183. package/out/chunk-GZ4MS3TE.js +0 -13
  184. package/out/chunk-GZWM7UQM.js +0 -2
  185. package/out/chunk-IU3CCINR.js +0 -3
  186. package/out/chunk-K3X5EJBQ.js +0 -2
  187. package/out/chunk-KEX2ATEW.js +0 -2
  188. package/out/chunk-KYCNJVB7.js +0 -1
  189. package/out/chunk-LPCTJYNA.js +0 -7
  190. package/out/chunk-O4RJY5TP.js +0 -2
  191. package/out/chunk-OD4LPN3H.js +0 -1
  192. package/out/chunk-QWDWTBM7.js +0 -1
  193. package/out/chunk-QYWKC557.js +0 -1
  194. package/out/chunk-R75XOKUP.js +0 -1
  195. package/out/chunk-SWTLADW5.js +0 -1
  196. package/out/chunk-T7OKH4VW.js +0 -1
  197. package/out/chunk-TZ5KB2Y5.js +0 -10
  198. package/out/chunk-UNTRGE26.js +0 -2
  199. package/out/chunk-UTR2LVME.js +0 -2
  200. package/out/chunk-UYAXVRVL.js +0 -1
  201. package/out/chunk-V3ME6FI5.js +0 -2
  202. package/out/chunk-V5O7M2YT.js +0 -1
  203. package/out/chunk-VIHGO77Y.js +0 -1
  204. package/out/chunk-W4J3MGLB.js +0 -1
  205. package/out/chunk-WGG7KQUH.js +0 -1
  206. package/out/chunk-XYFXXB2G.js +0 -10
  207. package/out/chunk-ZESLQCTF.js +0 -6
@@ -179,7 +179,10 @@ export async function renameDiffOf(request: RenameDiffRequest, texts: () => Prom
179
179
  return opened(await answerOf(request, texts, keep))
180
180
  }
181
181
 
182
- async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string, string]>, keep: boolean): Promise<Buffer> {
182
+ // Sides `verified` to be the files the request names (renamedPairs) are read
183
+ // only where no answer is kept or on its way; others are read first, as
184
+ // reading them checks they are.
185
+ async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string, string]>, keep: boolean, verified = false): Promise<Buffer> {
183
186
  // Sides not kept are formatted again for each diff (pretty-print.ts), so
184
187
  // they are read in its turn: the files a Diff view asks for at once wait
185
188
  // here rather than being refused by the formats running. Within its minute
@@ -193,21 +196,27 @@ async function answerOf(request: RenameDiffRequest, texts: () => Promise<[string
193
196
  })
194
197
  return encoded(diff, request, sides)
195
198
  }
196
- const [before, after] = await texts()
197
199
  // A file's type is part of how it is read: the same text as a script and as a module differ.
198
200
  const key = `${request.base.hash}\0${request.base.extension}\0${request.other.hash}\0${request.other.extension}`
199
- const found = kept.get(key)
200
- if (found) {
201
- kept.delete(key)
202
- kept.set(key, found)
203
- return found
201
+ const held = () => {
202
+ const found = kept.get(key)
203
+ if (found) {
204
+ kept.delete(key)
205
+ kept.set(key, found)
206
+ }
207
+ return found ?? pending.get(key)
204
208
  }
205
- let job = pending.get(key)
206
- if (!job) {
207
- const deadline = Date.now() + DIFF_TIMEOUT_MS
208
- pending.set(key, job = inTurn(deadline, () => inWorker(before, after, request, deadline)).then(diff => encoded(diff, request, [before, after])).finally(() => pending.delete(key)))
209
+ let answer = verified ? held() : undefined
210
+ if (!answer) {
211
+ const [before, after] = await texts()
212
+ answer = held()
213
+ if (!answer) {
214
+ const deadline = Date.now() + DIFF_TIMEOUT_MS
215
+ const job = inTurn(deadline, () => inWorker(before, after, request, deadline)).then(diff => encoded(diff, request, [before, after])).finally(() => pending.delete(key))
216
+ pending.set(key, answer = job)
217
+ }
209
218
  }
210
- const body = await job
219
+ const body = await answer
211
220
  kept.set(key, body)
212
221
  if (kept.size > KEPT_DIFFS) kept.delete(kept.keys().next().value!)
213
222
  return body
@@ -258,7 +267,7 @@ export function renamedPairs([base, other]: readonly [ComparedSide, ComparedSide
258
267
  const named = { path: from.path, hash: from.hash, otherPath: to.path, otherHash: to.hash }
259
268
  const copy = async (side: ComparedSide, { text, ...file }: typeof from) => prettyText(await side.kept(file) ?? await side.made(file, text))
260
269
  let body
261
- try { body = await answerOf(request, async () => [await copy(base, from), await copy(other, to)], keep) }
270
+ try { body = await answerOf(request, async () => [await copy(base, from), await copy(other, to)], keep, true) }
262
271
  catch (err) {
263
272
  if (!(err instanceof PrettyError)) throw err
264
273
  yield { ...named, error: err.message, status: err.status }
@@ -146,7 +146,7 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
146
146
  }
147
147
  return {
148
148
  async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
149
- const repo = { github: (await db.listAllRepos()).find(entry => entry.repoId === report.repoId)?.fullName ?? null }
149
+ const repo = { github: report.repoId == null ? null : await db.getRepoFullName(report.repoId) }
150
150
  const target = sourceCacheFilename(report, bundle, permissions, repo, sourcePaths)
151
151
  try { return { ...await storage.open(target), repo } }
152
152
  catch (error) { if (!(error instanceof CacheMissError)) throw error }
@@ -15,33 +15,66 @@ async function readBytes(stored: OpenedBlob): Promise<Buffer> {
15
15
  } finally { stored.stream.destroy() }
16
16
  }
17
17
 
18
+ // Ids whose original copy is known gone, at most this many.
19
+ const COMPRESSED_ONLY_MAX = 16_384
20
+
18
21
  export function createReportStore(legacy: BlobStore, compressed: BlobStore,
19
22
  replaceLegacy: (id: string, bytes: Buffer) => Promise<unknown>): BlobStore {
23
+ // Reports this store wrote, converted, or found with only the compressed
24
+ // copy. Uploads write only that copy and nothing writes an original again,
25
+ // so their reads stop asking storage for one, each a miss (and, encrypted,
26
+ // a row read too). A compressed copy missing there asks for the original
27
+ // after all. An original left by a conversion that failed is never in it:
28
+ // the next read finds that original and resumes the conversion.
29
+ const compressedOnly = new Set<string>()
30
+ function remember(id: string): void {
31
+ compressedOnly.delete(id)
32
+ if (compressedOnly.size >= COMPRESSED_ONLY_MAX) compressedOnly.delete(compressedOnly.values().next().value!)
33
+ compressedOnly.add(id)
34
+ }
35
+ async function openCompressed(id: string): Promise<OpenedBlob | null> {
36
+ const stored = await compressed.open(id)
37
+ if (!stored) return null
38
+ const stream = createBrotliDecompress()
39
+ // pipeline carries storage/authentication errors into the decoded stream
40
+ // and closes the stored source when a reader leaves before EOF.
41
+ void pipeline(stored.stream, stream).catch(() => {})
42
+ return { size: null, stream }
43
+ }
20
44
  async function open(id: string): Promise<OpenedBlob | null> {
45
+ if (compressedOnly.has(id)) {
46
+ const stored = await openCompressed(id)
47
+ if (stored) return stored
48
+ compressedOnly.delete(id)
49
+ }
21
50
  // Keep the original available until conversion succeeds. Concurrent reads
22
51
  // can finish using an already-open stream while another reader converts it.
23
52
  const original = await legacy.open(id)
24
53
  if (original) {
25
54
  const bytes = await readBytes(original)
26
55
  await replaceLegacy(id, await encodeBrotli(bytes, 9))
56
+ remember(id)
27
57
  return { size: bytes.length, stream: Readable.from([bytes]) }
28
58
  }
29
- const stored = await compressed.open(id)
30
- if (!stored) return null
31
- const stream = createBrotliDecompress()
32
- // pipeline carries storage/authentication errors into the decoded stream
33
- // and closes the stored source when a reader leaves before EOF.
34
- void pipeline(stored.stream, stream).catch(() => {})
35
- return { size: null, stream }
59
+ const stored = await openCompressed(id)
60
+ if (stored) remember(id)
61
+ return stored
36
62
  }
37
63
  return {
38
- put: async (id, bytes) => compressed.put(id, await encodeBrotli(bytes, 9)),
64
+ async put(id, bytes) {
65
+ const wrapped = await compressed.put(id, await encodeBrotli(bytes, 9))
66
+ remember(id)
67
+ return wrapped
68
+ },
39
69
  open,
40
70
  async get(id) {
41
71
  const stored = await open(id)
42
72
  if (!stored) return null
43
73
  return readBytes(stored)
44
74
  },
45
- async delete(id) { await Promise.all([compressed.delete(id), legacy.delete(id)]) },
75
+ async delete(id) {
76
+ compressedOnly.delete(id)
77
+ await Promise.all([compressed.delete(id), legacy.delete(id)])
78
+ },
46
79
  }
47
80
  }
@@ -0,0 +1,218 @@
1
+ // A repository's GitHub Actions in a risk scan (repository-risk.ts): its
2
+ // workflows and its own actions scanned (risk scanWorkflow), and the
3
+ // references they make resolved (risk resolveAction). Of a public
4
+ // repository, a reference resolved and what GitHub answers are kept for
5
+ // every scan, as many repositories use the same few actions.
6
+ import { type ActionsSource, type ResolvedAction, type ScanOptions, type WorkflowScan, dockerfileFindings, keptSource, ownFilesOf, resolveAction,
7
+ scanWorkflow, usesOfKey } from '../risk/index.js'
8
+ import type { OpenedRepository } from './repository-risk-read.ts'
9
+ import type { Lru } from './lru.ts'
10
+ import type { KeptStore } from './npm-kept.ts'
11
+ import { type Queue, queue } from './queue.ts'
12
+ import { RISK_KEPT_MS, type RiskFailure, failureOf } from './repository-risk-assess.ts'
13
+
14
+ export const WORKFLOWS_DIR = '.github/workflows'
15
+ // How many files of a repository's Actions are read, and how large.
16
+ const MAX_FILES = 200
17
+ const MAX_FILE_CHARS = 1024 * 1024
18
+ // How many references a scan resolves, how deep it follows what they name,
19
+ // and how many at once.
20
+ const MAX_ACTIONS = 1000
21
+ const MAX_DEPTH = 4
22
+ const RESOLVERS = 4
23
+
24
+ // A file of a repository's Actions: its text, or why it couldn't be had.
25
+ export type ActionsFile = { path: string; kind: 'workflow' | 'action' | 'dockerfile' } & ({ text: string } | RiskFailure)
26
+ export type WorkflowsRead = { files: ActionsFile[] } | RiskFailure
27
+
28
+ // What a scan tells of them: the workflows' paths, each file's scan, and
29
+ // each reference resolved, or why not.
30
+ export type WorkflowsLine = { type: 'workflows'; repo: number } & ({ paths: string[] } | RiskFailure)
31
+ export type WorkflowLine = { type: 'workflow'; repo: number; path: string; kind: ActionsFile['kind'] } & (WorkflowScan | RiskFailure)
32
+ export type ActionLine = { type: 'action' } & (ResolvedAction | { key: string } & RiskFailure)
33
+
34
+ // A reference to resolve, by the owner of the workflow naming it.
35
+ export interface ActionRef { key: string; owner: string | null; depth: number }
36
+
37
+ // What reading a repository's Actions asks of it.
38
+ export type ActionsReading = Required<Pick<OpenedRepository, 'workflows' | 'file'>>
39
+
40
+ // A file's text, or why not: one GitHub hasn't, or larger than read.
41
+ async function readFile(reading: ActionsReading, path: string): Promise<{ text: string } | RiskFailure> {
42
+ try {
43
+ const text = await reading.file(path)
44
+ return text.length > MAX_FILE_CHARS ? { error: 'workflow-too-large' } : { text }
45
+ } catch (error) { return failureOf(error) }
46
+ }
47
+
48
+ // Whether a read holds what any read of its commit would: no file that
49
+ // couldn't be had for a while, as GitHub's limits are.
50
+ export const lastingRead = (read: WorkflowsRead) => !('error' in read) && read.files.every(file => 'text' in file || file.error === 'workflow-too-large' || file.error === 'github-not-found')
51
+
52
+ // An action's directory, by its metadata's path.
53
+ const actionDir = (path: string) => path.replace(/\/?action\.ya?ml$/u, '')
54
+ const inRepository = (path: string) => !path.split('/').includes('..')
55
+ const notFound = (read: { text: string } | RiskFailure) => 'error' in read && read.error === 'github-not-found'
56
+
57
+ // A repository's workflows, the actions of its own they use in turn (`./`
58
+ // as the workspace has it, the repository checked out first) and those's
59
+ // Dockerfiles, MAX_FILES at most; why not, where they couldn't be listed.
60
+ export async function readActions(reading: ActionsReading): Promise<WorkflowsRead> {
61
+ let listed
62
+ try { listed = await reading.workflows() } catch (error) { return failureOf(error, 'listing its workflows') }
63
+ const files: ActionsFile[] = await Promise.all(listed.slice(0, MAX_FILES).map(async path => ({ path, kind: 'workflow' as const, ...await readFile(reading, path) })))
64
+ const seen = new Set<string>()
65
+ for (let at = 0; at < files.length; at++) {
66
+ const file = files[at]!
67
+ if (!('text' in file) || file.kind === 'dockerfile') continue
68
+ const { actions, dockerfile } = ownFilesOf(file.kind, file.text)
69
+ const path = dockerfile && [actionDir(file.path), dockerfile].filter(Boolean).join('/')
70
+ if (path && inRepository(path) && files.length < MAX_FILES) files.push({ path, kind: 'dockerfile', ...await readFile(reading, path) })
71
+ for (const dir of actions) {
72
+ if (files.length >= MAX_FILES || seen.has(dir) || !inRepository(dir) || !seen.add(dir)) continue
73
+ const named = dir === '' ? 'action' : `${dir}/action`, yml = await readFile(reading, `${named}.yml`)
74
+ const yaml = notFound(yml) ? await readFile(reading, `${named}.yaml`) : null
75
+ files.push(yaml && !notFound(yaml) ? { path: `${named}.yaml`, kind: 'action', ...yaml } : { path: `${named}.yml`, kind: 'action', ...yml })
76
+ }
77
+ }
78
+ return { files }
79
+ }
80
+
81
+ // A lockfile's packages, by name and by `name@version`, and those only
82
+ // devDependencies need.
83
+ export function lockedNames(path: string, packages: readonly { name: string; version: string | null; dev?: true }[]) {
84
+ const dev = new Map<string, boolean>()
85
+ for (const { name, version, dev: only } of packages) for (const key of version ? [name, `${name}@${version}`] : [name]) dev.set(key, (dev.get(key) ?? true) && only === true)
86
+ return { path, names: new Set(dev.keys()), dev: new Set([...dev].flatMap(([name, only]) => only ? [name] : [])) }
87
+ }
88
+
89
+ // The lines a repository's Actions read are told by, and the references
90
+ // they make to resolve. `lockfiles`, its lockfiles' packages' names by path.
91
+ export function actionsLines(repo: number, owner: string, read: WorkflowsRead, lockfiles: NonNullable<ScanOptions['lockfiles']>) {
92
+ if ('error' in read) return { lines: [{ type: 'workflows', repo, ...read } as WorkflowsLine], refs: [] as ActionRef[] }
93
+ const lines: (WorkflowsLine | WorkflowLine)[] = [{ type: 'workflows', repo, paths: read.files.filter(file => file.kind === 'workflow').map(file => file.path) }]
94
+ const refs = new Map<string, ActionRef>()
95
+ // Its own actions by their directories and workflows by their paths, as a step or a job uses one.
96
+ const actions = new Map(read.files.flatMap(file => file.kind !== 'dockerfile' && 'text' in file ? [[file.kind === 'action' ? actionDir(file.path) : file.path, file.text] as const] : []))
97
+ for (const file of read.files) {
98
+ const scan = 'error' in file ? file : file.kind === 'dockerfile' ? { uses: [], findings: dockerfileFindings(file.text) } : scanWorkflow(file.kind, file.text, { owner, lockfiles, actions })
99
+ lines.push({ type: 'workflow', repo, path: file.path, kind: file.kind, ...scan })
100
+ if ('uses' in scan) for (const uses of scan.uses) if (uses.kind === 'action' || uses.kind === 'workflow') refs.set(uses.key!, { key: uses.key!, owner, depth: 0 })
101
+ }
102
+ return { lines, refs: [...refs.values()] }
103
+ }
104
+
105
+ export type ActionAnswers = Lru<{ at: number; answer: unknown }>
106
+ // An answer's weight: an entry of a list, a kilobyte of a file.
107
+ export const answerWeight = ({ answer }: { at: number; answer: unknown }) => Array.isArray(answer) ? answer.length + 1
108
+ : typeof answer === 'string' ? Math.ceil(answer.length / 1024) + 1 : ((answer as { branches?: unknown[] } | null)?.branches?.length ?? 0) + 1
109
+
110
+ const isCommit = (text: string) => /^[\da-f]{40}$/u.test(text)
111
+
112
+ // `source`, what it answers of a public repository kept for every scan, the
113
+ // same whoever asks: of a commit while kept (but none, as one can be pushed
114
+ // later), the rest for RISK_KEPT_MS. A private one's, or one missing, never.
115
+ export function sharedSource(source: ActionsSource, answers: ActionAnswers): ActionsSource {
116
+ const keyOf = (what: string, name: string, ...more: string[]) => [what, name.toLowerCase(), ...more].join('\0')
117
+ const kept = (key: string, lasting = false) => {
118
+ const at = answers.get(key)
119
+ return at && ((lasting && at.answer !== null) || Date.now() - at.at < RISK_KEPT_MS) ? at : undefined
120
+ }
121
+ async function shared<T>(name: string, key: string, lasting: boolean, ask: () => Promise<T>): Promise<T> {
122
+ if (!kept(keyOf('repo', name))) return ask()
123
+ const at = kept(key, lasting)
124
+ if (at) return at.answer as T
125
+ const answer = await ask()
126
+ answers.set(key, { at: Date.now(), answer })
127
+ return answer
128
+ }
129
+ return {
130
+ async repo(name) {
131
+ const at = kept(keyOf('repo', name))
132
+ if (at) return at.answer as Awaited<ReturnType<ActionsSource['repo']>>
133
+ const info = await source.repo(name)
134
+ if (info?.private === false) for (const each of [name, info.fullName]) answers.set(keyOf('repo', each), { at: Date.now(), answer: info })
135
+ return info
136
+ },
137
+ tags: name => shared(name, keyOf('tags', name), false, () => source.tags(name)),
138
+ tag: (name, tag) => shared(name, keyOf('tag', name, tag), false, () => source.tag(name, tag)),
139
+ branch: (name, branch) => shared(name, keyOf('branch', name, branch), false, () => source.branch(name, branch)),
140
+ branches: name => shared(name, keyOf('branches', name), false, () => source.branches(name)),
141
+ contains: (name, base, sha) => shared(name, keyOf('contains', name, base, sha), isCommit(base), () => source.contains(name, base, sha)),
142
+ file: (name, path, commit) => shared(name, keyOf('file', name, path, commit), isCommit(commit), () => source.file(name, path, commit)),
143
+ }
144
+ }
145
+
146
+ export interface ActionsResolving {
147
+ source: ActionsSource
148
+ signal: AbortSignal
149
+ tell(line: ActionLine): void
150
+ memory: Lru<{ at: number; line: ActionLine }>
151
+ answers: ActionAnswers
152
+ store?: KeptStore<ActionLine> | undefined
153
+ }
154
+
155
+ // A reference's line: kept (`stored`, as read of the store with others),
156
+ // else resolved, and kept where it is a public repository's; why not, where
157
+ // it couldn't be.
158
+ async function resolved(resolving: ActionsResolving, source: ActionsSource, { key, owner }: ActionRef,
159
+ stored = resolving.store?.read(key)): Promise<ActionLine | null> {
160
+ const kept = resolving.memory.get(key) ?? await stored?.then(row => row && { at: row.at, line: row.value })
161
+ if (kept && Date.now() - kept.at < RISK_KEPT_MS) { resolving.memory.set(key, kept); return kept.line }
162
+ const uses = usesOfKey(key)
163
+ if (!uses) return { type: 'action', key, error: 'invalid-uses' }
164
+ let found: ResolvedAction
165
+ try { found = await resolveAction(uses, source, { owner }) } catch (error) { return resolving.signal.aborted ? null : { type: 'action', key, ...failureOf(error) } }
166
+ const line: ActionLine = { type: 'action', ...found }
167
+ if (found.repository?.private === false) {
168
+ const at = Date.now()
169
+ resolving.memory.set(key, { at, line })
170
+ await resolving.store?.write(key, line, at)
171
+ }
172
+ return line
173
+ }
174
+
175
+ // Whether a reference is named by a workflow of its repository's owner.
176
+ const ownOwners = ({ key, owner }: ActionRef) => key.split('/')[0] === owner?.toLowerCase()
177
+
178
+ // Resolves the references added, and those they name in turn, each once a
179
+ // scan, RESOLVERS at once, each told as it is; once more where another
180
+ // owner's workflow names one its own owner's did, as a repository missing is
181
+ // red then, or one is met nearer, its own followed as far as they then go. `close` settles once all are told, or the scan stops.
182
+ export function resolvingActions(resolving: ActionsResolving) {
183
+ const source = keptSource(sharedSource(resolving.source, resolving.answers))
184
+ const pending: Queue<ActionRef> = queue(resolving.signal)
185
+ const latest = new Map<string, ActionRef>()
186
+ // Those of the references added at once kept beyond this instance, read of
187
+ // the store together as they are added.
188
+ const stored = new Map<string, Promise<{ at: number; value: ActionLine } | null>>()
189
+ let closed = false, waiting = 0
190
+ const settle = () => { if (closed && waiting === 0) pending.close() }
191
+ const add = (refs: ActionRef[]) => {
192
+ for (const ref of refs) {
193
+ const first = latest.get(ref.key)
194
+ if (ref.depth > MAX_DEPTH || (first ? (!ownOwners(first) || ownOwners(ref)) && first.depth <= ref.depth : latest.size >= MAX_ACTIONS)) continue
195
+ const kept = first ? { ...ref, owner: ownOwners(first) ? ref.owner : first.owner, depth: Math.min(ref.depth, first.depth) } : ref
196
+ latest.set(ref.key, kept)
197
+ waiting++
198
+ pending.add(kept)
199
+ }
200
+ const unread = [...new Set(refs.map(ref => ref.key))].filter(key => latest.has(key) && !stored.has(key) && !resolving.memory.get(key))
201
+ const read = unread.length > 0 ? resolving.store?.readMany(unread) : undefined
202
+ for (const key of read ? unread : []) stored.set(key, read!.then(found => found.get(key) ?? null))
203
+ }
204
+ const work = async () => {
205
+ for (let ref = await pending.take(); ref !== undefined; ref = await pending.take()) {
206
+ try {
207
+ const line = await resolved(resolving, source, ref, stored.get(ref.key))
208
+ if (line && latest.get(ref.key) === ref) {
209
+ resolving.tell(line)
210
+ const owner = 'repository' in line ? (line.repository?.fullName ?? line.repo).split('/')[0]! : ref.owner
211
+ if ('uses' in line) add(line.uses.map(key => ({ key, owner, depth: ref.depth + 1 })))
212
+ }
213
+ } finally { waiting--; settle() }
214
+ }
215
+ }
216
+ const workers = Array.from({ length: RESOLVERS }, work)
217
+ return { add, async close() { closed = true; settle(); await Promise.all(workers) } }
218
+ }
@@ -0,0 +1,220 @@
1
+ // What the code viewer's Agents tab reconstructs agents' sessions
2
+ // from (GET /api/admin/repositories/agents, common/managed/agent-tasks.ts):
3
+ // a page of the repository's pull requests, the most recently updated first,
4
+ // and of its default branch's commits, the newest first, those no pull
5
+ // request merged; on the first page, its latest issues and the newest of
6
+ // its agents' branches (GitHub lists branches by name alone, so every one an
7
+ // agent's prefix names is read, as many as fifty pages of each, and the newest
8
+ // kept). Each artifact's own texts (never comments: an agent links its
9
+ // session in a pull request's body and its commits' messages) are read here
10
+ // for the marks an agent leaves, and a pull request's labels for one naming
11
+ // an agent, and those alone are answered, never the texts. GraphQL alone
12
+ // gives them, so it needs a token.
13
+ import { type AgentAuthor, type AgentBranch, type AgentCommit, type AgentIssue, type AgentPull, type AppAgent, agentSignals } from '../common/managed/agent-tasks.ts'
14
+ import { AGENT_BRANCH_PREFIXES, APP_AGENTS } from './app-agents.ts'
15
+ import { GithubApiError } from './github-app.ts'
16
+ import { ACTOR, type NamedAccount, actorOf, commitAccount, githubGraphql } from './repository-history.ts'
17
+ import { ISSUES_CURSOR } from './repository-issues.ts'
18
+
19
+ export { APP_AGENTS }
20
+
21
+ export const AGENT_PULLS_PAGE = 50
22
+ export const AGENT_COMMITS_PAGE = 100
23
+ const AGENT_ISSUES = 100
24
+ const AGENT_BRANCHES = 500
25
+ const MAX_BRANCH_PAGES = 50
26
+ const CLOSES = 5
27
+ // A pull request's labels read, for one naming an agent: GitHub's most a
28
+ // page.
29
+ const LABELS = 100
30
+ const MAX_TITLE = 1000
31
+ const MAX_NAME = 256
32
+ const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
33
+ const SHA = /^[a-f\d]{40}$/u
34
+ // The address Claude's own commits are authored with.
35
+ const CLAUDE_EMAIL = 'noreply@anthropic.com'
36
+
37
+ // The app agents' accounts' no-reply addresses, by each login of theirs.
38
+ const AGENT_EMAILS = APP_AGENTS.flatMap(({ account }) => account?.logins.map(login => `${account.id}+${login}@users.noreply.github.com`) ?? [])
39
+
40
+ // An agent's branches, a page at a time, as GitHub lists them, by name;
41
+ // each its tip commit's time, message and author.
42
+ const BRANCHES_QUERY = `query($owner: String!, $name: String!, $prefix: String!, $after: String) {
43
+ repository(owner: $owner, name: $name) {
44
+ refs(refPrefix: $prefix, first: 100, after: $after) {
45
+ pageInfo { hasNextPage endCursor }
46
+ nodes { prefix name target { ... on Commit { committedDate message author { email user { login databaseId } } } } }
47
+ }
48
+ }
49
+ }`
50
+ // What a commit of the default branch is read by.
51
+ const COMMIT_FIELDS = 'oid messageHeadline message committedDate additions deletions author { email user { login databaseId } } associatedPullRequests(first: 1) { totalCount }'
52
+ // The newest of the default branch's commits the app agents' accounts
53
+ // authored, by their GitHub no-reply addresses, however far back: a bot's
54
+ // commits pushed with no pull request here (as before a repository moved)
55
+ // may lie deeper than pages are read on to.
56
+ const AUTHORED_QUERY = `query($owner: String!, $name: String!, $emails: [String!]!) {
57
+ repository(owner: $owner, name: $name) {
58
+ defaultBranchRef { target { ... on Commit { history(first: ${AGENT_COMMITS_PAGE}, author: { emails: $emails }) { nodes { ${COMMIT_FIELDS} } } } } }
59
+ }
60
+ }`
61
+ const PAGE_QUERY = `query($owner: String!, $name: String!, $pulls: String, $commits: String, $first: Boolean!, $readPulls: Boolean!, $readCommits: Boolean!) {
62
+ repository(owner: $owner, name: $name) {
63
+ pullRequests(first: ${AGENT_PULLS_PAGE}, after: $pulls, orderBy: { field: UPDATED_AT, direction: DESC }) @include(if: $readPulls) {
64
+ pageInfo { hasNextPage endCursor }
65
+ nodes { number title state isDraft createdAt updatedAt author { ${ACTOR} } body headRefName isCrossRepository headRepositoryOwner { login } additions deletions
66
+ commits(last: 1) { totalCount nodes { commit { message } } }
67
+ mergeCommit { message }
68
+ closingIssuesReferences(first: ${CLOSES}) { nodes { number } }
69
+ labels(first: ${LABELS}) { nodes { name } } }
70
+ }
71
+ defaultBranchRef { target { ... on Commit {
72
+ history(first: ${AGENT_COMMITS_PAGE}, after: $commits) @include(if: $readCommits) {
73
+ pageInfo { hasNextPage endCursor }
74
+ nodes { ${COMMIT_FIELDS} }
75
+ }
76
+ } } }
77
+ issues(first: ${AGENT_ISSUES}, orderBy: { field: UPDATED_AT, direction: DESC }) @include(if: $first) {
78
+ nodes { number title state createdAt updatedAt author { ${ACTOR} } }
79
+ }
80
+ }
81
+ }`
82
+
83
+ type Login = { login?: unknown; databaseId?: unknown } | null | undefined
84
+ type PullNode = { number?: unknown; title?: unknown; state?: unknown; isDraft?: unknown; createdAt?: unknown; updatedAt?: unknown; author?: Login; body?: unknown
85
+ headRefName?: unknown; isCrossRepository?: unknown; headRepositoryOwner?: Login; additions?: unknown; deletions?: unknown; commits?: { totalCount?: unknown; nodes?: ({ commit?: { message?: unknown } | null } | null)[] }
86
+ mergeCommit?: { message?: unknown } | null; closingIssuesReferences?: { nodes?: ({ number?: unknown } | null)[] }; labels?: { nodes?: ({ name?: unknown } | null)[] } }
87
+ type GitActor = { email?: unknown; user?: Login }
88
+ type CommitNode = { oid?: unknown; messageHeadline?: unknown; message?: unknown; committedDate?: unknown; additions?: unknown; deletions?: unknown
89
+ author?: GitActor | null; associatedPullRequests?: { totalCount?: unknown } }
90
+ type IssueNode = { number?: unknown; title?: unknown; state?: unknown; createdAt?: unknown; updatedAt?: unknown; author?: Login }
91
+ type RefNode = { prefix?: unknown; name?: unknown; target?: { committedDate?: unknown; message?: unknown; author?: GitActor | null } | null }
92
+ type Connection<T> = { pageInfo?: { hasNextPage?: unknown; endCursor?: unknown }; nodes?: (T | null)[] }
93
+ type Page = { repository?: { pullRequests?: Connection<PullNode>; defaultBranchRef?: { target?: { history?: Connection<CommitNode> } | null } | null
94
+ issues?: Connection<IssueNode> } | null }
95
+ type RefsPage = { repository?: { refs?: Connection<RefNode> } | null }
96
+
97
+ const count = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0
98
+ const number = (value: unknown) => Number.isSafeInteger(value) && (value as number) > 0 ? value as number : null
99
+ const timeOf = (value: unknown) => {
100
+ const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
101
+ return Number.isSafeInteger(at) ? at : null
102
+ }
103
+ const nameOf = (value: unknown, max = MAX_NAME) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null
104
+ const loginOf = (author: Login) => typeof author?.login === 'string' && LOGIN.test(author.login) ? author.login : null
105
+ const PULL_STATES: Record<string, AgentPull['state']> = { OPEN: 'open', MERGED: 'merged', CLOSED: 'closed' }
106
+
107
+ function pull(node: PullNode | null): AgentPull | null {
108
+ const at = number(node?.number)
109
+ if (!node || !at) return null
110
+ const author = accountOf(node.author)
111
+ return { number: at, title: nameOf(node.title, MAX_TITLE) ?? '', state: PULL_STATES[String(node.state)] ?? 'closed', draft: node.isDraft === true,
112
+ author: loginOf(node.author), authorId: author?.id ?? null, createdAt: timeOf(node.createdAt), updatedAt: timeOf(node.updatedAt), head: nameOf(node.headRefName),
113
+ // A fork's, its owner; one GitHub no longer names, a fork of its own.
114
+ fork: node.isCrossRepository === true ? loginOf(node.headRepositoryOwner) ?? `pull-${at}` : null,
115
+ additions: count(node.additions), deletions: count(node.deletions), commits: count(node.commits?.totalCount),
116
+ closes: (node.closingIssuesReferences?.nodes ?? []).flatMap(closed => number(closed?.number) ?? []),
117
+ signals: agentSignals([node.body, node.commits?.nodes?.[0]?.commit?.message, node.mergeCommit?.message], APP_AGENTS, nameOf(node.headRefName),
118
+ author, (node.labels?.nodes ?? []).map(label => label?.name)) }
119
+ }
120
+
121
+ // An account as GitHub names it, by its login and id, both.
122
+ const both = ({ login, id }: NamedAccount): AgentAuthor | null => login !== null && id !== null ? { login, id } : null
123
+ const accountOf = (actor: Login) => both(actorOf(actor))
124
+ // A commit's author: the account GitHub knows by its address, else the one
125
+ // its no-reply address names by id and login, as a bot's, Copilot's or
126
+ // Dependabot's, is told by that alone (repository-history.ts commitAccount).
127
+ const authorOf = (actor: GitActor | null | undefined) => both(commitAccount(actor))
128
+
129
+ // A commit to the default branch no pull request merged, where an agent left
130
+ // its marks on it, or Claude authored it.
131
+ function directCommit(node: CommitNode | null): AgentCommit | null {
132
+ if (!node || typeof node.oid !== 'string' || !SHA.test(node.oid) || count(node.associatedPullRequests?.totalCount) > 0) return null
133
+ const author = authorOf(node.author)
134
+ const signals = agentSignals([node.message], APP_AGENTS, null, author)
135
+ if (node.author?.email === CLAUDE_EMAIL) signals.claude = true
136
+ if (!signals.claude && !signals.codex && signals.agents.length === 0 && signals.sessions.length === 0 && signals.tasks.length === 0) return null
137
+ return { oid: node.oid, title: nameOf(node.messageHeadline, MAX_TITLE) ?? '', at: timeOf(node.committedDate),
138
+ author: author?.login ?? loginOf(node.author?.user), authorId: author?.id ?? null,
139
+ additions: count(node.additions), deletions: count(node.deletions), signals }
140
+ }
141
+
142
+ function issue(node: IssueNode | null): AgentIssue | null {
143
+ const at = number(node?.number)
144
+ if (!node || !at) return null
145
+ return { number: at, title: nameOf(node.title, MAX_TITLE) ?? '', state: node.state === 'OPEN' ? 'open' : 'closed', author: loginOf(node.author),
146
+ authorId: accountOf(node.author)?.id ?? null, createdAt: timeOf(node.createdAt), updatedAt: timeOf(node.updatedAt) }
147
+ }
148
+
149
+ // A branch under `prefix`, an agent's, named in full however GitHub splits
150
+ // its name from the prefix it was listed under.
151
+ const branch = (prefix: string) => (node: RefNode | null): AgentBranch | null => {
152
+ const ref = typeof node?.prefix === 'string' && typeof node.name === 'string' ? `${node.prefix}${node.name}` : null
153
+ const name = ref?.startsWith('refs/heads/') ? nameOf(ref.slice('refs/heads/'.length)) : null
154
+ const author = authorOf(node?.target?.author)
155
+ return name?.startsWith(prefix) ? { name, at: timeOf(node?.target?.committedDate), author: author?.login ?? loginOf(node?.target?.author?.user),
156
+ authorId: author?.id ?? null, signals: agentSignals([node?.target?.message], APP_AGENTS, name, author) } : null
157
+ }
158
+
159
+ // The newest of an agent's branches, those under `prefix`: every one, as many
160
+ // as MAX_BRANCH_PAGES of them list, kept by their tips' times.
161
+ async function agentBranches(owner: string, name: string, prefix: string, token: string, fetchImpl: typeof fetch) {
162
+ const found: AgentBranch[] = []
163
+ let after: string | null = null
164
+ for (let page = 0; page < MAX_BRANCH_PAGES; page++) {
165
+ const refs: Connection<RefNode> | undefined = (await githubGraphql(BRANCHES_QUERY, { owner, name, prefix: `refs/heads/${prefix}`, after }, token, fetchImpl) as RefsPage)
166
+ ?.repository?.refs
167
+ found.push(...(refs?.nodes ?? []).flatMap(node => branch(prefix)(node) ?? []))
168
+ const next: Connection<RefNode>['pageInfo'] = refs?.pageInfo
169
+ if (next?.hasNextPage !== true || typeof next.endCursor !== 'string') break
170
+ after = next.endCursor
171
+ }
172
+ return found.toSorted((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, AGENT_BRANCHES)
173
+ }
174
+
175
+ // What its answers are kept by, with their cursors (http.ts
176
+ // handleRepositoryAgents): bumped as what a page reads, or how it tells an
177
+ // agent, changes, so that no answer an older server kept is sent for a day.
178
+ export const AGENTS_READ_VERSION = 5
179
+
180
+ // A cursor of the default branch's commits: GitHub names one by a commit
181
+ // and how many on from it, `<oid> <offset>`, a space between.
182
+ export const COMMITS_CURSOR = /^(?:[!-~]{1,256}|[\da-f]{40} \d{1,9})$/u
183
+
184
+ const nextCursor = (connection: Connection<unknown> | undefined, cursor = ISSUES_CURSOR) => {
185
+ const info = connection?.pageInfo
186
+ return info?.hasNextPage === true && typeof info.endCursor === 'string' && cursor.test(info.endCursor) ? info.endCursor : null
187
+ }
188
+
189
+ // A page of what the repository's agents left: its pull requests on from
190
+ // the cursor `pulls` ends at, its default branch's commits on from
191
+ // `commits` (the first page where neither is named, with its issues, its
192
+ // agents' branches and the commits the app agents' accounts authored, and
193
+ // the app agents it may name), and the cursors reading on from each, null
194
+ // where either is read whole.
195
+ export async function repositoryAgents(fullName: string, token: string, cursors: { pulls: string | null; commits: string | null },
196
+ fetchImpl: typeof fetch = globalThis.fetch) {
197
+ const [owner, name] = fullName.split('/') as [string, string]
198
+ const first = cursors.pulls === null && cursors.commits === null
199
+ const [data, branches, authored] = await Promise.all([
200
+ githubGraphql(PAGE_QUERY, { owner, name, pulls: cursors.pulls, commits: cursors.commits, first,
201
+ readPulls: first || cursors.pulls !== null, readCommits: first || cursors.commits !== null }, token, fetchImpl) as Promise<Page>,
202
+ first ? Promise.all(AGENT_BRANCH_PREFIXES.map(prefix => agentBranches(owner, name, prefix, token, fetchImpl))) : [],
203
+ first ? githubGraphql(AUTHORED_QUERY, { owner, name, emails: AGENT_EMAILS }, token, fetchImpl) as Promise<Page> : null,
204
+ ])
205
+ const repository = data?.repository
206
+ if (!repository) throw new GithubApiError(404, 'github-not-found')
207
+ const history = repository.defaultBranchRef?.target?.history
208
+ const after = { pulls: nextCursor(repository.pullRequests), commits: nextCursor(history, COMMITS_CURSOR) }
209
+ const commits = [...history?.nodes ?? [], ...authored?.repository?.defaultBranchRef?.target?.history?.nodes ?? []].flatMap(node => directCommit(node) ?? [])
210
+ return {
211
+ pulls: (repository.pullRequests?.nodes ?? []).flatMap(node => pull(node) ?? []),
212
+ commits: commits.filter((commit, index) => commits.findIndex(other => other.oid === commit.oid) === index),
213
+ ...first ? {
214
+ issues: (repository.issues?.nodes ?? []).flatMap(node => issue(node) ?? []),
215
+ branches: branches.flat(),
216
+ agents: APP_AGENTS.map(({ id, name: agent, avatar }): AppAgent => ({ id, name: agent, ...avatar ? { avatar } : {} })),
217
+ } : {},
218
+ more: after.pulls !== null || after.commits !== null, after,
219
+ }
220
+ }