@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.
- package/client/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +25 -2
- package/common/default-scan-models.ts +1 -1
- package/common/dependency-reviews.d.ts +0 -5
- package/common/dependency-reviews.js +0 -17
- package/common/line-diff.js +5 -4
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/github-agents.ts +92 -0
- package/common/managed/github-logins.ts +49 -0
- package/common/managed/roles.ts +7 -0
- package/common/managed/routes.js +88 -7
- package/common/managed/team-app.d.ts +5 -2
- package/common/managed/team-app.js +2 -2
- package/common/server-info.ts +3 -0
- package/common/workspace-app.js +6 -3
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2XUEZHIP.js +18 -18
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-3PS4KWD2.js +10 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4UFAMJAX.js +1 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7XRR4OFU.js +1 -0
- package/out/chunk-BC2UCLFJ.js +1 -0
- package/out/chunk-BOIXMUU5.js +3 -0
- package/out/chunk-C4Z6ZBXV.js +6 -6
- package/out/chunk-CC4OM6TZ.js +3 -0
- package/out/chunk-G24A42V6.js +2 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-I2MDGSQX.js +3 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -1
- package/out/chunk-JKGQ3O7O.js +6 -0
- package/out/chunk-LE5ZWLG4.js +235 -0
- package/out/chunk-MSION3G4.js +36 -0
- package/out/chunk-NRPC74AM.js +1 -1
- package/out/chunk-OKRLLE5I.js +17 -0
- package/out/chunk-OOJM4CTU.js +1 -1
- package/out/chunk-PNHBZGDD.js +1 -1
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +1 -1
- package/out/chunk-S6GIXWQW.js +52 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TUII34K2.js +1 -1
- package/out/chunk-V3KWNNMY.js +1 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WQ74ZBZF.js +7 -0
- package/out/client-managed-import.js +3 -3
- package/out/client-managed.js +31 -35
- package/out/client-sync.js +11 -11
- package/out/compare.js +35 -0
- package/out/dialogs.js +51 -0
- package/out/github-issue-authorized.html +1 -1
- package/out/github-issue-authorized.js +1 -1
- package/out/graph.js +17 -17
- package/out/index.html +30 -34
- package/out/prism.js +14 -14
- package/out/scan.js +2 -0
- package/out/terminal.js +294 -293
- package/out/view.css +1 -1
- package/out/view.js +16 -107
- package/package.json +53 -6
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/hosts.js +6 -6
- package/risk/files/readability.js +23 -17
- package/risk/files/runs-code.js +18 -6
- package/risk/files/script.js +7 -3
- package/risk/files/stamps.js +8 -7
- package/risk/index.d.ts +71 -1
- package/risk/index.js +6 -2
- package/risk/npm/assess.js +1 -1
- package/risk/npm/compare.js +34 -10
- package/risk/npm/files.js +413 -2
- package/risk/npm/tarball.js +56 -73
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/agent-merges.ts +198 -0
- package/server-managed/app-agents.ts +30 -0
- package/server-managed/avatar-store.ts +1 -0
- package/server-managed/bundle-cache.ts +7 -1
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-coauthors.ts +83 -0
- package/server-managed/commit-lockfiles.ts +72 -0
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +66 -19
- package/server-managed/db-neon.ts +89 -21
- package/server-managed/db-schema.ts +13 -2
- package/server-managed/db.ts +9 -1
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +97 -0
- package/server-managed/dependency-reviews.ts +78 -18
- package/server-managed/github-accounts.ts +33 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +33 -13
- package/server-managed/github-errors.ts +2 -0
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-oauth.ts +5 -2
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +1251 -97
- package/server-managed/index.ts +13 -8
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/locked-packages.ts +37 -16
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +178 -0
- package/server-managed/npm-insights.ts +20 -11
- package/server-managed/npm-kept.ts +83 -6
- package/server-managed/npm-loads.ts +114 -37
- package/server-managed/npm-packages.ts +110 -64
- package/server-managed/npm-risk.ts +4 -1
- package/server-managed/pretty-print.ts +67 -23
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +300 -0
- package/server-managed/pull-dependency-reviews.ts +318 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +16 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +42 -9
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +220 -0
- package/server-managed/repository-browser.ts +166 -37
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +52 -0
- package/server-managed/repository-dependency-reviews.ts +157 -0
- package/server-managed/repository-history.ts +461 -0
- package/server-managed/repository-issues.ts +181 -0
- package/server-managed/repository-pulse.ts +439 -0
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +133 -118
- package/server-managed/reviewed-dependencies.ts +149 -0
- package/server-managed/session.ts +27 -9
- package/server-managed/sql.ts +6 -3
- package/server-managed/storage-stores.ts +5 -0
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-app.ts +19 -3
- package/server-managed/team-reports.ts +24 -12
- package/server-managed/upstream-cache.ts +12 -0
- package/server-managed/workspace-shares.ts +4 -1
- package/server.ts +25 -5
- package/out/chunk-2ABLKCOY.js +0 -46
- package/out/chunk-2KBF5TZ7.js +0 -2
- package/out/chunk-35VE6CDH.js +0 -2
- package/out/chunk-3BZ7JERB.js +0 -1
- package/out/chunk-4XCDFGDP.js +0 -10
- package/out/chunk-5LSXOLUS.js +0 -1
- package/out/chunk-5RCM2J63.js +0 -1
- package/out/chunk-7B6HB4D7.js +0 -1
- package/out/chunk-AJ4GTATZ.js +0 -3
- package/out/chunk-CH4UN6WK.js +0 -225
- package/out/chunk-CMQA4DM6.js +0 -1
- package/out/chunk-CVVJAXVY.js +0 -2
- package/out/chunk-DIJJNTRH.js +0 -10
- package/out/chunk-EDKLBQQN.js +0 -17
- package/out/chunk-F47DXCEV.js +0 -33
- package/out/chunk-FK3DZEKL.js +0 -1
- package/out/chunk-G6AJWENO.js +0 -1
- package/out/chunk-GJ4IUCNS.js +0 -1
- package/out/chunk-GNXOKIGP.js +0 -80
- package/out/chunk-GZ4MS3TE.js +0 -13
- package/out/chunk-GZWM7UQM.js +0 -2
- package/out/chunk-IU3CCINR.js +0 -3
- package/out/chunk-K3X5EJBQ.js +0 -2
- package/out/chunk-KEX2ATEW.js +0 -2
- package/out/chunk-KYCNJVB7.js +0 -1
- package/out/chunk-LPCTJYNA.js +0 -7
- package/out/chunk-O4RJY5TP.js +0 -2
- package/out/chunk-OD4LPN3H.js +0 -1
- package/out/chunk-QWDWTBM7.js +0 -1
- package/out/chunk-QYWKC557.js +0 -1
- package/out/chunk-R75XOKUP.js +0 -1
- package/out/chunk-SWTLADW5.js +0 -1
- package/out/chunk-T7OKH4VW.js +0 -1
- package/out/chunk-TZ5KB2Y5.js +0 -10
- package/out/chunk-UNTRGE26.js +0 -2
- package/out/chunk-UTR2LVME.js +0 -2
- package/out/chunk-UYAXVRVL.js +0 -1
- package/out/chunk-V3ME6FI5.js +0 -2
- package/out/chunk-V5O7M2YT.js +0 -1
- package/out/chunk-VIHGO77Y.js +0 -1
- package/out/chunk-W4J3MGLB.js +0 -1
- package/out/chunk-WGG7KQUH.js +0 -1
- package/out/chunk-XYFXXB2G.js +0 -10
- 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.
|
|
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
|
package/common/bundle-sources.js
CHANGED
|
@@ -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 (
|
|
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-
|
|
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
|
-
}
|
package/common/line-diff.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/common/managed/roles.ts
CHANGED
|
@@ -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
|
+
}
|