@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
@@ -63,6 +63,9 @@ function entryId(entry) {
63
63
  // A file whose every link normalises away is still a links file; it
64
64
  // just has no links, which its view says plainly.
65
65
  export function parseLinkedFindings(content) {
66
+ // Only text opening an array can parse to one, so a report — which
67
+ // opens an object — is turned away without being parsed twice.
68
+ if (typeof content === 'string' && !/^\s*\[/u.test(content)) return null
66
69
  let data
67
70
  try { data = JSON.parse(content) } catch { return null }
68
71
  if (!Array.isArray(data) || data.length === 0) return null
@@ -5,6 +5,7 @@ export const FILE_HASH: RegExp
5
5
  export interface BundleIdentity { integrity: string; kind: string | null; size: number }
6
6
  export interface BundleDetails extends BundleIdentity { bundle?: unknown; json?: unknown; map?: SourceMap; edges?: readonly SourcemapEdge[] }
7
7
  export function parseBundleContents(text: string, identity: BundleIdentity): BundleDetails
8
+ export function computeBundleFileHashes(details: BundleDetails): Promise<Map<string, string>>
8
9
  export interface BundleMetadata extends Record<string, unknown> {
9
10
  files: [string, number | null, string | null, number | null][]
10
11
  codeStats: { files: number; lines: number; bytes: number }
@@ -174,7 +174,7 @@ export function parseBundleMetadata(data, integrity) {
174
174
  || [...unsized].some((path) => typeof path !== 'string' || !fileSizes.has(path) || fileSizes.get(path) !== null)) throw new Error('Invalid bundle metadata')
175
175
  const details = { integrity, kind: data.kind, size: data.size, metadataOnly: true, fileSizes, fileHashes, lineCounts, unsizedFiles: unsized, stale }
176
176
  if (data.kind === 'stasis') {
177
- details.bundle = Bundle.parse(JSON.stringify(data.bundle))
177
+ details.bundle = Bundle.fromJSON(data.bundle)
178
178
  const paths = details.bundle.sources
179
179
  if (paths.size !== fileSizes.size || [...paths.keys()].some((path) => !fileSizes.has(path))) throw new Error('Invalid bundle metadata inventory')
180
180
  // A file is hashed exactly when it is source: a resource has bytes and
@@ -111,11 +111,30 @@ export function bundleSourceOrder(sources) {
111
111
  // Directory captures stay out. They are the one entry that is not a
112
112
  // file in any encoding, and mounting one puts a second `lib` beside
113
113
  // the directory it names.
114
+ // Files as the managed server reads a tarball's (server-managed/npm-packages.ts
115
+ // npmFileRows), `[path, bytes, text]` or `[path, bytes, null, digest]`, as
116
+ // details of `entry` in the shape of a sourcemap carrying its sources: one
117
+ // source per file, text inline, and every file's size from its bytes, so a
118
+ // file that is not text still lists with its size. Those carry a digest of
119
+ // their bytes instead (`npmBinaries`), which Compare compares them by. An npm
120
+ // version's files come so (view/npm-package.js), and a repository
121
+ // directory's (view/repository-code.js).
122
+ export function fileRowDetails(entry, rows) {
123
+ const files = Array.isArray(rows) ? rows : []
124
+ return {
125
+ integrity: entry.integrity, kind: 'sourcemap', size: entry.size,
126
+ json: { version: 3, sources: files.map(([path]) => path), sourcesContent: files.map(([, , text]) => typeof text === 'string' ? text : null) },
127
+ fileSizes: new Map(files.map(([path, size]) => [path, size])),
128
+ npmBinaries: new Map(files.filter(([, , text, digest]) => typeof text !== 'string' && typeof digest === 'string')
129
+ .map(([path, size, , digest]) => [path, { size, digest }])),
130
+ }
131
+ }
132
+
114
133
  export function bundleFilesAsMap(details) {
115
134
  const key = details?.bundle ?? details?.json
116
135
  if (key && filesCache.has(key)) return filesCache.get(key)
117
136
  const files = new Map(bundleSourcesAsMap(details))
118
- // An npm package's files that are not text (view/npm-package.js) carry
137
+ // An npm package's files that are not text (fileRowDetails) carry
119
138
  // their size and digest alone: enough for Compare to tell a changed one
120
139
  // from an unchanged one, as `{ format: 'digest', size, digest }`.
121
140
  for (const [path, file] of details?.npmBinaries ?? []) files.set(path, { format: 'digest', ...file })
@@ -263,15 +282,19 @@ export function bundleSourceSizes(details) {
263
282
  //
264
283
  // Returns null for sourcemap bundles (and anything without parsed
265
284
  // modules) — they carry no package metadata, so callers fall back to
266
- // the path heuristic alone.
285
+ // the path heuristic alone. Kept by the bundle, as its sources are:
286
+ // callers only read it.
287
+ const packageDirsCache = new WeakMap()
267
288
  export function bundlePackageDirs(details) {
268
289
  if (details?.kind !== 'stasis' || !details.bundle?.modules) return null
290
+ if (packageDirsCache.has(details.bundle)) return packageDirsCache.get(details.bundle)
269
291
  const map = new Map()
270
292
  for (const [dir, info] of details.bundle.modules) {
271
293
  for (const rel of Object.keys(info.files)) {
272
294
  map.set(dir === '.' ? rel : `${dir}/${rel}`, dir)
273
295
  }
274
296
  }
297
+ packageDirsCache.set(details.bundle, map)
275
298
  return map
276
299
  }
277
300
 
@@ -13,7 +13,7 @@ export function defaultScanModels(provider: string | null = null) {
13
13
  'openai/gpt-6-luna',
14
14
  'openai/gpt-6-luna-pro',
15
15
  'moonshotai/kimi-k3',
16
- 'google/gemini-3.8-flash',
16
+ 'google/gemini-4-argon',
17
17
  'google/gemma-4-31b-it',
18
18
  'x-ai/grok-4.7',
19
19
  'z-ai/glm-5.3',
@@ -16,8 +16,3 @@ export function npmReviewVerdictLimit(npm: ReviewNpmVersion | null | undefined,
16
16
  socket?: { alerts: { type: string }[] } | null, risk?: { findings: { id: string }[] } | null): ReviewVerdict
17
17
  export function needsPackageJsonReview(entries: { file: string; status: ReviewVerdict | null }[], files: string[], nonTextFiles?: string[] | null): boolean
18
18
  export function parseReviewNotes(comment: string): { text: string; bullet: boolean }[]
19
- export function auditMarkdown(review: ReviewSummary & {
20
- package: string; version: string; integrity: string; basePackage: string | null; baseVersion: string | null
21
- publishedAt: number | null; revokedAt: number | null; entries: { file: string; comment: string }[]
22
- status: ReviewVerdict | null; comment: string
23
- }): string | null
@@ -58,24 +58,7 @@ export function needsPackageJsonReview(entries, files, nonTextFiles = []) {
58
58
  || !entries.some(entry => entry.file === 'package.json' && Object.keys(REVIEW_VERDICTS).includes(entry.status)))
59
59
  }
60
60
 
61
- const markdownText = text => text.replaceAll(/[\\`*_{}[\]<>]/gu, '\\$&').replaceAll(/\r\n?/gu, '\n').replaceAll('\n', '\n ')
62
-
63
61
  export function parseReviewNotes(comment) {
64
62
  return comment.replaceAll(/\r\n?/gu, '\n').split(/(?:^|\n)[\t ]*\*[\t ]+/u)
65
63
  .map((text, index) => ({ text: text.trim(), bullet: index > 0 })).filter(note => note.text)
66
64
  }
67
-
68
- function auditNotes(comment, prefix = '') {
69
- return parseReviewNotes(comment).map(note => `* ${prefix}${note.text.replaceAll('\n', '\n ')}`)
70
- }
71
-
72
- export function auditMarkdown(review) {
73
- if (review.publishedAt === null || review.revokedAt !== null || review.coverage !== 'full'
74
- || !['accept', 'accept-with-nits'].includes(review.status)) return null
75
- const target = `${review.package} ${review.version} ${review.integrity}`
76
- const dependency = review.basePackage === null ? target
77
- : review.basePackage === review.package ? `${review.package} ${review.baseVersion} -> ${review.version} ${review.integrity}`
78
- : `${review.basePackage} ${review.baseVersion} -> ${target}`
79
- const notes = [...review.entries.filter(entry => entry.status).flatMap(entry => auditNotes(entry.comment, `In ${markdownText(entry.file)}: `)), ...auditNotes(review.comment)]
80
- return `Audited:\n\`\`\`\n${dependency}\n\`\`\`${notes.length > 0 ? `\n\nNotes:\n${notes.join('\n')}` : ''}`
81
- }
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Lines are numbered the way the diff numbers them: from zero, a final
7
7
  // newline ending the last line rather than starting an empty one.
8
- import { diff, parseDiff } from '@preventive/diff'
8
+ import { changeSet } from '@preventive/diff'
9
9
 
10
10
  export function textLines(text) {
11
11
  if (text === '') return []
@@ -25,10 +25,11 @@ const spacing = line => line.replace(/[ \t\n\v\f\r]+$/u, '').replaceAll(/[ \t\n\
25
25
 
26
26
  // Each `a[a0..a1)` replaced by `b[b0..b1)`. `ignoreWhitespace` is diff -b:
27
27
  // lines differing only in how much whitespace they have pair up as unchanged,
28
- // whitespace added or removed between other characters still a change.
28
+ // whitespace added or removed between other characters still a change. Those
29
+ // a unified diff with no context shows (horizon 0), found without printing
30
+ // one and reading it back.
29
31
  export function changeBlocks(before, after, ignoreWhitespace = false) {
30
- const text = diff(before, after, { format: 'unified', context: 0, whitespace: ignoreWhitespace ? 'change' : 'none' })
31
- return text ? (parseDiff(text)[0]?.blocks ?? []).map(({ a0, a1, b0, b1 }) => ({ a0, a1, b0, b1 })) : []
32
+ return changeSet(before, after, { horizon: 0, whitespace: ignoreWhitespace ? 'change' : 'none' }).map(({ a0, a1, b0, b1 }) => ({ a0, a1, b0, b1 }))
32
33
  }
33
34
 
34
35
  // Whether `blocks` take `before`'s lines to `after`'s: each in order and apart
@@ -0,0 +1,292 @@
1
+ // Agents' sessions and their tasks, reconstructed from what each
2
+ // leaves on GitHub (the code viewer's Agents tab, repository-agents.js): a
3
+ // task is a pull request it opened, or a commit it pushed to the default
4
+ // branch without one; a session holds as many as name it, with the branches
5
+ // they are on and the issues they work on. The server reads each artifact's
6
+ // own texts, never others' comments on it (a pull request's body and its
7
+ // head and merge commits' messages, where an agent links its session; a
8
+ // commit's message; a branch's tip commit) for the marks an agent leaves
9
+ // (agentSignals), sending those alone; agentSessions then groups them:
10
+ // those naming one session, a pull request with its branch, a branch with
11
+ // the session its commits name, an issue with the pull requests closing it
12
+ // or a branch named for it. Without a session named, a branch's pull
13
+ // requests are one session, and a commit its own.
14
+ //
15
+ // The agents are Claude and Codex, and those the server knows by their GitHub
16
+ // Apps or accounts (repository-agents.ts APP_AGENTS): each told by its
17
+ // fingerprint, a line of its texts, by the account its work is authored by,
18
+ // or by its branches' prefix, the server's to read, and named to the client
19
+ // by its id, name and avatar (AppAgent). Such an agent names no session:
20
+ // a pull request of its is a session alone, with its branch; Claude's or
21
+ // Codex's marks on its work show those as its tools. A pull request labelled
22
+ // for an agent (labelAgents) is that agent's too, where its own texts and
23
+ // branch name none: the mark one who ran it gives work it left unmarked.
24
+
25
+ // 'claude', 'codex', or an app agent's id.
26
+ export type Agent = string
27
+
28
+ // An agent known by its GitHub App or account, as the server names it to the
29
+ // client: its id, its name, and its avatar, its mark (/api/avatar/<avatar>:
30
+ // `app-<id>` a GitHub App's, `user-<id>` a GitHub account's), where the app
31
+ // draws no mark of its own for it (Copilot's, ui/view/repository-tiles.js).
32
+ export interface AppAgent { id: string; name: string; avatar?: string }
33
+ // How the server tells one: its fingerprint, a line of an artifact's texts
34
+ // starting with it; the GitHub account its work is authored by, its id and
35
+ // the logins GitHub names it by; its branches' prefix.
36
+ export interface AppAgentMarks { id: string; fingerprint?: string; account?: { id: number; logins: readonly string[] }; prefix: string }
37
+ // An artifact's author, as GitHub names its account: its login and id.
38
+ export interface AgentAuthor { login: string; id: number }
39
+
40
+ // What an artifact's texts say of the agents that made it: the Claude Code
41
+ // sessions and Codex tasks they link, whether they carry Claude's marks,
42
+ // its co-author trailer or its "Generated with" line, and Codex's, its
43
+ // co-author trailer, and the app agents whose fingerprints they carry, or
44
+ // whose branch it is.
45
+ export interface AgentSignals { sessions: string[]; tasks: string[]; claude: boolean; codex: boolean; agents: string[] }
46
+
47
+ // Each by its author's login, and their account's numeric id (`authorId`)
48
+ // where GitHub names it.
49
+ export interface AgentPull { number: number; title: string; state: 'open' | 'merged' | 'closed'; draft: boolean; author: string | null; authorId?: number | null
50
+ // Its branch, and the owner of the fork it is in, null where it is in the
51
+ // repository itself.
52
+ createdAt: number | null; updatedAt: number | null; head: string | null; fork: string | null; additions: number; deletions: number; commits: number
53
+ closes: number[]; signals: AgentSignals }
54
+ export interface AgentIssue { number: number; title: string; state: 'open' | 'closed'; author: string | null; authorId?: number | null; createdAt: number | null; updatedAt: number | null }
55
+ export interface AgentBranch { name: string; at: number | null; author: string | null; authorId?: number | null; signals: AgentSignals }
56
+ // A commit to the default branch no pull request merged.
57
+ export interface AgentCommit { oid: string; title: string; at: number | null; author: string | null; authorId?: number | null; additions: number; deletions: number
58
+ signals: AgentSignals }
59
+
60
+ export interface AgentSession {
61
+ id: string
62
+ agents: Agent[]
63
+ // Where to open it: a Claude Code session or a Codex task.
64
+ sessions: string[]
65
+ tasks: string[]
66
+ title: string
67
+ authors: string[]
68
+ startedAt: number | null
69
+ updatedAt: number | null
70
+ state: 'open' | 'merged' | 'closed' | 'branch'
71
+ // Its tasks: the pull requests it opened, and the commits it pushed to
72
+ // the default branch, the newest first.
73
+ pulls: Pick<AgentPull, 'number' | 'title' | 'state' | 'draft'>[]
74
+ commits: Pick<AgentCommit, 'oid' | 'title'>[]
75
+ issues: Pick<AgentIssue, 'number' | 'title' | 'state'>[]
76
+ branches: string[]
77
+ // The commits its pull requests hold, and their lines and its commits'.
78
+ commitCount: number
79
+ additions: number
80
+ deletions: number
81
+ }
82
+
83
+ const SESSION = /https:\/\/claude\.ai\/code\/(session_[A-Za-z\d]{6,64})\b/gu
84
+ const CODEX_TASK = /https:\/\/chatgpt\.com\/codex\/tasks\/(task_[a-z]_[A-Za-z\d]{6,64})\b/gu
85
+ // Claude's marks on its commits and pull requests: its co-author trailer,
86
+ // its "Generated with" line, its session trailer.
87
+ const CLAUDE_MARK = /^co-authored-by: claude\b|generated with \[claude code\]|^claude-session:/imu
88
+ // Codex's: its co-author trailer, by its address, as Codex names itself
89
+ // co-author of the commits it makes as whoever ran it; a trailer of a text's
90
+ // trailers (trailerBlock), not a line before them that shows one.
91
+ const CODEX_MARK = /^co-authored-by:[ \t]*[^<\n]*<codex@openai\.com>[ \t]*$/imu
92
+ const MAX_LINKS = 8
93
+
94
+ // A message's trailers, as git reads them (`git interpret-trailers`): its
95
+ // last paragraph, past its subject's, where each of its lines is a trailer
96
+ // (`Key: value`) or goes on with one (indented), or, where it holds git's
97
+ // own `Signed-off-by:`, a quarter of them are; none where it isn't, as
98
+ // lines a description or example holds, fenced or not, are not.
99
+ const TRAILER_LINE = /^[A-Za-z\d-]+[ \t]*:/u
100
+ export function trailerBlock(text: string): string {
101
+ const paragraphs = text.replaceAll('\r\n', '\n').trim().split(/\n[ \t]*\n/u)
102
+ const block = paragraphs.length > 1 ? paragraphs.at(-1)! : ''
103
+ const lines = block.split('\n')
104
+ const trailers = lines.filter(line => TRAILER_LINE.test(line)).length
105
+ const others = lines.filter((line, at) => !TRAILER_LINE.test(line) && !(at > 0 && /^[ \t]/u.test(line))).length
106
+ return others === 0 || (lines.some(line => /^signed-off-by[ \t]*:/iu.test(line)) && trailers * 3 >= others) ? block : ''
107
+ }
108
+
109
+ // The agents of those whose ids are `ids` that `labels` name (a pull
110
+ // request's): a label an agent's id, regardless of case, alone or after
111
+ // `agent:` or `agent/` (`Codex`, `agent: claude`).
112
+ const LABEL = /^(?:agent[:/] ?)?([a-z][a-z\d-]*)$/u
113
+ export function labelAgents(labels: unknown[], ids: readonly string[]): Agent[] {
114
+ return [...new Set(labels.flatMap(label => {
115
+ const id = typeof label === 'string' ? LABEL.exec(label.trim().toLowerCase())?.[1] : undefined
116
+ return id !== undefined && ids.includes(id) ? [id] : []
117
+ }))]
118
+ }
119
+
120
+ // The sessions and tasks `texts` link, whether they carry Claude's marks and
121
+ // Codex's, and the agents of `apps` whose fingerprints start a line of them
122
+ // (not one a text quotes, as one describing it may), whose account `author`
123
+ // is, by its id and login both, or whose prefix the branch `branch` is under;
124
+ // Claude, Codex or the agents of `apps` its `labels` name (labelAgents).
125
+ export function agentSignals(texts: unknown[], apps: readonly AppAgentMarks[] = [], branch: string | null = null,
126
+ author: AgentAuthor | null = null, labels: unknown[] = []): AgentSignals {
127
+ const strings = texts.filter((text): text is string => typeof text === 'string')
128
+ const found = (pattern: RegExp) => [...new Set(strings.flatMap(text => [...text.matchAll(pattern)].map(match => match[1]!)))].slice(0, MAX_LINKS)
129
+ const lines = strings.flatMap(text => text.split('\n').map(line => line.trimStart()))
130
+ const login = author?.login.toLowerCase()
131
+ const authored = ({ account }: AppAgentMarks) => author !== null && account !== undefined && account.id === author.id
132
+ && account.logins.some(name => name.toLowerCase() === login)
133
+ const labelled = labelAgents(labels, ['claude', 'codex', ...apps.map(app => app.id)])
134
+ return { sessions: found(SESSION), tasks: found(CODEX_TASK), claude: labelled.includes('claude') || strings.some(text => CLAUDE_MARK.test(text)),
135
+ codex: labelled.includes('codex') || strings.some(text => CODEX_MARK.test(trailerBlock(text))),
136
+ agents: apps.filter(app => (app.fingerprint && lines.some(line => line.startsWith(app.fingerprint!)))
137
+ || authored(app) || branch?.startsWith(app.prefix) || labelled.includes(app.id)).map(app => app.id) }
138
+ }
139
+
140
+ // The agent a branch's name says made it, and the issue or pull request the
141
+ // Claude GitHub App names its branch for (`claude/issue-12-…`).
142
+ export function agentBranch(name: string | null): { agent: Agent | null; issue: number | null; pull: number | null } {
143
+ const agent = name?.startsWith('claude/') ? 'claude' : name?.startsWith('codex/') ? 'codex' : null
144
+ const named = agent === 'claude' ? /^claude\/(issue|pr)-([1-9]\d{0,9})-/u.exec(name!) : null
145
+ return { agent, issue: named?.[1] === 'issue' ? Number(named[2]) : null, pull: named?.[1] === 'pr' ? Number(named[2]) : null }
146
+ }
147
+
148
+ // A pull request's branch, as GitHub names one in a fork: `owner:branch`;
149
+ // two forks' of one name, or a fork's and the repository's, are two.
150
+ const headName = (pull: AgentPull) => pull.head && pull.fork ? `${pull.fork}:${pull.head}` : pull.head
151
+
152
+ const signalAgents = (signals: AgentSignals): Agent[] => [...signals.agents, ...signals.sessions.length > 0 || signals.claude ? ['claude'] : [],
153
+ ...signals.tasks.length > 0 || signals.codex ? ['codex'] : []]
154
+ const TOOLS = new Set(['claude', 'codex'])
155
+
156
+ type Artifacts = { pulls?: AgentPull[]; commits?: AgentCommit[]; issues?: AgentIssue[]; branches?: AgentBranch[] }
157
+ type Group = { agents: Set<Agent>; keys: string[] }
158
+
159
+ // The artifacts joined (`pull:<n>`, `commit:<oid>`, `branch:<name>`,
160
+ // `session:<id>`, `task:<id>`), each set with the agents whose marks are on
161
+ // them: by the sessions and tasks they link and the branches pull requests
162
+ // are from. An app agent's join none by what they link, and a pull request
163
+ // of its its branch alone, where no pull request newer is from it, an app
164
+ // agent's or another's: one a session.
165
+ function artifactGroups({ pulls = [], commits = [], branches = [] }: Artifacts): Group[] {
166
+ const parent = new Map<string, string>()
167
+ const find = (key: string): string => {
168
+ let root = key
169
+ while (parent.has(root) && parent.get(root) !== root) root = parent.get(root)!
170
+ parent.set(key, root)
171
+ return root
172
+ }
173
+ const join = (a: string, b: string) => {
174
+ const [x, y] = [find(a), find(b)]
175
+ if (x !== y) parent.set(y, x)
176
+ }
177
+ const agentsOf = new Map<string, Set<Agent>>()
178
+ const mark = (key: string, agents: Agent[]) => { for (const agent of agents) agentsOf.set(key, new Set([...agentsOf.get(key) ?? [], agent])) }
179
+ const linked = (key: string, signals: AgentSignals) => {
180
+ find(key)
181
+ if (signals.agents.length === 0) for (const session of signals.sessions) join(key, `session:${session}`)
182
+ if (signals.agents.length === 0) for (const task of signals.tasks) join(key, `task:${task}`)
183
+ mark(key, signalAgents(signals))
184
+ }
185
+ const branchNames = new Set(branches.map(branch => branch.name))
186
+ for (const branch of branches) {
187
+ const key = `branch:${branch.name}`
188
+ linked(key, branch.signals)
189
+ const { agent } = agentBranch(branch.name)
190
+ if (agent) mark(key, [agent])
191
+ }
192
+ // The newest pull request read from each branch, by its number, as GitHub
193
+ // numbers them as they are opened: they are read as they were updated.
194
+ const newest = new Map<string, number>()
195
+ for (const pull of pulls) {
196
+ const head = headName(pull)
197
+ if (head !== null && pull.number > (newest.get(head) ?? 0)) newest.set(head, pull.number)
198
+ }
199
+ for (const pull of pulls) {
200
+ const key = `pull:${pull.number}`
201
+ linked(key, pull.signals)
202
+ const { agent, pull: named } = agentBranch(pull.head)
203
+ if (agent) mark(key, [agent])
204
+ // Its branch, an agent's or its own, read or not; a fork's its own.
205
+ const head = headName(pull)
206
+ if (pull.signals.agents.length > 0) {
207
+ if (head !== null && newest.get(head) === pull.number) join(key, `branch:${head}`)
208
+ continue
209
+ }
210
+ if (head && (agent || signalAgents(pull.signals).length > 0 || branchNames.has(head))) join(key, `branch:${head}`)
211
+ if (named) join(key, `pull:${named}`)
212
+ }
213
+ for (const commit of commits) linked(`commit:${commit.oid}`, commit.signals)
214
+
215
+ const groups = new Map<string, Group>()
216
+ for (const key of parent.keys()) {
217
+ const root = find(key)
218
+ const group = groups.get(root) ?? { agents: new Set(), keys: [] }
219
+ group.keys.push(key)
220
+ for (const agent of agentsOf.get(key) ?? []) group.agents.add(agent)
221
+ groups.set(root, group)
222
+ }
223
+ return [...groups.values()]
224
+ }
225
+
226
+ const byTime = <T>(time: (item: T) => number | null) => (a: T, b: T) => (time(a) ?? 0) - (time(b) ?? 0)
227
+
228
+ // The session a group of artifacts is, its tasks and what they touched; null
229
+ // for one no agent left marks on, or holding none read.
230
+ function sessionOf({ agents, keys }: Group, { pulls, commits, issues, branches }: {
231
+ pulls: Map<number, AgentPull>; commits: Map<string, AgentCommit>; issues: Map<number, AgentIssue>; branches: Map<string, AgentBranch> }): AgentSession | null {
232
+ if (agents.size === 0) return null
233
+ const of = (prefix: string) => keys.filter(key => key.startsWith(prefix)).map(key => key.slice(prefix.length))
234
+ const taskPulls = of('pull:').flatMap(number => pulls.get(Number(number)) ?? []).toSorted(byTime(pull => pull.createdAt))
235
+ const taskCommits = of('commit:').flatMap(oid => commits.get(oid) ?? []).toSorted(byTime(commit => commit.at))
236
+ const names = [...new Set([...of('branch:'), ...taskPulls.flatMap(pull => headName(pull) ?? [])])]
237
+ // The issues its pull requests close, and those its branches are named
238
+ // for, by a pull request's own name for its branch, a fork's too.
239
+ const issueNumbers = new Set([...taskPulls.flatMap(pull => pull.closes),
240
+ ...taskPulls.flatMap(pull => agentBranch(pull.head).issue ?? []), ...of('branch:').flatMap(name => agentBranch(name).issue ?? [])])
241
+ const taskIssues = [...issueNumbers].flatMap(number => issues.get(number) ?? []).toSorted(byTime(issue => issue.createdAt))
242
+ const taskBranches = names.flatMap(name => branches.get(name) ?? [])
243
+ const items = [...taskPulls, ...taskCommits, ...taskIssues, ...taskBranches]
244
+ if (items.length === 0) return null
245
+ const [codexTasks, sessions] = [of('task:'), of('session:')]
246
+ const started = items.flatMap(item => ('createdAt' in item ? item.createdAt : item.at) ?? [])
247
+ const updated = items.flatMap(item => ('updatedAt' in item ? item.updatedAt : item.at) ?? [])
248
+ const states = new Set(taskPulls.map(pull => pull.state))
249
+ // Its first task names it: the earliest of its pull requests and commits.
250
+ const first = [...taskPulls.map(pull => ({ at: pull.createdAt, title: pull.title })), ...taskCommits.map(commit => ({ at: commit.at, title: commit.title }))]
251
+ .toSorted(byTime(task => task.at))[0]
252
+ const landed = [...taskPulls, ...taskCommits]
253
+ return {
254
+ id: sessions[0] ?? codexTasks[0] ?? (taskPulls[0] ? `pull-${taskPulls[0].number}` : taskCommits[0]?.oid ?? names[0] ?? ''),
255
+ // An app agent's first, the one whose session it is, then the tools it
256
+ // used, Claude's or Codex's.
257
+ agents: [...[...agents].filter(agent => !TOOLS.has(agent)), ...['claude', 'codex'].filter(agent => agents.has(agent))],
258
+ sessions, tasks: codexTasks,
259
+ title: first?.title ?? taskIssues[0]?.title ?? names[0] ?? '',
260
+ authors: [...new Set(items.flatMap(item => item.author ?? []))],
261
+ startedAt: started.length > 0 ? Math.min(...started) : null,
262
+ updatedAt: updated.length > 0 ? Math.max(...updated) : null,
263
+ state: states.has('open') ? 'open' : states.has('merged') || taskCommits.length > 0 ? 'merged' : states.has('closed') ? 'closed' : 'branch',
264
+ pulls: taskPulls.map(({ number, title, state, draft }) => ({ number, title, state, draft })).toReversed(),
265
+ commits: taskCommits.map(({ oid, title }) => ({ oid, title })).toReversed(),
266
+ issues: taskIssues.map(({ number, title, state }) => ({ number, title, state })),
267
+ branches: names,
268
+ commitCount: taskPulls.reduce((sum, pull) => sum + pull.commits, 0) + taskCommits.length,
269
+ additions: landed.reduce((sum, item) => sum + item.additions, 0),
270
+ deletions: landed.reduce((sum, item) => sum + item.deletions, 0),
271
+ }
272
+ }
273
+
274
+ // The sessions `pulls`, `commits`, `issues` and `branches` make, the most
275
+ // recently updated first: each a set of them, joined by the sessions and
276
+ // tasks they link and the branches pull requests are from, with the issues
277
+ // a pull request closes or a branch is named for.
278
+ // Artifacts no agent left marks on belong to none. Where pull requests
279
+ // updated before `branchesSince` are yet to be read, a session of branches
280
+ // alone older than that waits for them: its pull request may be among them.
281
+ export function agentSessions(artifacts: Artifacts, { branchesSince = null }: { branchesSince?: number | null } = {}): AgentSession[] {
282
+ const lookups = {
283
+ pulls: new Map((artifacts.pulls ?? []).map(pull => [pull.number, pull])),
284
+ commits: new Map((artifacts.commits ?? []).map(commit => [commit.oid, commit])),
285
+ issues: new Map((artifacts.issues ?? []).map(issue => [issue.number, issue])),
286
+ branches: new Map((artifacts.branches ?? []).map(branch => [branch.name, branch])),
287
+ }
288
+ const waiting = (session: AgentSession) => branchesSince !== null && session.pulls.length === 0 && session.commits.length === 0
289
+ && (session.updatedAt ?? 0) < branchesSince
290
+ return artifactGroups(artifacts).flatMap(group => sessionOf(group, lookups) ?? []).filter(session => !waiting(session))
291
+ .toSorted(byTime(session => session.updatedAt)).toReversed()
292
+ }
@@ -0,0 +1,92 @@
1
+ // Agents by their own GitHub accounts, where a repository's activity names
2
+ // them: each its name, and how the app draws it, by its mark, as the Agents
3
+ // tab does (ui/view/provider-icons.js), or by its avatar, which
4
+ // /api/avatar/<avatar> serves (server-managed/repository-agents.ts
5
+ // APP_AGENTS, the Agents tab's marks); its accounts, each by the logins GitHub's REST and GraphQL APIs
6
+ // name it by and its numeric id where known, which GitHub never gives
7
+ // another; and the names its commits carry where GitHub names no account for
8
+ // them. Claude Code's commits are authored as Claude <noreply@anthropic.com>,
9
+ // which GitHub names `claude`; the Claude GitHub App's as `claude[bot]`.
10
+ // Codex's GitHub connector's are its own; Codex otherwise commits as whoever
11
+ // ran it, and those commits are theirs, with a
12
+ // `Co-authored-by: Codex <codex@openai.com>` trailer, an address GitHub
13
+ // names someone else's account for, so Codex is known by the address, not
14
+ // that account. Copilot's coding agent authors its commits as its account,
15
+ // Copilot; Copilot's code review reviews as another, its bot, which GitHub
16
+ // names Copilot as well where it is asked to review and in its threads, and
17
+ // `copilot-pull-request-reviewer` (`[bot]` to GitHub's REST API) on its
18
+ // reviews, the accounts' ids telling the two apart: a login may so be more
19
+ // than one account's. Dependabot, its GitHub App, as its bot,
20
+ // `dependabot` to GitHub's GraphQL API, `dependabot[bot]` to its REST API and
21
+ // in its address. GitHub's Agentic Workflows (gh-aw) post, and approve or
22
+ // review pull requests, as GitHub Actions' bot, `github-actions` (41898282,
23
+ // `[bot]` to the REST API), its mark the GitHub Actions App's avatar; other
24
+ // workflows the bot runs are as much its own. Cursor's agents commit as whoever ran them, with a
25
+ // `Co-authored-by: Cursor Agent <cursoragent@cursor.com>` trailer; the app
26
+ // has no mark of Cursor's, and draws it by its name's letter. Each by the
27
+ // addresses (`emails`) its commits name it by, as author or co-author.
28
+ export interface GithubAgentAccount { id?: number; logins: readonly string[] }
29
+ export interface GithubAgent { name: string; mark?: 'anthropic' | 'openai' | 'github-copilot'; avatar?: string; accounts: readonly GithubAgentAccount[]; names: readonly string[]
30
+ emails?: readonly string[] }
31
+
32
+ export const GITHUB_AGENTS = {
33
+ claude: { name: 'Claude', mark: 'anthropic', accounts: [{ id: 81847, logins: ['claude'] }, { logins: ['claude[bot]'] }], names: ['Claude'], emails: ['noreply@anthropic.com'] },
34
+ codex: { name: 'Codex', mark: 'openai', accounts: [{ id: 199175422, logins: ['chatgpt-codex-connector', 'chatgpt-codex-connector[bot]'] }], names: ['Codex'],
35
+ emails: ['codex@openai.com'] },
36
+ copilot: { name: 'Copilot', mark: 'github-copilot', accounts: [{ id: 198982749, logins: ['Copilot', 'copilot-swe-agent'] },
37
+ { id: 175728472, logins: ['Copilot', 'copilot-pull-request-reviewer', 'copilot-pull-request-reviewer[bot]'] }], names: ['Copilot'] },
38
+ dependabot: { name: 'Dependabot', avatar: 'app-29110', accounts: [{ id: 49699333, logins: ['dependabot', 'dependabot[bot]'] }], names: ['dependabot[bot]'] },
39
+ actions: { name: 'GitHub Actions', avatar: 'app-15368', accounts: [{ id: 41898282, logins: ['github-actions', 'github-actions[bot]'] }], names: ['github-actions[bot]'] },
40
+ cursor: { name: 'Cursor', accounts: [], names: ['Cursor Agent'], emails: ['cursoragent@cursor.com'] },
41
+ } as const satisfies Record<string, GithubAgent>
42
+ export type GithubAgentId = keyof typeof GITHUB_AGENTS
43
+
44
+ const agents = Object.entries(GITHUB_AGENTS) as [GithubAgentId, GithubAgent][]
45
+ // Each login's agent, and the ids of the accounts it names, undefined for
46
+ // one whose id the app doesn't know.
47
+ const byLogin = new Map<string, { agent: GithubAgentId; ids: (number | undefined)[] }>()
48
+ for (const [agent, { accounts }] of agents) {
49
+ for (const { id, logins } of accounts) {
50
+ for (const login of logins) byLogin.set(login.toLowerCase(), { agent, ids: [...byLogin.get(login.toLowerCase())?.ids ?? [], id] })
51
+ }
52
+ }
53
+ const byName = new Map(agents.flatMap(([agent, { names }]) => names.map(name => [name.toLowerCase(), agent] as const)))
54
+ const byEmail = new Map(agents.flatMap(([agent, { emails = [] }]) => emails.map(email => [email, agent] as const)))
55
+
56
+ // The agent whose address `email` is, GitHub matching addresses regardless of
57
+ // case; null for anyone else's.
58
+ export const emailAgent = (email: unknown): GithubAgentId | null => typeof email === 'string' ? byEmail.get(email.trim().toLowerCase()) ?? null : null
59
+
60
+ // The agent an author is: by their login, GitHub matching logins regardless
61
+ // of case, their account's id the agent's where both are known, so another
62
+ // account GitHub names by the same login (a user's, the bot's GraphQL login
63
+ // may be) is not; for an author GitHub names no account for, by the name
64
+ // they carry. Null for anyone else: a human's account is theirs, whatever
65
+ // their commits name them.
66
+ export function githubAgent(login: string | null, account: number | null = null, name: string | null = null): GithubAgentId | null {
67
+ if (typeof login !== 'string') return typeof name === 'string' ? byName.get(name.trim().toLowerCase()) ?? null : null
68
+ const known = byLogin.get(login.toLowerCase())
69
+ return known && (account === null || known.ids.includes(undefined) || known.ids.includes(account)) ? known.agent : null
70
+ }
71
+
72
+ // Whether the accounts `ids` a login is named by are all one agent's, as
73
+ // Copilot's coding agent's and its code review's, both named Copilot, are:
74
+ // the login is that agent's whichever it is, rather than no one's.
75
+ export function oneAgent(login: string, ids: Iterable<number>): boolean {
76
+ const theirs = new Set([...ids].map(id => githubAgent(login, id)))
77
+ return theirs.size === 1 && !theirs.has(null)
78
+ }
79
+
80
+ // The accounts agents work as, a person's to GitHub, as the app is told of
81
+ // them, each by its id and logins: the pull requests they open and the
82
+ // commits they push to a branch directly are agents' (the Pulse's,
83
+ // server-managed/agent-merges.ts agentMerges, repository-pulse.ts
84
+ // commitStats), whatever their branches and labels, the rest of what they do
85
+ // theirs. Christopher Turing's.
86
+ export const AGENT_OPERATED = [{ id: 188797125, logins: ['ChristopherTuring'] }] as const satisfies readonly GithubAgentAccount[]
87
+
88
+ // Whether an author is an account agents work as (AGENT_OPERATED): by its id
89
+ // where GitHub gives it, whatever it is renamed to, else by its login,
90
+ // regardless of case.
91
+ export const agentOperated = (login: string | null, id: number | null = null): boolean => AGENT_OPERATED.some(account => id === null
92
+ ? typeof login === 'string' && account.logins.some(name => name.toLowerCase() === login.toLowerCase()) : account.id === id)
@@ -0,0 +1,49 @@
1
+ // The GitHub accounts an answer about a repository's activity names (its
2
+ // commits', issues', pull requests', agents' and pulse's): as an author, one
3
+ // who merged or a contributor, by login, and by the account's numeric id
4
+ // where the answer names it beside the login. The server resolves them to the
5
+ // app's users and agents (server-managed/github-users.ts), and the client
6
+ // keeps those it answered with in place of what it held for them
7
+ // (ui/view/github-users.js).
8
+ import { oneAgent } from './github-agents.ts'
9
+
10
+ // Where answers name a login, each with where the account's id is named
11
+ // beside it.
12
+ const LOGIN_KEYS = new Map([['author', 'authorId'], ['authorLogin', 'authorId'], ['mergedBy', 'mergedById'], ['login', 'id']])
13
+ const LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
14
+ const MAX_DEPTH = 8
15
+ const accountId = (value: unknown) => Number.isSafeInteger(value) && (value as number) > 0 ? value as number : null
16
+
17
+ // The logins `answer` names, each once, with the numeric id of the account
18
+ // named beside them (null where none is, or where those named are all one
19
+ // agent's, oneAgent); `ambiguous` where a login, GitHub matching logins
20
+ // regardless of case, is named beside more than one: by an account GitHub
21
+ // named by it once, as what was kept of the history says, and one it names by
22
+ // it now, which is no telling whose each is.
23
+ export function namedAccounts(answer: unknown): Map<string, number | 'ambiguous' | null> {
24
+ const named = new Map<string, Set<number>>()
25
+ collect(answer, named, 0)
26
+ const ids = new Map<string, Set<number>>()
27
+ for (const [login, theirs] of named) ids.set(login.toLowerCase(), new Set([...ids.get(login.toLowerCase()) ?? [], ...theirs]))
28
+ return new Map([...named.keys()].map((login): [string, number | 'ambiguous' | null] => {
29
+ const theirs = ids.get(login.toLowerCase())!
30
+ if (theirs.size <= 1) return [login, [...theirs][0] ?? null]
31
+ return [login, oneAgent(login, theirs) ? null : 'ambiguous']
32
+ }))
33
+ }
34
+
35
+ function collect(answer: unknown, named: Map<string, Set<number>>, depth: number): void {
36
+ if (depth > MAX_DEPTH || answer === null || typeof answer !== 'object') return
37
+ if (Array.isArray(answer)) {
38
+ for (const value of answer) collect(value, named, depth + 1)
39
+ return
40
+ }
41
+ const fields = answer as Record<string, unknown>
42
+ for (const [key, value] of Object.entries(fields)) {
43
+ const idKey = LOGIN_KEYS.get(key)
44
+ if (idKey && typeof value === 'string' && LOGIN.test(value)) {
45
+ const id = accountId(fields[idKey])
46
+ named.set(value, new Set([...named.get(value) ?? [], ...id === null ? [] : [id]]))
47
+ } else collect(value, named, depth + 1)
48
+ }
49
+ }
@@ -26,3 +26,10 @@ export function roleAtLeast(role: Role, min: Role): boolean {
26
26
  const r = ROLES.indexOf(role)
27
27
  return r !== -1 && r <= ROLES.indexOf(min)
28
28
  }
29
+
30
+ // Who reads connected repositories' code (the code viewer): a team member,
31
+ // view or above, the server reading only what their team grants reach and
32
+ // their own GitHub account may read. A public link reads none.
33
+ export function canReadRepositoryCode(session: { role: Role; publicShare?: boolean } | null | undefined): boolean {
34
+ return session != null && !session.publicShare && roleAtLeast(session.role, 'view')
35
+ }